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 - "