From fa051621d4f1c90ec7c99d8d78dcf1efe755d96b Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Wed, 16 Apr 2025 19:17:26 -0500 Subject: [PATCH 01/10] init --- src/_pytest/_code/code.py | 44 +++++++++++++++++++++--------------- testing/code/test_excinfo.py | 3 +++ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 2c872df3008..107fe116f99 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1099,35 +1099,51 @@ def _makepath(self, path: Path | str) -> str: return np return str(path) - def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: - traceback = excinfo.traceback + def _filtered_traceback(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: + traceback_ = excinfo.traceback if callable(self.tbfilter): - traceback = self.tbfilter(excinfo) + traceback_ = self.tbfilter(excinfo) elif self.tbfilter: - traceback = traceback.filter(excinfo) + traceback_ = traceback_.filter(excinfo) + return traceback_ + + def _repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: + traceback_ = self._filtered_traceback(excinfo) if isinstance(excinfo.value, RecursionError): - traceback, extraline = self._truncate_recursive_traceback(traceback) + traceback_, extraline = self._truncate_recursive_traceback(traceback_) else: extraline = None - if not traceback: + if not traceback_: if extraline is None: extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames." entries = [self.repr_traceback_entry(None, excinfo)] return ReprTraceback(entries, extraline, style=self.style) - last = traceback[-1] + last = traceback_[-1] if self.style == "value": entries = [self.repr_traceback_entry(last, excinfo)] return ReprTraceback(entries, None, style=self.style) entries = [ self.repr_traceback_entry(entry, excinfo if last == entry else None) - for entry in traceback + for entry in traceback_ ] return ReprTraceback(entries, extraline, style=self.style) + def _repr_exception_group_traceback( + self, excinfo: ExceptionInfo[BaseExceptionGroup] + ) -> ReprTracebackNative: + traceback_ = self._filtered_traceback(excinfo) + return ReprTracebackNative( + traceback.format_exception( + type(excinfo.value), + excinfo.value, + traceback_[0]._rawentry, + ) + ) + def _truncate_recursive_traceback( self, traceback: Traceback ) -> tuple[Traceback, str | None]: @@ -1179,17 +1195,9 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 if isinstance(e, BaseExceptionGroup): - reprtraceback: ReprTracebackNative | ReprTraceback = ( - ReprTracebackNative( - traceback.format_exception( - type(excinfo_.value), - excinfo_.value, - excinfo_.traceback[0]._rawentry, - ) - ) - ) + reprtraceback = self._repr_exception_group_traceback(excinfo_) else: - reprtraceback = self.repr_traceback(excinfo_) + reprtraceback = self._repr_traceback(excinfo_) reprcrash = excinfo_._getreprcrash() else: # Fallback to native repr if the exception doesn't have a traceback: diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 89088576980..c728c0ef5bb 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1797,6 +1797,9 @@ def test(): rf"FAILED test_excgroup.py::test - {pre_catch}BaseExceptionGroup: Oops \(2.*" ) result.stdout.re_match_lines(match_lines) + # check for traceback filtering of pytest internals + result.stdout.no_fnmatch_line("*, line *, in pytest_pyfunc_call") + result.stdout.no_fnmatch_line("*, line *, in pytest_runtest_call") @pytest.mark.skipif( From 1e40e8319074c9e5411163dc8c870825cdb37c17 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Wed, 16 Apr 2025 19:21:16 -0500 Subject: [PATCH 02/10] + author --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 9004008bfa5..e19a0ae5871 100644 --- a/AUTHORS +++ b/AUTHORS @@ -346,6 +346,7 @@ Pavel Karateev Pavel Zhukov Paweł Adamczak Pedro Algarvio +Peter Gessler Petter Strandmark Philipp Loose Pierre Sassoulas From 02137486f7be82e4199709b5aaccc19d54ec95e5 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Wed, 16 Apr 2025 19:35:03 -0500 Subject: [PATCH 03/10] fix --- changelog/13380.bugfix.rst | 1 + src/_pytest/_code/code.py | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 changelog/13380.bugfix.rst diff --git a/changelog/13380.bugfix.rst b/changelog/13380.bugfix.rst new file mode 100644 index 00000000000..51f374fbf01 --- /dev/null +++ b/changelog/13380.bugfix.rst @@ -0,0 +1 @@ +Fix :class:`ExceptionGroup` traceback filtering to exclude pytest internals. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 107fe116f99..975e9beeee5 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1107,7 +1107,7 @@ def _filtered_traceback(self, excinfo: ExceptionInfo[BaseException]) -> Tracebac traceback_ = traceback_.filter(excinfo) return traceback_ - def _repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: + def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: traceback_ = self._filtered_traceback(excinfo) if isinstance(excinfo.value, RecursionError): @@ -1133,7 +1133,7 @@ def _repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTracebac return ReprTraceback(entries, extraline, style=self.style) def _repr_exception_group_traceback( - self, excinfo: ExceptionInfo[BaseExceptionGroup] + self, excinfo: ExceptionInfo[BaseException] ) -> ReprTracebackNative: traceback_ = self._filtered_traceback(excinfo) return ReprTracebackNative( @@ -1195,9 +1195,11 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 if isinstance(e, BaseExceptionGroup): - reprtraceback = self._repr_exception_group_traceback(excinfo_) + reprtraceback: ReprTracebackNative | ReprTraceback = ( + self._repr_exception_group_traceback(excinfo_) + ) else: - reprtraceback = self._repr_traceback(excinfo_) + reprtraceback = self.repr_traceback(excinfo_) reprcrash = excinfo_._getreprcrash() else: # Fallback to native repr if the exception doesn't have a traceback: From 22ef71be150668699bbf2e68c0d3d14479bafea6 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 19 Apr 2025 08:47:40 -0300 Subject: [PATCH 04/10] Simplify the code a bit --- src/_pytest/_code/code.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 975e9beeee5..f618b605535 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1100,35 +1100,35 @@ def _makepath(self, path: Path | str) -> str: return str(path) def _filtered_traceback(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: - traceback_ = excinfo.traceback + """Filter the exception traceback in ``excinfo`` according to ``tbfilter``, if any.""" if callable(self.tbfilter): - traceback_ = self.tbfilter(excinfo) + return self.tbfilter(excinfo) elif self.tbfilter: - traceback_ = traceback_.filter(excinfo) - return traceback_ + return excinfo.traceback.filter(excinfo) + return excinfo.traceback def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: - traceback_ = self._filtered_traceback(excinfo) + traceback = self._filtered_traceback(excinfo) if isinstance(excinfo.value, RecursionError): - traceback_, extraline = self._truncate_recursive_traceback(traceback_) + traceback, extraline = self._truncate_recursive_traceback(traceback) else: extraline = None - if not traceback_: + if not traceback: if extraline is None: extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames." entries = [self.repr_traceback_entry(None, excinfo)] return ReprTraceback(entries, extraline, style=self.style) - last = traceback_[-1] + last = traceback[-1] if self.style == "value": entries = [self.repr_traceback_entry(last, excinfo)] return ReprTraceback(entries, None, style=self.style) entries = [ self.repr_traceback_entry(entry, excinfo if last == entry else None) - for entry in traceback_ + for entry in traceback ] return ReprTraceback(entries, extraline, style=self.style) From 3c580a000b45ee08b15e579860c399d926fbd617 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sat, 19 Apr 2025 09:09:51 -0300 Subject: [PATCH 05/10] Small refactor * Import functions from `traceback` directly, to allow free use of `traceback` as a variable name. * Extract `_filtered_traceback` into a function. * Inline `_repr_exception_group_traceback` given it is used only in one place. * Make a type alias for the type of `tbfilter`. --- ...13380.bugfix.rst => 13380.improvement.rst} | 0 src/_pytest/_code/code.py | 75 +++++++++++-------- testing/code/test_excinfo.py | 2 +- 3 files changed, 43 insertions(+), 34 deletions(-) rename changelog/{13380.bugfix.rst => 13380.improvement.rst} (100%) diff --git a/changelog/13380.bugfix.rst b/changelog/13380.improvement.rst similarity index 100% rename from changelog/13380.bugfix.rst rename to changelog/13380.improvement.rst diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f618b605535..295d5dd83db 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -15,8 +15,10 @@ from pathlib import Path import re import sys -import traceback +from traceback import extract_tb +from traceback import format_exception from traceback import format_exception_only +from traceback import FrameSummary from types import CodeType from types import FrameType from types import TracebackType @@ -28,6 +30,7 @@ from typing import Literal from typing import overload from typing import SupportsIndex +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union @@ -208,10 +211,10 @@ def with_repr_style( def lineno(self) -> int: return self._rawentry.tb_lineno - 1 - def get_python_framesummary(self) -> traceback.FrameSummary: + def get_python_framesummary(self) -> 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) + stack_summary = extract_tb(self._rawentry, limit=1) return stack_summary[0] # Column and end line numbers introduced in python 3.11 @@ -694,8 +697,7 @@ def getrepr( showlocals: bool = False, style: TracebackStyle = "long", abspath: bool = False, - tbfilter: bool - | Callable[[ExceptionInfo[BaseException]], _pytest._code.code.Traceback] = True, + tbfilter: TracebackFilter = True, funcargs: bool = False, truncate_locals: bool = True, truncate_args: bool = True, @@ -742,7 +744,7 @@ def getrepr( if style == "native": return ReprExceptionInfo( reprtraceback=ReprTracebackNative( - traceback.format_exception( + format_exception( self.type, self.value, self.traceback[0]._rawentry if self.traceback else None, @@ -851,6 +853,17 @@ def group_contains( return self._group_contains(self.value, expected_exception, match, depth) +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + # Type alias for the `tbfilter` setting: + # bool: If True, it should be filtered using Traceback.filter() + # callable: A callable that takes an ExceptionInfo and returns the filtered traceback. + TracebackFilter: TypeAlias = Union[ + bool, Callable[[ExceptionInfo[BaseException]], Traceback] + ] + + @dataclasses.dataclass class FormattedExcinfo: """Presenting information about failing Functions and Generators.""" @@ -862,7 +875,7 @@ class FormattedExcinfo: showlocals: bool = False style: TracebackStyle = "long" abspath: bool = True - tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = True + tbfilter: TracebackFilter = True funcargs: bool = False truncate_locals: bool = True truncate_args: bool = True @@ -1099,16 +1112,8 @@ def _makepath(self, path: Path | str) -> str: return np return str(path) - def _filtered_traceback(self, excinfo: ExceptionInfo[BaseException]) -> Traceback: - """Filter the exception traceback in ``excinfo`` according to ``tbfilter``, if any.""" - if callable(self.tbfilter): - return self.tbfilter(excinfo) - elif self.tbfilter: - return excinfo.traceback.filter(excinfo) - return excinfo.traceback - def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: - traceback = self._filtered_traceback(excinfo) + traceback = filter_excinfo_traceback(self.tbfilter, excinfo) if isinstance(excinfo.value, RecursionError): traceback, extraline = self._truncate_recursive_traceback(traceback) @@ -1132,18 +1137,6 @@ def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback ] return ReprTraceback(entries, extraline, style=self.style) - def _repr_exception_group_traceback( - self, excinfo: ExceptionInfo[BaseException] - ) -> ReprTracebackNative: - traceback_ = self._filtered_traceback(excinfo) - return ReprTracebackNative( - traceback.format_exception( - type(excinfo.value), - excinfo.value, - traceback_[0]._rawentry, - ) - ) - def _truncate_recursive_traceback( self, traceback: Traceback ) -> tuple[Traceback, str | None]: @@ -1194,9 +1187,15 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR # Fall back to native traceback as a temporary workaround until # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 + reprtraceback: ReprTraceback | ReprTracebackNative if isinstance(e, BaseExceptionGroup): - reprtraceback: ReprTracebackNative | ReprTraceback = ( - self._repr_exception_group_traceback(excinfo_) + traceback = filter_excinfo_traceback(self.tbfilter, excinfo) + reprtraceback = ReprTracebackNative( + format_exception( + type(excinfo.value), + excinfo.value, + traceback[0]._rawentry, + ) ) else: reprtraceback = self.repr_traceback(excinfo_) @@ -1204,9 +1203,7 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. - reprtraceback = ReprTracebackNative( - traceback.format_exception(type(e), e, None) - ) + reprtraceback = ReprTracebackNative(format_exception(type(e), e, None)) reprcrash = None repr_chain += [(reprtraceback, reprcrash, descr)] @@ -1555,3 +1552,15 @@ def filter_traceback(entry: TracebackEntry) -> bool: return False return True + + +def filter_excinfo_traceback( + tbfilter: TracebackFilter, excinfo: ExceptionInfo[BaseException] +) -> Traceback: + """Filter the exception traceback in ``excinfo`` according to ``tbfilter``.""" + if callable(tbfilter): + return tbfilter(excinfo) + elif tbfilter: + return excinfo.traceback.filter(excinfo) + else: + return excinfo.traceback diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index c728c0ef5bb..555645030fc 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1797,7 +1797,7 @@ def test(): rf"FAILED test_excgroup.py::test - {pre_catch}BaseExceptionGroup: Oops \(2.*" ) result.stdout.re_match_lines(match_lines) - # check for traceback filtering of pytest internals + # Check for traceback filtering of pytest internals. result.stdout.no_fnmatch_line("*, line *, in pytest_pyfunc_call") result.stdout.no_fnmatch_line("*, line *, in pytest_runtest_call") From 018ce130d43ab5e14fc050b549ebf490427cd9a0 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Sat, 19 Apr 2025 08:32:37 -0500 Subject: [PATCH 06/10] action rerun From e468420f11ed85af7543a8abf7adef9650540083 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Sat, 19 Apr 2025 09:28:03 -0500 Subject: [PATCH 07/10] + comment --- src/_pytest/_code/code.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 295d5dd83db..d2319d2097b 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1189,6 +1189,7 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR # See https://github.com/pytest-dev/pytest/issues/9159 reprtraceback: ReprTraceback | ReprTracebackNative if isinstance(e, BaseExceptionGroup): + # don't filter any sub-exceptions since they shouldn't have any internal frames traceback = filter_excinfo_traceback(self.tbfilter, excinfo) reprtraceback = ReprTracebackNative( format_exception( From fc505cbaecb1131f52f1db6e3b0614054d3cba9a Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Sat, 19 Apr 2025 09:40:39 -0500 Subject: [PATCH 08/10] fix docs --- src/_pytest/_code/code.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index d2319d2097b..f08859b49bd 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -30,11 +30,11 @@ from typing import Literal from typing import overload from typing import SupportsIndex -from typing import TYPE_CHECKING from typing import TypeVar from typing import Union import pluggy +from typing_extensions import TypeAlias import _pytest from _pytest._code.source import findsource @@ -853,15 +853,12 @@ def group_contains( return self._group_contains(self.value, expected_exception, match, depth) -if TYPE_CHECKING: - from typing_extensions import TypeAlias - - # Type alias for the `tbfilter` setting: - # bool: If True, it should be filtered using Traceback.filter() - # callable: A callable that takes an ExceptionInfo and returns the filtered traceback. - TracebackFilter: TypeAlias = Union[ - bool, Callable[[ExceptionInfo[BaseException]], Traceback] - ] +# Type alias for the `tbfilter` setting: +# bool: If True, it should be filtered using Traceback.filter() +# callable: A callable that takes an ExceptionInfo and returns the filtered traceback. +TracebackFilter: TypeAlias = Union[ + bool, Callable[[ExceptionInfo[BaseException]], Traceback] +] @dataclasses.dataclass From 9d045697366827f58a072a26c618547305ef5487 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Sat, 19 Apr 2025 09:44:58 -0500 Subject: [PATCH 09/10] Revert "fix docs" This reverts commit fc505cbaecb1131f52f1db6e3b0614054d3cba9a. --- src/_pytest/_code/code.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index f08859b49bd..d2319d2097b 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -30,11 +30,11 @@ from typing import Literal from typing import overload from typing import SupportsIndex +from typing import TYPE_CHECKING from typing import TypeVar from typing import Union import pluggy -from typing_extensions import TypeAlias import _pytest from _pytest._code.source import findsource @@ -853,12 +853,15 @@ def group_contains( return self._group_contains(self.value, expected_exception, match, depth) -# Type alias for the `tbfilter` setting: -# bool: If True, it should be filtered using Traceback.filter() -# callable: A callable that takes an ExceptionInfo and returns the filtered traceback. -TracebackFilter: TypeAlias = Union[ - bool, Callable[[ExceptionInfo[BaseException]], Traceback] -] +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + # Type alias for the `tbfilter` setting: + # bool: If True, it should be filtered using Traceback.filter() + # callable: A callable that takes an ExceptionInfo and returns the filtered traceback. + TracebackFilter: TypeAlias = Union[ + bool, Callable[[ExceptionInfo[BaseException]], Traceback] + ] @dataclasses.dataclass From f68ccd9e051255f947a55605b69ad1eb6abcb073 Mon Sep 17 00:00:00 2001 From: Peter Gessler Date: Sat, 19 Apr 2025 09:47:34 -0500 Subject: [PATCH 10/10] fix --- 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 d2319d2097b..f1241f14136 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -697,7 +697,7 @@ def getrepr( showlocals: bool = False, style: TracebackStyle = "long", abspath: bool = False, - tbfilter: TracebackFilter = True, + tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = True, funcargs: bool = False, truncate_locals: bool = True, truncate_args: bool = True,