Skip to content

Commit 2a2844d

Browse files
committed
Add very basic implementation of PEP657 style line markers in tracebacks
1 parent e8c2082 commit 2a2844d

File tree

3 files changed

+140
-7
lines changed

3 files changed

+140
-7
lines changed

src/_pytest/_code/code.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,26 @@ 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+
@property
218+
def end_lineno(self) -> int:
219+
return self.get_python_framesummary().end_lineno - 1
220+
221+
@property
222+
def colno(self) -> int | None:
223+
"""Starting byte offset of the expression in the traceback entry."""
224+
return self.get_python_framesummary().colno
225+
226+
@property
227+
def end_colno(self) -> int | None:
228+
"""Ending byte offset of the expression in the traceback entry."""
229+
return self.get_python_framesummary().end_colno
230+
211231
@property
212232
def frame(self) -> Frame:
213233
return Frame(self._rawentry.tb_frame)
@@ -856,6 +876,9 @@ def get_source(
856876
line_index: int = -1,
857877
excinfo: ExceptionInfo[BaseException] | None = None,
858878
short: bool = False,
879+
end_line_index: int | None = None,
880+
colno: int | None = None,
881+
end_colno: int | None = None,
859882
) -> list[str]:
860883
"""Return formatted and marked up source lines."""
861884
lines = []
@@ -869,17 +892,76 @@ def get_source(
869892
space_prefix = " "
870893
if short:
871894
lines.append(space_prefix + source.lines[line_index].strip())
895+
lines.extend(
896+
self.get_highlight_arrows_for_line(
897+
raw_line=source.raw_lines[line_index],
898+
line=source.lines[line_index].strip(),
899+
lineno=line_index,
900+
end_lineno=end_line_index,
901+
colno=colno,
902+
end_colno=end_colno,
903+
)
904+
)
872905
else:
873906
for line in source.lines[:line_index]:
874907
lines.append(space_prefix + line)
875908
lines.append(self.flow_marker + " " + source.lines[line_index])
909+
lines.extend(
910+
self.get_highlight_arrows_for_line(
911+
raw_line=source.raw_lines[line_index],
912+
line=source.lines[line_index],
913+
lineno=line_index,
914+
end_lineno=end_line_index,
915+
colno=colno,
916+
end_colno=end_colno,
917+
)
918+
)
876919
for line in source.lines[line_index + 1 :]:
877920
lines.append(space_prefix + line)
878921
if excinfo is not None:
879922
indent = 4 if short else self._getindent(source)
880923
lines.extend(self.get_exconly(excinfo, indent=indent, markall=True))
881924
return lines
882925

926+
def get_highlight_arrows_for_line(
927+
self,
928+
line: str,
929+
raw_line: str,
930+
lineno: int | None,
931+
end_lineno: int | None,
932+
colno: int | None,
933+
end_colno: int | None,
934+
) -> list[str]:
935+
"""Return characters highlighting a source line.
936+
937+
Example with colno and end_colno pointing to the bar expression:
938+
"foo() + bar()"
939+
returns " ^^^^^"
940+
"""
941+
if lineno != end_lineno:
942+
# Don't handle expressions that span multiple lines.
943+
return []
944+
if colno is None or end_colno is None:
945+
# Can't do anything without column information.
946+
return []
947+
948+
num_stripped_chars = len(raw_line) - len(line)
949+
950+
start_char_offset = traceback._byte_offset_to_character_offset(raw_line, colno)
951+
end_char_offset = traceback._byte_offset_to_character_offset(
952+
raw_line, end_colno
953+
)
954+
num_carets = end_char_offset - start_char_offset
955+
# If the highlight would span the whole line, it is redundant, don't
956+
# show it.
957+
if num_carets >= len(line.strip()):
958+
return []
959+
960+
highlights = " "
961+
highlights += " " * (start_char_offset - num_stripped_chars + 1)
962+
highlights += "^" * num_carets
963+
return [highlights]
964+
883965
def get_exconly(
884966
self,
885967
excinfo: ExceptionInfo[BaseException],
@@ -939,11 +1021,23 @@ def repr_traceback_entry(
9391021
if source is None:
9401022
source = Source("???")
9411023
line_index = 0
1024+
end_line_index, colno, end_colno = None, None, None
9421025
else:
9431026
line_index = entry.lineno - entry.getfirstlinesource()
1027+
end_line_index = entry.end_lineno - entry.getfirstlinesource()
1028+
colno = entry.colno
1029+
end_colno = entry.end_colno
9441030
short = style == "short"
9451031
reprargs = self.repr_args(entry) if not short else None
946-
s = self.get_source(source, line_index, excinfo, short=short)
1032+
s = self.get_source(
1033+
source=source,
1034+
line_index=line_index,
1035+
excinfo=excinfo,
1036+
short=short,
1037+
end_line_index=end_line_index,
1038+
colno=colno,
1039+
end_colno=end_colno,
1040+
)
9471041
lines.extend(s)
9481042
if short:
9491043
message = f"in {entry.name}"

src/_pytest/_code/source.py

Lines changed: 10 additions & 0 deletions
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

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,33 @@ def entry():
849849
assert basename in str(reprtb.reprfileloc.path)
850850
assert reprtb.reprfileloc.lineno == 3
851851

852+
def test_repr_traceback_entry_short_carets(self, importasmod) -> None:
853+
mod = importasmod(
854+
"""
855+
def div_by_zero():
856+
return 1 / 0
857+
def func1():
858+
return 42 + div_by_zero()
859+
def entry():
860+
func1()
861+
"""
862+
)
863+
excinfo = pytest.raises(ZeroDivisionError, mod.entry)
864+
p = FormattedExcinfo(style="short")
865+
reprtb = p.repr_traceback_entry(excinfo.traceback[-3])
866+
assert len(reprtb.lines) == 1
867+
assert reprtb.lines[0] == " func1()"
868+
869+
reprtb = p.repr_traceback_entry(excinfo.traceback[-2])
870+
assert len(reprtb.lines) == 2
871+
assert reprtb.lines[0] == " return 42 + div_by_zero()"
872+
assert reprtb.lines[1] == " ^^^^^^^^^^^^^"
873+
874+
reprtb = p.repr_traceback_entry(excinfo.traceback[-1])
875+
assert len(reprtb.lines) == 2
876+
assert reprtb.lines[0] == " return 1 / 0"
877+
assert reprtb.lines[1] == " ^^^^^"
878+
852879
def test_repr_tracebackentry_no(self, importasmod):
853880
mod = importasmod(
854881
"""
@@ -1309,7 +1336,7 @@ def g():
13091336
raise ValueError()
13101337
13111338
def h():
1312-
raise AttributeError()
1339+
if True: raise AttributeError()
13131340
"""
13141341
)
13151342
excinfo = pytest.raises(AttributeError, mod.f)
@@ -1370,12 +1397,13 @@ def h():
13701397
assert tw_mock.lines[40] == ("_ ", None)
13711398
assert tw_mock.lines[41] == ""
13721399
assert tw_mock.lines[42] == " def h():"
1373-
assert tw_mock.lines[43] == "> raise AttributeError()"
1374-
assert tw_mock.lines[44] == "E AttributeError"
1375-
assert tw_mock.lines[45] == ""
1376-
line = tw_mock.get_write_msg(46)
1400+
assert tw_mock.lines[43] == "> if True: raise AttributeError()"
1401+
assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^"
1402+
assert tw_mock.lines[45] == "E AttributeError"
1403+
assert tw_mock.lines[46] == ""
1404+
line = tw_mock.get_write_msg(47)
13771405
assert line.endswith("mod.py")
1378-
assert tw_mock.lines[47] == ":15: AttributeError"
1406+
assert tw_mock.lines[48] == ":15: AttributeError"
13791407

13801408
@pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"])
13811409
def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock):
@@ -1509,6 +1537,7 @@ def unreraise():
15091537
fail()
15101538
:5: in fail
15111539
return 0 / 0
1540+
^^^^^
15121541
E ZeroDivisionError: division by zero"""
15131542
)
15141543
assert out == expected_out

0 commit comments

Comments
 (0)