From acacfc1ef17e0c0ae7fbe0ae05cafb73a88631f3 Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Wed, 24 Jun 2026 16:46:42 +0800 Subject: [PATCH 1/2] Fix __tracebackhide__ not respected in ExceptionGroup sub-exception tracebacks ExceptionGroup tracebacks use the native style via traceback.format_exception. The top-level traceback gets filtered, but format_exception then walks each sub-exception's original __traceback__ and renders those frames unfiltered, so __tracebackhide__ set inside a sub-exception's call stack has no effect. Fix by building a traceback.TracebackException and filtering its stack in place, recursing into sub-exceptions and __cause__/__context__ chains. Original exception objects are not mutated. Closes #14036 --- changelog/14036.bugfix.rst | 1 + src/_pytest/_code/code.py | 46 +++++++++++++++++++++++++++---- testing/code/test_excinfo.py | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 changelog/14036.bugfix.rst 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..0bb920baf7b 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 ) + te = TracebackException.from_exception(excinfo.value) + _filter_tracebackexception(te, excinfo.value, self.tbfilter) reprtraceback = ReprTracebackNative( - format_exception( - type(excinfo.value), - excinfo.value, - traceback[0]._rawentry if traceback else None, - ), + list(te.format()), extraline=extraline, ) @@ -1630,3 +1629,38 @@ def filter_excinfo_traceback( return excinfo.traceback.filter(excinfo) else: return excinfo.traceback + + +def _filter_tracebackexception( + te: 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 + } + te.stack = StackSummary.from_list( + [fs for fs in te.stack if (fs.filename, fs.lineno) in kept] + ) + if isinstance(e, BaseExceptionGroup): + sub_tes = getattr(te, "exceptions", None) or [] + for sub_te, sub_e in zip(sub_tes, e.exceptions, strict=True): + _filter_tracebackexception(sub_te, sub_e, tbfilter) + if te.__cause__ is not None and e.__cause__ is not None: + _filter_tracebackexception(te.__cause__, e.__cause__, tbfilter) + if te.__context__ is not None and e.__context__ is not None: + _filter_tracebackexception(te.__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): From 9a17bec4221bac40e769bb80073e15c1a03e4642 Mon Sep 17 00:00:00 2001 From: eternalrights <3147268827@qq.com> Date: Wed, 24 Jun 2026 17:16:40 +0800 Subject: [PATCH 2/2] rename te to tb_exc to avoid codespell false positive --- src/_pytest/_code/code.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 0bb920baf7b..cce66bc68c8 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1212,10 +1212,10 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR if not traceback else None ) - te = TracebackException.from_exception(excinfo.value) - _filter_tracebackexception(te, excinfo.value, self.tbfilter) + tb_exc = TracebackException.from_exception(excinfo.value) + _filter_tracebackexception(tb_exc, excinfo.value, self.tbfilter) reprtraceback = ReprTracebackNative( - list(te.format()), + list(tb_exc.format()), extraline=extraline, ) @@ -1632,7 +1632,7 @@ def filter_excinfo_traceback( def _filter_tracebackexception( - te: TracebackException, + tb_exc: TracebackException, e: BaseException, tbfilter: TracebackFilter, ) -> None: @@ -1653,14 +1653,14 @@ def _filter_tracebackexception( (str(entry.frame.code.path), entry._rawentry.tb_lineno) for entry in filtered } - te.stack = StackSummary.from_list( - [fs for fs in te.stack if (fs.filename, fs.lineno) in kept] + 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_tes = getattr(te, "exceptions", None) or [] - for sub_te, sub_e in zip(sub_tes, e.exceptions, strict=True): - _filter_tracebackexception(sub_te, sub_e, tbfilter) - if te.__cause__ is not None and e.__cause__ is not None: - _filter_tracebackexception(te.__cause__, e.__cause__, tbfilter) - if te.__context__ is not None and e.__context__ is not None: - _filter_tracebackexception(te.__context__, e.__context__, tbfilter) + 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)