• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pyta-uoft / pyta / 13645028336

04 Mar 2025 01:36AM UTC coverage: 92.042% (-0.9%) from 92.914%
13645028336

Pull #1156

github

web-flow
Merge 7cb85821d into 89ce63ced
Pull Request #1156: Integrate Watchdog for Live Code Re-Checking

52 of 84 new or added lines in 1 file covered. (61.9%)

8 existing lines in 1 file now uncovered.

3273 of 3556 relevant lines covered (92.04%)

17.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.87
/python_ta/__init__.py
1
"""Python Teaching Assistant
2

3
The goal of this module is to provide automated feedback to students in our
4
introductory Python courses, using static analysis of their code.
5

6
To run the checker, call the check function on the name of the module to check.
7

8
> import python_ta
9
> python_ta.check_all('mymodule.py')
10

11
Or, put the following code in your Python module:
12

13
if __name__ == '__main__':
14
    import python_ta
15
    python_ta.check_all()
16
"""
17

18
from __future__ import annotations
20✔
19

20
__version__ = "2.10.2.dev"  # Version number
20✔
21
# First, remove underscore from builtins if it has been bound in the REPL.
22
# Must appear before other imports from pylint/python_ta.
23
import builtins
20✔
24

25
from pylint.reporters import BaseReporter, MultiReporter
20✔
26

27
try:
20✔
28
    del builtins._
20✔
29
except AttributeError:
20✔
30
    pass
20✔
31

32
import importlib.util
20✔
33
import logging
20✔
34
import os
20✔
35
import sys
20✔
36
import time
20✔
37
import tokenize
20✔
38
import webbrowser
20✔
39
from builtins import FileNotFoundError
20✔
40
from os import listdir
20✔
41
from typing import Any, AnyStr, Generator, Optional, TextIO, Tuple, Union
20✔
42

43
import pylint.config
20✔
44
import pylint.lint
20✔
45
import pylint.utils
20✔
46
from astroid import MANAGER, modutils
20✔
47
from pylint.lint import PyLinter
20✔
48
from pylint.utils.pragma_parser import OPTION_PO
20✔
49
from watchdog.events import FileSystemEventHandler
20✔
50
from watchdog.observers import Observer
20✔
51

52
from .config import (
20✔
53
    find_local_config,
54
    load_config,
55
    load_messages_config,
56
    override_config,
57
)
58
from .patches import patch_all
20✔
59
from .reporters import REPORTERS
20✔
60
from .reporters.core import PythonTaReporter
20✔
61
from .upload import upload_to_server
20✔
62
from .util.autoformat import run_autoformat
20✔
63

64
HELP_URL = "http://www.cs.toronto.edu/~david/pyta/checkers/index.html"
20✔
65

66
# Flag to determine if we've previously patched pylint
67
PYLINT_PATCHED = False
20✔
68

69

70
def check_errors(
20✔
71
    module_name: Union[list[str], str] = "",
72
    config: Union[dict[str, Any], str] = "",
73
    output: Optional[str] = None,
74
    load_default_config: bool = True,
75
    autoformat: Optional[bool] = False,
76
) -> PythonTaReporter:
77
    """Check a module for errors, printing a report."""
78
    return _check(
×
79
        module_name=module_name,
80
        level="error",
81
        local_config=config,
82
        output=output,
83
        load_default_config=load_default_config,
84
        autoformat=autoformat,
85
    )
86

87

88
def check_all(
20✔
89
    module_name: Union[list[str], str] = "",
90
    config: Union[dict[str, Any], str] = "",
91
    output: Optional[str] = None,
92
    load_default_config: bool = True,
93
    autoformat: Optional[bool] = False,
94
) -> PythonTaReporter:
95
    """Analyse one or more Python modules for code issues and display the results.
96

97
    Args:
98
        module_name:
99
            If an empty string (default), the module where this function is called is checked.
100
            If a non-empty string, it is interpreted as a path to a single Python module or a
101
            directory containing Python modules. If the latter, all Python modules in the directory
102
            are checked.
103
            If a list of strings, each string is interpreted as a path to a module or directory,
104
            and all modules across all paths are checked.
105
        config:
106
            If a string, a path to a configuration file to use.
107
            If a dictionary, a map of configuration options (each key is the name of an option).
108
        output:
109
            If provided, the PythonTA report is written to this path. Otherwise, the report
110
            is written to standard out or automatically displayed in a web browser, depending
111
            on which reporter is used.
112
        load_default_config:
113
            If True (default), additional configuration passed with the ``config`` option is
114
            merged with the default PythonTA configuration file.
115
            If False, the default PythonTA configuration is not used.
116
        autoformat:
117
            If True, autoformat all modules using the black formatting tool before analyzing code.
118

119
    Returns:
120
        The ``PythonTaReporter`` object that generated the report.
121
    """
122
    return _check(
20✔
123
        module_name=module_name,
124
        level="all",
125
        local_config=config,
126
        output=output,
127
        load_default_config=load_default_config,
128
        autoformat=autoformat,
129
    )
130

131

132
def _setup_linter(
20✔
133
    local_config: Union[dict[str, Any], str],
134
    load_default_config: bool,
135
    output: Optional[str],
136
) -> tuple[PyLinter, BaseReporter | MultiReporter]:
137
    """Set up the linter and reporter for the check."""
138
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
20✔
139
    current_reporter = linter.reporter
20✔
140
    current_reporter.set_output(output)
20✔
141
    messages_config_path = linter.config.messages_config_path
20✔
142
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20✔
143
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20✔
144
    messages_config = load_messages_config(
20✔
145
        messages_config_path, messages_config_default_path, use_pyta_error_messages
146
    )
147

148
    global PYLINT_PATCHED
149
    if not PYLINT_PATCHED:
20✔
150
        patch_all(
20✔
151
            messages_config, linter.config.z3
152
        )  # Monkeypatch pylint (override certain methods)
153
        PYLINT_PATCHED = True
20✔
154
    return linter, current_reporter
20✔
155

156

157
def _check_file(
20✔
158
    linter: PyLinter,
159
    file_py: AnyStr,
160
    local_config: Union[dict[str, Any], str],
161
    load_default_config: bool,
162
    autoformat: Optional[bool],
163
    is_any_file_checked: bool,
164
    current_reporter: BaseReporter | MultiReporter,
165
    level: str,
166
    f_paths: list,
167
) -> tuple[bool, BaseReporter | MultiReporter, PyLinter]:
168
    """Check the file that called this function."""
169
    logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
20✔
170
    allowed_pylint = linter.config.allow_pylint_comments
20✔
171
    if not _verify_pre_check(file_py, allowed_pylint):
20✔
NEW
172
        return is_any_file_checked, current_reporter, linter
×
173
    # Load config file in user location. Construct new linter each
174
    # time, so config options don't bleed to unintended files.
175
    # Reuse the same reporter each time to accumulate the results across different files.
176
    linter = reset_linter(
20✔
177
        config=local_config,
178
        file_linted=file_py,
179
        load_default_config=load_default_config,
180
    )
181

182
    if autoformat:
20✔
183
        run_autoformat(file_py, linter.config.autoformat_options, linter.config.max_line_length)
20✔
184

185
    if not is_any_file_checked:
20✔
186
        prev_output = current_reporter.out
20✔
187
        current_reporter = linter.reporter
20✔
188
        current_reporter.out = prev_output
20✔
189

190
        # At this point, the only possible errors are those from parsing the config file
191
        # so print them, if there are any.
192
        if current_reporter.messages:
20✔
193
            current_reporter.print_messages()
20✔
194
    else:
195
        linter.set_reporter(current_reporter)
20✔
196

197
    # The current file was checked so update the flag
198
    is_any_file_checked = True
20✔
199

200
    module_name = os.path.splitext(os.path.basename(file_py))[0]
20✔
201
    if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
20✔
202
        del MANAGER.astroid_cache[module_name]
20✔
203
    linter.check([file_py])  # Lint !
20✔
204
    if linter.config.pyta_file_permission:
20✔
NEW
205
        f_paths.append(file_py)  # Appending paths for upload
×
206
    logging.debug(
20✔
207
        "File: {} was checked using the configuration file: {}".format(file_py, linter.config_file)
208
    )
209
    logging.debug(
20✔
210
        "File: {} was checked using the messages-config file: {}".format(
211
            file_py, linter.config.messages_config_path
212
        )
213
    )
214
    return is_any_file_checked, current_reporter, linter
20✔
215

216

217
def _upload_to_server(
20✔
218
    linter: PyLinter,
219
    current_reporter: BaseReporter | MultiReporter,
220
    f_paths: list,
221
    local_config: Union[dict[str, Any], str],
222
):
223
    config = {}  # Configuration settings for data submission
20✔
224
    errs = []  # Errors caught in files for data submission
20✔
225
    if linter.config.pyta_error_permission:
20✔
NEW
226
        errs = list(current_reporter.messages.values())
×
227
    if f_paths != [] or errs != []:  # Only call upload_to_server() if there's something to upload
20✔
228
        # Checks if default configuration was used without changing options through the local_config argument
NEW
229
        if linter.config_file[-19:-10] != "python_ta" or local_config != "":
×
NEW
230
            config = linter.config.__dict__
×
NEW
231
        upload_to_server(
×
232
            errors=errs,
233
            paths=f_paths,
234
            config=config,
235
            url=linter.config.pyta_server_address,
236
            version=__version__,
237
        )
238

239

240
def _check(
20✔
241
    module_name: Union[list[str], str] = "",
242
    level: str = "all",
243
    local_config: Union[dict[str, Any], str] = "",
244
    output: Optional[str] = None,
245
    load_default_config: bool = True,
246
    autoformat: Optional[bool] = False,
247
) -> PythonTaReporter:
248
    """Check a module for problems, printing a report.
249

250
    The `module_name` can take several inputs:
251
      - string of a directory, or file to check (`.py` extension optional).
252
      - list of strings of directories or files -- can have multiple.
253
      - no argument -- checks the python file containing the function call.
254
    `level` is used to specify which checks should be made.
255
    `local_config` is a dict of config options or string (config file name).
256
    `output` is an absolute or relative path to capture pyta data output. If None, stdout is used.
257
    `load_default_config` is used to specify whether to load the default .pylintrc file that comes
258
    with PythonTA. It will load it by default.
259
    `autoformat` is used to specify whether the black formatting tool is run. It is not run by default.
260
    """
261
    # Configuring logger
262
    logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
20✔
263
    linter, current_reporter = _setup_linter(local_config, load_default_config, output)
20✔
264
    try:
20✔
265
        # Flag indicating whether at least one file has been checked
266
        is_any_file_checked = False
20✔
267
        linted_files = set()
20✔
268
        for locations in _get_valid_files_to_check(module_name):
20✔
269
            f_paths = []  # Paths to files for data submission
20✔
270
            for file_py in get_file_paths(locations):
20✔
271
                linted_files.add(file_py)
20✔
272
                is_any_file_checked, current_reporter, linter = _check_file(
20✔
273
                    linter,
274
                    file_py,
275
                    local_config,
276
                    load_default_config,
277
                    autoformat,
278
                    is_any_file_checked,
279
                    current_reporter,
280
                    level,
281
                    f_paths,
282
                )
283
                current_reporter.print_messages(level)
20✔
284
            _upload_to_server(linter, current_reporter, f_paths, local_config)
20✔
285
        # Only generate reports (display the webpage) if there were valid files to check
286
        if is_any_file_checked:
20✔
287
            linter.generate_reports()
20✔
288
            if linter.config.watch:
20✔
NEW
289
                _watch_files(
×
290
                    linted_files,
291
                    level,
292
                    local_config,
293
                    load_default_config,
294
                    autoformat,
295
                    linter,
296
                    current_reporter,
297
                )
298
        return current_reporter
20✔
299
    except Exception as e:
20✔
300
        logging.error(
20✔
301
            "Unexpected error encountered! Please report this to your instructor (and attach the code that caused the error)."
302
        )
303
        logging.error('Error message: "{}"'.format(e))
20✔
304
        raise e
20✔
305

306

307
def reset_linter(
20✔
308
    config: Optional[Union[dict, str]] = None,
309
    file_linted: Optional[AnyStr] = None,
310
    load_default_config: bool = True,
311
) -> PyLinter:
312
    """Construct a new linter. Register config and checker plugins.
313

314
    To determine which configuration to use:
315
    - If the option is enabled, load the default PythonTA config file,
316
    - If the config argument is a string, use the config found at that location,
317
    - Otherwise,
318
        - Try to use the config file at directory of the file being linted,
319
        - If the config argument is a dictionary, apply those options afterward.
320
    Do not re-use a linter object. Returns a new linter.
321
    """
322

323
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
324
    new_checker_options = (
20✔
325
        (
326
            "server-port",
327
            {
328
                "default": 0,
329
                "type": "int",
330
                "metavar": "<port>",
331
                "help": "Port number for the HTML report server",
332
            },
333
        ),
334
        (
335
            "watch",
336
            {
337
                "default": False,
338
                "type": "yn",
339
                "metavar": "<yn>",
340
                "help": "Run the HTML report server in persistent mode",
341
            },
342
        ),
343
        (
344
            "pyta-number-of-messages",
345
            {
346
                "default": 0,  # If the value is 0, all messages are displayed.
347
                "type": "int",
348
                "metavar": "<number_messages>",
349
                "help": "The maximum number of occurrences of each check to report.",
350
            },
351
        ),
352
        (
353
            "pyta-template-file",
354
            {
355
                "default": "",
356
                "type": "string",
357
                "metavar": "<pyta_reporter>",
358
                "help": "HTML template file for the HTMLReporter.",
359
            },
360
        ),
361
        (
362
            "pyta-error-permission",
363
            {
364
                "default": False,
365
                "type": "yn",
366
                "metavar": "<yn>",
367
                "help": "Permission to anonymously submit errors",
368
            },
369
        ),
370
        (
371
            "pyta-file-permission",
372
            {
373
                "default": False,
374
                "type": "yn",
375
                "metavar": "<yn>",
376
                "help": "Permission to anonymously submit files and errors",
377
            },
378
        ),
379
        (
380
            "pyta-server-address",
381
            {
382
                "default": "http://127.0.0.1:5000",
383
                "type": "string",
384
                "metavar": "<server-url>",
385
                "help": "Server address to submit anonymous data",
386
            },
387
        ),
388
        (
389
            "messages-config-path",
390
            {
391
                "default": os.path.join(
392
                    os.path.dirname(__file__), "config", "messages_config.toml"
393
                ),
394
                "type": "string",
395
                "metavar": "<messages_config>",
396
                "help": "Path to patch config toml file.",
397
            },
398
        ),
399
        (
400
            "allow-pylint-comments",
401
            {
402
                "default": False,
403
                "type": "yn",
404
                "metavar": "<yn>",
405
                "help": "Allows or disallows 'pylint:' comments",
406
            },
407
        ),
408
        (
409
            "use-pyta-error-messages",
410
            {
411
                "default": True,
412
                "type": "yn",
413
                "metavar": "<yn>",
414
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
415
            },
416
        ),
417
        (
418
            "autoformat-options",
419
            {
420
                "default": ["skip-string-normalization"],
421
                "type": "csv",
422
                "metavar": "<autoformatter options>",
423
                "help": "List of command-line arguments for black",
424
            },
425
        ),
426
    )
427

428
    parent_dir_path = os.path.dirname(__file__)
20✔
429
    custom_checkers = [
20✔
430
        ("python_ta.checkers." + os.path.splitext(f)[0])
431
        for f in listdir(parent_dir_path + "/checkers")
432
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
433
    ]
434

435
    # Register new options to a checker here to allow references to
436
    # options in `.pylintrc` config file.
437
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
438
    linter = pylint.lint.PyLinter(options=new_checker_options)
20✔
439
    linter.load_default_plugins()  # Load checkers, reporters
20✔
440
    linter.load_plugin_modules(custom_checkers)
20✔
441
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
20✔
442

443
    default_config_path = find_local_config(os.path.dirname(__file__))
20✔
444
    set_config = load_config
20✔
445

446
    if load_default_config:
20✔
447
        load_config(linter, default_config_path)
20✔
448
        # If we do specify to load the default config, we just need to override the options later.
449
        set_config = override_config
20✔
450

451
    if isinstance(config, str) and config != "":
20✔
452
        set_config(linter, config)
20✔
453
    else:
454
        # If available, use config file at directory of the file being linted.
455
        pylintrc_location = None
20✔
456
        if file_linted:
20✔
457
            pylintrc_location = find_local_config(file_linted)
20✔
458

459
        # Load or override the options if there is a config file in the current directory.
460
        if pylintrc_location:
20✔
461
            set_config(linter, pylintrc_location)
×
462

463
        # Override part of the default config, with a dict of config options.
464
        # Note: these configs are overridden by config file in user's codebase
465
        # location.
466
        if isinstance(config, dict):
20✔
467
            for key in config:
20✔
468
                linter.set_option(key, config[key])
20✔
469

470
    return linter
20✔
471

472

473
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
20✔
474
    """A generator for iterating python files within a directory.
475
    `rel_path` is a relative path to a file or directory.
476
    Returns paths to all files in a directory.
477
    """
478
    if not os.path.isdir(rel_path):
20✔
479
        yield rel_path  # Don't do anything; return the file name.
20✔
480
    else:
481
        for root, _, files in os.walk(rel_path):
20✔
482
            for filename in (f for f in files if f.endswith(".py")):
20✔
483
                yield os.path.join(root, filename)  # Format path, from root.
20✔
484

485

486
def _verify_pre_check(filepath: AnyStr, allow_pylint_comments: bool) -> bool:
20✔
487
    """Check student code for certain issues.
488
    The additional allow_pylint_comments parameter indicates whether we want the user to be able to add comments
489
    beginning with pylint which can be used to locally disable checks.
490
    """
491
    # Make sure the program doesn't crash for students.
492
    # Could use some improvement for better logging and error reporting.
493
    try:
20✔
494
        # Check for inline "pylint:" comment, which may indicate a student
495
        # trying to disable a check.
496
        if allow_pylint_comments:
20✔
497
            return True
20✔
498
        with tokenize.open(os.path.expanduser(filepath)) as f:
20✔
499
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
20✔
500
                if tok_type != tokenize.COMMENT:
20✔
501
                    continue
20✔
502
                match = OPTION_PO.search(content)
20✔
503
                if match is not None:
20✔
504
                    logging.error(
20✔
505
                        'String "pylint:" found in comment. '
506
                        + "No check run on file `{}.`\n".format(filepath)
507
                    )
508
                    return False
20✔
509
    except IndentationError as e:
20✔
510
        logging.error(
20✔
511
            "python_ta could not check your code due to an "
512
            + "indentation error at line {}.".format(e.lineno)
513
        )
514
        return False
20✔
515
    except tokenize.TokenError as e:
20✔
516
        logging.error(
20✔
517
            "python_ta could not check your code due to a " + "syntax error in your file."
518
        )
519
        return False
20✔
520
    except UnicodeDecodeError:
20✔
521
        logging.error(
20✔
522
            "python_ta could not check your code due to an "
523
            + "invalid character. Please check the following lines "
524
            "in your file and all characters that are marked with a �."
525
        )
526
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
20✔
527
            for i, line in enumerate(f):
20✔
528
                if "�" in line:
20✔
529
                    logging.error(f"  Line {i + 1}: {line}")
20✔
530
        return False
20✔
531
    return True
20✔
532

533

534
def _get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[AnyStr, None, None]:
20✔
535
    """A generator for all valid files to check."""
536
    # Allow call to check with empty args
537
    if module_name == "":
20✔
538
        m = sys.modules["__main__"]
10✔
539
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
10✔
540
        module_name = [spec.origin]
10✔
541
    # Enforce API to expect 1 file or directory if type is list
542
    elif isinstance(module_name, str):
20✔
543
        module_name = [module_name]
20✔
544
    # Otherwise, enforce API to expect `module_name` type as list
545
    elif not isinstance(module_name, list):
20✔
546
        logging.error(
20✔
547
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
548
                module_name
549
            )
550
        )
551
        return
20✔
552

553
    # Filter valid files to check
554
    for item in module_name:
20✔
555
        if not isinstance(item, str):  # Issue errors for invalid types
20✔
556
            logging.error(
20✔
557
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
558
            )
559
        elif os.path.isdir(item):
20✔
560
            yield item
20✔
561
        elif not os.path.exists(os.path.expanduser(item)):
20✔
562
            try:
20✔
563
                # For files with dot notation, e.g., `examples.<filename>`
564
                filepath = modutils.file_from_modpath(item.split("."))
20✔
565
                if os.path.exists(filepath):
×
566
                    yield filepath
×
567
                else:
568
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
569
            except ImportError:
20✔
570
                logging.error("Could not find the file called, `{}`\n".format(item))
20✔
571
        else:
572
            yield item  # Check other valid files.
20✔
573

574

575
def doc(msg_id: str) -> None:
20✔
576
    """Open the PythonTA documentation page for the given error message id.
577

578
    Args:
579
        msg_id: The five-character error code, e.g. ``"E0401"``.
580
    """
581
    msg_url = HELP_URL + "#" + msg_id.lower()
20✔
582
    print("Opening {} in a browser.".format(msg_url))
20✔
583
    webbrowser.open(msg_url)
20✔
584

585

586
def _watch_files(
20✔
587
    file_paths: set,
588
    level: str,
589
    local_config: Union[dict[str, Any], str],
590
    load_default_config: bool,
591
    autoformat: Optional[bool],
592
    linter: PyLinter,
593
    current_reporter: BaseReporter | MultiReporter,
594
):
595
    """Watch a list of files for modifications and trigger a callback when changes occur."""
596

NEW
597
    class FileChangeHandler(FileSystemEventHandler):
×
598
        """Internal class to handle file modifications."""
599

NEW
600
        def __init__(self, files_to_watch):
×
NEW
601
            self.files_to_watch = set(files_to_watch)
×
NEW
602
            self.linter = linter
×
NEW
603
            self.current_reporter = current_reporter
×
604

NEW
605
        def on_modified(self, event):
×
606
            """Trigger the callback when a watched file is modified."""
NEW
607
            if event.src_path in self.files_to_watch:
×
NEW
608
                print(f"File modified: {event.src_path}, re-running checks...")
×
609

NEW
610
                if event.src_path in self.current_reporter.messages:
×
NEW
611
                    del self.current_reporter.messages[event.src_path]
×
612

NEW
613
                _, self.current_reporter, self.linter = _check_file(
×
614
                    self.linter,
615
                    event.src_path,
616
                    local_config,
617
                    load_default_config,
618
                    autoformat,
619
                    True,
620
                    self.current_reporter,
621
                    level,
622
                    [],
623
                )
NEW
624
                self.current_reporter.print_messages(level)
×
NEW
625
                self.linter.generate_reports()
×
NEW
626
                _upload_to_server(linter, current_reporter, [event.src_path], local_config)
×
627

NEW
628
    directories_to_watch = {os.path.dirname(file) for file in file_paths}
×
NEW
629
    event_handler = FileChangeHandler(file_paths)
×
NEW
630
    observer = Observer()
×
NEW
631
    for directory in directories_to_watch:
×
NEW
632
        observer.schedule(event_handler, path=directory, recursive=False)
×
NEW
633
    observer.start()
×
634

NEW
635
    try:
×
636
        while True:
NEW
637
            time.sleep(1)
×
NEW
638
    except KeyboardInterrupt:
×
NEW
639
        observer.stop()
×
640

NEW
641
    observer.join()
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc