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

pyta-uoft / pyta / 12943845089

24 Jan 2025 05:27AM UTC coverage: 92.314% (-0.2%) from 92.514%
12943845089

Pull #1135

github

web-flow
Merge cab30de5e into a02e735ac
Pull Request #1135: Added Watch Property to HTML Server

16 of 31 new or added lines in 2 files covered. (51.61%)

7 existing lines in 1 file now uncovered.

3219 of 3487 relevant lines covered (92.31%)

13.3 hits per line

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

91.95
/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
15✔
19

20
__version__ = "2.9.3.dev"  # Version number
15✔
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
15✔
24
import subprocess
15✔
25

26
try:
15✔
27
    del builtins._
15✔
28
except AttributeError:
15✔
29
    pass
15✔
30

31

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

42
import pylint.config
15✔
43
import pylint.lint
15✔
44
import pylint.utils
15✔
45
from astroid import MANAGER, modutils
15✔
46
from pylint.lint import PyLinter
15✔
47
from pylint.utils.pragma_parser import OPTION_PO
15✔
48

49
from .config import (
15✔
50
    find_local_config,
51
    load_config,
52
    load_messages_config,
53
    override_config,
54
)
55
from .patches import patch_all
15✔
56
from .reporters import REPORTERS
15✔
57
from .reporters.core import PythonTaReporter
15✔
58
from .upload import upload_to_server
15✔
59

60
HELP_URL = "http://www.cs.toronto.edu/~david/pyta/checkers/index.html"
15✔
61

62

63
# Flag to determine if we've previously patched pylint
64
PYLINT_PATCHED = False
15✔
65

66

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

84

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

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

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

128

129
def _check(
15✔
130
    module_name: Union[list[str], str] = "",
131
    level: str = "all",
132
    local_config: Union[dict[str, Any], str] = "",
133
    output: Optional[str] = None,
134
    load_default_config: bool = True,
135
    autoformat: Optional[bool] = False,
136
) -> PythonTaReporter:
137
    """Check a module for problems, printing a report.
138

139
    The `module_name` can take several inputs:
140
      - string of a directory, or file to check (`.py` extension optional).
141
      - list of strings of directories or files -- can have multiple.
142
      - no argument -- checks the python file containing the function call.
143
    `level` is used to specify which checks should be made.
144
    `local_config` is a dict of config options or string (config file name).
145
    `output` is an absolute or relative path to capture pyta data output. If None, stdout is used.
146
    `load_default_config` is used to specify whether to load the default .pylintrc file that comes
147
    with PythonTA. It will load it by default.
148
    `autoformat` is used to specify whether the black formatting tool is run. It is not run by default.
149
    """
150
    # Configuring logger
151
    logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.NOTSET)
15✔
152

153
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
15✔
154
    current_reporter = linter.reporter
15✔
155
    current_reporter.set_output(output)
15✔
156
    messages_config_path = linter.config.messages_config_path
15✔
157
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
15✔
158
    use_pyta_error_messages = linter.config.use_pyta_error_messages
15✔
159
    messages_config = load_messages_config(
15✔
160
        messages_config_path, messages_config_default_path, use_pyta_error_messages
161
    )
162

163
    global PYLINT_PATCHED
164
    if not PYLINT_PATCHED:
15✔
165
        patch_all(
15✔
166
            messages_config, linter.config.z3
167
        )  # Monkeypatch pylint (override certain methods)
168
        PYLINT_PATCHED = True
15✔
169

170
    # Try to check file, issue error message for invalid files.
171
    try:
15✔
172
        # Flag indicating whether at least one file has been checked
173
        is_any_file_checked = False
15✔
174

175
        for locations in _get_valid_files_to_check(module_name):
15✔
176
            f_paths = []  # Paths to files for data submission
15✔
177
            errs = []  # Errors caught in files for data submission
15✔
178
            config = {}  # Configuration settings for data submission
15✔
179
            for file_py in get_file_paths(locations):
15✔
180
                allowed_pylint = linter.config.allow_pylint_comments
15✔
181
                if not _verify_pre_check(file_py, allowed_pylint):
15✔
182
                    continue  # Check the other files
×
183
                # Load config file in user location. Construct new linter each
184
                # time, so config options don't bleed to unintended files.
185
                # Reuse the same reporter each time to accumulate the results across different files.
186
                linter = reset_linter(
15✔
187
                    config=local_config,
188
                    file_linted=file_py,
189
                    load_default_config=load_default_config,
190
                )
191

192
                if autoformat:
15✔
193
                    linelen = (
15✔
194
                        local_config["max-line-length"] if "max-line-length" in local_config else 88
195
                    )
196
                    subprocess.run(
15✔
197
                        [
198
                            sys.executable,
199
                            "-m",
200
                            "black",
201
                            "--skip-string-normalization",
202
                            "--line-length=" + str(linelen),
203
                            file_py,
204
                        ],
205
                        encoding="utf-8",
206
                        capture_output=True,
207
                        text=True,
208
                        check=True,
209
                    )
210

211
                if not is_any_file_checked:
15✔
212
                    prev_output = current_reporter.out
15✔
213
                    current_reporter = linter.reporter
15✔
214
                    current_reporter.out = prev_output
15✔
215

216
                    # At this point, the only possible errors are those from parsing the config file
217
                    # so print them, if there are any.
218
                    if current_reporter.messages:
15✔
219
                        current_reporter.print_messages()
15✔
220
                else:
221
                    linter.set_reporter(current_reporter)
15✔
222

223
                # The current file was checked so update the flag
224
                is_any_file_checked = True
15✔
225

226
                module_name = os.path.splitext(os.path.basename(file_py))[0]
15✔
227
                if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
15✔
228
                    del MANAGER.astroid_cache[module_name]
15✔
229
                linter.check([file_py])  # Lint !
15✔
230
                current_reporter.print_messages(level)
15✔
231
                if linter.config.pyta_file_permission:
15✔
232
                    f_paths.append(file_py)  # Appending paths for upload
×
233
                logging.info(
15✔
234
                    "File: {} was checked using the configuration file: {}".format(
235
                        file_py, linter.config_file
236
                    )
237
                )
238
                logging.info(
15✔
239
                    "File: {} was checked using the messages-config file: {}".format(
240
                        file_py, messages_config_path
241
                    )
242
                )
243
            if linter.config.pyta_error_permission:
15✔
244
                errs = list(current_reporter.messages.values())
×
245
            if (
15✔
246
                f_paths != [] or errs != []
247
            ):  # Only call upload_to_server() if there's something to upload
248
                # Checks if default configuration was used without changing options through the local_config argument
249
                if linter.config_file[-19:-10] != "python_ta" or local_config != "":
×
250
                    config = linter.config.__dict__
×
251
                upload_to_server(
×
252
                    errors=errs,
253
                    paths=f_paths,
254
                    config=config,
255
                    url=linter.config.pyta_server_address,
256
                    version=__version__,
257
                )
258
        # Only generate reports (display the webpage) if there were valid files to check
259
        if is_any_file_checked:
15✔
260
            linter.generate_reports()
15✔
261
        return current_reporter
15✔
262
    except Exception as e:
15✔
263
        logging.error(
15✔
264
            "Unexpected error encountered! Please report this to your instructor (and attach the code that caused the error)."
265
        )
266
        logging.error('Error message: "{}"'.format(e))
15✔
267
        raise e
15✔
268

269

270
def reset_linter(
15✔
271
    config: Optional[Union[dict, str]] = None,
272
    file_linted: Optional[AnyStr] = None,
273
    load_default_config: bool = True,
274
) -> PyLinter:
275
    """Construct a new linter. Register config and checker plugins.
276

277
    To determine which configuration to use:
278
    - If the option is enabled, load the default PythonTA config file,
279
    - If the config argument is a string, use the config found at that location,
280
    - Otherwise,
281
        - Try to use the config file at directory of the file being linted,
282
        - If the config argument is a dictionary, apply those options afterward.
283
    Do not re-use a linter object. Returns a new linter.
284
    """
285
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
286
    new_checker_options = (
15✔
287
        (
288
            "watch",
289
            {
290
                "default": False,
291
                "type": "yn",
292
                "metavar": "<yn>",
293
                "help": "Run the HTML report server in persistent mode",
294
            },
295
        ),
296
        (
297
            "pyta-number-of-messages",
298
            {
299
                "default": 0,  # If the value is 0, all messages are displayed.
300
                "type": "int",
301
                "metavar": "<number_messages>",
302
                "help": "The maximum number of occurrences of each check to report.",
303
            },
304
        ),
305
        (
306
            "pyta-template-file",
307
            {
308
                "default": "template.html.jinja",
309
                "type": "string",
310
                "metavar": "<pyta_reporter>",
311
                "help": "HTML template file for the HTMLReporter.",
312
            },
313
        ),
314
        (
315
            "pyta-error-permission",
316
            {
317
                "default": False,
318
                "type": "yn",
319
                "metavar": "<yn>",
320
                "help": "Permission to anonymously submit errors",
321
            },
322
        ),
323
        (
324
            "pyta-file-permission",
325
            {
326
                "default": False,
327
                "type": "yn",
328
                "metavar": "<yn>",
329
                "help": "Permission to anonymously submit files and errors",
330
            },
331
        ),
332
        (
333
            "pyta-server-address",
334
            {
335
                "default": "http://127.0.0.1:5000",
336
                "type": "string",
337
                "metavar": "<server-url>",
338
                "help": "Server address to submit anonymous data",
339
            },
340
        ),
341
        (
342
            "messages-config-path",
343
            {
344
                "default": os.path.join(
345
                    os.path.dirname(__file__), "config", "messages_config.toml"
346
                ),
347
                "type": "string",
348
                "metavar": "<messages_config>",
349
                "help": "Path to patch config toml file.",
350
            },
351
        ),
352
        (
353
            "allow-pylint-comments",
354
            {
355
                "default": False,
356
                "type": "yn",
357
                "metavar": "<yn>",
358
                "help": "Allows or disallows 'pylint:' comments",
359
            },
360
        ),
361
        (
362
            "use-pyta-error-messages",
363
            {
364
                "default": True,
365
                "type": "yn",
366
                "metavar": "<yn>",
367
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
368
            },
369
        ),
370
    )
371

372
    parent_dir_path = os.path.dirname(__file__)
15✔
373
    custom_checkers = [
15✔
374
        ("python_ta.checkers." + os.path.splitext(f)[0])
375
        for f in listdir(parent_dir_path + "/checkers")
376
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
377
    ]
378

379
    # Register new options to a checker here to allow references to
380
    # options in `.pylintrc` config file.
381
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
382
    linter = pylint.lint.PyLinter(options=new_checker_options)
15✔
383
    linter.load_default_plugins()  # Load checkers, reporters
15✔
384
    linter.load_plugin_modules(custom_checkers)
15✔
385
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
15✔
386

387
    default_config_path = find_local_config(os.path.dirname(__file__))
15✔
388
    set_config = load_config
15✔
389

390
    if load_default_config:
15✔
391
        load_config(linter, default_config_path)
15✔
392
        # If we do specify to load the default config, we just need to override the options later.
393
        set_config = override_config
15✔
394

395
    if isinstance(config, str) and config != "":
15✔
396
        set_config(linter, config)
15✔
397
    else:
398
        # If available, use config file at directory of the file being linted.
399
        pylintrc_location = None
15✔
400
        if file_linted:
15✔
401
            pylintrc_location = find_local_config(file_linted)
15✔
402

403
        # Load or override the options if there is a config file in the current directory.
404
        if pylintrc_location:
15✔
UNCOV
405
            set_config(linter, pylintrc_location)
×
406

407
        # Override part of the default config, with a dict of config options.
408
        # Note: these configs are overridden by config file in user's codebase
409
        # location.
410
        if isinstance(config, dict):
15✔
411
            for key in config:
15✔
412
                linter.set_option(key, config[key])
15✔
413

414
    return linter
15✔
415

416

417
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
15✔
418
    """A generator for iterating python files within a directory.
419
    `rel_path` is a relative path to a file or directory.
420
    Returns paths to all files in a directory.
421
    """
422
    if not os.path.isdir(rel_path):
15✔
423
        yield rel_path  # Don't do anything; return the file name.
15✔
424
    else:
425
        for root, _, files in os.walk(rel_path):
15✔
426
            for filename in (f for f in files if f.endswith(".py")):
15✔
427
                yield os.path.join(root, filename)  # Format path, from root.
15✔
428

429

430
def _verify_pre_check(filepath: AnyStr, allow_pylint_comments: bool) -> bool:
15✔
431
    """Check student code for certain issues.
432
    The additional allow_pylint_comments parameter indicates whether we want the user to be able to add comments
433
    beginning with pylint which can be used to locally disable checks.
434
    """
435
    # Make sure the program doesn't crash for students.
436
    # Could use some improvement for better logging and error reporting.
437
    try:
15✔
438
        # Check for inline "pylint:" comment, which may indicate a student
439
        # trying to disable a check.
440
        if allow_pylint_comments:
15✔
441
            return True
15✔
442
        with tokenize.open(os.path.expanduser(filepath)) as f:
15✔
443
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
15✔
444
                if tok_type != tokenize.COMMENT:
15✔
445
                    continue
15✔
446
                match = OPTION_PO.search(content)
15✔
447
                if match is not None:
15✔
448
                    logging.error(
15✔
449
                        'String "pylint:" found in comment. '
450
                        + "No check run on file `{}.`\n".format(filepath)
451
                    )
452
                    return False
15✔
453
    except IndentationError as e:
15✔
454
        logging.error(
15✔
455
            "python_ta could not check your code due to an "
456
            + "indentation error at line {}.".format(e.lineno)
457
        )
458
        return False
15✔
459
    except tokenize.TokenError as e:
15✔
460
        logging.error(
15✔
461
            "python_ta could not check your code due to a " + "syntax error in your file."
462
        )
463
        return False
15✔
464
    except UnicodeDecodeError:
15✔
465
        logging.error(
15✔
466
            "python_ta could not check your code due to an "
467
            + "invalid character. Please check the following lines "
468
            "in your file and all characters that are marked with a �."
469
        )
470
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
15✔
471
            for i, line in enumerate(f):
15✔
472
                if "�" in line:
15✔
473
                    logging.error(f"  Line {i + 1}: {line}")
15✔
474
        return False
15✔
475
    return True
15✔
476

477

478
def _get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[AnyStr, None, None]:
15✔
479
    """A generator for all valid files to check."""
480
    # Allow call to check with empty args
481
    if module_name == "":
15✔
UNCOV
482
        m = sys.modules["__main__"]
×
UNCOV
483
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
×
UNCOV
484
        module_name = [spec.origin]
×
485
    # Enforce API to expect 1 file or directory if type is list
486
    elif isinstance(module_name, str):
15✔
487
        module_name = [module_name]
15✔
488
    # Otherwise, enforce API to expect `module_name` type as list
489
    elif not isinstance(module_name, list):
15✔
490
        logging.error(
15✔
491
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
492
                module_name
493
            )
494
        )
495
        return
15✔
496

497
    # Filter valid files to check
498
    for item in module_name:
15✔
499
        if not isinstance(item, str):  # Issue errors for invalid types
15✔
500
            logging.error(
15✔
501
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
502
            )
503
        elif os.path.isdir(item):
15✔
504
            yield item
15✔
505
        elif not os.path.exists(os.path.expanduser(item)):
15✔
506
            try:
15✔
507
                # For files with dot notation, e.g., `examples.<filename>`
508
                filepath = modutils.file_from_modpath(item.split("."))
15✔
UNCOV
509
                if os.path.exists(filepath):
×
UNCOV
510
                    yield filepath
×
511
                else:
UNCOV
512
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
513
            except ImportError:
15✔
514
                logging.error("Could not find the file called, `{}`\n".format(item))
15✔
515
        else:
516
            yield item  # Check other valid files.
15✔
517

518

519
def doc(msg_id: str) -> None:
15✔
520
    """Open the PythonTA documentation page for the given error message id.
521

522
    Args:
523
        msg_id: The five-character error code, e.g. ``"E0401"``.
524
    """
525
    msg_url = HELP_URL + "#" + msg_id.lower()
15✔
526
    print("Opening {} in a browser.".format(msg_url))
15✔
527
    webbrowser.open(msg_url)
15✔
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