From 2a2844d6dd4b246894d1d8fc02991e1d7264d2b1 Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Fri, 3 Jan 2025 23:09:52 -0500 Subject: [PATCH 1/7] Add very basic implementation of PEP657 style line markers in tracebacks --- src/_pytest/_code/code.py | 96 +++++++++++++++++++++++++++++++++++- src/_pytest/_code/source.py | 10 ++++ testing/code/test_excinfo.py | 41 ++++++++++++--- 3 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index bba8896076..d14c823541 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -208,6 +208,26 @@ def with_repr_style( def lineno(self) -> int: return self._rawentry.tb_lineno - 1 + def get_python_framesummary(self) -> traceback.FrameSummary: + # Python's built-in traceback module implements all the nitty gritty + # details to get column numbers of out frames. + stack_summary = traceback.extract_tb(self._rawentry, limit=1) + return stack_summary[0] + + @property + def end_lineno(self) -> int: + return self.get_python_framesummary().end_lineno - 1 + + @property + def colno(self) -> int | None: + """Starting byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().colno + + @property + def end_colno(self) -> int | None: + """Ending byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().end_colno + @property def frame(self) -> Frame: return Frame(self._rawentry.tb_frame) @@ -856,6 +876,9 @@ def get_source( line_index: int = -1, excinfo: ExceptionInfo[BaseException] | None = None, short: bool = False, + end_line_index: int | None = None, + colno: int | None = None, + end_colno: int | None = None, ) -> list[str]: """Return formatted and marked up source lines.""" lines = [] @@ -869,10 +892,30 @@ def get_source( space_prefix = " " if short: lines.append(space_prefix + source.lines[line_index].strip()) + lines.extend( + self.get_highlight_arrows_for_line( + raw_line=source.raw_lines[line_index], + line=source.lines[line_index].strip(), + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) else: for line in source.lines[:line_index]: lines.append(space_prefix + line) lines.append(self.flow_marker + " " + source.lines[line_index]) + lines.extend( + self.get_highlight_arrows_for_line( + raw_line=source.raw_lines[line_index], + line=source.lines[line_index], + lineno=line_index, + end_lineno=end_line_index, + colno=colno, + end_colno=end_colno, + ) + ) for line in source.lines[line_index + 1 :]: lines.append(space_prefix + line) if excinfo is not None: @@ -880,6 +923,45 @@ def get_source( lines.extend(self.get_exconly(excinfo, indent=indent, markall=True)) return lines + def get_highlight_arrows_for_line( + self, + line: str, + raw_line: str, + lineno: int | None, + end_lineno: int | None, + colno: int | None, + end_colno: int | None, + ) -> list[str]: + """Return characters highlighting a source line. + + Example with colno and end_colno pointing to the bar expression: + "foo() + bar()" + returns " ^^^^^" + """ + if lineno != end_lineno: + # Don't handle expressions that span multiple lines. + return [] + if colno is None or end_colno is None: + # Can't do anything without column information. + return [] + + num_stripped_chars = len(raw_line) - len(line) + + start_char_offset = traceback._byte_offset_to_character_offset(raw_line, colno) + end_char_offset = traceback._byte_offset_to_character_offset( + raw_line, end_colno + ) + num_carets = end_char_offset - start_char_offset + # If the highlight would span the whole line, it is redundant, don't + # show it. + if num_carets >= len(line.strip()): + return [] + + highlights = " " + highlights += " " * (start_char_offset - num_stripped_chars + 1) + highlights += "^" * num_carets + return [highlights] + def get_exconly( self, excinfo: ExceptionInfo[BaseException], @@ -939,11 +1021,23 @@ def repr_traceback_entry( if source is None: source = Source("???") line_index = 0 + end_line_index, colno, end_colno = None, None, None else: line_index = entry.lineno - entry.getfirstlinesource() + end_line_index = entry.end_lineno - entry.getfirstlinesource() + colno = entry.colno + end_colno = entry.end_colno short = style == "short" reprargs = self.repr_args(entry) if not short else None - s = self.get_source(source, line_index, excinfo, short=short) + s = self.get_source( + source=source, + line_index=line_index, + excinfo=excinfo, + short=short, + end_line_index=end_line_index, + colno=colno, + end_colno=end_colno, + ) lines.extend(s) if short: message = f"in {entry.name}" diff --git a/src/_pytest/_code/source.py b/src/_pytest/_code/source.py index e24ee3a260..a8f7201a40 100644 --- a/src/_pytest/_code/source.py +++ b/src/_pytest/_code/source.py @@ -22,12 +22,16 @@ class Source: def __init__(self, obj: object = None) -> None: if not obj: self.lines: list[str] = [] + self.raw_lines: list[str] = [] elif isinstance(obj, Source): self.lines = obj.lines + self.raw_lines = obj.raw_lines elif isinstance(obj, (tuple, list)): self.lines = deindent(x.rstrip("\n") for x in obj) + self.raw_lines = list(x.rstrip("\n") for x in obj) elif isinstance(obj, str): self.lines = deindent(obj.split("\n")) + self.raw_lines = obj.split("\n") else: try: rawcode = getrawcode(obj) @@ -35,6 +39,7 @@ def __init__(self, obj: object = None) -> None: except TypeError: src = inspect.getsource(obj) # type: ignore[arg-type] self.lines = deindent(src.split("\n")) + self.raw_lines = src.split("\n") def __eq__(self, other: object) -> bool: if not isinstance(other, Source): @@ -58,6 +63,7 @@ def __getitem__(self, key: int | slice) -> str | Source: raise IndexError("cannot slice a Source with a step") newsource = Source() newsource.lines = self.lines[key.start : key.stop] + newsource.raw_lines = self.raw_lines[key.start : key.stop] return newsource def __iter__(self) -> Iterator[str]: @@ -74,6 +80,7 @@ def strip(self) -> Source: while end > start and not self.lines[end - 1].strip(): end -= 1 source = Source() + source.raw_lines = self.raw_lines source.lines[:] = self.lines[start:end] return source @@ -81,6 +88,7 @@ def indent(self, indent: str = " " * 4) -> Source: """Return a copy of the source object with all lines indented by the given indent-string.""" newsource = Source() + newsource.raw_lines = self.raw_lines newsource.lines = [(indent + line) for line in self.lines] return newsource @@ -102,6 +110,7 @@ def deindent(self) -> Source: """Return a new Source object deindented.""" newsource = Source() newsource.lines[:] = deindent(self.lines) + newsource.raw_lines = self.raw_lines return newsource def __str__(self) -> str: @@ -120,6 +129,7 @@ def findsource(obj) -> tuple[Source | None, int]: return None, -1 source = Source() source.lines = [line.rstrip() for line in sourcelines] + source.raw_lines = sourcelines return source, lineno diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 22e695977e..c11e913c42 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -849,6 +849,33 @@ def entry(): assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 3 + def test_repr_traceback_entry_short_carets(self, importasmod) -> None: + mod = importasmod( + """ + def div_by_zero(): + return 1 / 0 + def func1(): + return 42 + div_by_zero() + def entry(): + func1() + """ + ) + excinfo = pytest.raises(ZeroDivisionError, mod.entry) + p = FormattedExcinfo(style="short") + reprtb = p.repr_traceback_entry(excinfo.traceback[-3]) + assert len(reprtb.lines) == 1 + assert reprtb.lines[0] == " func1()" + + reprtb = p.repr_traceback_entry(excinfo.traceback[-2]) + assert len(reprtb.lines) == 2 + assert reprtb.lines[0] == " return 42 + div_by_zero()" + assert reprtb.lines[1] == " ^^^^^^^^^^^^^" + + reprtb = p.repr_traceback_entry(excinfo.traceback[-1]) + assert len(reprtb.lines) == 2 + assert reprtb.lines[0] == " return 1 / 0" + assert reprtb.lines[1] == " ^^^^^" + def test_repr_tracebackentry_no(self, importasmod): mod = importasmod( """ @@ -1309,7 +1336,7 @@ def g(): raise ValueError() def h(): - raise AttributeError() + if True: raise AttributeError() """ ) excinfo = pytest.raises(AttributeError, mod.f) @@ -1370,12 +1397,13 @@ def h(): assert tw_mock.lines[40] == ("_ ", None) assert tw_mock.lines[41] == "" assert tw_mock.lines[42] == " def h():" - assert tw_mock.lines[43] == "> raise AttributeError()" - assert tw_mock.lines[44] == "E AttributeError" - assert tw_mock.lines[45] == "" - line = tw_mock.get_write_msg(46) + assert tw_mock.lines[43] == "> if True: raise AttributeError()" + assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^" + assert tw_mock.lines[45] == "E AttributeError" + assert tw_mock.lines[46] == "" + line = tw_mock.get_write_msg(47) assert line.endswith("mod.py") - assert tw_mock.lines[47] == ":15: AttributeError" + assert tw_mock.lines[48] == ":15: AttributeError" @pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"]) def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock): @@ -1509,6 +1537,7 @@ def unreraise(): fail() :5: in fail return 0 / 0 + ^^^^^ E ZeroDivisionError: division by zero""" ) assert out == expected_out From 4895b72d952180f318028b1942fa02ceecb00b5e Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sat, 4 Jan 2025 15:59:57 -0500 Subject: [PATCH 2/7] Version guard the py3.11 attributes --- src/_pytest/_code/code.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index d14c823541..1bd70bfa45 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -215,17 +215,26 @@ def get_python_framesummary(self) -> traceback.FrameSummary: return stack_summary[0] @property - def end_lineno(self) -> int: - return self.get_python_framesummary().end_lineno - 1 + def end_lineno_relative(self) -> int | None: + if sys.version_info < (3, 11): + return None + frame_summary = self.get_python_framesummary() + if frame_summary.end_lineno is None: + return None + return frame_summary.end_lineno - 1 - self.frame.code.firstlineno @property def colno(self) -> int | None: """Starting byte offset of the expression in the traceback entry.""" + if sys.version_info < (3, 11): + return None return self.get_python_framesummary().colno @property def end_colno(self) -> int | None: """Ending byte offset of the expression in the traceback entry.""" + if sys.version_info < (3, 11): + return None return self.get_python_framesummary().end_colno @property @@ -1023,8 +1032,8 @@ def repr_traceback_entry( line_index = 0 end_line_index, colno, end_colno = None, None, None else: - line_index = entry.lineno - entry.getfirstlinesource() - end_line_index = entry.end_lineno - entry.getfirstlinesource() + line_index = entry.relline + end_line_index = entry.end_lineno_relative colno = entry.colno end_colno = entry.end_colno short = style == "short" From 3661ee3a2fb10bad4badb0ec90b794a4099496d2 Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sat, 4 Jan 2025 16:14:41 -0500 Subject: [PATCH 3/7] Change version guard to make mypy happy. Also, stop using private traceback function --- src/_pytest/_code/code.py | 56 ++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 1bd70bfa45..c666953a89 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -214,28 +214,38 @@ def get_python_framesummary(self) -> traceback.FrameSummary: stack_summary = traceback.extract_tb(self._rawentry, limit=1) return stack_summary[0] - @property - def end_lineno_relative(self) -> int | None: - if sys.version_info < (3, 11): - return None - frame_summary = self.get_python_framesummary() - if frame_summary.end_lineno is None: + # Column and end line numbers introduced in python 3.11 + if sys.version_info < (3, 11): + + @property + def end_lineno_relative(self) -> int | None: return None - return frame_summary.end_lineno - 1 - self.frame.code.firstlineno - @property - def colno(self) -> int | None: - """Starting byte offset of the expression in the traceback entry.""" - if sys.version_info < (3, 11): + @property + def colno(self) -> int | None: return None - return self.get_python_framesummary().colno - @property - def end_colno(self) -> int | None: - """Ending byte offset of the expression in the traceback entry.""" - if sys.version_info < (3, 11): + @property + def end_colno(self) -> int | None: return None - return self.get_python_framesummary().end_colno + else: + + @property + def end_lineno_relative(self) -> int | None: + frame_summary = self.get_python_framesummary() + if frame_summary.end_lineno is None: + return None + return frame_summary.end_lineno - 1 - self.frame.code.firstlineno + + @property + def colno(self) -> int | None: + """Starting byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().colno + + @property + def end_colno(self) -> int | None: + """Ending byte offset of the expression in the traceback entry.""" + return self.get_python_framesummary().end_colno @property def frame(self) -> Frame: @@ -956,10 +966,8 @@ def get_highlight_arrows_for_line( num_stripped_chars = len(raw_line) - len(line) - start_char_offset = traceback._byte_offset_to_character_offset(raw_line, colno) - end_char_offset = traceback._byte_offset_to_character_offset( - raw_line, end_colno - ) + start_char_offset = _byte_offset_to_character_offset(raw_line, colno) + end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno) num_carets = end_char_offset - start_char_offset # If the highlight would span the whole line, it is redundant, don't # show it. @@ -1477,6 +1485,12 @@ def getfslineno(obj: object) -> tuple[str | Path, int]: return code.path, code.firstlineno +def _byte_offset_to_character_offset(str, offset): + """Converts a byte based offset in a string to a code-point.""" + as_utf8 = str.encode("utf-8") + return len(as_utf8[:offset].decode("utf-8", errors="replace")) + + # Relative paths that we use to filter traceback entries from appearing to the user; # see filter_traceback. # note: if we need to add more paths than what we have now we should probably use a list From 85b0504b7f1d14f8323ed49314922d3f688031de Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sat, 4 Jan 2025 16:26:04 -0500 Subject: [PATCH 4/7] Version guard unit test --- testing/code/test_excinfo.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index c11e913c42..7327cb57ce 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -849,6 +849,10 @@ def entry(): assert basename in str(reprtb.reprfileloc.path) assert reprtb.reprfileloc.lineno == 3 + @pytest.mark.skipif( + "sys.version_info < (3,11)", + reason="Column level traceback info added in python 3.11", + ) def test_repr_traceback_entry_short_carets(self, importasmod) -> None: mod = importasmod( """ @@ -1397,13 +1401,22 @@ def h(): assert tw_mock.lines[40] == ("_ ", None) assert tw_mock.lines[41] == "" assert tw_mock.lines[42] == " def h():" - assert tw_mock.lines[43] == "> if True: raise AttributeError()" - assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^" - assert tw_mock.lines[45] == "E AttributeError" - assert tw_mock.lines[46] == "" - line = tw_mock.get_write_msg(47) - assert line.endswith("mod.py") - assert tw_mock.lines[48] == ":15: AttributeError" + # On python 3.11 and greater, check for carets in the traceback. + if sys.version_info >= (3, 11): + assert tw_mock.lines[43] == "> if True: raise AttributeError()" + assert tw_mock.lines[44] == " ^^^^^^^^^^^^^^^^^^^^^^" + assert tw_mock.lines[45] == "E AttributeError" + assert tw_mock.lines[46] == "" + line = tw_mock.get_write_msg(47) + assert line.endswith("mod.py") + assert tw_mock.lines[48] == ":15: AttributeError" + else: + assert tw_mock.lines[43] == "> raise AttributeError()" + assert tw_mock.lines[44] == "E AttributeError" + assert tw_mock.lines[45] == "" + line = tw_mock.get_write_msg(46) + assert line.endswith("mod.py") + assert tw_mock.lines[47] == ":15: AttributeError" @pytest.mark.parametrize("mode", ["from_none", "explicit_suppress"]) def test_exc_repr_chain_suppression(self, importasmod, mode, tw_mock): From 29102cbff71bbecc6561a156db6a07e87e14edf5 Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sat, 4 Jan 2025 16:43:42 -0500 Subject: [PATCH 5/7] Fix repr cycle test --- testing/code/test_excinfo.py | 58 ++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 7327cb57ce..c0b7366a21 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1411,7 +1411,7 @@ def h(): assert line.endswith("mod.py") assert tw_mock.lines[48] == ":15: AttributeError" else: - assert tw_mock.lines[43] == "> raise AttributeError()" + assert tw_mock.lines[43] == "> if True: raise AttributeError()" assert tw_mock.lines[44] == "E AttributeError" assert tw_mock.lines[45] == "" line = tw_mock.get_write_msg(46) @@ -1535,24 +1535,44 @@ def unreraise(): r = excinfo.getrepr(style="short") r.toterminal(tw_mock) out = "\n".join(line for line in tw_mock.lines if isinstance(line, str)) - expected_out = textwrap.dedent( - """\ - :13: in unreraise - reraise() - :10: in reraise - raise Err() from e - E test_exc_chain_repr_cycle0.mod.Err - - During handling of the above exception, another exception occurred: - :15: in unreraise - raise e.__cause__ - :8: in reraise - fail() - :5: in fail - return 0 / 0 - ^^^^^ - E ZeroDivisionError: division by zero""" - ) + # Assert highlighting carets in python3.11+ + if sys.version_info >= (3, 11): + expected_out = textwrap.dedent( + """\ + :13: in unreraise + reraise() + :10: in reraise + raise Err() from e + E test_exc_chain_repr_cycle0.mod.Err + + During handling of the above exception, another exception occurred: + :15: in unreraise + raise e.__cause__ + :8: in reraise + fail() + :5: in fail + return 0 / 0 + ^^^^^ + E ZeroDivisionError: division by zero""" + ) + else: + expected_out = textwrap.dedent( + """\ + :13: in unreraise + reraise() + :10: in reraise + raise Err() from e + E test_exc_chain_repr_cycle0.mod.Err + + During handling of the above exception, another exception occurred: + :15: in unreraise + raise e.__cause__ + :8: in reraise + fail() + :5: in fail + return 0 / 0 + E ZeroDivisionError: division by zero""" + ) assert out == expected_out def test_exec_type_error_filter(self, importasmod): From ffc7e5b9317ddf7095926c364242b1bfe6459e2e Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sun, 26 Jan 2025 15:32:16 -0500 Subject: [PATCH 6/7] Add changelog entry --- AUTHORS | 1 + changelog/10224.improvement.rst | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 changelog/10224.improvement.rst diff --git a/AUTHORS b/AUTHORS index 9629e00bcf..f81c87026e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Alice Purcell Allan Feldman Aly Sivji Amir Elkess +Ammar Askar Anatoly Bubenkoff Anders Hovmöller Andras Mitzki diff --git a/changelog/10224.improvement.rst b/changelog/10224.improvement.rst new file mode 100644 index 0000000000..93afe9e2c1 --- /dev/null +++ b/changelog/10224.improvement.rst @@ -0,0 +1,18 @@ +pytest's ``short`` and ``long`` traceback styles (:ref:`how-to-modifying-python-tb-printing`) +now have partial :pep:`657` support and will show specific code segments in the +traceback. + +.. code-block:: pytest + + ================================= FAILURES ================================= + _______________________ test_gets_correct_tracebacks _______________________ + + test_tracebacks.py:12: in test_gets_correct_tracebacks + assert manhattan_distance(p1, p2) == 1 + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + test_tracebacks.py:6: in manhattan_distance + return abs(point_1.x - point_2.x) + abs(point_1.y - point_2.y) + ^^^^^^^^^ + E AttributeError: 'NoneType' object has no attribute 'x' + +-- by :user:`ammaraskar` From 23350b548431082e1e9cb33d7deb3654c3a1b7a2 Mon Sep 17 00:00:00 2001 From: Ammar Askar Date: Sun, 26 Jan 2025 21:02:58 -0500 Subject: [PATCH 7/7] Add pragma no cover for branch where column info is missing --- src/_pytest/_code/code.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index c666953a89..919f00e753 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -233,7 +233,7 @@ def end_colno(self) -> int | None: @property def end_lineno_relative(self) -> int | None: frame_summary = self.get_python_framesummary() - if frame_summary.end_lineno is None: + if frame_summary.end_lineno is None: # pragma: no cover return None return frame_summary.end_lineno - 1 - self.frame.code.firstlineno