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

pyta-uoft / pyta / 7121070634

06 Dec 2023 10:29PM UTC coverage: 94.948% (+0.3%) from 94.638%
7121070634

Pull #974

github

web-flow
Merge b13d09c89 into 9586a85c7
Pull Request #974: Replacing "print" with "logging" Module (Draft)

27 of 28 new or added lines in 3 files covered. (96.43%)

1 existing line in 1 file now uncovered.

3477 of 3662 relevant lines covered (94.95%)

5.65 hits per line

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

99.62
/python_ta/__init__.py
1
"""Python Teaching Assistant
4✔
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
__version__ = "2.6.5.dev"  # Version number
8✔
18

19
# First, remove underscore from builtins if it has been bound in the REPL.
20
import builtins
8✔
21

22
from pylint.lint import PyLinter
8✔
23

24
from .config import (
8✔
25
    find_local_config,
26
    load_config,
27
    load_messages_config,
28
    override_config,
29
)
30
from .reporters.core import PythonTaReporter
8✔
31

32
try:
8✔
33
    del builtins._
8✔
34
except AttributeError:
8✔
35
    pass
8✔
36

37
import importlib.util
8✔
38
import logging
8✔
39
import os
8✔
40
import sys
8✔
41
import tokenize
8✔
42
import webbrowser
8✔
43
from builtins import FileNotFoundError
8✔
44
from os import listdir
8✔
45
from typing import AnyStr, Generator, List, Optional, TextIO, Union
8✔
46

47
import pylint.config
8✔
48
import pylint.lint
8✔
49
import pylint.utils
8✔
50
from astroid import MANAGER, modutils
8✔
51
from pylint.utils.pragma_parser import OPTION_PO
8✔
52

53
from .patches import patch_all
8✔
54
from .reporters import REPORTERS
8✔
55
from .upload import upload_to_server
8✔
56

57
# Configuring logger
58
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.NOTSET)
8✔
59

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

62
# check the python version
63
if sys.version_info < (3, 7, 0):
8✔
NEW
UNCOV
64
    logging.warning("You need Python 3.7 or later to run PythonTA.")
×
65

66

67
# Flag to determine if we've previously patched pylint
68
PYLINT_PATCHED = False
8✔
69

70

71
def check_errors(
8✔
72
    module_name: Union[List[str], str] = "",
4✔
73
    config: Union[dict, str] = "",
4✔
74
    output: Optional[TextIO] = None,
4✔
75
    load_default_config: bool = True,
4✔
76
) -> PythonTaReporter:
4✔
77
    """Check a module for errors, printing a report."""
78
    return _check(
79
        module_name=module_name,
80
        level="error",
81
        local_config=config,
82
        output=output,
83
        load_default_config=load_default_config,
84
    )
85

86

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

102

103
def _check(
8✔
104
    module_name: Union[List[str], str] = "",
4✔
105
    level: str = "all",
4✔
106
    local_config: Union[dict, str] = "",
4✔
107
    output: Optional[TextIO] = None,
4✔
108
    load_default_config: bool = True,
4✔
109
) -> PythonTaReporter:
4✔
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
    """
122
    linter = reset_linter(config=local_config, load_default_config=load_default_config)
8✔
123
    current_reporter = linter.reporter
8✔
124
    current_reporter.set_output(output)
8✔
125
    messages_config_path = linter.config.messages_config_path
8✔
126
    messages_config_default_path = linter._option_dicts["messages-config-path"]["default"]
8✔
127
    use_pyta_error_messages = linter.config.use_pyta_error_messages
8✔
128
    messages_config = load_messages_config(
8✔
129
        messages_config_path, messages_config_default_path, use_pyta_error_messages
4✔
130
    )
131

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

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

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

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

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

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

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

217

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

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

315
    parent_dir_path = os.path.dirname(__file__)
8✔
316
    custom_checkers = [
8✔
317
        ("python_ta.checkers." + os.path.splitext(f)[0])
4✔
318
        for f in listdir(parent_dir_path + "/checkers")
4✔
319
        if f != "__init__.py" and os.path.splitext(f)[1] == ".py"
4✔
320
    ]
321

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

330
    default_config_path = find_local_config(os.path.dirname(__file__))
8✔
331
    set_config = load_config
8✔
332

333
    if load_default_config:
8✔
334
        load_config(linter, default_config_path)
8✔
335
        # If we do specify to load the default config, we just need to override the options later.
336
        set_config = override_config
8✔
337

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

346
        # Load or override the options if there is a config file in the current directory.
347
        if pylintrc_location:
8✔
348
            set_config(linter, pylintrc_location)
349

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

357
    return linter
8✔
358

359

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

372

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

420

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

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

461

462
def doc(msg_id: str) -> None:
4✔
463
    """Open a webpage explaining the error for the given message."""
464
    msg_url = HELP_URL + "#" + msg_id.lower()
8✔
465
    logging.info("Opening {} in a browser.".format(msg_url))
8✔
466
    webbrowser.open(msg_url)
8✔
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