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}"
|