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

pyta-uoft / pyta / 13886086761

16 Mar 2025 06:05PM UTC coverage: 92.43% (-0.5%) from 92.957%
13886086761

Pull #1156

github

web-flow
Merge 2521fddfe into 2b00cbabf
Pull Request #1156: Integrate Watchdog for Live Code Re-Checking

179 of 205 new or added lines in 3 files covered. (87.32%)

8 existing lines in 1 file now uncovered.

3321 of 3593 relevant lines covered (92.43%)

17.6 hits per line

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

93.71
/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 sys
20✔
10
import tokenize
20✔
11
from typing import IO, Any, AnyStr, Generator, Optional, Union
20✔
12

13
import pylint.config
20✔
14
import pylint.lint
20✔
15
import pylint.utils
20✔
16
from astroid import MANAGER, modutils
20✔
17
from pylint.lint import PyLinter
20✔
18
from pylint.reporters import BaseReporter, MultiReporter
20✔
19
from pylint.utils.pragma_parser import OPTION_PO
20✔
20

21
from python_ta import __version__
20✔
22

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

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

36

37
def setup_linter(
20✔
38
    local_config: Union[dict[str, Any], str],
39
    load_default_config: bool,
40
    output: Optional[Union[str, IO]],
41
) -> tuple[PyLinter, Union[BaseReporter, MultiReporter]]:
42
    """Set up the linter and reporter for the check."""
43
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
20✔
44
    current_reporter = linter.reporter
20✔
45
    current_reporter.set_output(output)
20✔
46
    messages_config_path = linter.config.messages_config_path
20✔
47
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
20✔
48
    use_pyta_error_messages = linter.config.use_pyta_error_messages
20✔
49
    messages_config = load_messages_config(
20✔
50
        messages_config_path, messages_config_default_path, use_pyta_error_messages
51
    )
52

53
    global PYLINT_PATCHED
54
    if not PYLINT_PATCHED:
20✔
55
        patch_all(
20✔
56
            messages_config, linter.config.z3
57
        )  # Monkeypatch pylint (override certain methods)
58
        PYLINT_PATCHED = True
20✔
59
    return linter, current_reporter
20✔
60

61

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

81
    if autoformat:
20✔
82
        run_autoformat(file_py, linter.config.autoformat_options, linter.config.max_line_length)
20✔
83

84
    if not is_any_file_checked:
20✔
85
        prev_output = current_reporter.out
20✔
86
        prev_should_close_out = current_reporter.should_close_out
20✔
87
        current_reporter = linter.reporter
20✔
88
        current_reporter.out = prev_output
20✔
89
        current_reporter.should_close_out = not linter.config.watch and prev_should_close_out
20✔
90

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

98
    # The current file was checked so update the flag
99
    is_any_file_checked = True
20✔
100

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

117

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

141

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

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

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

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

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

278
    default_config_path = find_local_config(os.path.dirname(os.path.dirname(__file__)))
20✔
279
    set_config = load_config
20✔
280

281
    if load_default_config:
20✔
282
        load_config(linter, default_config_path)
20✔
283
        # If we do specify to load the default config, we just need to override the options later.
284
        set_config = override_config
20✔
285

286
    if isinstance(config, str) and config != "":
20✔
287
        set_config(linter, config)
20✔
288
    else:
289
        # If available, use config file at directory of the file being linted.
290
        pylintrc_location = None
20✔
291
        if file_linted:
20✔
292
            pylintrc_location = find_local_config(file_linted)
20✔
293

294
        # Load or override the options if there is a config file in the current directory.
295
        if pylintrc_location:
20✔
NEW
296
            set_config(linter, pylintrc_location)
×
297

298
        # Override part of the default config, with a dict of config options.
299
        # Note: these configs are overridden by config file in user's codebase
300
        # location.
301
        if isinstance(config, dict):
20✔
302
            for key in config:
20✔
303
                linter.set_option(key, config[key])
20✔
304

305
    return linter
20✔
306

307

308
def get_valid_files_to_check(module_name: Union[list[str], str]) -> Generator[AnyStr, None, None]:
20✔
309
    """A generator for all valid files to check."""
310
    # Allow call to check with empty args
311
    if module_name == "":
20✔
312
        m = sys.modules["__main__"]
10✔
313
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
10✔
314
        module_name = [spec.origin]
10✔
315
    # Enforce API to expect 1 file or directory if type is list
316
    elif isinstance(module_name, str):
20✔
317
        module_name = [module_name]
20✔
318
    # Otherwise, enforce API to expect `module_name` type as list
319
    elif not isinstance(module_name, list):
20✔
320
        logging.error(
20✔
321
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
322
                module_name
323
            )
324
        )
325
        return
20✔
326

327
    # Filter valid files to check
328
    for item in module_name:
20✔
329
        if not isinstance(item, str):  # Issue errors for invalid types
20✔
330
            logging.error(
20✔
331
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
332
            )
333
        elif os.path.isdir(item):
20✔
334
            yield item
20✔
335
        elif not os.path.exists(os.path.expanduser(item)):
20✔
336
            try:
20✔
337
                # For files with dot notation, e.g., `examples.<filename>`
338
                filepath = modutils.file_from_modpath(item.split("."))
20✔
NEW
339
                if os.path.exists(filepath):
×
NEW
340
                    yield filepath
×
341
                else:
NEW
342
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
343
            except ImportError:
20✔
344
                logging.error("Could not find the file called, `{}`\n".format(item))
20✔
345
        else:
346
            yield item  # Check other valid files.
20✔
347

348

349
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
20✔
350
    """A generator for iterating python files within a directory.
351
    `rel_path` is a relative path to a file or directory.
352
    Returns paths to all files in a directory.
353
    """
354
    if not os.path.isdir(rel_path):
20✔
355
        yield rel_path  # Don't do anything; return the file name.
20✔
356
    else:
357
        for root, _, files in os.walk(rel_path):
20✔
358
            for filename in (f for f in files if f.endswith(".py")):
20✔
359
                yield os.path.join(root, filename)  # Format path, from root.
20✔
360

361

362
def verify_pre_check(filepath: AnyStr, allow_pylint_comments: bool) -> bool:
20✔
363
    """Check student code for certain issues.
364
    The additional allow_pylint_comments parameter indicates whether we want the user to be able to add comments
365
    beginning with pylint which can be used to locally disable checks.
366
    """
367
    # Make sure the program doesn't crash for students.
368
    # Could use some improvement for better logging and error reporting.
369
    try:
20✔
370
        # Check for inline "pylint:" comment, which may indicate a student
371
        # trying to disable a check.
372
        if allow_pylint_comments:
20✔
373
            return True
20✔
374
        with tokenize.open(os.path.expanduser(filepath)) as f:
20✔
375
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
20✔
376
                if tok_type != tokenize.COMMENT:
20✔
377
                    continue
20✔
378
                match = OPTION_PO.search(content)
20✔
379
                if match is not None:
20✔
380
                    logging.error(
20✔
381
                        'String "pylint:" found in comment. '
382
                        + "No check run on file `{}.`\n".format(filepath)
383
                    )
384
                    return False
20✔
385
    except IndentationError as e:
20✔
386
        logging.error(
20✔
387
            "python_ta could not check your code due to an "
388
            + "indentation error at line {}.".format(e.lineno)
389
        )
390
        return False
20✔
391
    except tokenize.TokenError as e:
20✔
392
        logging.error(
20✔
393
            "python_ta could not check your code due to a " + "syntax error in your file."
394
        )
395
        return False
20✔
396
    except UnicodeDecodeError:
20✔
397
        logging.error(
20✔
398
            "python_ta could not check your code due to an "
399
            + "invalid character. Please check the following lines "
400
            "in your file and all characters that are marked with a �."
401
        )
402
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
20✔
403
            for i, line in enumerate(f):
20✔
404
                if "�" in line:
20✔
405
                    logging.error(f"  Line {i + 1}: {line}")
20✔
406
        return False
20✔
407
    return True
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