Skip to content

Commit a0e3a49

Browse files
authored
Add basic implementation of PEP657 style expression markers in tracebacks (#13102)
* Add very basic implementation of PEP657 style line markers in tracebacks * Version guard the py3.11 attributes * Change version guard to make mypy happy. Also, stop using private traceback function * Version guard unit test * Fix repr cycle test * Add changelog entry * Add pragma no cover for branch where column info is missing
1 parent bfb8648 commit a0e3a49

File tree

5 files changed

+234
-26
lines changed

5 files changed

+234
-26
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Alice Purcell
2424
Allan Feldman
2525
Aly Sivji
2626
Amir Elkess
27+
Ammar Askar
2728
Anatoly Bubenkoff
2829
Anders Hovmöller
2930
Andras Mitzki

changelog/10224.improvement.rst

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
pytest's ``short`` and ``long`` traceback styles (:ref:`how-to-modifying-python-tb-printing`)
2+
now have partial :pep:`657` support and will show specific code segments in the
3+
traceback.
4+
5+
.. code-block:: pytest
6+
7+
================================= FAILURES =================================
8+
_______________________ test_gets_correct_tracebacks _______________________
9+
10+
test_tracebacks.py:12: in test_gets_correct_tracebacks
11+
assert manhattan_distance(p1, p2) == 1
12+
^^^^^^^^^^^^^^^^^^^^^^^^^^
13+
test_tracebacks.py:6: in manhattan_distance
14+
return abs(point_1.x - point_2.x) + abs(point_1.y - point_2.y)
15+
^^^^^^^^^
16+
E AttributeError: 'NoneType' object has no attribute 'x'
17+
18+
-- by :user:`ammaraskar`

src/_pytest/_code/code.py

+119-2
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,45 @@ def with_repr_style(
208208
def lineno(self) -> int:
209209
return self._rawentry.tb_lineno - 1
210210

211+
def get_python_framesummary(self) -> traceback.FrameSummary:
212+
# Python's built-in traceback module implements all the nitty gritty
213+
# details to get column numbers of out frames.
214+
stack_summary = traceback.extract_tb(self._rawentry, limit=1)
215+
return stack_summary[0]
216+
217+
# Column and end line numbers introduced in python 3.11
218+
if sys.version_info < (3, 11):
219+
220+
@property
221+
def end_lineno_relative(self) -> int | None:
222+
return None
223+
224+
@property
225+
def colno(self) -> int | None:
226+
return None
227+
228+
@property
229+
def end_colno(self) -> int | None:
230+
return None
231+
else:
232+
233+
@property
234+
def end_lineno_relative(self) -> int | None:
235+
frame_summary = self.get_python_framesummary()
236+
if frame_summary.end_lineno is None: # pragma: no cover
237+
return None
238+
return frame_summary.end_lineno - 1 - self.frame.code.firstlineno
239+
240+
@property
241+
def colno(self) -> int | None:
242+
"""Starting byte offset of the expression in the traceback entry."""
243+
return self.get_python_framesummary().colno
244+
245+
@property
246+
def end_colno(self) -> int | None:
247+
"""Ending byte offset of the expression in the traceback entry."""
248+
return self.get_python_framesummary().end_colno
249+
211250
@property
212251
def frame(self) -> Frame:
213252
return Frame(self._rawentry.tb_frame)
@@ -856,6 +895,9 @@ def get_source(
856895
line_index: int = -1,
857896
excinfo: ExceptionInfo[BaseException] | None = None,
858897
short: bool = False,
898+
end_line_index: int | None = None,
899+
colno: int | None = None,
900+
end_colno: int | None = None,
859901
) -> list[str]:
860902
"""Return formatted and marked up source lines."""
861903
lines = []
@@ -869,17 +911,74 @@ def get_source(
869911
space_prefix = " "
870912
if short:
871913
lines.append(space_prefix + source.lines[line_index].strip())
914+
lines.extend(
915+
self.get_highlight_arrows_for_line(
916+
raw_line=source.raw_lines[line_index],
917+
line=source.lines[line_index].strip(),
918+
lineno=line_index,
919+
end_lineno=end_line_index,
920+
colno=colno,
921+
end_colno=end_colno,
922+
)
923+
)
872924
else:
873925
for line in source.lines[:line_index]:
874926
lines.append(space_prefix + line)
875927
lines.append(self.flow_marker + " " + source.lines[line_index])
928+
lines.extend(
929+
self.get_highlight_arrows_for_line(
930+
raw_line=source.raw_lines[line_index],
931+
line=source.lines[line_index],
932+
lineno=line_index,
933+
end_lineno=end_line_index,
934+
colno=colno,
935+
end_colno=end_colno,
936+
)
937+
)
876938
for line in source.lines[line_index + 1 :]:
877939
lines.append(space_prefix + line)
878940
if excinfo is not None:
879941
indent = 4 if short else self._getindent(source)
880942
lines.extend(self.get_exconly(excinfo, indent=indent, markall=True))
881943
return lines
882944

945+
def get_highlight_arrows_for_line(
946+
self,
947+
line: str,
948+
raw_line: str,
949+
lineno: int | None,
950+
end_lineno: int | None,
951+
colno: int | None,
952+
end_colno: int | None,
953+
) -> list[str]:
954+
"""Return characters highlighting a source line.
955+
956+
Example with colno and end_colno pointing to the bar expression:
957+
"foo() + bar()"
958+
returns " ^^^^^"
959+
"""
960+
if lineno != end_lineno:
961+
# Don't handle expressions that span multiple lines.
962+
return []
963+
if colno is None or end_colno is None:
964+
# Can't do anything without column information.
965+
return []
966+
967+
num_stripped_chars = len(raw_line) - len(line)
968+
969+
start_char_offset = _byte_offset_to_character_offset(raw_line, colno)
970+
end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno)
971+
num_carets = end_char_offset - start_char_offset
972+
# If the highlight would span the whole line, it is redundant, don't
973+
# show it.
974+
if num_carets >= len(line.strip()):
975+
return []
976+
977+
highlights = " "
978+
highlights += " " * (start_char_offset - num_stripped_chars + 1)
979+
highlights += "^" * num_carets
980+
return [highlights]
981+
883982
def get_exconly(
884983
self,
885984
excinfo: ExceptionInfo[BaseException],
@@ -939,11 +1038,23 @@ def repr_traceback_entry(
9391038
if source is None:
9401039
source = Source("???")
9411040
line_index = 0
1041+
end_line_index, colno, end_colno = None, None, None
9421042
else:
943-
line_index = entry.lineno - entry.getfirstlinesource()
1043+
line_index = entry.relline
1044+
end_line_index = entry.end_lineno_relative
1045+
colno = entry.colno
1046+
end_colno = entry.end_colno
9441047
short = style == "short"
9451048
reprargs = self.repr_args(entry) if not short else None
946-
s = self.get_source(source, line_index, excinfo, short=short)
1049+
s = self.get_source(
1050+
source=source,
1051+
line_index=line_index,
1052+
excinfo=excinfo,
1053+
short=short,
1054+
end_line_index=end_line_index,
1055+
colno=colno,
1056+
end_colno=end_colno,
1057+
)
9471058
lines.extend(s)
9481059
if short:
9491060
message = f"in {entry.name}"
@@ -1374,6 +1485,12 @@ def getfslineno(obj: object) -> tuple[str | Path, int]:
13741485
return code.path, code.firstlineno
13751486

13761487

1488+
def _byte_offset_to_character_offset(str, offset):
1489+
"""Converts a byte based offset in a string to a code-point."""
1490+
as_utf8 = str.encode("utf-8")
1491+
return len(as_utf8[:offset].decode("utf-8", errors="replace"))
1492+
1493+
13771494
# Relative paths that we use to filter traceback entries from appearing to the user;
13781495
# see filter_traceback.
13791496
# note: if we need to add more paths than what we have now we should probably use a list

src/_pytest/_code/source.py

+10
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,24 @@ class Source:
2222
def __init__(self, obj: object = None) -> None:
2323
if not obj:
2424
self.lines: list[str] = []
25+
self.raw_lines: list[str] = []
2526
elif isinstance(obj, Source):
2627
self.lines = obj.lines
28+
self.raw_lines = obj.raw_lines
2729
elif isinstance(obj, (tuple, list)):
2830
self.lines = deindent(x.rstrip("\n") for x in obj)
31+
self.raw_lines = list(x.rstrip("\n") for x in obj)
2932
elif isinstance(obj, str):
3033
self.lines = deindent(obj.split("\n"))
34+
self.raw_lines = obj.split("\n")
3135
else:
3236
try:
3337
rawcode = getrawcode(obj)
3438
src = inspect.getsource(rawcode)
3539
except TypeError:
3640
src = inspect.getsource(obj) # type: ignore[arg-type]
3741
self.lines = deindent(src.split("\n"))
42+
self.raw_lines = src.split("\n")
3843

3944
def __eq__(self, other: object) -> bool:
4045
if not isinstance(other, Source):
@@ -58,6 +63,7 @@ def __getitem__(self, key: int | slice) -> str | Source:
5863
raise IndexError("cannot slice a Source with a step")
5964
newsource = Source()
6065
newsource.lines = self.lines[key.start : key.stop]
66+
newsource.raw_lines = self.raw_lines[key.start : key.stop]
6167
return newsource
6268

6369
def __iter__(self) -> Iterator[str]:
@@ -74,13 +80,15 @@ def strip(self) -> Source:
7480
while end > start and not self.lines[end - 1].strip():
7581
end -= 1
7682
source = Source()
83+
source.raw_lines = self.raw_lines
7784
source.lines[:] = self.lines[start:end]
7885
return source
7986

8087
def indent(self, indent: str = " " * 4) -> Source:
8188
"""Return a copy of the source object with all lines indented by the
8289
given indent-string."""
8390
newsource = Source()
91+
newsource.raw_lines = self.raw_lines
8492
newsource.lines = [(indent + line) for line in self.lines]
8593
return newsource
8694

@@ -102,6 +110,7 @@ def deindent(self) -> Source:
102110
"""Return a new Source object deindented."""
103111
newsource = Source()
104112
newsource.lines[:] = deindent(self.lines)
113+
newsource.raw_lines = self.raw_lines
105114
return newsource
106115

107116
def __str__(self) -> str:
@@ -120,6 +129,7 @@ def findsource(obj) -> tuple[Source | None, int]:
120129
return None, -1
121130
source = Source()
122131
source.lines = [line.rstrip() for line in sourcelines]
132+
source.raw_lines = sourcelines
123133
return source, lineno
124134

125135

testing/code/test_excinfo.py

+86-24
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,37 @@ def entry():
883883
assert basename in str(reprtb.reprfileloc.path)
884884
assert reprtb.reprfileloc.lineno == 3
885885

886+
@pytest.mark.skipif(
887+
"sys.version_info < (3,11)",
888+
reason="Column level traceback info added in python 3.11",
889+
)
890+
def test_repr_traceback_entry_short_carets(self, importasmod) -> None:
891+
mod = importasmod(
892+
"""
893+
def div_by_zero():
894+
return 1 / 0
895+
def func1():
896+
return 42 + div_by_zero()
897+
def entry():
898+
func1()
899+
"""
900+
)
901+
excinfo = pytest.raises(ZeroDivisionError, mod.entry)
902+
p = FormattedExcinfo(style="short")
903+
reprtb = p.repr_traceback_entry(excinfo.traceback[-3])
904+
assert len(reprtb.lines) == 1
905+
assert reprtb.lines[0] == " func1()"
906+
907+
reprtb = p.repr_traceback_entry(excinfo.traceback[-2])
908+
assert len(reprtb.lines) == 2
909+
assert reprtb.lines[0] == " return 42 + div_by_zero()"
910+
assert reprtb.lines[1] == " ^^^^^^^^^^^^^"
911+
912+
reprtb = p.repr_traceback_entry(excinfo.traceback[-1])
913+
assert len(reprtb.lines) == 2
914+
assert reprtb.lines[0] == " return 1 / 0"
915+
assert reprtb.lines[1] == " ^^^^^"
916+
886917
def test_repr_tracebackentry_no(self, importasmod):
887918
mod = importasmod(
888919
"""
@@ -1343,7 +1374,7 @@ def g():
13431374
raise ValueError()
13441375
13451376
def h():
1346-
raise AttributeError()
1377+
if True: raise AttributeError()
13471378
"""
13481379
)
13491380
excinfo = pytest.raises(AttributeError, mod.f)
@@ -1404,12 +1435,22 @@ def h():
14041435
assert tw_mock.lines[40] == ("_ ", None)
14051436
assert tw_mock.lines[41] == ""
14061437
assert tw_mock.lines[42] == " def h():"
1407-
assert tw_mock.lines[43] == "> raise AttributeError()"
1408-
assert tw_mock.lines[44] == "E AttributeError"
1409-
assert tw_mock.lines[45] == ""
1410-
line = tw_mock.get_write_msg(46)
1411-
assert line.endswith("mod.py")
1412-
assert tw_mock.lines[47] == ":15: AttributeError"
1438+
# On python 3.11 and greater, check for carets in the traceback.
1439+
if sys.version_info >= (3, 11):
1440+
assert tw_mock.lines[43] == "> if True: raise AttributeError()"
1441+
assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^"
1442+
assert tw_mock.lines[45] == "E AttributeError"
1443+
assert tw_mock.lines[46] == ""
1444+
line = tw_mock.get_write_msg(47)
1445+
assert line.endswith("mod.py")
1446+
assert tw_mock.lines[48] == ":15: AttributeError"
1447+
else:
1448+
assert tw_mock.lines[43] == "> if True: raise AttributeError()"
1449+
assert tw_mock.lines[44] == "E AttributeError"
1450+
assert tw_mock.lines[45] == ""
1451+
line = tw_mock.get_write_msg(46)
1452+
assert line.endswith("mod.py")
1453+
assert tw_mock.lines[47] == ":15: AttributeError"
14131454

14141455
@pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"])
14151456
def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock):
@@ -1528,23 +1569,44 @@ def unreraise():
15281569
r = excinfo.getrepr(style="short")
15291570
r.toterminal(tw_mock)
15301571
out = "\n".join(line for line in tw_mock.lines if isinstance(line, str))
1531-
expected_out = textwrap.dedent(
1532-
"""\
1533-
:13: in unreraise
1534-
reraise()
1535-
:10: in reraise
1536-
raise Err() from e
1537-
E test_exc_chain_repr_cycle0.mod.Err
1538-
1539-
During handling of the above exception, another exception occurred:
1540-
:15: in unreraise
1541-
raise e.__cause__
1542-
:8: in reraise
1543-
fail()
1544-
:5: in fail
1545-
return 0 / 0
1546-
E ZeroDivisionError: division by zero"""
1547-
)
1572+
# Assert highlighting carets in python3.11+
1573+
if sys.version_info >= (3, 11):
1574+
expected_out = textwrap.dedent(
1575+
"""\
1576+
:13: in unreraise
1577+
reraise()
1578+
:10: in reraise
1579+
raise Err() from e
1580+
E test_exc_chain_repr_cycle0.mod.Err
1581+
1582+
During handling of the above exception, another exception occurred:
1583+
:15: in unreraise
1584+
raise e.__cause__
1585+
:8: in reraise
1586+
fail()
1587+
:5: in fail
1588+
return 0 / 0
1589+
^^^^^
1590+
E ZeroDivisionError: division by zero"""
1591+
)
1592+
else:
1593+
expected_out = textwrap.dedent(
1594+
"""\
1595+
:13: in unreraise
1596+
reraise()
1597+
:10: in reraise
1598+
raise Err() from e
1599+
E test_exc_chain_repr_cycle0.mod.Err
1600+
1601+
During handling of the above exception, another exception occurred:
1602+
:15: in unreraise
1603+
raise e.__cause__
1604+
:8: in reraise
1605+
fail()
1606+
:5: in fail
1607+
return 0 / 0
1608+
E ZeroDivisionError: division by zero"""
1609+
)
15481610
assert out == expected_out
15491611

15501612
def test_exec_type_error_filter(self, importasmod):

0 commit comments

Comments
 (0)