Repository: ipython/ipython

Issue:
ipdb: support for chained exceptions
This is a common pattern to enrich an exception with more information:
```
def foo(x):
    try:
        bar(x)
    except Exception as e:
        raise ValueError("foo(): bar failed") from e
def bar(x):
    1/x
```

This is common when a second exception occurs during exception handling:
```
def foo(x):
    try:
        bar(x)
    except Exception as e:
        raise ValueError("some other error")
def bar(x):
    1/x
```

In both cases, ipdb (and pdb) wont let us step up the stack from to find the error in `bar`. While python 3.11 has [Exception.add_note](https://peps.python.org/pep-0678/), there will continue to be lots of code using the methods above (and its not always practical to use this).

Example:
![image](https://user-images.githubusercontent.com/1813603/227288166-6a551e63-5fcc-4f3c-adbd-e690a75174b7.png)

Can we enhance ipdb to handle both cases?


Discussion:
Apologies for the late reply to this issue. After a bit of investigating it should be possible with some refactor; I believe in both case. 

One limitation is that pdb get a Traceback by default instead of a full Exception and you can go from Exception to TB  but not the other way around.

I can mess around with the internal state of pdb when you do `up`  (from the oldest frame), or `down` ( from the Newest frame), and jump to the `__context__` (or `__cause__` of `raise ...from`) it seem to work, at least on the surface.
Discussion upstream before opening an issue https://discuss.python.org/t/interested-in-support-to-jump-between-chained-exception-in-pdb/29470
See discussion in https://github.com/python/cpython/issues/106670 as well.

Resolving patch:
diff --git a/IPython/core/debugger.py b/IPython/core/debugger.py
--- a/IPython/core/debugger.py
+++ b/IPython/core/debugger.py
@@ -108,6 +108,7 @@
 import os
 
 from IPython import get_ipython
+from contextlib import contextmanager
 from IPython.utils import PyColorize
 from IPython.utils import coloransi, py3compat
 from IPython.core.excolors import exception_colors
@@ -127,6 +128,11 @@
 DEBUGGERSKIP = "__debuggerskip__"
 
 
+# this has been implemented in Pdb in Python 3.13 (https://github.com/python/cpython/pull/106676
+# on lower python versions, we backported the feature.
+CHAIN_EXCEPTIONS = sys.version_info < (3, 13)
+
+
 def make_arrow(pad):
     """generate the leading arrow in front of traceback or debugger"""
     if pad >= 2:
@@ -185,6 +191,9 @@ class Pdb(OldPdb):
 
     """
 
+    if CHAIN_EXCEPTIONS:
+        MAX_CHAINED_EXCEPTION_DEPTH = 999
+
     default_predicates = {
         "tbhide": True,
         "readonly": False,
@@ -281,6 +290,10 @@ def __init__(self, completekey=None, stdin=None, stdout=None, context=5, **kwarg
         # list of predicates we use to skip frames
         self._predicates = self.default_predicates
 
+        if CHAIN_EXCEPTIONS:
+            self._chained_exceptions = tuple()
+            self._chained_exception_index = 0
+
     #
     def set_colors(self, scheme):
         """Shorthand access to the color table scheme selector method."""
@@ -330,9 +343,106 @@ def hidden_frames(self, stack):
             ip_hide = [h if i > ip_start[0] else True for (i, h) in enumerate(ip_hide)]
         return ip_hide
 
-    def interaction(self, frame, traceback):
+    if CHAIN_EXCEPTIONS:
+
+        def _get_tb_and_exceptions(self, tb_or_exc):
+            """
+            Given a tracecack or an exception, return a tuple of chained exceptions
+            and current traceback to inspect.
+            This will deal with selecting the right ``__cause__`` or ``__context__``
+            as well as handling cycles, and return a flattened list of exceptions we
+            can jump to with do_exceptions.
+            """
+            _exceptions = []
+            if isinstance(tb_or_exc, BaseException):
+                traceback, current = tb_or_exc.__traceback__, tb_or_exc
+
+                while current is not None:
+                    if current in _exceptions:
+                        break
+                    _exceptions.append(current)
+                    if current.__cause__ is not None:
+                        current = current.__cause__
+                    elif (
+                        current.__context__ is not None
+                        and not current.__suppress_context__
+                    ):
+                        current = current.__context__
+
+                    if len(_exceptions) >= self.MAX_CHAINED_EXCEPTION_DEPTH:
+                        self.message(
+                            f"More than {self.MAX_CHAINED_EXCEPTION_DEPTH}"
+                            " chained exceptions found, not all exceptions"
+                            "will be browsable with `exceptions`."
+                        )
+                        break
+            else:
+                traceback = tb_or_exc
+            return tuple(reversed(_exceptions)), traceback
+
+        @contextmanager
+        def _hold_exceptions(self, exceptions):
+            """
+            Context manager to ensure proper cleaning of exceptions references
+            When given a chained exception instead of a traceback,
+            pdb may hold references to many objects which may leak memory.
+            We use this context manager to make sure everything is properly cleaned
+            """
+            try:
+                self._chained_exceptions = exceptions
+                self._chained_exception_index = len(exceptions) - 1
+                yield
+            finally:
+                # we can't put those in forget as otherwise they would
+                # be cleared on exception change
+                self._chained_exceptions = tuple()
+                self._chained_exception_index = 0
+
+        def do_exceptions(self, arg):
+            """exceptions [number]
+            List or change current exception in an exception chain.
+            Without arguments, list all the current exception in the exception
+            chain. Exceptions will be numbered, with the current exception indicated
+            with an arrow.
+            If given an integer as argument, switch to the exception at that index.
+            """
+            if not self._chained_exceptions:
+                self.message(
+                    "Did not find chained exceptions. To move between"
+                    " exceptions, pdb/post_mortem must be given an exception"
+                    " object rather than a traceback."
+                )
+                return
+            if not arg:
+                for ix, exc in enumerate(self._chained_exceptions):
+                    prompt = ">" if ix == self._chained_exception_index else " "
+                    rep = repr(exc)
+                    if len(rep) > 80:
+                        rep = rep[:77] + "..."
+                    self.message(f"{prompt} {ix:>3} {rep}")
+            else:
+                try:
+                    number = int(arg)
+                except ValueError:
+                    self.error("Argument must be an integer")
+                    return
+                if 0 <= number < len(self._chained_exceptions):
+                    self._chained_exception_index = number
+                    self.setup(None, self._chained_exceptions[number].__traceback__)
+                    self.print_stack_entry(self.stack[self.curindex])
+                else:
+                    self.error("No exception with that number")
+
+    def interaction(self, frame, tb_or_exc):
         try:
-            OldPdb.interaction(self, frame, traceback)
+            if CHAIN_EXCEPTIONS:
+                # this context manager is part of interaction in 3.13
+                _chained_exceptions, tb = self._get_tb_and_exceptions(tb_or_exc)
+                with self._hold_exceptions(_chained_exceptions):
+                    OldPdb.interaction(self, frame, tb)
+            else:
+                OldPdb.interaction(self, frame, traceback)
+
         except KeyboardInterrupt:
             self.stdout.write("\n" + self.shell.get_exception_only())
 
diff --git a/IPython/core/ultratb.py b/IPython/core/ultratb.py
--- a/IPython/core/ultratb.py
+++ b/IPython/core/ultratb.py
@@ -1246,7 +1246,13 @@ def debugger(self, force: bool = False):
                 if etb and etb.tb_next:
                     etb = etb.tb_next
                 self.pdb.botframe = etb.tb_frame
-                self.pdb.interaction(None, etb)
+                # last_value should be deprecated, but last-exc sometimme not set
+                # please check why later and remove the getattr.
+                exc = sys.last_value if sys.version_info < (3, 12) else getattr(sys, "last_exc", sys.last_value)  # type: ignore[attr-defined]
+                if exc:
+                    self.pdb.interaction(None, exc)
+                else:
+                    self.pdb.interaction(None, etb)
 
         if hasattr(self, 'tb'):
             del self.tb


Test patch:
diff --git a/IPython/terminal/tests/test_debug_magic.py b/IPython/terminal/tests/test_debug_magic.py
--- a/IPython/terminal/tests/test_debug_magic.py
+++ b/IPython/terminal/tests/test_debug_magic.py
@@ -68,8 +68,10 @@ def test_debug_magic_passes_through_generators():
     child.expect_exact('----> 1 for x in gen:')
 
     child.expect(ipdb_prompt)
-    child.sendline('u')
-    child.expect_exact('*** Oldest frame')
+    child.sendline("u")
+    child.expect_exact(
+        "*** all frames above hidden, use `skip_hidden False` to get get into those."
+    )
 
     child.expect(ipdb_prompt)
     child.sendline('exit')