Skip to content
Open
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 AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ Sankt Petersbug
Saravanan Padmanaban
Sean Malloy
Segev Finer
SemTiOne
Serhii Mozghovyi
Seth Junot
Shantanu Jain
Expand Down
1 change: 1 addition & 0 deletions changelog/8321.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed exception chains being duplicated in the traceback output when an exception in the chain (``__cause__``/``__context__``) has no traceback of its own, and that exception is itself linked to further exceptions.
4 changes: 3 additions & 1 deletion src/_pytest/_code/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,7 +1226,9 @@ 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(format_exception(type(e), e, None))
reprtraceback = ReprTracebackNative(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

am i understanding it correct that this bandaids our lack of proper walking with disabling chaining to prevent running out of it

i think this works as a initial fix but needs a followup with more substantial work in excinfo wrt handling more detailed traces

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I agree this is a bandaid.

I wonder if we should make the traceback optional in ExceptionInfo? Or, perhaps we add a lightweight chain member representation? With repr_excinfo as the one and only chain walker, no native format_exception fallback is needed for traceback-less members. The ExceptionGroup fallback stays native for now and we no longer need the chain=False workaround. What do you think @RonnyPfannschmidt?

@RonnyPfannschmidt RonnyPfannschmidt Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't actually ever dubbed to deep into that domain (most exceptions I have to handle are simple

So I'm not familiar with the developer experience and I'm of the opinion that someone that deals with tricky exceptions is more qualified to answer this

Perhaps @Zach-HD can chime in

format_exception(type(e), e, None, chain=False)
)
reprcrash = None
repr_chain.append((reprtraceback, reprcrash, description))

Expand Down
67 changes: 67 additions & 0 deletions testing/code/test_excinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,73 @@ def g():
]
)

def test_exc_chain_repr_without_traceback_multiple_links(self) -> None:
"""
Exceptions without a traceback that are themselves part of a longer
chain must not have their remaining chain printed twice: once by
Python's own ``traceback.format_exception`` (used as a fallback when
an exception has no ``__traceback__``) and once more by pytest's own
chain-walking loop (#8321).
"""
exc1 = ValueError("abcd")
exc2 = IndexError("efgh")
exc3 = KeyError("ijkl")
exc4 = RuntimeError("mnop")
exc1.__cause__ = exc2
exc2.__context__ = exc3
exc3.__cause__ = exc4

try:
raise exc1
except ValueError:
excinfo = ExceptionInfo.from_current()

r = excinfo.getrepr()
file = io.StringIO()
tw = TerminalWriter(file=file)
tw.hasmarkup = False
r.toterminal(tw)

output = file.getvalue()
for message in (
"ValueError: abcd",
"IndexError: efgh",
"KeyError: 'ijkl'",
"RuntimeError: mnop",
):
assert output.count(message) == 1, (
f"{message!r} should appear exactly once in the output, "
f"got {output.count(message)} occurrences:\n{output}"
)
assert output.count("The above exception was the direct cause") == 2
assert output.count("During handling of the above exception") == 1

def test_exc_chain_repr_mixed_traceback(self) -> None:
"""
An exception without a traceback whose chain member has a traceback
must have that member printed once, in pytest's own style.
"""
exc1 = ValueError("outer without traceback")
try:
raise RuntimeError("inner with traceback")
except RuntimeError as exc:
exc1.__cause__ = exc
try:
raise exc1
except ValueError:
excinfo = ExceptionInfo.from_current()

r = excinfo.getrepr()
file = io.StringIO()
tw = TerminalWriter(file=file)
tw.hasmarkup = False
r.toterminal(tw)

output = file.getvalue()
assert output.count("ValueError: outer without traceback") == 1
assert output.count("RuntimeError: inner with traceback") == 1
assert output.count("The above exception was the direct cause") == 1

def test_exc_chain_repr_cycle(self, importasmod, tw_mock):
__tracebackhide__ = True
mod = importasmod(
Expand Down