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 diff --git a/changelog/13380.improvement.rst b/changelog/13380.improvement.rst new file mode 100644 index 00000000000..51f374fbf01 --- /dev/null +++ b/changelog/13380.improvement.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 2c872df3008..f1241f14136 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: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = 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 @@ -1100,11 +1113,7 @@ def _makepath(self, path: Path | str) -> str: return str(path) def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback: - traceback = excinfo.traceback - if callable(self.tbfilter): - traceback = self.tbfilter(excinfo) - elif self.tbfilter: - traceback = traceback.filter(excinfo) + traceback = filter_excinfo_traceback(self.tbfilter, excinfo) if isinstance(excinfo.value, RecursionError): traceback, extraline = self._truncate_recursive_traceback(traceback) @@ -1178,14 +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 = ( - ReprTracebackNative( - traceback.format_exception( - type(excinfo_.value), - excinfo_.value, - excinfo_.traceback[0]._rawentry, - ) + # 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( + type(excinfo.value), + excinfo.value, + traceback[0]._rawentry, ) ) else: @@ -1194,9 +1204,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)] @@ -1545,3 +1553,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 89088576980..555645030fc 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(