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

pyta-uoft / pyta / 36646375051

29 Sep 2026 11:39PM UTC coverage: 90.867% (-0.06%) from 90.925%
36646375051

push

github

web-flow
Added type annotations and fixed mypy errors in python-ta package (#1397)

256 of 269 new or added lines in 33 files covered. (95.17%)

2 existing lines in 2 files now uncovered.

3761 of 4139 relevant lines covered (90.87%)

17.67 hits per line

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

93.94
/packages/python-ta/src/python_ta/check/helpers.py
1
"""Helper functions for PythonTA's checking and reporting processes.
2
These functions are designed to support the main checking workflow by
3
modularizing core operations like file validation, linting, and result uploads.
4
"""
5

6
import importlib.util
20 ✔
7
import logging
20 ✔
8
import os
20 ✔
9
import re
20 ✔
10
import sys
20 ✔
11
import tokenize
20 ✔
12
from configparser import Error as ConfigParserError
20 ✔
13
from pathlib import Path
20 ✔
14
from typing import IO, Any, Generator, Literal, Optional, Union
20 ✔
15

16
from astroid import MANAGER, modutils
20 ✔
17
from pylint.config.config_file_parser import _RawConfParser
20 ✔
18
from pylint.exceptions import UnknownMessageError
20 ✔
19
from pylint.lint import PyLinter
20 ✔
20
from pylint.lint.pylinter import _load_reporter_by_class
20 ✔
21
from pylint.utils.pragma_parser import OPTION_PO
20 ✔
22

23
from .. import __version__
20 ✔
24
from ..config import (
20 ✔
25
    find_local_config,
26
    load_config,
27
    load_messages_config,
28
    override_config,
29
)
30
from ..patches import patch_all
20 ✔
31
from ..reporters.core import PythonTaReporter
20 ✔
32
from ..upload import upload_to_server
20 ✔
33
from ..util.autoformat import run_autoformat
20 ✔
34
from ..util.extended_markup import ExtendedMarkup
20 ✔
35

36
# Flag to determine if we've previously patched pylint
37
PYLINT_PATCHED = False
20 ✔
38

39

40
class PytaPyLinter(PyLinter):
20 ✔
41
    """Extension to PyLinter that blocks the default behavior of loading the output format"""
42

43
    def _load_reporters(self, reporter_names: str) -> None:
20 ✔
44
        """Override to skip the default behaviour"""
45
        return
20 ✔
46

47

48
def setup_linter(
20 ✔
49
    local_config: Union[dict[str, Any], str],
50
    load_default_config: bool,
51
    output: Optional[Union[str, IO[str]]],
52
    pylint_args: Optional[list[str]] = None,
53
) -> tuple[PyLinter, PythonTaReporter]:
54
    """Set up the linter and reporter for the check."""
55
    linter = reset_linter(
20 ✔
56
        config=local_config,
57
        load_default_config=load_default_config,
58
        pylint_args=pylint_args,
59
    )
60
    current_reporter: PythonTaReporter = linter.reporter  # type: ignore[assignment]
20 ✔
61
    current_reporter.set_output(output)
20 ✔
62
    messages_config_path = linter.config.messages_config_path
20 ✔
63

64
    global PYLINT_PATCHED
65
    if not PYLINT_PATCHED:
20 ✔
66
        patch_all()
20 ✔
67
        PYLINT_PATCHED = True
20 ✔
68
    return linter, current_reporter
20 ✔
69

70

71
def check_file(
20 ✔
72
    file_py: str,
73
    local_config: Union[dict[str, Any], str],
74
    load_default_config: bool,
75
    autoformat: Optional[bool],
76
    is_any_file_checked: bool,
77
    current_reporter: PythonTaReporter,
78
    f_paths: list[str],
79
    pylint_args: Optional[list[str]] = None,
80
) -> tuple[bool, PyLinter]:
81
    """Perform linting on a single Python file using the provided linter and configuration"""
82
    # Load config file in user location. Construct new linter each
83
    # time, so config options don't bleed to unintended files.
84
    # Reuse the same reporter each time to accumulate the results across different files.
85
    linter = reset_linter(
20 ✔
86
        config=local_config,
87
        file_linted=file_py,
88
        load_default_config=load_default_config,
89
        pylint_args=pylint_args,
90
    )
91

92
    if autoformat:
20 ✔
93
        run_autoformat(file_py, linter.config.autoformat_options, linter.config.max_line_length)
20 ✔
94

95
    if not is_any_file_checked:
20 ✔
96
        prev_output = current_reporter.out
20 ✔
97
        prev_should_close_out = current_reporter.should_close_out
20 ✔
98
        current_reporter = linter.reporter  # type: ignore[assignment]
20 ✔
99
        current_reporter.out = prev_output
20 ✔
100
        current_reporter.should_close_out = not linter.config.watch and prev_should_close_out
20 ✔
101

102
        # At this point, the only possible errors are those from parsing the config file
103
        # so print them, if there are any.
104
        if current_reporter.has_messages():
20 ✔
105
            current_reporter.print_messages()
20 ✔
106
    else:
107
        linter.set_reporter(current_reporter)
20 ✔
108

109
    # The current file was checked so update the flag
110
    is_any_file_checked = True
20 ✔
111

112
    module_name = os.path.splitext(os.path.basename(file_py))[0]
20 ✔
113
    if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
20 ✔
114
        del MANAGER.astroid_cache[module_name]
20 ✔
115
    linter.check([file_py])  # Lint !
20 ✔
116
    if linter.config.pyta_file_permission:
20 ✔
117
        f_paths.append(file_py)  # Appending paths for upload
×
118
    logging.debug(
20 ✔
119
        "File: {} was checked using the configuration file: {}".format(
120
            file_py, linter.config_file  # type: ignore[attr-defined]
121
        )
122
    )
123
    logging.debug(
20 ✔
124
        "File: {} was checked using the messages-config file: {}".format(
125
            file_py, linter.config.messages_config_path
126
        )
127
    )
128
    return is_any_file_checked, linter
20 ✔
129

130

131
def upload_linter_results(
20 ✔
132
    linter: PyLinter,
133
    current_reporter: PythonTaReporter,
134
    f_paths: list[str],
135
    local_config: Union[dict[str, Any], str],
136
) -> None:
137
    """Upload linter results and configuration data to the specified server if permissions allow."""
138
    config: dict[str, Any] = {}  # Configuration settings for data submission
20 ✔
139
    errs: list = []  # Errors caught in files for data submission
20 ✔
140
    if linter.config.pyta_error_permission:  # type: ignore[attr-defined]
20 ✔
UNCOV
141
        errs = list(current_reporter.messages.values())
×
142
    if f_paths != [] or errs != []:  # Only call upload_to_server() if there's something to upload
20 ✔
143
        # Checks if default configuration was used without changing options through the local_config argument
NEW
144
        if linter.config_file[-19:-10] != "python_ta" or local_config != "":  # type: ignore[attr-defined]
×
145
            config = linter.config.__dict__
×
146
        upload_to_server(
×
147
            errors=errs,
148
            paths=f_paths,
149
            config=config,
150
            url=linter.config.pyta_server_address,
151
            version=__version__,
152
        )
153

154

155
def reset_linter(
20 ✔
156
    config: Optional[Union[dict, str]] = None,
157
    file_linted: Optional[Union[str, bytes]] = None,
158
    load_default_config: bool = True,
159
    pylint_args: Optional[list[str]] = None,
160
) -> PyLinter:
161
    """Construct a new linter. Register config and checker plugins.
162

163
    To determine which configuration to use:
164
    - If the option is enabled, load the default PythonTA config file,
165
    - If the config argument is a string, use the config found at that location,
166
    - Otherwise,
167
        - Try to use the config file at directory of the file being linted,
168
        - If the config argument is a dictionary, apply those options afterward.
169
    Do not re-use a linter object. Returns a new linter.
170
    """
171

172
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
173
    new_checker_options: tuple[tuple[str, dict[str, Any]], ...] = (
20 ✔
174
        (
175
            "server-port",
176
            {
177
                "default": 0,
178
                "type": "int",
179
                "metavar": "<port>",
180
                "help": "Port number for the HTML report server",
181
            },
182
        ),
183
        (
184
            "watch",
185
            {
186
                "default": False,
187
                "type": "yn",
188
                "metavar": "<yn>",
189
                "help": "Run the HTML report server in persistent mode",
190
            },
191
        ),
192
        (
193
            "pyta-number-of-messages",
194
            {
195
                "default": 0,  # If the value is 0, all messages are displayed.
196
                "type": "int",
197
                "metavar": "<number_messages>",
198
                "help": "The maximum number of occurrences of each check to report.",
199
            },
200
        ),
201
        (
202
            "pyta-template-file",
203
            {
204
                "default": "",
205
                "type": "string",
206
                "metavar": "<pyta_reporter>",
207
                "help": "HTML template file for the HTMLReporter.",
208
            },
209
        ),
210
        (
211
            "pyta-error-permission",
212
            {
213
                "default": False,
214
                "type": "yn",
215
                "metavar": "<yn>",
216
                "help": "Permission to anonymously submit errors",
217
            },
218
        ),
219
        (
220
            "pyta-file-permission",
221
            {
222
                "default": False,
223
                "type": "yn",
224
                "metavar": "<yn>",
225
                "help": "Permission to anonymously submit files and errors",
226
            },
227
        ),
228
        (
229
            "pyta-server-address",
230
            {
231
                "default": "http://127.0.0.1:5000",
232
                "type": "string",
233
                "metavar": "<server-url>",
234
                "help": "Server address to submit anonymous data",
235
            },
236
        ),
237
        (
238
            "messages-config-path",
239
            {
240
                "default": os.path.join(
241
                    os.path.dirname(os.path.dirname(__file__)), "config", "messages_config.toml"
242
                ),
243
                "type": "string",
244
                "metavar": "<messages_config>",
245
                "help": "Path to patch config toml file.",
246
            },
247
        ),
248
        (
249
            "allow-pylint-comments",
250
            {
251
                "default": False,
252
                "type": "yn",
253
                "metavar": "<yn>",
254
                "help": "Allows or disallows 'pylint:' comments",
255
            },
256
        ),
257
        (
258
            "use-pyta-error-messages",
259
            {
260
                "default": True,
261
                "type": "yn",
262
                "metavar": "<yn>",
263
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
264
            },
265
        ),
266
        (
267
            "autoformat-options",
268
            {
269
                "default": ["skip-string-normalization"],
270
                "type": "csv",
271
                "metavar": "<autoformatter options>",
272
                "help": "List of command-line arguments for black",
273
            },
274
        ),
275
    )
276

277
    parent_dir_path = os.path.dirname(os.path.dirname(__file__))
20 ✔
278
    custom_checkers = [
20 ✔
279
        ("python_ta.checkers." + os.path.splitext(f)[0])
280
        for f in os.listdir(os.path.join(parent_dir_path, "checkers"))
281
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
282
    ]
283

284
    # Register new options to a checker here to allow references to
285
    # options in config file.
286
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
287
    linter = PytaPyLinter(options=new_checker_options)
20 ✔
288
    linter.load_default_plugins()  # Load checkers, reporters
20 ✔
289
    linter.load_plugin_modules(custom_checkers)
20 ✔
290
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
20 ✔
291

292
    default_config_path = find_local_config(os.path.dirname(os.path.dirname(__file__)))
20 ✔
293
    assert default_config_path is not None
20 ✔
294
    set_config = load_config
20 ✔
295

296
    output_format_override = _get_output_format_override(config, pylint_args)
20 ✔
297

298
    reporter_class_path = _get_reporter_class_path(output_format_override)
20 ✔
299
    reporter_class = _load_reporter_by_class(reporter_class_path)
20 ✔
300
    linter.set_reporter(reporter_class())
20 ✔
301

302
    if load_default_config:
20 ✔
303
        load_config(linter, default_config_path)
20 ✔
304
        # If we do specify to load the default config, we just need to override the options later.
305
        set_config = override_config
20 ✔
306

307
    if isinstance(config, str) and config != "":
20 ✔
308
        set_config(linter, config, pylint_args=pylint_args)
20 ✔
309
    else:
310
        # If available, use config file at directory of the file being linted.
311
        config_location = None
20 ✔
312
        if file_linted:
20 ✔
313
            config_location = find_local_config(os.fsdecode(file_linted))
20 ✔
314

315
        # Load or override the options if there is a config file in the current directory.
316
        if config_location:
20 ✔
317
            set_config(linter, config_location, pylint_args=pylint_args)
×
318

319
        # Override part of the default config, with a dict of config options.
320
        # Note: these configs are overridden by config file in user's codebase
321
        # location.
322
        if isinstance(config, dict):
20 ✔
323
            for key in config:
20 ✔
324
                linter.set_option(key, config[key])
20 ✔
325

326
    # Override error messages
327
    messages_config_path = linter.config.messages_config_path
20 ✔
328
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20 ✔
329
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20 ✔
330
    messages_config = load_messages_config(
20 ✔
331
        messages_config_path, messages_config_default_path, use_pyta_error_messages  # type: ignore[arg-type]
332
    )
333
    for error_id, new_msg in messages_config.items():
20 ✔
334
        # Create new message definition object according to configured error messages
335
        try:
20 ✔
336
            message = linter.msgs_store.get_message_definitions(error_id)
20 ✔
337
        except UnknownMessageError:
20 ✔
338
            logging.warning(f"{error_id} is not a valid error id.")
20 ✔
339
            continue
20 ✔
340

341
        for message_definition in message:
20 ✔
342
            message_definition.msg = new_msg
20 ✔
343
            # Mutate the message definitions of the linter object
344
            linter.msgs_store.register_message(message_definition)
20 ✔
345

346
    if linter.reporter.name == "pyta-html":
20 ✔
347
        msgs = linter.msgs_store.messages
20 ✔
348
        for msg in msgs:
20 ✔
349
            msg.msg = ExtendedMarkup(msg.msg)
20 ✔
350
            linter.msgs_store.register_message(msg)
20 ✔
351

352
    return linter
20 ✔
353

354

355
def get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[str, None, None]:
20 ✔
356
    """A generator for all valid files to check."""
357
    # Allow call to check with empty args
358
    if module_name == "":
20 ✔
359
        m = sys.modules["__main__"]
20 ✔
360
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
20 ✔
361
        if spec is None or spec.origin is None:
20 ✔
NEW
362
            return None
×
363
        module_name = [spec.origin]
20 ✔
364
    # Enforce API to expect 1 file or directory if type is list
365
    elif isinstance(module_name, str):
20 ✔
366
        module_name = [module_name]
20 ✔
367
    # Otherwise, enforce API to expect `module_name` type as list
368
    elif not isinstance(module_name, list):
20 ✔
369
        logging.error(
20 ✔
370
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
371
                module_name
372
            )
373
        )
374
        return
20 ✔
375

376
    # Filter valid files to check
377
    for item in module_name:
20 ✔
378
        if not isinstance(item, str):  # Issue errors for invalid types
20 ✔
379
            logging.error(
20 ✔
380
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
381
            )
382
        elif os.path.isdir(item):
20 ✔
383
            yield item
20 ✔
384
        elif not os.path.exists(os.path.expanduser(item)):
20 ✔
385
            try:
20 ✔
386
                # For files with dot notation, e.g., `examples.<filename>`
387
                yield modutils.file_from_modpath(item.split("."))
20 ✔
388
            except ImportError:
20 ✔
389
                logging.error("Could not find the file called, `{}`\n".format(item))
20 ✔
390
        else:
391
            yield item  # Check other valid files.
20 ✔
392

393

394
def get_file_paths(rel_path: str) -> Generator[str, None, None]:
20 ✔
395
    """A generator for iterating python files within a directory.
396
    `rel_path` is a relative path to a file or directory.
397
    Returns paths to all files in a directory.
398
    """
399
    if not os.path.isdir(rel_path):
20 ✔
400
        yield rel_path  # Don't do anything; return the file name.
20 ✔
401
    else:
402
        for root, _, files in os.walk(rel_path):
20 ✔
403
            for filename in (f for f in files if f.endswith(".py")):
20 ✔
404
                yield os.path.join(root, filename)  # Format path, from root.
20 ✔
405

406

407
def verify_pre_check(
20 ✔
408
    filepath: str,
409
    allow_pylint_comments: bool,
410
    on_verify_fail: Literal["log", "raise"] = "log",
411
) -> bool:
412
    """Check student code for certain issues.
413

414
    Precondition: `filepath` variable must be a valid file path.
415

416
    - `filepath` corresponds to the file path of the file that needs to be checked.
417
    - `allow_pylint_comments` parameter indicates whether we want the user to be able to add comments
418
       beginning with pylint which can be used to locally disable checks.
419
    - `on_verify_fail` determines how to handle files that cannot be checked. In the event that a file cannot be
420
       checked, if `on_verify_fail="raise"`, then an error is raised. However, if 'on_verify_fail="log"' (default), then
421
       False is returned.
422
    """
423
    # Make sure the program doesn't crash for students.
424
    # Could use some improvement for better logging and error reporting.
425
    try:
20 ✔
426
        # Check for inline "pylint:" comment, which may indicate a student
427
        # trying to disable a check.
428
        if allow_pylint_comments:
20 ✔
429
            return True
20 ✔
430
        with tokenize.open(os.path.expanduser(filepath)) as f:
20 ✔
431
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
20 ✔
432
                if tok_type != tokenize.COMMENT:
20 ✔
433
                    continue
20 ✔
434
                match = OPTION_PO.search(content)
20 ✔
435
                if match is not None:
20 ✔
436
                    logging.error(
20 ✔
437
                        'String "pylint:" found in comment. '
438
                        + "No check run on file `{}.`\n".format(filepath)
439
                    )
440
                    return False
20 ✔
441
    except IndentationError as e:
20 ✔
442
        logging.error(
20 ✔
443
            "python_ta could not check your code due to an "
444
            + "indentation error at line {}.".format(e.lineno)
445
        )
446
        if on_verify_fail == "raise":
20 ✔
447
            raise
20 ✔
448
        return False
20 ✔
449
    except tokenize.TokenError as e:
20 ✔
450
        logging.error(
20 ✔
451
            "python_ta could not check your code due to a " + "syntax error in your file."
452
        )
453
        if on_verify_fail == "raise":
20 ✔
454
            raise
20 ✔
455
        return False
20 ✔
456
    except UnicodeDecodeError as e:
20 ✔
457
        logging.error(
20 ✔
458
            "python_ta could not check your code due to an "
459
            + "invalid character. Please check the following lines "
460
            "in your file and all characters that are marked with a �."
461
        )
462
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
20 ✔
463
            for i, line in enumerate(f):
20 ✔
464
                if "�" in line:
20 ✔
465
                    logging.error(f"  Line {i + 1}: {line}")
20 ✔
466
        if on_verify_fail == "raise":
20 ✔
467
            raise
20 ✔
468
        return False
20 ✔
469
    return True
20 ✔
470

471

472
def _get_output_format_override(
20 ✔
473
    config: Optional[Union[str, dict[str, Any]]], pylint_args: Optional[list[str]] = None
474
) -> Optional[str]:
475
    """Retrieve the output format override from the parsed configuration prematurely"""
476
    if pylint_args and "--output-format" in pylint_args:
20 ✔
477
        output_format_index = pylint_args.index("--output-format")
×
478
        if output_format_index + 1 < len(pylint_args):
×
479
            return pylint_args[output_format_index + 1]
×
480

481
    output_format_override: Optional[str] = None
20 ✔
482
    if isinstance(config, str) and config != "":
20 ✔
483
        config_path = Path(os.path.abspath(config))
20 ✔
484
        if not config_path.exists():
20 ✔
485
            logging.warn(f"The following config file was not found: {config}")
×
NEW
486
            return None
×
487

488
        try:
20 ✔
489
            config_data, _ = _RawConfParser.parse_config_file(config_path, verbose=False)
20 ✔
490
            output_format_override = config_data.get("output-format")
20 ✔
491
        except ConfigParserError:
20 ✔
492
            logging.warn(f"Failed to parse config file {config}")
20 ✔
493
    elif isinstance(config, dict) and config.get("output-format"):
20 ✔
494
        output_format_override = config.get("output-format")
20 ✔
495
    return output_format_override
20 ✔
496

497

498
def _get_reporter_class_path(reporter_name: Optional[str]) -> str:
20 ✔
499
    """Return the fully qualified class path for a given PyTA reporter name. Defaults to pyta-html"""
500
    reporter_map = {
20 ✔
501
        "pyta-html": "python_ta.reporters.html_reporter.HTMLReporter",
502
        "pyta-plain": "python_ta.reporters.plain_reporter.PlainReporter",
503
        "pyta-color": "python_ta.reporters.color_reporter.ColorReporter",
504
        "pyta-json": "python_ta.reporters.json_reporter.JSONReporter",
505
        "pyta-lsp": "python_ta.reporters.lsp_reporter.LSPReporter",
506
    }
507
    key = reporter_name if reporter_name is not None else "pyta-html"
20 ✔
508
    return reporter_map.get(key, "python_ta.reporters.html_reporter.HTMLReporter")
20 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc