From fc08634e013e9eee816406881ca9722e21757961 Mon Sep 17 00:00:00 2001 From: SemTiOne Date: Sun, 2 Aug 2026 21:43:06 +0700 Subject: [PATCH 1/2] Fix duplicated exception chain output when an exception group has a cause An exception group raised from another exception used to print that exception's cause chain twice: once natively by format_exception (which walks the chain), and once more by the exception chain loop in repr_excinfo. Stop walking the chain in the loop after the native representation has already been generated, since it includes the cause/context chain. Co-authored-by: Claude --- changelog/14828.bugfix.rst | 1 + src/_pytest/_code/code.py | 4 +++- testing/code/test_excinfo.py | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changelog/14828.bugfix.rst diff --git a/changelog/14828.bugfix.rst b/changelog/14828.bugfix.rst new file mode 100644 index 00000000000..c343b8610c1 --- /dev/null +++ b/changelog/14828.bugfix.rst @@ -0,0 +1 @@ +Exception groups raised from another exception now print their cause chain only once in the traceback output. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3c453b15dd7..b2845885c79 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1230,7 +1230,9 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR reprcrash = None repr_chain.append((reprtraceback, reprcrash, description)) - if e.__cause__ is not None and self.chain: + if isinstance(e, BaseExceptionGroup) and self.chain: + e = None + elif e.__cause__ is not None and self.chain: e = e.__cause__ excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None description = "The above exception was the direct cause of the following exception:" diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 883a7c5f9b0..b9e6a89761c 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1592,6 +1592,29 @@ def g(): ] ) + def test_exc_chain_repr_exception_group_with_cause(self) -> None: + """An exception group raised from another exception must not print that + exception's cause chain twice.""" + try: + try: + raise RuntimeError("original cause") + except RuntimeError as exc: + raise ExceptionGroup("group", [ValueError("inner")]) from exc + except ExceptionGroup: + 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("RuntimeError: original cause") == 1 + assert output.count("ExceptionGroup: group") == 1 + assert output.count("ValueError: inner") == 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( From d07e8eff479f5b37749fc406462d1fdff8b7e17a Mon Sep 17 00:00:00 2001 From: Dane Parin Date: Fri, 14 Aug 2026 08:39:39 +0700 Subject: [PATCH 2/2] Add edge case tests for exception group chain deduplication Add tests for BaseExceptionGroup with implicit __context__ chain and nested ExceptionGroup with __cause__, covering the two remaining paths in the chain-walking loop. Extract _render_output helper to deduplicate the render boilerplate across all chain-repr tests (5 call sites). Co-authored-by: Claude --- testing/code/test_excinfo.py | 67 +++++++++++++++++++++++++----------- testing/test_unittest.py | 2 +- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index e7e6e305914..af32f3506fb 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1592,6 +1592,14 @@ def g(): ] ) + def _render_output(self, excinfo: ExceptionInfo[BaseException]) -> str: + r = excinfo.getrepr() + file = io.StringIO() + tw = TerminalWriter(file=file) + tw.hasmarkup = False + r.toterminal(tw) + return file.getvalue() + def test_exc_chain_repr_exception_group_with_cause(self) -> None: """An exception group raised from another exception must not print that exception's cause chain twice.""" @@ -1603,18 +1611,47 @@ def test_exc_chain_repr_exception_group_with_cause(self) -> None: except ExceptionGroup: excinfo = ExceptionInfo.from_current() - r = excinfo.getrepr() - file = io.StringIO() - tw = TerminalWriter(file=file) - tw.hasmarkup = False - r.toterminal(tw) - - output = file.getvalue() + output = self._render_output(excinfo) assert output.count("RuntimeError: original cause") == 1 assert output.count("ExceptionGroup: group") == 1 assert output.count("ValueError: inner") == 1 assert output.count("The above exception was the direct cause") == 1 + def test_exc_chain_repr_exception_group_with_context(self) -> None: + """An exception group raised during handling must not print the + implicit context chain twice.""" + exc1 = RuntimeError("implicit context") + try: + raise exc1 + except RuntimeError as exc: + group = ExceptionGroup("group", [ValueError("inner")]) + group.__context__ = exc + try: + raise group + except ExceptionGroup: + excinfo = ExceptionInfo.from_current() + + output = self._render_output(excinfo) + assert output.count("RuntimeError: implicit context") == 1 + assert output.count("During handling of the above exception") == 1 + + def test_exc_chain_repr_nested_exception_group_with_cause(self) -> None: + """A nested exception group raised from another exception must not + print the cause chain twice.""" + try: + try: + raise RuntimeError("root cause") + except RuntimeError as exc: + raise ExceptionGroup( + "outer", [ExceptionGroup("inner", [ValueError("v")])] + ) from exc + except ExceptionGroup: + excinfo = ExceptionInfo.from_current() + + output = self._render_output(excinfo) + assert output.count("RuntimeError: root cause") == 1 + assert output.count("The above exception was the direct cause") == 1 + def test_exc_chain_repr_without_traceback_multiple_links(self) -> None: """ Exceptions without a traceback that are themselves part of a longer @@ -1636,13 +1673,7 @@ def test_exc_chain_repr_without_traceback_multiple_links(self) -> None: 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() + output = self._render_output(excinfo) for message in ( "ValueError: abcd", "IndexError: efgh", @@ -1671,13 +1702,7 @@ def test_exc_chain_repr_mixed_traceback(self) -> None: 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() + output = self._render_output(excinfo) 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 diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 20287d12cb3..203503ff06f 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1609,7 +1609,7 @@ def test(self): result.stdout.fnmatch_lines( [ "* ERROR at setup of MyTestCase.test *", - "E * Exception: fail 0", + "*Exception: fail 0", ] )