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

pyta-uoft / pyta / 21491946693

29 Jan 2026 07:30PM UTC coverage: 94.183% (+0.009%) from 94.174%
21491946693

Pull #1286

github

web-flow
Merge 5c3927db7 into 1fbc5d121
Pull Request #1286: Fix markdown formatting issue

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

3 existing lines in 2 files now uncovered.

3562 of 3782 relevant lines covered (94.18%)

17.92 hits per line

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

98.5
/packages/python-ta/src/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
from python_ta.util.extended_markup import ExtendedMarkup
20✔
16

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

23

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

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

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

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

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

48

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

59

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

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

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

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

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

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

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

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

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

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

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

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

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

129
        This is used by our patched version of MessagesHandlerMixIn.add_message
130
        (see python_ta/patches/messages.py).
131
        """
132
        curr_messages = self.messages[self.current_file]
20✔
133
        if len(curr_messages) >= 1 and curr_messages[-1].msg_id == msg_definition.msgid:
20✔
134
            msg = curr_messages[-1]
20✔
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✔
207
            snippet += text
20✔
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✔
230
            return pre_spaces + self._colourify("grey-line", number) + spaces
20✔
231
        elif linetype == LineType.DOCSTRING:
20✔
232
            return pre_spaces + self._colourify("black-line", number) + spaces
20✔
233
        else:
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