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

pyta-uoft / pyta / 18591646837

17 Oct 2025 11:41AM UTC coverage: 94.222% (-0.07%) from 94.292%
18591646837

Pull #1250

github

web-flow
Merge fed91afbe into 95816615e
Pull Request #1250: Added new f-string checker

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

25 existing lines in 2 files now uncovered.

3490 of 3704 relevant lines covered (94.22%)

17.92 hits per line

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

96.97
/python_ta/reporters/core.py
1
"""This module provides the core functionality for all PythonTA reporters."""
2

3
from __future__ import annotations
20✔
4

5
import os.path
20✔
6
import sys
20✔
7
from collections import defaultdict
20✔
8
from datetime import datetime
20✔
9
from pathlib import Path
20✔
10
from typing import IO, TYPE_CHECKING, Optional, Union
20✔
11

12
from pylint.reporters import BaseReporter
20✔
13

14
from .node_printers import LineType, render_message
20✔
15

16
if TYPE_CHECKING:
17
    from astroid import NodeNG
18
    from pylint.message import Message
19
    from pylint.message.message_definition import MessageDefinition
20
    from pylint.reporters.ureports.nodes import BaseLayout
21

22

23
class NewMessage:
20✔
24
    """Extension of Pylint's Message class to incorporate astroid node and source code snippet."""
25

26
    def __init__(self, message: Message, node: NodeNG, snippet: Optional[str]) -> None:
20✔
27
        self.message = message
20✔
28
        self.node = node
20✔
29
        self.snippet = snippet
20✔
30

31
    def __getattr__(self, item):
20✔
32
        return getattr(self.message, item)
20✔
33

34
    def to_dict(self) -> dict:
20✔
35
        """Return a dictionary containing the fields of this message.
36

37
        Useful for JSON output.
38
        """
39
        return {
20✔
40
            **vars(self.message),
41
            "snippet": self.snippet,
42
            # The following are DEPRECATED and will be removed in a future release.
43
            "line_end": self.message.end_line,
44
            "column_end": self.message.end_column,
45
        }
46

47

48
# Messages without a source code line to highlight
49
NO_SNIPPET = {
20✔
50
    "invalid-name",
51
    "unknown-option-value",
52
    "module-name-violation",
53
    "config-parse-error",
54
    "useless-option-value",
55
    "unrecognized-option",
56
}
57

58

59
class PythonTaReporter(BaseReporter):
20✔
60
    """Abstract superclass for all PythonTA reporters.
61

62
    Reminder: see pylint BaseReporter for other instance variables.
63
    """
64

65
    # Rendering constants
66
    _SPACE = " "
20✔
67
    _BREAK = "\n"
20✔
68
    _COLOURING = {}
20✔
69
    _PRE_LINE_NUM_SPACES = 2
20✔
70
    _NUM_LENGTH_SPACES = 3
20✔
71
    _AFTER_NUM_SPACES = 2
20✔
72

73
    # The error messages to report, mapping filename to a list of messages.
74
    messages: dict[str, list[Message]]
20✔
75
    # Whether the reporter's output stream should be closed out.
76
    should_close_out: bool
20✔
77

78
    def __init__(self) -> None:
20✔
79
        """Initialize this reporter."""
80
        super().__init__()
20✔
81
        self.messages = defaultdict(list)
20✔
82
        self.source_lines = []
20✔
83
        self.module_name = ""
20✔
84
        self.current_file = ""
20✔
85
        self.should_close_out = False
20✔
86

87
    def print_messages(self, level: str = "all") -> None:
20✔
88
        """Print messages for the current file.
89

90
        If level == 'all', both errors and style errors are displayed. Otherwise,
91
        only errors are displayed.
92
        """
93

94
    def has_messages(self) -> bool:
20✔
95
        """Return whether there are any messages registered."""
96
        return any(messages for messages in self.messages.values())
20✔
97

98
    def set_output(self, out: Optional[Union[str, IO]] = None) -> None:
20✔
99
        """Set output stream based on out.
100

101
        If out is None or '-', sys.stdout is used.
102
        If out is the path to a file, that file is used (overwriting any existing contents).
103
        If out is the path to a directory, a new file is created in that directory
104
        (with default filename self.OUTPUT_FILENAME).
105
        If out is a typing.IO object, that object is used.
106
        """
107
        if out is None or out == "-":
20✔
108
            self.out = sys.stdout
20✔
109
        elif isinstance(out, str):
20✔
110
            # Paths may contain system-specific or relative syntax, e.g. `~`, `../`
111
            out = os.path.expanduser(out)
20✔
112
            if os.path.isdir(out):
20✔
113
                out = os.path.join(out, self.OUTPUT_FILENAME)
×
114

115
            self.out = open(out, "w", encoding="utf-8")
20✔
116
            self.should_close_out = True
20✔
117
        else:
118
            # out is a typing.IO object
119
            self.out = out
20✔
120

121
    def handle_message(self, msg: Message) -> None:
20✔
122
        """Handle a new message triggered on the current file."""
123
        self.messages[self.current_file].append(msg)
20✔
124

125
    def handle_node(self, msg_definition: MessageDefinition, node: NodeNG) -> None:
20✔
126
        """Add node attribute to most recently-added message.
127

128
        This is used by our patched version of MessagesHandlerMixIn.add_message
129
        (see python_ta/patches/messages.py).
130
        """
131
        curr_messages = self.messages[self.current_file]
20✔
132
        if len(curr_messages) >= 1 and curr_messages[-1].msg_id == msg_definition.msgid:
20✔
133
            msg = curr_messages[-1]
20✔
134

135
            if msg.symbol in NO_SNIPPET or msg.msg.startswith("Invalid module"):
20✔
136
                snippet = ""
20✔
137
            else:
138
                snippet = self._build_snippet(msg, node)
20✔
139

140
            curr_messages[-1] = NewMessage(msg, node, snippet)
20✔
141

142
    def gather_messages(self) -> dict[str, list[Message]]:
20✔
143
        """Return a filtered version of self.messages for reporting.
144

145
        This filters out configuration files (i.e., non-".py" files) that do not have any errors.
146
        """
147
        return {path: msgs for path, msgs in self.messages.items() if path.endswith(".py") or msgs}
20✔
148

149
    def group_messages(
20✔
150
        self, messages: list[Message]
151
    ) -> tuple[dict[str, list[Message]], dict[str, list[Message]]]:
152
        """Group messages for the current file by their (error/style) and type (msg_id)."""
153
        error_msgs_by_type = defaultdict(list)
20✔
154
        style_msgs_by_type = defaultdict(list)
20✔
155
        for msg in messages:
20✔
156
            if msg.msg_id in ERROR_CHECKS or msg.symbol in ERROR_CHECKS:
20✔
157
                error_msgs_by_type[msg.msg_id].append(msg)
20✔
158
            else:
159
                style_msgs_by_type[msg.msg_id].append(msg)
20✔
160

161
        return error_msgs_by_type, style_msgs_by_type
20✔
162

163
    def display_messages(self, layout: BaseLayout) -> None:
20✔
164
        """Hook for displaying the messages of the reporter
165

166
        This will be called whenever the underlying messages
167
        needs to be displayed. For some reporters, it probably
168
        doesn't make sense to display messages as soon as they
169
        are available, so some mechanism of storing them could be used.
170
        This method can be implemented to display them after they've
171
        been aggregated.
172
        """
173

174
    # Rendering
175
    def _build_snippet(self, msg: Message, node: NodeNG) -> str:
20✔
176
        """Return a code snippet for the given Message object, formatted appropriately according
177
        to line type.
178
        """
179
        code_snippet = ""
20✔
180

181
        for lineno, slice_, line_type, text in render_message(
20✔
182
            msg, node, self.source_lines, self.linter.config
183
        ):
184
            code_snippet += self._add_line(lineno, line_type, slice_, text)
20✔
185

186
        return code_snippet
20✔
187

188
    def _add_line(self, lineno: int, linetype: LineType, slice_: slice, text: str = "") -> str:
20✔
189
        """Format given source code line as specified and return as str.
190

191
        Called by _build_snippet, relies on _colourify.
192
        """
193
        snippet = self._add_line_number(lineno, linetype)
20✔
194

195
        if linetype == LineType.ERROR:
20✔
196
            start_col = slice_.start or 0
20✔
197
            end_col = slice_.stop or len(text)
20✔
198

199
            if text[:start_col]:
20✔
200
                snippet += self._colourify("black", text[:start_col])
20✔
201
            snippet += self._colourify("highlight", text[slice_])
20✔
202
            if text[end_col:]:
20✔
203
                snippet += self._colourify("black", text[end_col:])
20✔
204
        elif linetype == LineType.CONTEXT:
20✔
205
            snippet += self._colourify("grey", text)
20✔
206
        elif linetype == LineType.OTHER:
20✔
UNCOV
207
            snippet += text
×
208
        elif linetype == LineType.DOCSTRING:
20✔
209
            space_c = len(text) - len(text.lstrip(" "))
20✔
210
            snippet += space_c * self._SPACE
20✔
211
            snippet += self._colourify("highlight", text.lstrip(" "))
20✔
212

213
        snippet += self._BREAK
20✔
214
        return snippet
20✔
215

216
    def _add_line_number(self, lineno: int, linetype: LineType) -> str:
20✔
217
        """Return a formatted string displaying a line number."""
218
        pre_spaces = self._PRE_LINE_NUM_SPACES * self._SPACE
20✔
219
        spaces = self._AFTER_NUM_SPACES * self._SPACE
20✔
220
        if lineno is not None:
20✔
221
            number = "{:>3}".format(lineno)
20✔
222
        else:
223
            number = self._NUM_LENGTH_SPACES * self._SPACE
20✔
224

225
        if linetype == LineType.ERROR:
20✔
226
            return pre_spaces + self._colourify("gbold-line", number) + spaces
20✔
227
        elif linetype == LineType.CONTEXT:
20✔
228
            return pre_spaces + self._colourify("grey-line", number) + spaces
20✔
229
        elif linetype == LineType.OTHER:
20✔
UNCOV
230
            return pre_spaces + self._colourify("grey-line", number) + spaces
×
231
        elif linetype == LineType.DOCSTRING:
20✔
232
            return pre_spaces + self._colourify("black-line", number) + spaces
20✔
233
        else:
UNCOV
234
            return pre_spaces + number + spaces
×
235

236
    def _display(self, layout: BaseLayout) -> None:
20✔
237
        """display the layout"""
238

239
    def _generate_report_date_time(self) -> str:
20✔
240
        """Return date and time the report was generated."""
241

242
        # Date/time (24 hour time) format:
243
        # Generated: ShortDay. ShortMonth. PaddedDay LongYear, Hour:Min:Sec
244
        dt = str(datetime.now().strftime("%a. %b. %d %Y, %I:%M:%S %p"))
20✔
245

246
        return dt
20✔
247

248
    @classmethod
20✔
249
    def _colourify(cls, colour_class: str, text: str) -> str:
20✔
250
        """Return a colourized version of text, using colour_class.
251

252
        By default, returns the text itself.
253
        """
254
        return text
20✔
255

256
    # Event callbacks
257
    def on_set_current_module(self, module: str, filepath: Optional[str]) -> None:
20✔
258
        """Hook called when a module starts to be analysed."""
259
        # First, check if `module` is the name of a config file and if so, make filepath the
260
        # corresponding path to that config file.
261
        possible_config_path = Path(os.path.expandvars(module)).expanduser()
20✔
262

263
        if possible_config_path.exists() and filepath is None:
20✔
264
            filepath = str(possible_config_path)
20✔
265

266
        # Skip if filepath is None
267
        if filepath is None:
20✔
268
            return
20✔
269

270
        self.module_name = module
20✔
271
        self.current_file = filepath
20✔
272

273
        if self.current_file not in self.messages:
20✔
274
            self.messages[self.current_file] = []
20✔
275

276
        with open(filepath, encoding="utf-8") as f:
20✔
277
            self.source_lines = [line.rstrip("\r\n") for line in f.readlines()]
20✔
278

279
    def on_close(self, stats, previous_stats):
20✔
280
        """Hook called when a module finished analyzing.
281

282
        Close the reporter's output stream if should_close_out is True.
283
        """
284
        if self.should_close_out:
20✔
285
            self.out.close()
20✔
286

287

288
# Checks to enable for basic_check (trying to find errors
289
# and forbidden constructs only)
290
ERROR_CHECKS = {
20✔
291
    "used-before-assignment",
292
    "undefined-variable",
293
    "undefined-loop-variable",
294
    "not-in-loop",
295
    "return-outside-function",
296
    "duplicate-key",
297
    "unreachable",
298
    "pointless-statement",
299
    "pointless-string-statement",
300
    "no-member",
301
    "not-callable",
302
    "assignment-from-no-return",
303
    "assignment-from-none",
304
    "no-value-for-parameter",
305
    "too-many-function-args",
306
    "invalid-sequence-index",
307
    "invalid-slice-index",
308
    "invalid-slice-step",
309
    "invalid-unary-operand-type",
310
    "unsupported-binary-operation",
311
    "unsupported-membership-test",
312
    "unsubscriptable-object",
313
    "unbalanced-tuple-unpacking",
314
    "unbalanced-dict-unpacking",
315
    "unpacking-non-sequence",
316
    "function-redefined",
317
    "duplicate-argument-name",
318
    "import-error",
319
    "no-name-in-module",
320
    "non-parent-init-called",
321
    "access-member-before-definition",
322
    "method-hidden",
323
    "unexpected-special-method-signature",
324
    "inherit-non-class",
325
    "duplicate-except",
326
    "bad-except-order",
327
    "raising-bad-type",
328
    "raising-non-exception",
329
    "catching-non-exception",
330
    "bad-indentation",
331
    "E0001",
332
    "unexpected-keyword-arg",
333
    "not-an-iterable",
334
    "nonexistent-operator",
335
    "invalid-length-returned",
336
    "abstract-method",
337
    "self-cls-assignment",
338
    "dict-iter-missing-items",
339
    "super-without-brackets",
340
    "modified-iterating-list",
341
    "modified-iterating-dict",
342
    "modified-iterating-set",
343
    # Custom error checks
344
    "missing-return-statement",
345
    "invalid-range-index",
346
    "one-iteration",
347
    "possibly-undefined",
348
    # Static type checks (from mypy)
349
    "incompatible-argument-type",
350
    "incompatible-assignment",
351
    "list-item-type-mismatch",
352
    "unsupported-operand-types",
353
    "union-attr-error",
354
    "dict-item-type-mismatch",
355
    # Forbidden code checks
356
    "forbidden-import",
357
    "forbidden-python-syntax",
358
    "forbidden-top-level-code",
359
}
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