From 47996bdc3870f9eed82951a15e07f430eb27a665 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Mon, 18 Nov 2024 15:05:04 +0100 Subject: [PATCH 1/5] display single contained exception in excgroups in short test summary info --- changelog/12943.improvement.rst | 1 + src/_pytest/_code/code.py | 25 ++++++++++++++- testing/code/test_excinfo.py | 57 +++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 changelog/12943.improvement.rst diff --git a/changelog/12943.improvement.rst b/changelog/12943.improvement.rst new file mode 100644 index 00000000000..eb8ac63650a --- /dev/null +++ b/changelog/12943.improvement.rst @@ -0,0 +1 @@ +If a test fails with an exceptiongroup with a single exception, the contained exception will now be displayed in the short test summary info. diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 8fac39ea298..79adc196aea 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -1033,6 +1033,24 @@ def _truncate_recursive_traceback( def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr: repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] + + def _get_single_subexc( + eg: BaseExceptionGroup[BaseException], + ) -> BaseException | None: + res: BaseException | None = None + for subexc in eg.exceptions: + if res is not None: + return None + + if isinstance(subexc, BaseExceptionGroup): + res = _get_single_subexc(subexc) + if res is None: + # there were multiple exceptions in the subgroup + return None + else: + res = subexc + return res + e: BaseException | None = excinfo.value excinfo_: ExceptionInfo[BaseException] | None = excinfo descr = None @@ -1041,6 +1059,7 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR seen.add(id(e)) if excinfo_: + reprcrash = excinfo_._getreprcrash() # Fall back to native traceback as a temporary workaround until # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 @@ -1054,9 +1073,13 @@ def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainR ) ) ) + if ( + reprcrash is not None + and (subexc := _get_single_subexc(e)) is not None + ): + reprcrash.message = f"[in {type(e).__name__}] {subexc!r}" else: reprtraceback = self.repr_traceback(excinfo_) - reprcrash = excinfo_._getreprcrash() else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index fc60ae9ac99..5bb65bf30af 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1703,6 +1703,63 @@ def test_exceptiongroup(pytester: Pytester, outer_chain, inner_chain) -> None: _exceptiongroup_common(pytester, outer_chain, inner_chain, native=False) +def test_exceptiongroup_short_summary_info(pytester: Pytester): + pytester.makepyfile( + """ + import sys + + if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup, ExceptionGroup + + def test_base() -> None: + raise BaseExceptionGroup("NOT IN SUMMARY", [SystemExit("a" * 10)]) + + def test_nonbase() -> None: + raise ExceptionGroup("NOT IN SUMMARY", [ValueError("a" * 10)]) + + def test_nested() -> None: + raise ExceptionGroup( + "NOT DISPLAYED", [ + ExceptionGroup("NOT IN SUMMARY", [ValueError("a" * 10)]) + ] + ) + + def test_multiple() -> None: + raise ExceptionGroup( + "b" * 10, + [ + ValueError("NOT IN SUMMARY"), + TypeError("NOT IN SUMMARY"), + ] + ) + """ + ) + result = pytester.runpytest("-vv") + assert result.ret == 1 + result.stdout.fnmatch_lines( + [ + "*= short test summary info =*", + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_base - " + "[in BaseExceptionGroup] SystemExit('aaaaaaaaaa')" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nonbase - " + "[in ExceptionGroup] ValueError('aaaaaaaaaa')" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nested - " + "[in ExceptionGroup] ValueError('aaaaaaaaaa')" + ), + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_multiple - " + "ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)" + ), + "*= 4 failed in *", + ] + ) + + @pytest.mark.parametrize("tbstyle", ("long", "short", "auto", "line", "native")) def test_all_entries_hidden(pytester: Pytester, tbstyle: str) -> None: """Regression test for #10903.""" From f59a8d18c5e7071e4af06b7f0571cfa22c1b1544 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 19 Nov 2024 17:03:34 +0100 Subject: [PATCH 2/5] fix test --- testing/code/test_excinfo.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 5bb65bf30af..3418c433541 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1736,6 +1736,7 @@ def test_multiple() -> None: ) result = pytester.runpytest("-vv") assert result.ret == 1 + backport_str = "exceptiongroup." if sys.version_info < (3, 11) else "" result.stdout.fnmatch_lines( [ "*= short test summary info =*", @@ -1753,7 +1754,7 @@ def test_multiple() -> None: ), ( "FAILED test_exceptiongroup_short_summary_info.py::test_multiple - " - "ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)" + f"{backport_str}ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)" ), "*= 4 failed in *", ] From 3311c180c75b99ab58d4b0b114953db2e80c1ff4 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 19 Nov 2024 17:16:29 +0100 Subject: [PATCH 3/5] move logic to ExceptionInfo --- src/_pytest/_code/code.py | 50 ++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 79adc196aea..0d635db132b 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -589,6 +589,31 @@ def exconly(self, tryshort: bool = False) -> str: representation is returned (so 'AssertionError: ' is removed from the beginning). """ + + def _get_single_subexc( + eg: BaseExceptionGroup[BaseException], + ) -> BaseException | None: + res: BaseException | None = None + for subexc in eg.exceptions: + if res is not None: + return None + + if isinstance(subexc, BaseExceptionGroup): + res = _get_single_subexc(subexc) + if res is None: + # there were multiple exceptions in the subgroup + return None + else: + res = subexc + return res + + if ( + tryshort + and isinstance(self.value, BaseExceptionGroup) + and (subexc := _get_single_subexc(self.value)) is not None + ): + return f"[in {type(self.value).__name__}] {subexc!r}" + lines = format_exception_only(self.type, self.value) text = "".join(lines) text = text.rstrip() @@ -1033,24 +1058,6 @@ def _truncate_recursive_traceback( def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr: repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = [] - - def _get_single_subexc( - eg: BaseExceptionGroup[BaseException], - ) -> BaseException | None: - res: BaseException | None = None - for subexc in eg.exceptions: - if res is not None: - return None - - if isinstance(subexc, BaseExceptionGroup): - res = _get_single_subexc(subexc) - if res is None: - # there were multiple exceptions in the subgroup - return None - else: - res = subexc - return res - e: BaseException | None = excinfo.value excinfo_: ExceptionInfo[BaseException] | None = excinfo descr = None @@ -1059,7 +1066,6 @@ def _get_single_subexc( seen.add(id(e)) if excinfo_: - reprcrash = excinfo_._getreprcrash() # Fall back to native traceback as a temporary workaround until # full support for exception groups added to ExceptionInfo. # See https://github.com/pytest-dev/pytest/issues/9159 @@ -1073,13 +1079,9 @@ def _get_single_subexc( ) ) ) - if ( - reprcrash is not None - and (subexc := _get_single_subexc(e)) is not None - ): - reprcrash.message = f"[in {type(e).__name__}] {subexc!r}" else: reprtraceback = self.repr_traceback(excinfo_) + reprcrash = excinfo_._getreprcrash() else: # Fallback to native repr if the exception doesn't have a traceback: # ExceptionInfo objects require a full traceback to work. From 033120b6546955b8b430a1f547879dd50a32775c Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 19 Nov 2024 17:25:07 +0100 Subject: [PATCH 4/5] add test case for codecov --- testing/code/test_excinfo.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 3418c433541..d23382c898c 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1732,8 +1732,23 @@ def test_multiple() -> None: TypeError("NOT IN SUMMARY"), ] ) + + def test_nested_multiple() -> None: + raise ExceptionGroup( + "b" * 10, + [ + ExceptionGroup( + "c" * 10, + [ + ValueError("NOT IN SUMMARY"), + TypeError("NOT IN SUMMARY"), + ] + ) + ] + ) """ ) + # run with -vv to not truncate summary info, default width in tests is very low result = pytester.runpytest("-vv") assert result.ret == 1 backport_str = "exceptiongroup." if sys.version_info < (3, 11) else "" @@ -1756,7 +1771,11 @@ def test_multiple() -> None: "FAILED test_exceptiongroup_short_summary_info.py::test_multiple - " f"{backport_str}ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)" ), - "*= 4 failed in *", + ( + "FAILED test_exceptiongroup_short_summary_info.py::test_nested_multiple - " + f"{backport_str}ExceptionGroup: bbbbbbbbbb (1 sub-exception)" + ), + "*= 5 failed in *", ] ) From 6f61360a2b195ad15aba0e09354d662647865846 Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 22 Nov 2024 15:50:12 +0100 Subject: [PATCH 5/5] fixes after review --- src/_pytest/_code/code.py | 20 ++++++-------------- testing/code/test_excinfo.py | 6 +++--- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 0d635db132b..85ed3145e66 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -593,26 +593,18 @@ def exconly(self, tryshort: bool = False) -> str: def _get_single_subexc( eg: BaseExceptionGroup[BaseException], ) -> BaseException | None: - res: BaseException | None = None - for subexc in eg.exceptions: - if res is not None: - return None - - if isinstance(subexc, BaseExceptionGroup): - res = _get_single_subexc(subexc) - if res is None: - # there were multiple exceptions in the subgroup - return None - else: - res = subexc - return res + if len(eg.exceptions) != 1: + return None + if isinstance(e := eg.exceptions[0], BaseExceptionGroup): + return _get_single_subexc(e) + return e if ( tryshort and isinstance(self.value, BaseExceptionGroup) and (subexc := _get_single_subexc(self.value)) is not None ): - return f"[in {type(self.value).__name__}] {subexc!r}" + return f"{subexc!r} [single exception in {type(self.value).__name__}]" lines = format_exception_only(self.type, self.value) text = "".join(lines) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index d23382c898c..b049e0cf188 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -1757,15 +1757,15 @@ def test_nested_multiple() -> None: "*= short test summary info =*", ( "FAILED test_exceptiongroup_short_summary_info.py::test_base - " - "[in BaseExceptionGroup] SystemExit('aaaaaaaaaa')" + "SystemExit('aaaaaaaaaa') [single exception in BaseExceptionGroup]" ), ( "FAILED test_exceptiongroup_short_summary_info.py::test_nonbase - " - "[in ExceptionGroup] ValueError('aaaaaaaaaa')" + "ValueError('aaaaaaaaaa') [single exception in ExceptionGroup]" ), ( "FAILED test_exceptiongroup_short_summary_info.py::test_nested - " - "[in ExceptionGroup] ValueError('aaaaaaaaaa')" + "ValueError('aaaaaaaaaa') [single exception in ExceptionGroup]" ), ( "FAILED test_exceptiongroup_short_summary_info.py::test_multiple - "