diff --git a/changelog/14036.bugfix.rst b/changelog/14036.bugfix.rst new file mode 100644 index 00000000000..2c82fe383da --- /dev/null +++ b/changelog/14036.bugfix.rst @@ -0,0 +1 @@ +``__tracebackhide__`` is now respected in ``ExceptionGroup`` tracebacks, both for the group itself and for its sub-exceptions. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3c453b15dd7..cce66bc68c8 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -17,6 +17,8 @@ from traceback import format_exception from traceback import format_exception_only from traceback import FrameSummary +from traceback import StackSummary +from traceback import TracebackException from types import CodeType from types import FrameType from types import TracebackType @@ -1204,19 +1206,16 @@ 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) extraline = ( "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames." if not traceback else None ) + tb_exc = TracebackException.from_exception(excinfo.value) + _filter_tracebackexception(tb_exc, excinfo.value, self.tbfilter) reprtraceback = ReprTracebackNative( - format_exception( - type(excinfo.value), - excinfo.value, - traceback[0]._rawentry if traceback else None, - ), + list(tb_exc.format()), extraline=extraline, ) @@ -1630,3 +1629,38 @@ def filter_excinfo_traceback( return excinfo.traceback.filter(excinfo) else: return excinfo.traceback + + +def _filter_tracebackexception( + tb_exc: TracebackException, + e: BaseException, + tbfilter: TracebackFilter, +) -> None: + """Filter a ``TracebackException`` in-place, respecting ``__tracebackhide__``. + + This is used to filter native-style tracebacks (currently the only style + used for ``BaseExceptionGroup``) without mutating the original exception + objects. It recurses into exception group sub-exceptions and into + ``__cause__`` / ``__context__`` chains. + + Frames are matched by ``(filename, lineno)``: ``TracebackEntry._rawentry.tb_lineno`` + is 1-based absolute, matching ``FrameSummary.lineno``. + """ + if e.__traceback__ is not None: + excinfo = ExceptionInfo.from_exception(e) + filtered = filter_excinfo_traceback(tbfilter, excinfo) + kept = { + (str(entry.frame.code.path), entry._rawentry.tb_lineno) + for entry in filtered + } + tb_exc.stack = StackSummary.from_list( + [fs for fs in tb_exc.stack if (fs.filename, fs.lineno) in kept] + ) + if isinstance(e, BaseExceptionGroup): + sub_tb_excs = getattr(tb_exc, "exceptions", None) or [] + for sub_tb_exc, sub_e in zip(sub_tb_excs, e.exceptions, strict=True): + _filter_tracebackexception(sub_tb_exc, sub_e, tbfilter) + if tb_exc.__cause__ is not None and e.__cause__ is not None: + _filter_tracebackexception(tb_exc.__cause__, e.__cause__, tbfilter) + if tb_exc.__context__ is not None and e.__context__ is not None: + _filter_tracebackexception(tb_exc.__context__, e.__context__, tbfilter) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 883a7c5f9b0..a927ddc0f06 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2011,6 +2011,59 @@ def test(): ) +def test_tracebackhide_in_exceptiongroup_is_respected(pytester: Pytester) -> None: + """ExceptionGroup tracebacks respect __tracebackhide__ (#14036).""" + p = pytester.makepyfile( + """ + import sys + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + def g1(): + __tracebackhide__ = True + str.does_not_exist + + def f3(): + __tracebackhide__ = True + 1 / 0 + + def f2(): + __tracebackhide__ = True + exc = None + try: + f3() + except Exception as e: + exc = e + exc2 = None + try: + g1() + except Exception as e: + exc2 = e + raise ExceptionGroup("blah", [exc, exc2]) + + def f1(): + __tracebackhide__ = True + f2() + + def test(): + f1() + """ + ) + result = pytester.runpytest(str(p), "--tb=short") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*ExceptionGroup: blah (2 sub-exceptions)*", + "*ZeroDivisionError: division by zero*", + "*AttributeError*does_not_exist*", + ] + ) + result.stdout.no_fnmatch_line("*in f1*") + result.stdout.no_fnmatch_line("*in f2*") + result.stdout.no_fnmatch_line("*in f3*") + result.stdout.no_fnmatch_line("*in g1*") + + def add_note(err: BaseException, msg: str) -> None: """Adds a note to an exception inplace.""" if sys.version_info < (3, 11):