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

pyta-uoft / pyta / 9376151692

05 Jun 2024 12:29AM UTC coverage: 91.523% (-1.6%) from 93.12%
9376151692

push

github

web-flow
test: Refactor `test_check_on_dir` method (#1042)

* refactor: refactor test_check_on_dir method

Create a test suite sample_dir that contains a subset of test cases in examples.
Change test_check_on_dir() to run on sample_dir rather than the whole examples.

Fix the regular expression for _EXAMPLE_PREFIX_REGIX which previously does not match the file names.
Rename test cases whose file names do not match message symbols.
Modify regular expression for pycodestyle file names for more precise matching.
Add a separate test to check ModuleNameViolation.
Modify get_file_paths to handle nested directories correctly.

2807 of 3067 relevant lines covered (91.52%)

9.02 hits per line

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

91.86
/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

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

29

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

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

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

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

60

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

64

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

80

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

96

97
def _check(
10✔
98
    module_name: Union[List[str], str] = "",
99
    level: str = "all",
100
    local_config: Union[dict, str] = "",
101
    output: Optional[TextIO] = None,
102
    load_default_config: bool = True,
103
) -> PythonTaReporter:
104
    """Check a module for problems, printing a report.
105

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

119
    # check the python version
120
    if sys.version_info < (3, 7, 0):
10✔
121
        logging.warning("You need Python 3.7 or later to run PythonTA.")
10✔
122

123
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
10✔
124
    current_reporter = linter.reporter
10✔
125
    current_reporter.set_output(output)
10✔
126
    messages_config_path = linter.config.messages_config_path
10✔
127
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
10✔
128
    use_pyta_error_messages = linter.config.use_pyta_error_messages
10✔
129
    messages_config = load_messages_config(
10✔
130
        messages_config_path, messages_config_default_path, use_pyta_error_messages
131
    )
132

133
    global PYLINT_PATCHED
134
    if not PYLINT_PATCHED:
10✔
135
        patch_all(messages_config)  # Monkeypatch pylint (override certain methods)
10✔
136
        PYLINT_PATCHED = True
10✔
137

138
    # Try to check file, issue error message for invalid files.
139
    try:
10✔
140
        # Flag indicating whether at least one file has been checked
141
        is_any_file_checked = False
10✔
142

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

160
                if not is_any_file_checked:
10✔
161
                    prev_output = current_reporter.out
10✔
162
                    current_reporter = linter.reporter
10✔
163
                    current_reporter.out = prev_output
10✔
164

165
                    # At this point, the only possible errors are those from parsing the config file
166
                    # so print them, if there are any.
167
                    if current_reporter.messages:
10✔
168
                        current_reporter.print_messages()
10✔
169
                else:
170
                    linter.set_reporter(current_reporter)
10✔
171

172
                # The current file was checked so update the flag
173
                is_any_file_checked = True
10✔
174

175
                module_name = os.path.splitext(os.path.basename(file_py))[0]
10✔
176
                if module_name in MANAGER.astroid_cache:  # Remove module from astroid cache
10✔
177
                    del MANAGER.astroid_cache[module_name]
10✔
178
                linter.check([file_py])  # Lint !
10✔
179
                current_reporter.print_messages(level)
10✔
180
                if linter.config.pyta_file_permission:
10✔
181
                    f_paths.append(file_py)  # Appending paths for upload
×
182
                logging.info(
10✔
183
                    "File: {} was checked using the configuration file: {}".format(
184
                        file_py, linter.config_file
185
                    )
186
                )
187
                logging.info(
10✔
188
                    "File: {} was checked using the messages-config file: {}".format(
189
                        file_py, messages_config_path
190
                    )
191
                )
192
            if linter.config.pyta_error_permission:
10✔
193
                errs = list(current_reporter.messages.values())
×
194
            if (
10✔
195
                f_paths != [] or errs != []
196
            ):  # Only call upload_to_server() if there's something to upload
197
                # Checks if default configuration was used without changing options through the local_config argument
198
                if linter.config_file[-19:-10] != "python_ta" or local_config != "":
×
199
                    config = linter.config.__dict__
×
200
                upload_to_server(
×
201
                    errors=errs,
202
                    paths=f_paths,
203
                    config=config,
204
                    url=linter.config.pyta_server_address,
205
                    version=__version__,
206
                )
207
        # Only generate reports (display the webpage) if there were valid files to check
208
        if is_any_file_checked:
10✔
209
            linter.generate_reports()
10✔
210
        return current_reporter
10✔
211
    except Exception as e:
10✔
212
        logging.error(
10✔
213
            "Unexpected error encountered! Please report this to your instructor (and attach the code that caused the error)."
214
        )
215
        logging.error('Error message: "{}"'.format(e))
10✔
216
        raise e
10✔
217

218

219
def reset_linter(
10✔
220
    config: Optional[Union[dict, str]] = None,
221
    file_linted: Optional[AnyStr] = None,
222
    load_default_config: bool = True,
223
) -> PyLinter:
224
    """Construct a new linter. Register config and checker plugins.
225

226
    To determine which configuration to use:
227
    - If the option is enabled, load the default PythonTA config file,
228
    - If the config argument is a string, use the config found at that location,
229
    - Otherwise,
230
        - Try to use the config file at directory of the file being linted,
231
        - If the config argument is a dictionary, apply those options afterward.
232
    Do not re-use a linter object. Returns a new linter.
233
    """
234
    # Tuple of custom options. Note: 'type' must map to a value equal a key in the pylint/config/option.py `VALIDATORS` dict.
235
    new_checker_options = (
10✔
236
        (
237
            "pyta-number-of-messages",
238
            {
239
                "default": 0,  # If the value is 0, all messages are displayed.
240
                "type": "int",
241
                "metavar": "<number_messages>",
242
                "help": "Display a certain number of messages to the user, without overwhelming them.",
243
            },
244
        ),
245
        (
246
            "pyta-template-file",
247
            {
248
                "default": "template.html.jinja",
249
                "type": "string",
250
                "metavar": "<pyta_reporter>",
251
                "help": "Template file for html format of htmlreporter output.",
252
            },
253
        ),
254
        (
255
            "pyta-error-permission",
256
            {
257
                "default": False,
258
                "type": "yn",
259
                "metavar": "<yn>",
260
                "help": "Permission to anonymously submit errors",
261
            },
262
        ),
263
        (
264
            "pyta-file-permission",
265
            {
266
                "default": False,
267
                "type": "yn",
268
                "metavar": "<yn>",
269
                "help": "Permission to anonymously submit files and errors",
270
            },
271
        ),
272
        (
273
            "pyta-server-address",
274
            {
275
                "default": "http://127.0.0.1:5000",
276
                "type": "string",
277
                "metavar": "<server-url>",
278
                "help": "Server address to submit anonymous data",
279
            },
280
        ),
281
        (
282
            "messages-config-path",
283
            {
284
                "default": os.path.join(
285
                    os.path.dirname(__file__), "config", "messages_config.toml"
286
                ),
287
                "type": "string",
288
                "metavar": "<messages_config>",
289
                "help": "Path to patch config toml file.",
290
            },
291
        ),
292
        (
293
            "allow-pylint-comments",
294
            {
295
                "default": False,
296
                "type": "yn",
297
                "metavar": "<yn>",
298
                "help": "allows or disallows pylint: comments",
299
            },
300
        ),
301
        (
302
            "use-pyta-error-messages",
303
            {
304
                "default": True,
305
                "type": "yn",
306
                "metavar": "<yn>",
307
                "help": "Overwrite the default pylint error messages with PythonTA's messages",
308
            },
309
        ),
310
    )
311

312
    parent_dir_path = os.path.dirname(__file__)
10✔
313
    custom_checkers = [
10✔
314
        ("python_ta.checkers." + os.path.splitext(f)[0])
315
        for f in listdir(parent_dir_path + "/checkers")
316
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
317
    ]
318

319
    # Register new options to a checker here to allow references to
320
    # options in `.pylintrc` config file.
321
    # Options stored in linter: `linter._all_options`, `linter._external_opts`
322
    linter = pylint.lint.PyLinter(options=new_checker_options)
10✔
323
    linter.load_default_plugins()  # Load checkers, reporters
10✔
324
    linter.load_plugin_modules(custom_checkers)
10✔
325
    linter.load_plugin_modules(["python_ta.transforms.setendings"])
10✔
326

327
    default_config_path = find_local_config(os.path.dirname(__file__))
10✔
328
    set_config = load_config
10✔
329

330
    if load_default_config:
10✔
331
        load_config(linter, default_config_path)
10✔
332
        # If we do specify to load the default config, we just need to override the options later.
333
        set_config = override_config
10✔
334

335
    if isinstance(config, str) and config != "":
10✔
336
        set_config(linter, config)
10✔
337
    else:
338
        # If available, use config file at directory of the file being linted.
339
        pylintrc_location = None
10✔
340
        if file_linted:
10✔
341
            pylintrc_location = find_local_config(file_linted)
10✔
342

343
        # Load or override the options if there is a config file in the current directory.
344
        if pylintrc_location:
10✔
345
            set_config(linter, pylintrc_location)
×
346

347
        # Override part of the default config, with a dict of config options.
348
        # Note: these configs are overridden by config file in user's codebase
349
        # location.
350
        if isinstance(config, dict):
10✔
351
            for key in config:
10✔
352
                linter.set_option(key, config[key])
10✔
353

354
    return linter
10✔
355

356

357
def get_file_paths(rel_path: AnyStr) -> Generator[AnyStr, None, None]:
10✔
358
    """A generator for iterating python files within a directory.
359
    `rel_path` is a relative path to a file or directory.
360
    Returns paths to all files in a directory.
361
    """
362
    if not os.path.isdir(rel_path):
10✔
363
        yield rel_path  # Don't do anything; return the file name.
10✔
364
    else:
365
        for root, _, files in os.walk(rel_path):
10✔
366
            for filename in (f for f in files if f.endswith(".py")):
10✔
367
                yield os.path.join(root, filename)  # Format path, from root.
10✔
368

369

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

417

418
def _get_valid_files_to_check(module_name: Union[List[str], str]) -> Generator[AnyStr, None, None]:
10✔
419
    """A generator for all valid files to check."""
420
    # Allow call to check with empty args
421
    if module_name == "":
10✔
422
        m = sys.modules["__main__"]
×
423
        spec = importlib.util.spec_from_file_location(m.__name__, m.__file__)
×
424
        module_name = [spec.origin]
×
425
    # Enforce API to expect 1 file or directory if type is list
426
    elif isinstance(module_name, str):
10✔
427
        module_name = [module_name]
10✔
428
    # Otherwise, enforce API to expect `module_name` type as list
429
    elif not isinstance(module_name, list):
10✔
430
        logging.error(
10✔
431
            "No checks run. Input to check, `{}`, has invalid type, must be a list of strings.".format(
432
                module_name
433
            )
434
        )
435
        return
10✔
436

437
    # Filter valid files to check
438
    for item in module_name:
10✔
439
        if not isinstance(item, str):  # Issue errors for invalid types
10✔
440
            logging.error(
10✔
441
                "No check run on file `{}`, with invalid type. Must be type: str.\n".format(item)
442
            )
443
        elif os.path.isdir(item):
10✔
444
            yield item
10✔
445
        elif not os.path.exists(os.path.expanduser(item)):
10✔
446
            try:
10✔
447
                # For files with dot notation, e.g., `examples.<filename>`
448
                filepath = modutils.file_from_modpath(item.split("."))
10✔
449
                if os.path.exists(filepath):
×
450
                    yield filepath
×
451
                else:
452
                    logging.error("Could not find the file called, `{}`\n".format(item))
×
453
            except ImportError:
10✔
454
                logging.error("Could not find the file called, `{}`\n".format(item))
10✔
455
        else:
456
            yield item  # Check other valid files.
10✔
457

458

459
def doc(msg_id: str) -> None:
10✔
460
    """Open a webpage explaining the error for the given message."""
461
    msg_url = HELP_URL + "#" + msg_id.lower()
10✔
462
    print("Opening {} in a browser.".format(msg_url))
10✔
463
    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