Repository: pytest-dev/pytest
Pull request: display single contained exception in excgroups in test summary
fixes #12943 

As `ExceptionInfo` still doesn't have proper support for exception groups this continues upon the hacky solution from #10209, modifying the reprcrash as well.

I find this solution ... incredibly hacky and ugly, and am not a fan that n==1 gets handled but not n==2. But conceptually it just gets really tricky to figure out what info to strip, and how to show that. If we're going with stripping structure & group messages we could extend this solution to something like `[in ExceptionGroup]: ValueError("foo"), TypeError("bar")`.

EDIT: less hacky now that I moved the code to `ExceptionInfo.exconly`

Problem statement:
`=== short test summary info ===` messages are not useful for `ExceptionGroup`s
If there is only a single leaf exception, at any depth, I suggest we produce the short summary message _for that leaf exception_, and append `[single exception in an ExceptionGroup]` to the message.

Hints from issue discussion:
bikeshedding: `[single exception in an ExceptionGroup]` to make it clear it is a single exception, and avoid someone thinking "ok and about the other exceptions in that exception group"?
One problem with appending the `[single exc...]` is it getting lost if the error message is very long and gets truncated, making it impossible to distinguish a wrapped exception from a non-wrapped exception.
```python
FAILED foo.py::test_bar - ValueError: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
```
so unless we want custom logic for the truncation in [`_format_trimmed`](https://github.com/pytest-dev/pytest/blob/831d061a84c86c2d5ba99a7b3efde65862d8ea58/src/_pytest/terminal.py#L1428)/[`_get_line_with_reprcrash_message`](https://github.com/pytest-dev/pytest/blob/831d061a84c86c2d5ba99a7b3efde65862d8ea58/src/_pytest/terminal.py#L1453) it's probably better to prepend any message.


(you don't get truncation if you run in CI or with `-vv`, but day-to-day users will encounter it)
I think it's okay to truncate here; I'd rather lose the note than the actual message at least - and the group will still be visible if you go look at the specific test.

Commit message: add test case for codecov

Patch to review:
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..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()
diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py
index fc60ae9ac99..d23382c898c 100644
--- a/testing/code/test_excinfo.py
+++ b/testing/code/test_excinfo.py
@@ -1703,6 +1703,83 @@ 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"),
+                ]
+            )
+
+        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 ""
+    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 - "
+                f"{backport_str}ExceptionGroup: bbbbbbbbbb (2 sub-exceptions)"
+            ),
+            (
+                "FAILED test_exceptiongroup_short_summary_info.py::test_nested_multiple - "
+                f"{backport_str}ExceptionGroup: bbbbbbbbbb (1 sub-exception)"
+            ),
+            "*= 5 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."""


Code review comments:
diff_hunk: @@ -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}"
path: src/_pytest/_code/code.py
text: @user1:
```suggestion
            return f"{subexc!r} [single exception in {type(self.value).__name__}]"
```
as discussed in the issue

@author:
fixed, but noted my disagreement in the issue :)