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 e37a1324c67..c2de27bbb82 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1232,7 +1232,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 d3872068a86..af32f3506fb 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1592,6 +1592,66 @@ 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.""" + try: + try: + raise RuntimeError("original cause") + except RuntimeError as exc: + raise ExceptionGroup("group", [ValueError("inner")]) from exc + except ExceptionGroup: + excinfo = ExceptionInfo.from_current() + + 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 @@ -1613,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", @@ -1648,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", ] )