Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/14940.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 17 additions & 9 deletions src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading