Repository: ipython/ipython

Issue:
`ultratb` hides the contents of `ExceptionGroup` errors
This probably isn't too bad yet, but Python 3.11 finally will be out next month, and `ExceptionGroup` is already seeing growing usage across the ecosystem - for example in Pytest, Hypothesis, and Trio.  Ideally we'd get this working soon, so that users who only rarely update are likely to have a compatible set of packages before they hit this interaction.

As a simple reproducing example:

```python
try:
    ExceptionGroup
except NameError:
    from exceptiongroup import ExceptionGroup

# Realistically these errors would have their own nested tracebacks, but for simplicity...
raise ExceptionGroup("Multiple errors", [ValueError("msg"), TypeError("msg")])
```
In the standard Python REPL, this shows:
```
>>> raise ExceptionGroup("Multiple errors", [ValueError("msg"), TypeError("msg")])
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  | exceptiongroup.ExceptionGroup: Multiple errors (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | ValueError: msg
    +---------------- 2 ----------------
    | TypeError: msg
    +------------------------------------
```
but in IPython, or a Jupyter notebook, we only see the container exception:
```python-traceback
In [2]: try:
   ...:     ExceptionGroup
   ...: except NameError:
   ...:     from exceptiongroup import ExceptionGroup
   ...: 
   ...: raise ExceptionGroup("Multiple errors", [ValueError("msg"), TypeError("msg")])
---------------------------------------------------------------------------
ExceptionGroup                            Traceback (most recent call last)
Input In [3], in <cell line: 6>()
      3 except NameError:
      4     from exceptiongroup import ExceptionGroup
----> 6 raise ExceptionGroup("Multiple errors", [ValueError("msg"), TypeError("msg")])

ExceptionGroup: Multiple errors (2 sub-exceptions)
```



Discussion:
We did a quick-and-dirty patch in Pytest that you might want to borrow from: https://github.com/pytest-dev/pytest/pull/10209

Resolving patch:
diff --git a/IPython/core/interactiveshell.py b/IPython/core/interactiveshell.py
--- a/IPython/core/interactiveshell.py
+++ b/IPython/core/interactiveshell.py
@@ -112,6 +112,8 @@ def sphinxify_docstring(docstring):
 except ImportError:
     sphinxify = None
 
+if sys.version_info[:2] < (3, 11):
+    from exceptiongroup import BaseExceptionGroup
 
 class ProvisionalWarning(DeprecationWarning):
     """
@@ -2095,25 +2097,38 @@ def showtraceback(self, exc_tuple=None, filename=None, tb_offset=None,
                     stb.extend(self.InteractiveTB.get_exception_only(etype,
                                                                      value))
                 else:
-                    try:
-                        # Exception classes can customise their traceback - we
-                        # use this in IPython.parallel for exceptions occurring
-                        # in the engines. This should return a list of strings.
-                        if hasattr(value, "_render_traceback_"):
-                            stb = value._render_traceback_()
-                        else:
-                            stb = self.InteractiveTB.structured_traceback(
-                                etype, value, tb, tb_offset=tb_offset
-                            )
 
-                    except Exception:
-                        print(
-                            "Unexpected exception formatting exception. Falling back to standard exception"
-                        )
+                    def contains_exceptiongroup(val):
+                        if val is None:
+                            return False
+                        return isinstance(
+                            val, BaseExceptionGroup
+                        ) or contains_exceptiongroup(val.__context__)
+
+                    if contains_exceptiongroup(value):
+                        # fall back to native exception formatting until ultratb
+                        # supports exception groups
                         traceback.print_exc()
-                        return None
+                    else:
+                        try:
+                            # Exception classes can customise their traceback - we
+                            # use this in IPython.parallel for exceptions occurring
+                            # in the engines. This should return a list of strings.
+                            if hasattr(value, "_render_traceback_"):
+                                stb = value._render_traceback_()
+                            else:
+                                stb = self.InteractiveTB.structured_traceback(
+                                    etype, value, tb, tb_offset=tb_offset
+                                )
+
+                        except Exception:
+                            print(
+                                "Unexpected exception formatting exception. Falling back to standard exception"
+                            )
+                            traceback.print_exc()
+                            return None
 
-                    self._showtraceback(etype, value, stb)
+                        self._showtraceback(etype, value, stb)
                     if self.call_pdb:
                         # drop into debugger
                         self.debugger(force=True)


Test patch:
diff --git a/IPython/core/tests/test_exceptiongroup_tb.py b/IPython/core/tests/test_exceptiongroup_tb.py
new file mode 100644
--- /dev/null
+++ b/IPython/core/tests/test_exceptiongroup_tb.py
@@ -0,0 +1,112 @@
+import unittest
+import re
+from IPython.utils.capture import capture_output
+import sys
+import pytest
+from tempfile import TemporaryDirectory
+from IPython.testing import tools as tt
+
+
+def _exceptiongroup_common(
+    outer_chain: str,
+    inner_chain: str,
+    native: bool,
+) -> None:
+    pre_raise = "exceptiongroup." if not native else ""
+    pre_catch = pre_raise if sys.version_info < (3, 11) else ""
+    filestr = f"""
+    {"import exceptiongroup" if not native else ""}
+    import pytest
+
+    def f(): raise ValueError("From f()")
+    def g(): raise BaseException("From g()")
+
+    def inner(inner_chain):
+        excs = []
+        for callback in [f, g]:
+            try:
+                callback()
+            except BaseException as err:
+                excs.append(err)
+        if excs:
+            if inner_chain == "none":
+                raise {pre_raise}BaseExceptionGroup("Oops", excs)
+            try:
+                raise SyntaxError()
+            except SyntaxError as e:
+                if inner_chain == "from":
+                    raise {pre_raise}BaseExceptionGroup("Oops", excs) from e
+                else:
+                    raise {pre_raise}BaseExceptionGroup("Oops", excs)
+
+    def outer(outer_chain, inner_chain):
+        try:
+            inner(inner_chain)
+        except {pre_catch}BaseExceptionGroup as e:
+            if outer_chain == "none":
+                raise
+            if outer_chain == "from":
+                raise IndexError() from e
+            else:
+                raise IndexError
+
+
+    outer("{outer_chain}", "{inner_chain}")
+    """
+    with capture_output() as cap:
+        ip.run_cell(filestr)
+
+    match_lines = []
+    if inner_chain == "another":
+        match_lines += [
+            "During handling of the above exception, another exception occurred:",
+        ]
+    elif inner_chain == "from":
+        match_lines += [
+            "The above exception was the direct cause of the following exception:",
+        ]
+
+    match_lines += [
+        "  + Exception Group Traceback (most recent call last):",
+        f"  | {pre_catch}BaseExceptionGroup: Oops (2 sub-exceptions)",
+        "    | ValueError: From f()",
+        "    | BaseException: From g()",
+    ]
+
+    if outer_chain == "another":
+        match_lines += [
+            "During handling of the above exception, another exception occurred:",
+            "IndexError",
+        ]
+    elif outer_chain == "from":
+        match_lines += [
+            "The above exception was the direct cause of the following exception:",
+            "IndexError",
+        ]
+
+    error_lines = cap.stderr.split("\n")
+
+    err_index = match_index = 0
+    for expected in match_lines:
+        for i, actual in enumerate(error_lines):
+            if actual == expected:
+                error_lines = error_lines[i + 1 :]
+                break
+        else:
+            assert False, f"{expected} not found in cap.stderr"
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 11), reason="Native ExceptionGroup not implemented"
+)
+@pytest.mark.parametrize("outer_chain", ["none", "from", "another"])
+@pytest.mark.parametrize("inner_chain", ["none", "from", "another"])
+def test_native_exceptiongroup(outer_chain, inner_chain) -> None:
+    _exceptiongroup_common(outer_chain, inner_chain, native=True)
+
+
+@pytest.mark.parametrize("outer_chain", ["none", "from", "another"])
+@pytest.mark.parametrize("inner_chain", ["none", "from", "another"])
+def test_native_exceptiongroup(outer_chain, inner_chain) -> None:
+    pytest.importorskip("exceptiongroup")
+    _exceptiongroup_common(outer_chain, inner_chain, native=False)