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

pyta-uoft / pyta / 13983965613

21 Mar 2025 03:24AM UTC coverage: 93.089% (+0.1%) from 92.967%
13983965613

Pull #1162

github

web-flow
Merge 8bc54c6ae into 54360404f
Pull Request #1162: Relax Representation Invariant Checking Logic for Recursive Classes

23 of 23 new or added lines in 1 file covered. (100.0%)

27 existing lines in 2 files now uncovered.

3300 of 3545 relevant lines covered (93.09%)

17.7 hits per line

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

94.65
/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 subprocess
20✔
25

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

31

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

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

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

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

64

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

68

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

86

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

96
    Args:
97
        module_name:
98
            If an empty string (default), the module where this function is called is checked.
99
            If a non-empty string, it is interpreted as a path to a single Python module or a
100
            directory containing Python modules. If the latter, all Python modules in the directory
101
            are checked.
102
            If a list of strings, each string is interpreted as a path to a module or directory,
103
            and all modules across all paths are checked.
104
        config:
105
            If a string, a path to a configuration file to use.
106
            If a dictionary, a map of configuration options (each key is the name of an option).
107
        output:
108
            If a string, a path to a file to which the PythonTA report is written.
109
            If a typing.IO object, the report is written to this stream.
110
            If None, the report is written to standard out or automatically displayed in a
111
            web browser, depending 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 _check(
20✔
133
    module_name: Union[list[str], str] = "",
134
    level: str = "all",
135
    local_config: Union[dict[str, Any], str] = "",
136
    output: Optional[Union[str, IO]] = None,
137
    load_default_config: bool = True,
138
    autoformat: Optional[bool] = False,
139
) -> PythonTaReporter:
140
    """Check a module for problems, printing a report.
141

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

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

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

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

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

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

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

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

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

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

254

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

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

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

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

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

391
    default_config_path = find_local_config(os.path.dirname(__file__))
20✔
392
    set_config = load_config
20✔
393

394
    if load_default_config:
20✔
395
        load_config(linter, default_config_path)
20✔
396
        # If we do specify to load the default config, we just need to override the options later.
397
        set_config = override_config
20✔
398

399
    if isinstance(config, str) and config != "":
20✔
400
        set_config(linter, config)
20✔
401
    else:
402
        # If available, use config file at directory of the file being linted.
403
        pylintrc_location = None
20✔
404
        if file_linted:
20✔
405
            pylintrc_location = find_local_config(file_linted)
20✔
406

407
        # Load or override the options if there is a config file in the current directory.
408
        if pylintrc_location:
20✔
UNCOV
409
            set_config(linter, pylintrc_location)
×
410

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

418
    # Override error messages
419
    messages_config_path = linter.config.messages_config_path
20✔
420
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20✔
421
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20✔
422
    messages_config = load_messages_config(
20✔
423
        messages_config_path, messages_config_default_path, use_pyta_error_messages
424
    )
425
    for error_id, new_msg in messages_config.items():
20✔
426
        # Create new message definition object according to configured error messages
427
        try:
20✔
428
            message = linter.msgs_store.get_message_definitions(error_id)
20✔
429
        except UnknownMessageError:
20✔
430
            logging.warning(f"{error_id} is not a valid error id.")
20✔
431
            continue
20✔
432

433
        for message_definition in message:
20✔
434
            message_definition.msg = new_msg
20✔
435
            # Mutate the message definitions of the linter object
436
            linter.msgs_store.register_message(message_definition)
20✔
437

438
    return linter
20✔
439

440

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

453

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

501

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

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

542

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

546
    Args:
547
        msg_id: The five-character error code, e.g. ``"E0401"``.
548
    """
549
    msg_url = HELP_URL + "#" + msg_id.lower()
20✔
550
    print("Opening {} in a browser.".format(msg_url))
20✔
551
    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