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

pyta-uoft / pyta / 14024413371

24 Mar 2025 12:12AM UTC coverage: 93.118% (+0.03%) from 93.089%
14024413371

Pull #1163

github

web-flow
Merge a70c9603d into 477f476ea
Pull Request #1163: Accept Reporter Aliases in `output-format` Config Option

13 of 13 new or added lines in 4 files covered. (100.0%)

12 existing lines in 3 files now uncovered.

3315 of 3560 relevant lines covered (93.12%)

17.71 hits per line

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

94.74
/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
import re
20✔
25
import subprocess
20✔
26

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

32

33
import importlib.util
20✔
34
import logging
20✔
35
import os
20✔
36
import sys
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 IO, Any, AnyStr, Generator, Optional, TextIO, 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.exceptions import UnknownMessageError
20✔
48
from pylint.lint import PyLinter
20✔
49
from pylint.utils.pragma_parser import OPTION_PO
20✔
50

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

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

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[Union[str, IO]] = 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(
20✔
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[Union[str, IO]] = 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 a string, a path to a file to which the PythonTA report is written.
110
            If a typing.IO object, the report is written to this stream.
111
            If None, the report is written to standard out or automatically displayed in a
112
            web browser, depending on which reporter is used.
113
        load_default_config:
114
            If True (default), additional configuration passed with the ``config`` option is
115
            merged with the default PythonTA configuration file.
116
            If False, the default PythonTA configuration is not used.
117
        autoformat:
118
            If True, autoformat all modules using the black formatting tool before analyzing code.
119

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

132

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

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

158
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
20✔
159
    current_reporter = linter.reporter
20✔
160
    current_reporter.set_output(output)
20✔
161
    messages_config_path = linter.config.messages_config_path
20✔
162

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

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

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

190
                if autoformat:
20✔
191
                    run_autoformat(
20✔
192
                        file_py, linter.config.autoformat_options, linter.config.max_line_length
193
                    )
194

195
                if not is_any_file_checked:
20✔
196
                    prev_output = current_reporter.out
20✔
197
                    prev_should_close_out = current_reporter.should_close_out
20✔
198
                    current_reporter = linter.reporter
20✔
199
                    current_reporter.out = prev_output
20✔
200
                    current_reporter.should_close_out = prev_should_close_out
20✔
201

202
                    # At this point, the only possible errors are those from parsing the config file
203
                    # so print them, if there are any.
204
                    if current_reporter.messages:
20✔
205
                        current_reporter.print_messages()
20✔
206
                else:
207
                    linter.set_reporter(current_reporter)
20✔
208

209
                # The current file was checked so update the flag
210
                is_any_file_checked = True
20✔
211

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

255

256
def reset_linter(
20✔
257
    config: Optional[Union[dict, str]] = None,
258
    file_linted: Optional[AnyStr] = None,
259
    load_default_config: bool = True,
260
) -> PyLinter:
261
    """Construct a new linter. Register config and checker plugins.
262

263
    To determine which configuration to use:
264
    - If the option is enabled, load the default PythonTA config file,
265
    - If the config argument is a string, use the config found at that location,
266
    - Otherwise,
267
        - Try to use the config file at directory of the file being linted,
268
        - If the config argument is a dictionary, apply those options afterward.
269
    Do not re-use a linter object. Returns a new linter.
270
    """
271

272
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
273
    new_checker_options = (
20✔
274
        (
275
            "server-port",
276
            {
277
                "default": 0,
278
                "type": "int",
279
                "metavar": "<port>",
280
                "help": "Port number for the HTML report server",
281
            },
282
        ),
283
        (
284
            "watch",
285
            {
286
                "default": False,
287
                "type": "yn",
288
                "metavar": "<yn>",
289
                "help": "Run the HTML report server in persistent mode",
290
            },
291
        ),
292
        (
293
            "pyta-number-of-messages",
294
            {
295
                "default": 0,  # If the value is 0, all messages are displayed.
296
                "type": "int",
297
                "metavar": "<number_messages>",
298
                "help": "The maximum number of occurrences of each check to report.",
299
            },
300
        ),
301
        (
302
            "pyta-template-file",
303
            {
304
                "default": "",
305
                "type": "string",
306
                "metavar": "<pyta_reporter>",
307
                "help": "HTML template file for the HTMLReporter.",
308
            },
309
        ),
310
        (
311
            "pyta-error-permission",
312
            {
313
                "default": False,
314
                "type": "yn",
315
                "metavar": "<yn>",
316
                "help": "Permission to anonymously submit errors",
317
            },
318
        ),
319
        (
320
            "pyta-file-permission",
321
            {
322
                "default": False,
323
                "type": "yn",
324
                "metavar": "<yn>",
325
                "help": "Permission to anonymously submit files and errors",
326
            },
327
        ),
328
        (
329
            "pyta-server-address",
330
            {
331
                "default": "http://127.0.0.1:5000",
332
                "type": "string",
333
                "metavar": "<server-url>",
334
                "help": "Server address to submit anonymous data",
335
            },
336
        ),
337
        (
338
            "messages-config-path",
339
            {
340
                "default": os.path.join(
341
                    os.path.dirname(__file__), "config", "messages_config.toml"
342
                ),
343
                "type": "string",
344
                "metavar": "<messages_config>",
345
                "help": "Path to patch config toml file.",
346
            },
347
        ),
348
        (
349
            "allow-pylint-comments",
350
            {
351
                "default": False,
352
                "type": "yn",
353
                "metavar": "<yn>",
354
                "help": "Allows or disallows 'pylint:' comments",
355
            },
356
        ),
357
        (
358
            "use-pyta-error-messages",
359
            {
360
                "default": True,
361
                "type": "yn",
362
                "metavar": "<yn>",
363
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
364
            },
365
        ),
366
        (
367
            "autoformat-options",
368
            {
369
                "default": ["skip-string-normalization"],
370
                "type": "csv",
371
                "metavar": "<autoformatter options>",
372
                "help": "List of command-line arguments for black",
373
            },
374
        ),
375
    )
376

377
    parent_dir_path = os.path.dirname(__file__)
20✔
378
    custom_checkers = [
20✔
379
        ("python_ta.checkers." + os.path.splitext(f)[0])
380
        for f in listdir(parent_dir_path + "/checkers")
381
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
382
    ]
383

384
    custom_reporters = [
20✔
385
        ("python_ta.reporters." + os.path.splitext(f)[0])
386
        for f in listdir(parent_dir_path + "/reporters")
387
        if f not in ["__init__.py", "stat_reporter.py"] and re.match(r".*_reporter\.py$", f)
388
    ]
389

390
    # Register new options to a checker here to allow references to
391
    # options in `.pylintrc` config file.
392
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
393
    linter = pylint.lint.PyLinter(options=new_checker_options)
20✔
394
    linter.load_default_plugins()  # Load checkers, reporters
20✔
395
    linter.load_plugin_modules(custom_checkers)
20✔
396
    linter.load_plugin_modules(custom_reporters)
20✔
397
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
20✔
398

399
    default_config_path = find_local_config(os.path.dirname(__file__))
20✔
400
    set_config = load_config
20✔
401

402
    if load_default_config:
20✔
403
        load_config(linter, default_config_path)
20✔
404
        # If we do specify to load the default config, we just need to override the options later.
405
        set_config = override_config
20✔
406

407
    if isinstance(config, str) and config != "":
20✔
408
        set_config(linter, config)
20✔
409
    else:
410
        # If available, use config file at directory of the file being linted.
411
        pylintrc_location = None
20✔
412
        if file_linted:
20✔
413
            pylintrc_location = find_local_config(file_linted)
20✔
414

415
        # Load or override the options if there is a config file in the current directory.
416
        if pylintrc_location:
20✔
UNCOV
417
            set_config(linter, pylintrc_location)
×
418

419
        # Override part of the default config, with a dict of config options.
420
        # Note: these configs are overridden by config file in user's codebase
421
        # location.
422
        if isinstance(config, dict):
20✔
423
            for key in config:
20✔
424
                linter.set_option(key, config[key])
20✔
425

426
    # Override error messages
427
    messages_config_path = linter.config.messages_config_path
20✔
428
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20✔
429
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20✔
430
    messages_config = load_messages_config(
20✔
431
        messages_config_path, messages_config_default_path, use_pyta_error_messages
432
    )
433
    for error_id, new_msg in messages_config.items():
20✔
434
        # Create new message definition object according to configured error messages
435
        try:
20✔
436
            message = linter.msgs_store.get_message_definitions(error_id)
20✔
437
        except UnknownMessageError:
20✔
438
            logging.warning(f"{error_id} is not a valid error id.")
20✔
439
            continue
20✔
440

441
        for message_definition in message:
20✔
442
            message_definition.msg = new_msg
20✔
443
            # Mutate the message definitions of the linter object
444
            linter.msgs_store.register_message(message_definition)
20✔
445

446
    return linter
20✔
447

448

449
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
20✔
450
    """A generator for iterating python files within a directory.
451
    `rel_path` is a relative path to a file or directory.
452
    Returns paths to all files in a directory.
453
    """
454
    if not os.path.isdir(rel_path):
20✔
455
        yield rel_path  # Don't do anything; return the file name.
20✔
456
    else:
457
        for root, _, files in os.walk(rel_path):
20✔
458
            for filename in (f for f in files if f.endswith(".py")):
20✔
459
                yield os.path.join(root, filename)  # Format path, from root.
20✔
460

461

462
def _verify_pre_check(filepath: AnyStr, allow_pylint_comments: bool) -> bool:
20✔
463
    """Check student code for certain issues.
464
    The additional allow_pylint_comments parameter indicates whether we want the user to be able to add comments
465
    beginning with pylint which can be used to locally disable checks.
466
    """
467
    # Make sure the program doesn't crash for students.
468
    # Could use some improvement for better logging and error reporting.
469
    try:
20✔
470
        # Check for inline "pylint:" comment, which may indicate a student
471
        # trying to disable a check.
472
        if allow_pylint_comments:
20✔
473
            return True
20✔
474
        with tokenize.open(os.path.expanduser(filepath)) as f:
20✔
475
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
20✔
476
                if tok_type != tokenize.COMMENT:
20✔
477
                    continue
20✔
478
                match = OPTION_PO.search(content)
20✔
479
                if match is not None:
20✔
480
                    logging.error(
20✔
481
                        'String "pylint:" found in comment. '
482
                        + "No check run on file `{}.`\n".format(filepath)
483
                    )
484
                    return False
20✔
485
    except IndentationError as e:
20✔
486
        logging.error(
20✔
487
            "python_ta could not check your code due to an "
488
            + "indentation error at line {}.".format(e.lineno)
489
        )
490
        return False
20✔
491
    except tokenize.TokenError as e:
20✔
492
        logging.error(
20✔
493
            "python_ta could not check your code due to a " + "syntax error in your file."
494
        )
495
        return False
20✔
496
    except UnicodeDecodeError:
20✔
497
        logging.error(
20✔
498
            "python_ta could not check your code due to an "
499
            + "invalid character. Please check the following lines "
500
            "in your file and all characters that are marked with a �."
501
        )
502
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
20✔
503
            for i, line in enumerate(f):
20✔
504
                if "�" in line:
20✔
505
                    logging.error(f"  Line {i + 1}: {line}")
20✔
506
        return False
20✔
507
    return True
20✔
508

509

510
def _get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[AnyStr, None, None]:
20✔
511
    """A generator for all valid files to check."""
512
    # Allow call to check with empty args
513
    if module_name == "":
20✔
514
        m = sys.modules["__main__"]
10✔
515
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
10✔
516
        module_name = [spec.origin]
10✔
517
    # Enforce API to expect 1 file or directory if type is list
518
    elif isinstance(module_name, str):
20✔
519
        module_name = [module_name]
20✔
520
    # Otherwise, enforce API to expect `module_name` type as list
521
    elif not isinstance(module_name, list):
20✔
522
        logging.error(
20✔
523
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
524
                module_name
525
            )
526
        )
527
        return
20✔
528

529
    # Filter valid files to check
530
    for item in module_name:
20✔
531
        if not isinstance(item, str):  # Issue errors for invalid types
20✔
532
            logging.error(
20✔
533
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
534
            )
535
        elif os.path.isdir(item):
20✔
536
            yield item
20✔
537
        elif not os.path.exists(os.path.expanduser(item)):
20✔
538
            try:
20✔
539
                # For files with dot notation, e.g., `examples.<filename>`
540
                filepath = modutils.file_from_modpath(item.split("."))
20✔
UNCOV
541
                if os.path.exists(filepath):
×
UNCOV
542
                    yield filepath
×
543
                else:
UNCOV
544
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
545
            except ImportError:
20✔
546
                logging.error("Could not find the file called, `{}`\n".format(item))
20✔
547
        else:
548
            yield item  # Check other valid files.
20✔
549

550

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

554
    Args:
555
        msg_id: The five-character error code, e.g. ``"E0401"``.
556
    """
557
    msg_url = HELP_URL + "#" + msg_id.lower()
20✔
558
    print("Opening {} in a browser.".format(msg_url))
20✔
559
    webbrowser.open(msg_url)
20✔
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