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

pyta-uoft / pyta / 15863937307

24 Jun 2025 11:53PM UTC coverage: 92.844% (-0.2%) from 93.012%
15863937307

Pull #1187

github

web-flow
Merge 69eb7e1e6 into 02ddeab02
Pull Request #1187: Added output format parameter to AccumulationTable

3425 of 3689 relevant lines covered (92.84%)

17.54 hits per line

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

95.68
/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 typing import IO, Any, AnyStr, Generator, Literal, Optional, Union
20✔
14

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

23
from python_ta import __version__
20✔
24

25
from ..config import (
20✔
26
    find_local_config,
27
    load_config,
28
    load_messages_config,
29
    override_config,
30
)
31
from ..patches import patch_all
20✔
32
from ..upload import upload_to_server
20✔
33
from ..util.autoformat import run_autoformat
20✔
34

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

38

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

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

46

47
def setup_linter(
20✔
48
    local_config: Union[dict[str, Any], str],
49
    load_default_config: bool,
50
    output: Optional[Union[str, IO]],
51
) -> tuple[PyLinter, Union[BaseReporter, MultiReporter]]:
52
    """Set up the linter and reporter for the check."""
53
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
20✔
54
    current_reporter = linter.reporter
20✔
55
    current_reporter.set_output(output)
20✔
56
    messages_config_path = linter.config.messages_config_path
20✔
57

58
    global PYLINT_PATCHED
59
    if not PYLINT_PATCHED:
20✔
60
        patch_all(linter.config.z3)
20✔
61
        PYLINT_PATCHED = True
20✔
62
    return linter, current_reporter
20✔
63

64

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

84
    if autoformat:
20✔
85
        run_autoformat(file_py, linter.config.autoformat_options, linter.config.max_line_length)
20✔
86

87
    if not is_any_file_checked:
20✔
88
        prev_output = current_reporter.out
20✔
89
        prev_should_close_out = current_reporter.should_close_out
20✔
90
        current_reporter = linter.reporter
20✔
91
        current_reporter.out = prev_output
20✔
92
        current_reporter.should_close_out = not linter.config.watch and prev_should_close_out
20✔
93

94
        # At this point, the only possible errors are those from parsing the config file
95
        # so print them, if there are any.
96
        if current_reporter.messages:
20✔
97
            current_reporter.print_messages()
20✔
98
    else:
99
        linter.set_reporter(current_reporter)
20✔
100

101
    # The current file was checked so update the flag
102
    is_any_file_checked = True
20✔
103

104
    module_name = os.path.splitext(os.path.basename(file_py))[0]
20✔
105
    if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
20✔
106
        del MANAGER.astroid_cache[module_name]
20✔
107
    linter.check([file_py])  # Lint !
20✔
108
    if linter.config.pyta_file_permission:
20✔
109
        f_paths.append(file_py)  # Appending paths for upload
×
110
    logging.debug(
20✔
111
        "File: {} was checked using the configuration file: {}".format(file_py, linter.config_file)
112
    )
113
    logging.debug(
20✔
114
        "File: {} was checked using the messages-config file: {}".format(
115
            file_py, linter.config.messages_config_path
116
        )
117
    )
118
    return is_any_file_checked, linter
20✔
119

120

121
def upload_linter_results(
20✔
122
    linter: PyLinter,
123
    current_reporter: Union[BaseReporter, MultiReporter],
124
    f_paths: list,
125
    local_config: Union[dict[str, Any], str],
126
) -> None:
127
    """Upload linter results and configuration data to the specified server if permissions allow."""
128
    config = {}  # Configuration settings for data submission
20✔
129
    errs = []  # Errors caught in files for data submission
20✔
130
    if linter.config.pyta_error_permission:
20✔
131
        errs = list(current_reporter.messages.values())
×
132
    if f_paths != [] or errs != []:  # Only call upload_to_server() if there's something to upload
20✔
133
        # Checks if default configuration was used without changing options through the local_config argument
134
        if linter.config_file[-19:-10] != "python_ta" or local_config != "":
×
135
            config = linter.config.__dict__
×
136
        upload_to_server(
×
137
            errors=errs,
138
            paths=f_paths,
139
            config=config,
140
            url=linter.config.pyta_server_address,
141
            version=__version__,
142
        )
143

144

145
def reset_linter(
20✔
146
    config: Optional[Union[dict, str]] = None,
147
    file_linted: Optional[AnyStr] = None,
148
    load_default_config: bool = True,
149
) -> PyLinter:
150
    """Construct a new linter. Register config and checker plugins.
151

152
    To determine which configuration to use:
153
    - If the option is enabled, load the default PythonTA config file,
154
    - If the config argument is a string, use the config found at that location,
155
    - Otherwise,
156
        - Try to use the config file at directory of the file being linted,
157
        - If the config argument is a dictionary, apply those options afterward.
158
    Do not re-use a linter object. Returns a new linter.
159
    """
160

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

266
    parent_dir_path = os.path.dirname(os.path.dirname(__file__))
20✔
267
    custom_checkers = [
20✔
268
        ("python_ta.checkers." + os.path.splitext(f)[0])
269
        for f in os.listdir(os.path.join(parent_dir_path, "checkers"))
270
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
271
    ]
272

273
    # Register new options to a checker here to allow references to
274
    # options in `.pylintrc` config file.
275
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
276
    linter = PytaPyLinter(options=new_checker_options)
20✔
277
    linter.load_default_plugins()  # Load checkers, reporters
20✔
278
    linter.load_plugin_modules(custom_checkers)
20✔
279
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
20✔
280

281
    default_config_path = find_local_config(os.path.dirname(os.path.dirname(__file__)))
20✔
282
    set_config = load_config
20✔
283

284
    output_format_override = _get_output_format_override(config)
20✔
285

286
    reporter_class_path = _get_reporter_class_path(output_format_override)
20✔
287
    reporter_class = _load_reporter_by_class(reporter_class_path)
20✔
288
    linter.set_reporter(reporter_class())
20✔
289

290
    if load_default_config:
20✔
291
        load_config(linter, default_config_path)
20✔
292
        # If we do specify to load the default config, we just need to override the options later.
293
        set_config = override_config
20✔
294
        if default_config_path in linter.reporter.messages:
20✔
295
            del linter.reporter.messages[default_config_path]
20✔
296

297
    if isinstance(config, str) and config != "":
20✔
298
        set_config(linter, config)
20✔
299
    else:
300
        # If available, use config file at directory of the file being linted.
301
        pylintrc_location = None
20✔
302
        if file_linted:
20✔
303
            pylintrc_location = find_local_config(file_linted)
20✔
304

305
        # Load or override the options if there is a config file in the current directory.
306
        if pylintrc_location:
20✔
307
            set_config(linter, pylintrc_location)
×
308

309
        # Override part of the default config, with a dict of config options.
310
        # Note: these configs are overridden by config file in user's codebase
311
        # location.
312
        if isinstance(config, dict):
20✔
313
            for key in config:
20✔
314
                linter.set_option(key, config[key])
20✔
315

316
    # Override error messages
317
    messages_config_path = linter.config.messages_config_path
20✔
318
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20✔
319
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20✔
320
    messages_config = load_messages_config(
20✔
321
        messages_config_path, messages_config_default_path, use_pyta_error_messages
322
    )
323
    for error_id, new_msg in messages_config.items():
20✔
324
        # Create new message definition object according to configured error messages
325
        try:
20✔
326
            message = linter.msgs_store.get_message_definitions(error_id)
20✔
327
        except UnknownMessageError:
20✔
328
            logging.warning(f"{error_id} is not a valid error id.")
20✔
329
            continue
20✔
330

331
        for message_definition in message:
20✔
332
            message_definition.msg = new_msg
20✔
333
            # Mutate the message definitions of the linter object
334
            linter.msgs_store.register_message(message_definition)
20✔
335

336
    return linter
20✔
337

338

339
def get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[AnyStr, None, None]:
20✔
340
    """A generator for all valid files to check."""
341
    # Allow call to check with empty args
342
    if module_name == "":
20✔
343
        m = sys.modules["__main__"]
10✔
344
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
10✔
345
        module_name = [spec.origin]
10✔
346
    # Enforce API to expect 1 file or directory if type is list
347
    elif isinstance(module_name, str):
20✔
348
        module_name = [module_name]
20✔
349
    # Otherwise, enforce API to expect `module_name` type as list
350
    elif not isinstance(module_name, list):
20✔
351
        logging.error(
20✔
352
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
353
                module_name
354
            )
355
        )
356
        return
20✔
357

358
    # Filter valid files to check
359
    for item in module_name:
20✔
360
        if not isinstance(item, str):  # Issue errors for invalid types
20✔
361
            logging.error(
20✔
362
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
363
            )
364
        elif os.path.isdir(item):
20✔
365
            yield item
20✔
366
        elif not os.path.exists(os.path.expanduser(item)):
20✔
367
            try:
20✔
368
                # For files with dot notation, e.g., `examples.<filename>`
369
                yield modutils.file_from_modpath(item.split("."))
20✔
370
            except ImportError:
20✔
371
                logging.error("Could not find the file called, `{}`\n".format(item))
20✔
372
        else:
373
            yield item  # Check other valid files.
20✔
374

375

376
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
20✔
377
    """A generator for iterating python files within a directory.
378
    `rel_path` is a relative path to a file or directory.
379
    Returns paths to all files in a directory.
380
    """
381
    if not os.path.isdir(rel_path):
20✔
382
        yield rel_path  # Don't do anything; return the file name.
20✔
383
    else:
384
        for root, _, files in os.walk(rel_path):
20✔
385
            for filename in (f for f in files if f.endswith(".py")):
20✔
386
                yield os.path.join(root, filename)  # Format path, from root.
20✔
387

388

389
def verify_pre_check(
20✔
390
    filepath: AnyStr,
391
    allow_pylint_comments: bool,
392
    on_verify_fail: Literal["log", "raise"] = "log",
393
) -> bool:
394
    """Check student code for certain issues.
395

396
    Precondition: `filepath` variable must be a valid file path.
397

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

453

454
def _get_output_format_override(config: Optional[Union[str, dict]]) -> Optional[str]:
20✔
455
    """Retrieve the output format override from the parsed configuration prematurely"""
456
    output_format_override = None
20✔
457
    if isinstance(config, str) and config != "":
20✔
458
        config_path = os.path.abspath(config)
20✔
459
        if not os.path.exists(config_path):
20✔
460
            logging.warn(f"The following config file was not found: {config}")
×
461
            return
×
462

463
        try:
20✔
464
            config_data, _ = _RawConfParser.parse_config_file(config_path, verbose=False)
20✔
465
            output_format_override = config_data.get("output-format")
20✔
466
        except ConfigParserError:
20✔
467
            logging.warn(f"Failed to parse config file {config}")
20✔
468
    elif isinstance(config, dict) and config.get("output-format"):
20✔
469
        output_format_override = config.get("output-format")
20✔
470
    return output_format_override
20✔
471

472

473
def _get_reporter_class_path(reporter_name: Optional[str]) -> str:
20✔
474
    """Return the fully qualified class path for a given PyTA reporter name. Defaults to pyta-html"""
475
    reporter_map = {
20✔
476
        "pyta-html": "python_ta.reporters.html_reporter.HTMLReporter",
477
        "pyta-plain": "python_ta.reporters.plain_reporter.PlainReporter",
478
        "pyta-color": "python_ta.reporters.color_reporter.ColorReporter",
479
        "pyta-json": "python_ta.reporters.json_reporter.JSONReporter",
480
    }
481
    return reporter_map.get(reporter_name, "python_ta.reporters.html_reporter.HTMLReporter")
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