File: frame_info.py

package info (click to toggle)
python-friendly-traceback 0.7.62%2Bgit20240811.d7dbff6-1.1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 9,264 kB
  • sloc: python: 21,500; makefile: 4
file content (282 lines) | stat: -rw-r--r-- 10,191 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import os
from typing import Iterable

import stack_data
from executing import only
from stack_data import LINE_GAP, BlankLineRange, BlankLines, Formatter, Line, Options
from stack_data.utils import cached_property

from friendly_traceback import token_utils

from . import debug_helper
from .ft_gettext import current_lang
from .source_cache import cache

_ = current_lang.translate


class FriendlyFormatter(Formatter):
    def __init__(
        self,
        **kwargs,
    ):
        self.indent = "    "
        super().__init__(**kwargs)

    def format_frame_source(self, frame: stack_data.FrameInfo) -> Iterable[str]:
        for line in frame.lines:
            if isinstance(line, Line):
                yield self.format_line(line)
            elif isinstance(line, BlankLineRange):
                yield self.format_blank_lines_linenumbers(line)
            else:
                assert line is LINE_GAP
                yield self.indent + self.line_gap_string + "\n"

    def format_line(self, line: Line) -> str:
        result = self.indent
        if line.is_current:
            result += self.current_line_indicator
        else:
            result += " " * len(self.current_line_indicator)
        result += self.line_number_format_string.format(line.lineno)
        prefix = result
        result += line.render() + "\n"

        for line_range in line.executing_node_ranges:
            start = line_range.start - line.leading_indent
            end = line_range.end - line.leading_indent
            # if end <= start, we have an empty line inside a highlighted
            # block of code. In this case, we need to avoid inserting
            # an extra blank line with no markers present.
            if end > start:
                result += (
                    " " * (start + len(prefix))
                    + self.executing_node_underline * (end - start)
                    + "\n"
                )
        return result

    def format_blank_lines_linenumbers(self, blank_line):
        result = self.indent + " " * len(self.current_line_indicator)
        if blank_line.begin_lineno == blank_line.end_lineno:
            return (
                result
                + self.line_number_format_string.format(blank_line.begin_lineno)
                + "\n"
            )
        return result + "{}\n".format(self.line_number_gap_string)


class FrameInfo(stack_data.FrameInfo):
    @cached_property
    def partial_source(self) -> str:
        return self._partial_source(with_node_range=False)

    @cached_property
    def partial_source_with_node_range(self) -> str:
        return self._partial_source(with_node_range=True)

    def _partial_source(self, with_node_range: bool) -> str:
        """Gets the part of the source where an exception occurred,
        formatted in a pre-determined way, as well as the content
        of the specific line where the exception occurred.
        """
        file_not_found = _("Problem: source of `{filename}` is not available\n").format(
            filename=self.filename
        )
        source = ""

        if not self.lines and self.filename:
            # protecting against https://github.com/alexmojaki/stack_data/issues/13
            try:
                lineno = self.lineno
                s_lines = cache.get_source_lines(self.filename)
                self.lines = []  # noqa
                with_node_range = False
                linenumber = max(lineno - 2, 0)
                for line in s_lines[linenumber : lineno + 1]:
                    self.lines.append(FakeLineObject(line, linenumber, lineno))
                    linenumber += 1
            except Exception as e:  # noqa
                debug_helper.log_error(e)

        if self.lines:
            source = self._highlighted_source(with_node_range)
        elif self.filename and os.path.abspath(self.filename):
            if self.filename not in ["<stdin>", "<string>"]:
                # When filename is "<stdin>", "<string>",
                # using a normal Python REPL - source unavailable.
                # An appropriate error message will have been given via
                # cannot_analyze_stdin
                source = file_not_found
                debug_helper.log("Problem in get_partial_source().")
                debug_helper.log(file_not_found)
        elif not self.filename:  # pragma: no cover
            source = file_not_found
            debug_helper.log("Problem in get_partial_source().")
            debug_helper.log(file_not_found)
        else:  # pragma: no cover
            debug_helper.log("Problem in get_partial_source().")
            debug_helper.log("Should not have reached this option")
            debug_helper.log_error()

        if not source.endswith("\n"):
            source += "\n"

        return source

    @cached_property
    def highlighted_source(self) -> str:
        return self._highlighted_source(with_node_range=False)

    def problem_line(self) -> str:
        if not self.lines:
            return ""
        for line_obj in self.lines:
            if line_obj is stack_data.LINE_GAP:
                continue
            elif isinstance(line_obj, stack_data.BlankLineRange):
                continue
            elif line_obj.is_current:
                return str(line_obj.text)
        return ""

    def _highlighted_source(self, with_node_range: bool) -> str:
        """Extracts a few relevant lines from a file content given as a list
        of lines, adding line number information and identifying
        a particular line.

        When dealing with a ``SyntaxError`` or its subclasses, offset is an
        integer normally used by Python to indicate the position of
        the error with a ``^``, like::

            if True
                  ^

        which, in this case, points to a missing colon. We use the same
        representation in this case.
        """
        lines = self.lines

        if not lines:
            return "", ""

        nb_digits = len(str(lines[-1].lineno))
        lineno_fmt_string = "{:%d}| " % nb_digits  # noqa
        line_gap_string = " " * nb_digits + "(...)"
        line_number_gap_string = " " * (nb_digits - 1) + ":"

        try:
            new_lines = [
                line
                for line in FriendlyFormatter(
                    options=Options(blank_lines=BlankLines.SINGLE),
                    line_number_format_string=lineno_fmt_string,
                    line_gap_string=line_gap_string,
                    line_number_gap_string=line_number_gap_string,
                ).format_frame_source(self)
            ]

            return "".join(new_lines)
        except Exception:
            return "<NO SOURCE>"

    @cached_property
    def node_info(self):
        """Finds the 'node', that is the exact part of a line of code
        that is related to the cause of the problem.
        """
        try:
            ex = self.executing
            node = ex.node
            node_text = ex.text()
        except Exception as e:  # pragma: no cover
            debug_helper.log("Exception raised in TracebackData.use_executing.")
            debug_helper.log(str(e))
            return
        # If we can find the precise location (node) on a line of code
        # causing the exception, we note this location
        # so that we can indicate it later with ^^^^^, something like:
        #
        #    20:     b = tuple(range(50))
        #    21:     try:
        # -->22:         print(a[50], b[0])
        #                      ^^^^^
        #    23:     except Exception as e:
        #
        # Sometimes, a node will span multiple lines. For example,
        # line 22 shown above might have been written as:
        #
        #    print(a[
        #            50], b[0])
        #
        # If that is the case, we rewrite the node as a single line.
        special_case = False
        if not node_text:
            node_text = self.handle_special_cases()
            special_case = True
            if not node_text:
                # Highlight the entire line
                try:
                    tokens = token_utils.tokenize(self.current_line.text)
                    tokens = token_utils.remove_meaningless_tokens(tokens)
                    return (
                        None,
                        (tokens[0].start_col, tokens[-1].end_col),
                        self.current_line.text,
                    )
                except Exception:
                    return None

        node_range = None
        bad_line = self.current_line.text
        if node_text and node_text in bad_line:
            begin = bad_line.find(node_text)
            end = begin + len(node_text)
            node_range = begin, end
        if special_case:  # use the entire bad_line to determine the cause
            # use node_range to highlight.
            return node, node_range, bad_line
        return node, node_range, node_text

    @cached_property
    def current_line(self):
        return only(
            line for line in self.lines if isinstance(line, Line) and line.is_current
        )

    def handle_special_cases(self) -> str:
        """Hack to try to identify a problematic text when node_text is None.

        We just use the information to highlight where in a statement
        the error is located.
        """
        try:
            exec(self.current_line.text)
        except Exception as exc:
            saved_exc = exc
        else:
            return ""

        if not hasattr(saved_exc, "msg"):
            return ""
        message = saved_exc.msg
        if message.startswith("cannot import name '"):
            return message.split("'")[1]
        elif message.startswith("No module named '"):
            return message.split("'")[1]

        return ""


class FakeLineObject:
    """Class reproducing the minimum attributes for formatting lines"""

    def __init__(self, line, linenumber, lineno):
        self.text = line
        self.lineno = linenumber + 1
        self.is_current = linenumber == lineno - 1

    def __repr__(self):
        return f"lineno: {self.lineno}; current: {self.is_current}; {self.text}"