File: about_warnings.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 (301 lines) | stat: -rw-r--r-- 11,274 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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
"""This module includes all relevant classes and functions so that
friendly-traceback can give help with warnings.

It contains one function (``enable_warnings``) which is part of the API,
and one (``get_warning_parser``) which is the only other function in this module
which is intended to be part of the public API. However, while the latter
can be imported using ``from friendly_traceback import enable_warnings``,
``get_warning_parser`` needs to be imported from this module.
"""

import inspect
import warnings
from importlib import import_module
from typing import List, Type

import executing
from stack_data import BlankLines, Formatter, Options

from .config import session
from .frame_info import FriendlyFormatter
from .ft_gettext import current_lang, internal_error
from .info_generic import get_generic_explanation
from .info_variables import get_var_info
from .path_info import path_utils
from .typing_info import _E, CauseInfo, Parser

_ = current_lang.translate
_warnings_seen = {}
_RUNNING_TESTS = False
IGNORE_WARNINGS = set()


def enable_warnings(testing: bool = False) -> None:
    """Used to enable all warnings, with 'always' being used as the
    parameter for warnings.simplefilter.

    While friendly_traceback, used by many third-party packages, does not
    automatically handle warnings by default, friendly, which is meant to
    be used by end-users instead of other packages/libraries, does call
    enable_warnings by default.
    """
    # Note: enable_warnings is imported by friendly_traceback.__init__
    # so that it is part of the public API.
    global _RUNNING_TESTS
    _RUNNING_TESTS = testing
    warnings.simplefilter("always")
    warnings.showwarning = show_warning


class MyFormatter(Formatter):
    def format_frame(self, frame):
        yield from super().format_frame(frame)


class WarningInfo:
    def __init__(
        self, warning_instance, warning_type, filename, lineno, frame=None, lines=None
    ):
        self.warning_instance = warning_instance
        self.message = str(warning_instance)
        self.warning_type = warning_type
        self.filename = filename
        self.lineno = lineno
        self.begin_lineno = lineno
        self.lines = lines
        self.frame = frame
        self.info = {"warning_message": f"{warning_type.__name__}: {self.message}\n"}
        self.info["message"] = self.info["warning_message"]

        if frame is not None:
            source = self.format_source()
            self.info["warning_source"] = source
            self.problem_statement = executing.Source.executing(frame).text()
            var_info = get_var_info(self.problem_statement, frame)
            self.info["warning_variables"] = var_info["var_info"]
            if "additional_variable_warning" in var_info:
                self.info["additional_variable_warning"] = var_info[
                    "additional_variable_warning"
                ]
        else:
            self.info["warning_source"] = self.get_source_frame_missing()
        self.recompile_info()

    def recompile_info(self):
        self.info["lang"] = session.lang
        self.info["generic"] = get_generic_explanation(self.warning_type)
        short_filename = path_utils.shorten_path(self.filename)
        if "[" in short_filename:
            location = _(
                "Warning issued on line `{line}` of code block {filename}."
            ).format(filename=short_filename, line=self.lineno)
        else:
            location = _(
                "Warning issued on line `{line}` of file '{filename}'."
            ).format(filename=short_filename, line=self.lineno)
        self.info["warning_location_header"] = location + "\n"

        self.info.update(**get_warning_cause(self.warning_type, self.message, self))

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

        formatter = FriendlyFormatter(
            options=Options(blank_lines=BlankLines.SINGLE, before=2),
            line_number_format_string=lineno_fmt_string,
            line_gap_string=line_gap_string,
            line_number_gap_string=line_number_gap_string,
        )
        formatted = formatter.format_frame(self.frame)
        return "".join(list(formatted)[1:])

    def get_source_frame_missing(self):
        new_lines = []
        try:
            source = executing.Source.for_filename(self.filename)
            statement = source.statements_at_line(self.lineno).pop()
            lines = source.lines[statement.lineno - 1 : statement.end_lineno]
            for number, line in enumerate(lines, start=statement.lineno):
                if number == self.lineno:
                    new_lines.append(f"    -->{number}| {line}")
                else:
                    new_lines.append(f"       {number}| {line}")
            self.problem_statement = "".join(lines)
            return "\n".join(new_lines)
        except Exception:
            self.problem_statement = None
            # self.lines comes from Python; it should correspond to a single logical line
            # but is sometimes seemingly split in two parts.
            self.problem_statement = "".join(
                self.lines if self.lines is not None else []
            )
            return (
                f"    -->{self.lineno}| {self.problem_statement}"
                if self.problem_statement
                else _(
                    "The source is unavailable.\n"
                    "If you used `exec`, consider using `friendly_exec` instead."
                )
            )


def saw_warning_before(warning_type, message, filename, lineno) -> bool:
    """Records a warning if it has not been seen at the exact location
    and returns True; returns False otherwise.
    """
    # Note: unlike show_warning whose API is dictated by Python,
    # we order the argument in some grouping that seems more logical
    # for the recorded structure
    if warning_type in _warnings_seen:
        if message in _warnings_seen[warning_type]:
            if filename in _warnings_seen[warning_type][message]:
                if lineno in _warnings_seen[warning_type][message][filename]:
                    return True
                _warnings_seen[warning_type][message][filename].append(lineno)
            else:
                _warnings_seen[warning_type][message][filename] = [lineno]
        else:
            _warnings_seen[warning_type][message] = {filename: [lineno]}
    else:
        _warnings_seen[warning_type] = {message: {}}
        _warnings_seen[warning_type][message][filename] = [lineno]
    return False


def show_warning(
    warning_instance, warning_type, filename, lineno, file=None, line=None
):
    for do_not_show_warning in IGNORE_WARNINGS:
        if do_not_show_warning(warning_instance, warning_type, filename, lineno):
            return

    if saw_warning_before(
        warning_type.__name__, str(warning_instance), filename, lineno
    ):
        # Avoid showing the same warning if it occurs in a loop, or in
        # other way in which a given instruction that give rise to a warning
        # is repeated
        return

    try:
        for outer_frame in inspect.getouterframes(inspect.currentframe()):
            if outer_frame.filename == filename and outer_frame.lineno == lineno:
                warning_data = WarningInfo(
                    warning_instance,
                    warning_type,
                    filename,
                    lineno,
                    frame=outer_frame.frame,
                    lines=outer_frame.code_context,
                )
                break
        else:
            warning_data = WarningInfo(warning_instance, warning_type, filename, lineno)
    except Exception:
        warning_data = WarningInfo(warning_instance, warning_type, filename, lineno)

    message = str(warning_instance)

    if not _RUNNING_TESTS:
        session.recorded_tracebacks.append(warning_data)
    elif "cause" in warning_data.info:
        # We know how to explain this; we do not print while running tests
        return
    session.write_err(f"`{warning_type.__name__}`: {message}\n")


INCLUDED_PARSERS = {
    SyntaxWarning: "syntax_warning",
}
WARNING_DATA_PARSERS = {}


class WarningDataParser:
    """This class is used to create objects that collect message parsers."""

    def __init__(self) -> None:
        self.parsers: List[Parser] = []
        self.core_parsers: List[Parser] = []
        self.custom_parsers: List[Parser] = []

    def _add(self, func: Parser) -> None:
        """This method is meant to be used only within friendly-traceback.
        It is used as a decorator to add a message parser to a list that is
        automatically updated.
        """
        self.parsers.append(func)
        self.core_parsers.append(func)

    def add(self, func: Parser) -> None:
        """This method is meant to be used by projects that extend
        friendly-traceback. It is used as a decorator to add a message parser
        to a list that is automatically updated::

            @instance.add
            def some_warning_parsers(message, traceback_data):
                ....
        """
        self.custom_parsers.append(func)
        self.parsers = self.custom_parsers + self.core_parsers


def get_warning_parser(warning_type: Type[_E]) -> WarningDataParser:
    """Gets a 'parser' to find the cause for a given warning.

    Args:

        warning_type: a warning class.

    Usage::

        parser = get_warning_parser(SomeSpecificWarning)

        @parser.add
        def some_meaningful_name(warning_message: str,
                                 warning_data: WarningDataParser) -> dict:
            if not handled_by_this_function(warning_message):
                return {}  # let other parsers deal with it

            ...
    """
    if warning_type not in WARNING_DATA_PARSERS:
        WARNING_DATA_PARSERS[warning_type] = WarningDataParser()
        if warning_type in INCLUDED_PARSERS:
            base_path = "friendly_traceback.warning_parsers."
            import_module(base_path + INCLUDED_PARSERS[warning_type])
    return WARNING_DATA_PARSERS[warning_type]


def get_warning_cause(
    warning_type,
    message: str,
    warning_data: WarningDataParser = None,
) -> CauseInfo:
    """Attempts to get the likely cause of an exception."""
    try:
        return get_cause(warning_type, message, warning_data)
    except Exception as e:  # noqa # pragma: no cover
        session.write_err("Exception raised")
        session.write_err(str(e))
        session.write_err(internal_error(e))
        return {}


def get_cause(
    warning_type,
    message: str,
    warning_data: WarningDataParser,
) -> CauseInfo:
    """For a given exception type, cycle through the known message parsers,
    looking for one that can find a cause of the exception."""
    warning_parsers = get_warning_parser(warning_type)

    for parser in warning_parsers.parsers:
        # This could be simpler if we could use the walrus operator
        cause = parser(message, warning_data)
        if cause:
            return cause
    return {}