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

pyta-uoft / pyta / 9700917475

27 Jun 2024 05:22PM UTC coverage: 91.566% (+0.04%) from 91.523%
9700917475

Pull #1054

github

web-flow
Merge 1e0ddac9a into 08704f3e2
Pull Request #1054: Added option to python_ta.check_all() to run black formatting tool

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

5 existing lines in 1 file now uncovered.

2812 of 3071 relevant lines covered (91.57%)

9.02 hits per line

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

92.05
/python_ta/__init__.py
1
"""Python Teaching Assistant
5✔
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
__version__ = "2.7.1.dev"  # Version number
10✔
19

20
# First, remove underscore from builtins if it has been bound in the REPL.
21
# Must appear before other imports from pylint/python_ta.
22
import builtins
10✔
23
import subprocess
10✔
24

25
try:
10✔
26
    del builtins._
10✔
27
except AttributeError:
10✔
28
    pass
10✔
29

30

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

41
import pylint.config
10✔
42
import pylint.lint
10✔
43
import pylint.utils
10✔
44
from astroid import MANAGER, modutils
10✔
45
from pylint.lint import PyLinter
10✔
46
from pylint.utils.pragma_parser import OPTION_PO
10✔
47

48
from .config import (
10✔
49
    find_local_config,
50
    load_config,
51
    load_messages_config,
52
    override_config,
53
)
54
from .patches import patch_all
10✔
55
from .reporters import REPORTERS
10✔
56
from .reporters.core import PythonTaReporter
10✔
57
from .upload import upload_to_server
10✔
58

59
HELP_URL = "http://www.cs.toronto.edu/~david/pyta/checkers/index.html"
10✔
60

61

62
# Flag to determine if we've previously patched pylint
63
PYLINT_PATCHED = False
10✔
64

65

66
def check_errors(
10✔
67
    module_name: Union[List[str], str] = "",
68
    config: Union[dict, str] = "",
69
    output: Optional[TextIO] = None,
70
    load_default_config: bool = True,
71
    autoformat: Optional[bool] = False,
72
) -> PythonTaReporter:
73
    """Check a module for errors, printing a report."""
74
    return _check(
×
75
        module_name=module_name,
76
        level="error",
77
        local_config=config,
78
        output=output,
79
        load_default_config=load_default_config,
80
        autoformat=autoformat,
81
    )
82

83

84
def check_all(
10✔
85
    module_name: Union[List[str], str] = "",
86
    config: Union[dict, str] = "",
87
    output: Optional[TextIO] = None,
88
    load_default_config: bool = True,
89
    autoformat: Optional[bool] = False,
90
) -> PythonTaReporter:
91
    """Check a module for errors and style warnings, printing a report."""
92
    return _check(
10✔
93
        module_name=module_name,
94
        level="all",
95
        local_config=config,
96
        output=output,
97
        load_default_config=load_default_config,
98
        autoformat=autoformat,
99
    )
100

101

102
def _check(
10✔
103
    module_name: Union[List[str], str] = "",
104
    level: str = "all",
105
    local_config: Union[dict, str] = "",
106
    output: Optional[TextIO] = None,
107
    load_default_config: bool = True,
108
    autoformat: Optional[bool] = False,
109
) -> PythonTaReporter:
110
    """Check a module for problems, printing a report.
111

112
    The `module_name` can take several inputs:
113
      - string of a directory, or file to check (`.py` extension optional).
114
      - list of strings of directories or files -- can have multiple.
115
      - no argument -- checks the python file containing the function call.
116
    `level` is used to specify which checks should be made.
117
    `local_config` is a dict of config options or string (config file name).
118
    `output` is an absolute or relative path to capture pyta data output. Default std out.
119
    `load_default_config` is used to specify whether to load the default .pylintrc file that comes
120
    with PythonTA. It will load it by default.
121
    `autoformat` is used to specify whether the black formatting tool is run. It is not run by default.
122
    """
123
    # Configuring logger
124
    logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.NOTSET)
10✔
125

126
    # check the python version
127
    if sys.version_info < (3, 7, 0):
10✔
128
        logging.warning("You need Python 3.7 or later to run PythonTA.")
10✔
129

130
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
10✔
131
    current_reporter = linter.reporter
10✔
132
    current_reporter.set_output(output)
10✔
133
    messages_config_path = linter.config.messages_config_path
10✔
134
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
10✔
135
    use_pyta_error_messages = linter.config.use_pyta_error_messages
10✔
136
    messages_config = load_messages_config(
10✔
137
        messages_config_path, messages_config_default_path, use_pyta_error_messages
138
    )
139

140
    global PYLINT_PATCHED
141
    if not PYLINT_PATCHED:
10✔
142
        patch_all(messages_config)  # Monkeypatch pylint (override certain methods)
10✔
143
        PYLINT_PATCHED = True
10✔
144

145
    # Try to check file, issue error message for invalid files.
146
    try:
10✔
147
        # Flag indicating whether at least one file has been checked
148
        is_any_file_checked = False
10✔
149

150
        for locations in _get_valid_files_to_check(module_name):
10✔
151
            f_paths = []  # Paths to files for data submission
10✔
152
            errs = []  # Errors caught in files for data submission
10✔
153
            config = {}  # Configuration settings for data submission
10✔
154
            for file_py in get_file_paths(locations):
10✔
155
                allowed_pylint = linter.config.allow_pylint_comments
10✔
156
                if not _verify_pre_check(file_py, allowed_pylint):
10✔
157
                    continue  # Check the other files
×
158
                # Load config file in user location. Construct new linter each
159
                # time, so config options don't bleed to unintended files.
160
                # Reuse the same reporter each time to accumulate the results across different files.
161
                linter = reset_linter(
10✔
162
                    config=local_config,
163
                    file_linted=file_py,
164
                    load_default_config=load_default_config,
165
                )
166

167
                if autoformat:
10✔
168
                    linelen = (
10✔
169
                        local_config["max-line-length"] if "max-line-length" in local_config else 88
170
                    )
171
                    subprocess.run(
10✔
172
                        [
173
                            sys.executable,
174
                            "-m",
175
                            "black",
176
                            "--skip-string-normalization",
177
                            "--line-length=" + str(linelen),
178
                            file_py,
179
                        ],
180
                        encoding="utf-8",
181
                        capture_output=True,
182
                        text=True,
183
                        check=True,
184
                    )
185

186
                if not is_any_file_checked:
10✔
187
                    prev_output = current_reporter.out
10✔
188
                    current_reporter = linter.reporter
10✔
189
                    current_reporter.out = prev_output
10✔
190

191
                    # At this point, the only possible errors are those from parsing the config file
192
                    # so print them, if there are any.
193
                    if current_reporter.messages:
10✔
194
                        current_reporter.print_messages()
10✔
195
                else:
196
                    linter.set_reporter(current_reporter)
10✔
197

198
                # The current file was checked so update the flag
199
                is_any_file_checked = True
10✔
200

201
                module_name = os.path.splitext(os.path.basename(file_py))[0]
10✔
202
                if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
10✔
203
                    del MANAGER.astroid_cache[module_name]
10✔
204
                linter.check([file_py])  # Lint !
10✔
205
                current_reporter.print_messages(level)
10✔
206
                if linter.config.pyta_file_permission:
10✔
207
                    f_paths.append(file_py)  # Appending paths for upload
×
208
                logging.info(
10✔
209
                    "File: {} was checked using the configuration file: {}".format(
210
                        file_py, linter.config_file
211
                    )
212
                )
213
                logging.info(
10✔
214
                    "File: {} was checked using the messages-config file: {}".format(
215
                        file_py, messages_config_path
216
                    )
217
                )
218
            if linter.config.pyta_error_permission:
10✔
219
                errs = list(current_reporter.messages.values())
×
220

221
            if (
10✔
222
                f_paths != [] or errs != []
223
            ):  # Only call upload_to_server() if there's something to upload
224
                # Checks if default configuration was used without changing options through the local_config argument
225
                if linter.config_file[-19:-10] != "python_ta" or local_config != "":
×
226
                    config = linter.config.__dict__
×
UNCOV
227
                upload_to_server(
×
228
                    errors=errs,
229
                    paths=f_paths,
230
                    config=config,
231
                    url=linter.config.pyta_server_address,
232
                    version=__version__,
233
                )
234
        # Only generate reports (display the webpage) if there were valid files to check
235
        if is_any_file_checked:
10✔
236
            linter.generate_reports()
10✔
237
        return current_reporter
10✔
238
    except Exception as e:
10✔
239
        logging.error(
10✔
240
            "Unexpected error encountered! Please report this to your instructor (and attach the code that caused the error)."
241
        )
242
        logging.error('Error message: "{}"'.format(e))
10✔
243
        raise e
10✔
244

245

246
def reset_linter(
10✔
247
    config: Optional[Union[dict, str]] = None,
248
    file_linted: Optional[AnyStr] = None,
249
    load_default_config: bool = True,
250
) -> PyLinter:
251
    """Construct a new linter. Register config and checker plugins.
252

253
    To determine which configuration to use:
254
    - If the option is enabled, load the default PythonTA config file,
255
    - If the config argument is a string, use the config found at that location,
256
    - Otherwise,
257
        - Try to use the config file at directory of the file being linted,
258
        - If the config argument is a dictionary, apply those options afterward.
259
    Do not re-use a linter object. Returns a new linter.
260
    """
261
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
262
    new_checker_options = (
10✔
263
        (
264
            "pyta-number-of-messages",
265
            {
266
                "default": 0,  # If the value is 0, all messages are displayed.
267
                "type": "int",
268
                "metavar": "<number_messages>",
269
                "help": "Display a certain number of messages to the user, without overwhelming them.",
270
            },
271
        ),
272
        (
273
            "pyta-template-file",
274
            {
275
                "default": "template.html.jinja",
276
                "type": "string",
277
                "metavar": "<pyta_reporter>",
278
                "help": "Template file for html format of htmlreporter output.",
279
            },
280
        ),
281
        (
282
            "pyta-error-permission",
283
            {
284
                "default": False,
285
                "type": "yn",
286
                "metavar": "<yn>",
287
                "help": "Permission to anonymously submit errors",
288
            },
289
        ),
290
        (
291
            "pyta-file-permission",
292
            {
293
                "default": False,
294
                "type": "yn",
295
                "metavar": "<yn>",
296
                "help": "Permission to anonymously submit files and errors",
297
            },
298
        ),
299
        (
300
            "pyta-server-address",
301
            {
302
                "default": "http://127.0.0.1:5000",
303
                "type": "string",
304
                "metavar": "<server-url>",
305
                "help": "Server address to submit anonymous data",
306
            },
307
        ),
308
        (
309
            "messages-config-path",
310
            {
311
                "default": os.path.join(
312
                    os.path.dirname(__file__), "config", "messages_config.toml"
313
                ),
314
                "type": "string",
315
                "metavar": "<messages_config>",
316
                "help": "Path to patch config toml file.",
317
            },
318
        ),
319
        (
320
            "allow-pylint-comments",
321
            {
322
                "default": False,
323
                "type": "yn",
324
                "metavar": "<yn>",
325
                "help": "allows or disallows pylint: comments",
326
            },
327
        ),
328
        (
329
            "use-pyta-error-messages",
330
            {
331
                "default": True,
332
                "type": "yn",
333
                "metavar": "<yn>",
334
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
335
            },
336
        ),
337
    )
338

339
    parent_dir_path = os.path.dirname(__file__)
10✔
340
    custom_checkers = [
10✔
341
        ("python_ta.checkers." + os.path.splitext(f)[0])
342
        for f in listdir(parent_dir_path + "/checkers")
343
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
344
    ]
345

346
    # Register new options to a checker here to allow references to
347
    # options in `.pylintrc` config file.
348
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
349
    linter = pylint.lint.PyLinter(options=new_checker_options)
10✔
350
    linter.load_default_plugins()  # Load checkers, reporters
10✔
351
    linter.load_plugin_modules(custom_checkers)
10✔
352
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
10✔
353

354
    default_config_path = find_local_config(os.path.dirname(__file__))
10✔
355
    set_config = load_config
10✔
356

357
    if load_default_config:
10✔
358
        load_config(linter, default_config_path)
10✔
359
        # If we do specify to load the default config, we just need to override the options later.
360
        set_config = override_config
10✔
361

362
    if isinstance(config, str) and config != "":
10✔
363
        set_config(linter, config)
10✔
364
    else:
365
        # If available, use config file at directory of the file being linted.
366
        pylintrc_location = None
10✔
367
        if file_linted:
10✔
368
            pylintrc_location = find_local_config(file_linted)
10✔
369

370
        # Load or override the options if there is a config file in the current directory.
371
        if pylintrc_location:
10✔
UNCOV
372
            set_config(linter, pylintrc_location)
×
373

374
        # Override part of the default config, with a dict of config options.
375
        # Note: these configs are overridden by config file in user's codebase
376
        # location.
377
        if isinstance(config, dict):
10✔
378
            for key in config:
10✔
379
                linter.set_option(key, config[key])
10✔
380

381
    return linter
10✔
382

383

384
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
10✔
385
    """A generator for iterating python files within a directory.
386
    `rel_path` is a relative path to a file or directory.
387
    Returns paths to all files in a directory.
388
    """
389
    if not os.path.isdir(rel_path):
10✔
390
        yield rel_path  # Don't do anything; return the file name.
10✔
391
    else:
392
        for root, _, files in os.walk(rel_path):
10✔
393
            for filename in (f for f in files if f.endswith(".py")):
10✔
394
                yield os.path.join(root, filename)  # Format path, from root.
10✔
395

396

397
def _verify_pre_check(filepath: AnyStr, allow_pylint_comments: bool) -> bool:
10✔
398
    """Check student code for certain issues.
399
    The additional 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
    """
402
    # Make sure the program doesn't crash for students.
403
    # Could use some improvement for better logging and error reporting.
404
    try:
10✔
405
        # Check for inline "pylint:" comment, which may indicate a student
406
        # trying to disable a check.
407
        if allow_pylint_comments:
10✔
408
            return True
10✔
409
        with tokenize.open(os.path.expanduser(filepath)) as f:
10✔
410
            for tok_type, content, _, _, _ in tokenize.generate_tokens(f.readline):
10✔
411
                if tok_type != tokenize.COMMENT:
10✔
412
                    continue
10✔
413
                match = OPTION_PO.search(content)
10✔
414
                if match is not None:
10✔
415
                    logging.error(
10✔
416
                        'String "pylint:" found in comment. '
417
                        + "No check run on file `{}.`\n".format(filepath)
418
                    )
419
                    return False
10✔
420
    except IndentationError as e:
10✔
421
        logging.error(
10✔
422
            "python_ta could not check your code due to an "
423
            + "indentation error at line {}.".format(e.lineno)
424
        )
425
        return False
10✔
426
    except tokenize.TokenError as e:
10✔
427
        logging.error(
10✔
428
            "python_ta could not check your code due to a " + "syntax error in your file."
429
        )
430
        return False
10✔
431
    except UnicodeDecodeError:
10✔
432
        logging.error(
10✔
433
            "python_ta could not check your code due to an "
434
            + "invalid character. Please check the following lines "
435
            "in your file and all characters that are marked with a �."
436
        )
437
        with open(os.path.expanduser(filepath), encoding="utf-8", errors="replace") as f:
10✔
438
            for i, line in enumerate(f):
10✔
439
                if "�" in line:
10✔
440
                    logging.error(f"  Line {i + 1}: {line}")
10✔
441
        return False
10✔
442
    return True
10✔
443

444

445
def _get_valid_files_to_check(module_name: Union[List[str], str]) -> Generator[AnyStr, None, None]:
10✔
446
    """A generator for all valid files to check."""
447
    # Allow call to check with empty args
448
    if module_name == "":
10✔
449
        m = sys.modules["__main__"]
×
450
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
×
UNCOV
451
        module_name = [spec.origin]
×
452
    # Enforce API to expect 1 file or directory if type is list
453
    elif isinstance(module_name, str):
10✔
454
        module_name = [module_name]
10✔
455
    # Otherwise, enforce API to expect `module_name` type as list
456
    elif not isinstance(module_name, list):
10✔
457
        logging.error(
10✔
458
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
459
                module_name
460
            )
461
        )
462
        return
10✔
463

464
    # Filter valid files to check
465
    for item in module_name:
10✔
466
        if not isinstance(item, str):  # Issue errors for invalid types
10✔
467
            logging.error(
10✔
468
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
469
            )
470
        elif os.path.isdir(item):
10✔
471
            yield item
10✔
472
        elif not os.path.exists(os.path.expanduser(item)):
10✔
473
            try:
10✔
474
                # For files with dot notation, e.g., `examples.<filename>`
475
                filepath = modutils.file_from_modpath(item.split("."))
10✔
476
                if os.path.exists(filepath):
×
UNCOV
477
                    yield filepath
×
478
                else:
UNCOV
479
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
480
            except ImportError:
10✔
481
                logging.error("Could not find the file called, `{}`\n".format(item))
10✔
482
        else:
483
            yield item  # Check other valid files.
10✔
484

485

486
def doc(msg_id: str) -> None:
10✔
487
    """Open a webpage explaining the error for the given message."""
488
    msg_url = HELP_URL + "#" + msg_id.lower()
10✔
489
    print("Opening {} in a browser.".format(msg_url))
10✔
490
    webbrowser.open(msg_url)
10✔
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