From 744f18b999c17fff56b99cd151759873cbd9d6d1 Mon Sep 17 00:00:00 2001 From: Dextheking1 Date: Wed, 23 Sep 2026 19:05:34 +0200 Subject: [PATCH] Fix __tracebackhide__ handling for ExceptionGroup tracebacks Match filtered frames by identity instead of (filename, lineno) in _filter_tracebackexception: distinct traceback entries can share the same source location while having different __tracebackhide__ values (e.g. via recursion), in which case a hidden frame was incorrectly retained when a visible frame from the same location was kept. Fixes #14940. --- changelog/14940.bugfix.rst | 1 + src/_pytest/_code/code.py | 26 +++++++++++++++++--------- testing/code/test_excinfo.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) create mode 100644 changelog/14940.bugfix.rst diff --git a/changelog/14940.bugfix.rst b/changelog/14940.bugfix.rst new file mode 100644 index 00000000000..1c8803b1176 --- /dev/null +++ b/changelog/14940.bugfix.rst @@ -0,0 +1 @@ +``__tracebackhide__`` is now applied per traceback occurrence rather than per source location in ``ExceptionGroup`` tracebacks. Previously, when distinct traceback entries shared the same ``(filename, lineno)`` but had different ``__tracebackhide__`` values (for example via recursion), a hidden frame could be retained because a visible frame from the same location was kept. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index e7712c48bf4..abf2c35461b 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1658,19 +1658,27 @@ def _filter_tracebackexception( 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``. + Frames are matched by identity (not by ``(filename, lineno)``): distinct + traceback entries may share the same source location while having + different ``__tracebackhide__`` values (e.g. via recursion), so a + ``(filename, lineno)`` key cannot identify an individual occurrence. """ 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] - ) + kept_ids = {id(entry._rawentry) for entry in filtered} + # tb_exc.stack holds one FrameSummary per raw traceback frame, in the + # same (outermost-first) order, so walk both in parallel and keep a + # FrameSummary only when its own traceback entry was kept. + raw_tb: TracebackType | None = e.__traceback__ + kept_frames: list[FrameSummary] = [] + for fs in tb_exc.stack: + if raw_tb is None: + break + if id(raw_tb) in kept_ids: + kept_frames.append(fs) + raw_tb = raw_tb.tb_next + tb_exc.stack = StackSummary.from_list(kept_frames) 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): diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index ec9f584dfba..25ff2af4b4b 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -2220,6 +2220,37 @@ def test(): result.stdout.no_fnmatch_line("*in g1*") +def test_tracebackhide_in_exceptiongroup_shared_source_location( + pytester: Pytester, +) -> None: + """__tracebackhide__ is applied per traceback occurrence, not per source location (#14940).""" + p = pytester.makepyfile( + """ + import sys + if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + + def fail(number): + __tracebackhide__ = number == 1 + if number == 0: + raise ValueError("boom") + fail(number - 1) + + def test_failure(): + try: + fail(2) + except ValueError as error: + raise ExceptionGroup("failure", [error]) from None + """ + ) + result = pytester.runpytest(str(p), "--tb=short") + assert result.ret == 1 + # The hidden fail(1) frame shares its source location with the visible + # fail(2) frame; it must be filtered out so the recursive call appears only once. + assert str(result.stdout).count("fail(number - 1)") == 1 + result.stdout.fnmatch_lines(["*ValueError: boom*"]) + + def add_note(err: BaseException, msg: str) -> None: """Adds a note to an exception inplace.""" if sys.version_info < (3, 11):