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

pyta-uoft / pyta / 9359717958

04 Jun 2024 01:26AM UTC coverage: 91.523% (-1.6%) from 93.12%
9359717958

Pull #1042

github

web-flow
Merge 2a53bb918 into d04089e06
Pull Request #1042: test: Refactor `test_check_on_dir` method

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