gh-106670: Allow Pdb to move between chained exceptions (#106676)

diff --git a/Doc/library/pdb.rst b/Doc/library/pdb.rst
index ef52370..3aaac15 100644
--- a/Doc/library/pdb.rst
+++ b/Doc/library/pdb.rst
@@ -175,8 +175,8 @@
 
 .. function:: pm()
 
-   Enter post-mortem debugging of the traceback found in
-   :data:`sys.last_traceback`.
+   Enter post-mortem debugging of the exception found in
+   :data:`sys.last_exc`.
 
 
 The ``run*`` functions and :func:`set_trace` are aliases for instantiating the
@@ -639,6 +639,55 @@
 
    Print the return value for the last return of the current function.
 
+.. pdbcommand:: exceptions [excnumber]
+
+   List or jump between chained exceptions.
+
+   When using ``pdb.pm()``  or ``Pdb.post_mortem(...)`` with a chained exception
+   instead of a traceback, it allows the user to move between the
+   chained exceptions using ``exceptions`` command to list exceptions, and
+   ``exception <number>`` to switch to that exception.
+
+
+   Example::
+
+        def out():
+            try:
+                middle()
+            except Exception as e:
+                raise ValueError("reraise middle() error") from e
+
+        def middle():
+            try:
+                return inner(0)
+            except Exception as e:
+                raise ValueError("Middle fail")
+
+        def inner(x):
+            1 / x
+
+         out()
+
+   calling ``pdb.pm()`` will allow to move between exceptions::
+
+    > example.py(5)out()
+    -> raise ValueError("reraise middle() error") from e
+
+    (Pdb) exceptions
+      0 ZeroDivisionError('division by zero')
+      1 ValueError('Middle fail')
+    > 2 ValueError('reraise middle() error')
+
+    (Pdb) exceptions 0
+    > example.py(16)inner()
+    -> 1 / x
+
+    (Pdb) up
+    > example.py(10)middle()
+    -> return inner(0)
+
+   .. versionadded:: 3.13
+
 .. rubric:: Footnotes
 
 .. [1] Whether a frame is considered to originate in a certain module
diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst
index a17b549..1c94da2 100644
--- a/Doc/whatsnew/3.13.rst
+++ b/Doc/whatsnew/3.13.rst
@@ -158,6 +158,13 @@
   :meth:`~pathlib.Path.is_dir`.
   (Contributed by Barney Gale in :gh:`77609` and :gh:`105793`.)
 
+pdb
+---
+
+* Add ability to move between chained exceptions during post mortem debugging in :func:`~pdb.pm` using
+  the new ``exceptions [exc_number]`` command for Pdb. (Contributed by Matthias
+  Bussonnier in :gh:`106676`.)
+
 sqlite3
 -------
 
diff --git a/Lib/pdb.py b/Lib/pdb.py
index 3db3e6a..90f26a2 100755
--- a/Lib/pdb.py
+++ b/Lib/pdb.py
@@ -85,6 +85,7 @@
 import traceback
 import linecache
 
+from contextlib import contextmanager
 from typing import Union
 
 
@@ -205,10 +206,15 @@ def namespace(self):
 # line_prefix = ': '    # Use this to get the old situation back
 line_prefix = '\n-> '   # Probably a better default
 
-class Pdb(bdb.Bdb, cmd.Cmd):
 
+
+class Pdb(bdb.Bdb, cmd.Cmd):
     _previous_sigint_handler = None
 
+    # Limit the maximum depth of chained exceptions, we should be handling cycles,
+    # but in case there are recursions, we stop at 999.
+    MAX_CHAINED_EXCEPTION_DEPTH = 999
+
     def __init__(self, completekey='tab', stdin=None, stdout=None, skip=None,
                  nosigint=False, readrc=True):
         bdb.Bdb.__init__(self, skip=skip)
@@ -256,6 +262,9 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, skip=None,
         self.commands_bnum = None # The breakpoint number for which we are
                                   # defining a list
 
+        self._chained_exceptions = tuple()
+        self._chained_exception_index = 0
+
     def sigint_handler(self, signum, frame):
         if self.allow_kbdint:
             raise KeyboardInterrupt
@@ -414,7 +423,64 @@ def preloop(self):
                     self.message('display %s: %r  [old: %r]' %
                                  (expr, newvalue, oldvalue))
 
-    def interaction(self, frame, traceback):
+    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 interaction(self, frame, tb_or_exc):
         # Restore the previous signal handler at the Pdb prompt.
         if Pdb._previous_sigint_handler:
             try:
@@ -423,14 +489,17 @@ def interaction(self, frame, traceback):
                 pass
             else:
                 Pdb._previous_sigint_handler = None
-        if self.setup(frame, traceback):
-            # no interaction desired at this time (happens if .pdbrc contains
-            # a command like "continue")
+
+        _chained_exceptions, tb = self._get_tb_and_exceptions(tb_or_exc)
+        with self._hold_exceptions(_chained_exceptions):
+            if self.setup(frame, tb):
+                # no interaction desired at this time (happens if .pdbrc contains
+                # a command like "continue")
+                self.forget()
+                return
+            self.print_stack_entry(self.stack[self.curindex])
+            self._cmdloop()
             self.forget()
-            return
-        self.print_stack_entry(self.stack[self.curindex])
-        self._cmdloop()
-        self.forget()
 
     def displayhook(self, obj):
         """Custom displayhook for the exec in default(), which prevents
@@ -1073,6 +1142,44 @@ def _select_frame(self, number):
         self.print_stack_entry(self.stack[self.curindex])
         self.lineno = None
 
+    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 do_up(self, arg):
         """u(p) [count]
 
@@ -1890,11 +1997,15 @@ def set_trace(*, header=None):
 # Post-Mortem interface
 
 def post_mortem(t=None):
-    """Enter post-mortem debugging of the given *traceback* object.
+    """Enter post-mortem debugging of the given *traceback*, or *exception*
+    object.
 
     If no traceback is given, it uses the one of the exception that is
     currently being handled (an exception must be being handled if the
     default is to be used).
+
+    If `t` is an exception object, the `exceptions` command makes it possible to
+    list and inspect its chained exceptions (if any).
     """
     # handling the default
     if t is None:
@@ -1911,12 +2022,8 @@ def post_mortem(t=None):
     p.interaction(None, t)
 
 def pm():
-    """Enter post-mortem debugging of the traceback found in sys.last_traceback."""
-    if hasattr(sys, 'last_exc'):
-        tb = sys.last_exc.__traceback__
-    else:
-        tb = sys.last_traceback
-    post_mortem(tb)
+    """Enter post-mortem debugging of the traceback found in sys.last_exc."""
+    post_mortem(sys.last_exc)
 
 
 # Main program for testing
@@ -1996,8 +2103,7 @@ def main():
             traceback.print_exc()
             print("Uncaught exception. Entering post mortem debugging")
             print("Running 'cont' or 'step' will restart the program")
-            t = e.__traceback__
-            pdb.interaction(None, t)
+            pdb.interaction(None, e)
             print("Post mortem debugger finished. The " + target +
                   " will be restarted")
 
diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py
index a669535..734b5c8 100644
--- a/Lib/test/test_pdb.py
+++ b/Lib/test/test_pdb.py
@@ -826,6 +826,349 @@ def test_convenience_variables():
     (Pdb) continue
     """
 
+
+def test_post_mortem_chained():
+    """Test post mortem traceback debugging of chained exception
+
+    >>> def test_function_2():
+    ...     try:
+    ...         1/0
+    ...     finally:
+    ...         print('Exception!')
+
+    >>> def test_function_reraise():
+    ...     try:
+    ...         test_function_2()
+    ...     except ZeroDivisionError as e:
+    ...         raise ZeroDivisionError('reraised') from e
+
+    >>> def test_function():
+    ...     import pdb;
+    ...     instance = pdb.Pdb(nosigint=True, readrc=False)
+    ...     try:
+    ...         test_function_reraise()
+    ...     except Exception as e:
+    ...         # same as pdb.post_mortem(e), but with custom pdb instance.
+    ...         instance.reset()
+    ...         instance.interaction(None, e)
+
+    >>> with PdbTestInput([  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    ...     'exceptions',
+    ...     'exceptions 0',
+    ...     'up',
+    ...     'down',
+    ...     'exceptions 1',
+    ...     'up',
+    ...     'down',
+    ...     'exceptions -1',
+    ...     'exceptions 3',
+    ...     'up',
+    ...     'exit',
+    ... ]):
+    ...    try:
+    ...        test_function()
+    ...    except ZeroDivisionError:
+    ...        print('Correctly reraised.')
+    Exception!
+    > <doctest test.test_pdb.test_post_mortem_chained[1]>(5)test_function_reraise()
+    -> raise ZeroDivisionError('reraised') from e
+    (Pdb) exceptions
+      0 ZeroDivisionError('division by zero')
+    > 1 ZeroDivisionError('reraised')
+    (Pdb) exceptions 0
+    > <doctest test.test_pdb.test_post_mortem_chained[0]>(3)test_function_2()
+    -> 1/0
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_chained[1]>(3)test_function_reraise()
+    -> test_function_2()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_chained[0]>(3)test_function_2()
+    -> 1/0
+    (Pdb) exceptions 1
+    > <doctest test.test_pdb.test_post_mortem_chained[1]>(5)test_function_reraise()
+    -> raise ZeroDivisionError('reraised') from e
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_chained[2]>(5)test_function()
+    -> test_function_reraise()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_chained[1]>(5)test_function_reraise()
+    -> raise ZeroDivisionError('reraised') from e
+    (Pdb) exceptions -1
+    *** No exception with that number
+    (Pdb) exceptions 3
+    *** No exception with that number
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_chained[2]>(5)test_function()
+    -> test_function_reraise()
+    (Pdb) exit
+    """
+
+
+def test_post_mortem_cause_no_context():
+    """Test post mortem traceback debugging of chained exception
+
+    >>> def main():
+    ...     try:
+    ...         raise ValueError('Context Not Shown')
+    ...     except Exception as e1:
+    ...         raise ValueError("With Cause") from TypeError('The Cause')
+
+    >>> def test_function():
+    ...     import pdb;
+    ...     instance = pdb.Pdb(nosigint=True, readrc=False)
+    ...     try:
+    ...         main()
+    ...     except Exception as e:
+    ...         # same as pdb.post_mortem(e), but with custom pdb instance.
+    ...         instance.reset()
+    ...         instance.interaction(None, e)
+
+    >>> with PdbTestInput([  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    ...     'exceptions',
+    ...     'exceptions 1',
+    ...     'up',
+    ...     'down',
+    ...     'exit',
+    ... ]):
+    ...    try:
+    ...        test_function()
+    ...    except ValueError:
+    ...        print('Ok.')
+    > <doctest test.test_pdb.test_post_mortem_cause_no_context[0]>(5)main()
+    -> raise ValueError("With Cause") from TypeError('The Cause')
+    (Pdb) exceptions
+      0 TypeError('The Cause')
+    > 1 ValueError('With Cause')
+    (Pdb) exceptions 1
+    > <doctest test.test_pdb.test_post_mortem_cause_no_context[0]>(5)main()
+    -> raise ValueError("With Cause") from TypeError('The Cause')
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_cause_no_context[1]>(5)test_function()
+    -> main()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_cause_no_context[0]>(5)main()
+    -> raise ValueError("With Cause") from TypeError('The Cause')
+    (Pdb) exit"""
+
+
+def test_post_mortem_context_of_the_cause():
+    """Test post mortem traceback debugging of chained exception
+
+
+    >>> def main():
+    ...     try:
+    ...         raise TypeError('Context of the cause')
+    ...     except Exception as e1:
+    ...         try:
+    ...             raise ValueError('Root Cause')
+    ...         except Exception as e2:
+    ...             ex = e2
+    ...         raise ValueError("With Cause, and cause has context") from ex
+
+    >>> def test_function():
+    ...     import pdb;
+    ...     instance = pdb.Pdb(nosigint=True, readrc=False)
+    ...     try:
+    ...         main()
+    ...     except Exception as e:
+    ...         # same as pdb.post_mortem(e), but with custom pdb instance.
+    ...         instance.reset()
+    ...         instance.interaction(None, e)
+
+    >>> with PdbTestInput([  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    ...     'exceptions',
+    ...     'exceptions 2',
+    ...     'up',
+    ...     'down',
+    ...     'exceptions 3',
+    ...     'up',
+    ...     'down',
+    ...     'exceptions 4',
+    ...     'up',
+    ...     'down',
+    ...     'exit',
+    ... ]):
+    ...    try:
+    ...        test_function()
+    ...    except ValueError:
+    ...        print('Correctly reraised.')
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[0]>(9)main()
+    -> raise ValueError("With Cause, and cause has context") from ex
+    (Pdb) exceptions
+      0 TypeError('Context of the cause')
+      1 ValueError('Root Cause')
+    > 2 ValueError('With Cause, and cause has context')
+    (Pdb) exceptions 2
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[0]>(9)main()
+    -> raise ValueError("With Cause, and cause has context") from ex
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[1]>(5)test_function()
+    -> main()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[0]>(9)main()
+    -> raise ValueError("With Cause, and cause has context") from ex
+    (Pdb) exceptions 3
+    *** No exception with that number
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[1]>(5)test_function()
+    -> main()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[0]>(9)main()
+    -> raise ValueError("With Cause, and cause has context") from ex
+    (Pdb) exceptions 4
+    *** No exception with that number
+    (Pdb) up
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[1]>(5)test_function()
+    -> main()
+    (Pdb) down
+    > <doctest test.test_pdb.test_post_mortem_context_of_the_cause[0]>(9)main()
+    -> raise ValueError("With Cause, and cause has context") from ex
+    (Pdb) exit
+    """
+
+
+def test_post_mortem_from_none():
+    """Test post mortem traceback debugging of chained exception
+
+    In particular that cause from None (which sets __supress_context__ to True)
+    does not show context.
+
+
+    >>> def main():
+    ...     try:
+    ...         raise TypeError('Context of the cause')
+    ...     except Exception as e1:
+    ...         raise ValueError("With Cause, and cause has context") from None
+
+    >>> def test_function():
+    ...     import pdb;
+    ...     instance = pdb.Pdb(nosigint=True, readrc=False)
+    ...     try:
+    ...         main()
+    ...     except Exception as e:
+    ...         # same as pdb.post_mortem(e), but with custom pdb instance.
+    ...         instance.reset()
+    ...         instance.interaction(None, e)
+
+    >>> with PdbTestInput([  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    ...     'exceptions',
+    ...     'exit',
+    ... ]):
+    ...    try:
+    ...        test_function()
+    ...    except ValueError:
+    ...        print('Correctly reraised.')
+    > <doctest test.test_pdb.test_post_mortem_from_none[0]>(5)main()
+    -> raise ValueError("With Cause, and cause has context") from None
+    (Pdb) exceptions
+    > 0 ValueError('With Cause, and cause has context')
+    (Pdb) exit
+    """
+
+
+def test_post_mortem_complex():
+    """Test post mortem traceback debugging of chained exception
+
+    Test with simple and complex cycles, exception groups,...
+
+    >>> def make_ex_with_stack(type_, *content, from_=None):
+    ...     try:
+    ...         raise type_(*content) from from_
+    ...     except Exception as out:
+    ...         return out
+    ...
+
+    >>> def cycle():
+    ...     try:
+    ...         raise ValueError("Cycle Leaf")
+    ...     except Exception as e:
+    ...         raise e from e
+    ...
+
+    >>> def tri_cycle():
+    ...     a = make_ex_with_stack(ValueError, "Cycle1")
+    ...     b = make_ex_with_stack(ValueError, "Cycle2")
+    ...     c = make_ex_with_stack(ValueError, "Cycle3")
+    ...
+    ...     a.__cause__ = b
+    ...     b.__cause__ = c
+    ...
+    ...     raise c from a
+    ...
+
+    >>> def cause():
+    ...     try:
+    ...         raise ValueError("Cause Leaf")
+    ...     except Exception as e:
+    ...         raise e
+    ...
+
+    >>> def context(n=10):
+    ...     try:
+    ...         raise ValueError(f"Context Leaf {n}")
+    ...     except Exception as e:
+    ...         if n == 0:
+    ...             raise ValueError(f"With Context {n}") from e
+    ...         else:
+    ...             context(n - 1)
+    ...
+
+    >>> def main():
+    ...     try:
+    ...         cycle()
+    ...     except Exception as e1:
+    ...         try:
+    ...             tri_cycle()
+    ...         except Exception as e2:
+    ...             ex = e2
+    ...         raise ValueError("With Context and With Cause") from ex
+
+
+    >>> def test_function():
+    ...     import pdb;
+    ...     instance = pdb.Pdb(nosigint=True, readrc=False)
+    ...     try:
+    ...         main()
+    ...     except Exception as e:
+    ...         # same as pdb.post_mortem(e), but with custom pdb instance.
+    ...         instance.reset()
+    ...         instance.interaction(None, e)
+
+    >>> with PdbTestInput(  # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    ...     ["exceptions",
+    ...     "exceptions 0",
+    ...     "exceptions 1",
+    ...     "exceptions 2",
+    ...     "exceptions 3",
+    ...     "exit"],
+    ... ):
+    ...    try:
+    ...        test_function()
+    ...    except ValueError:
+    ...        print('Correctly reraised.')
+        > <doctest test.test_pdb.test_post_mortem_complex[5]>(9)main()
+    -> raise ValueError("With Context and With Cause") from ex
+    (Pdb) exceptions
+        0 ValueError('Cycle2')
+        1 ValueError('Cycle1')
+        2 ValueError('Cycle3')
+    >   3 ValueError('With Context and With Cause')
+    (Pdb) exceptions 0
+    > <doctest test.test_pdb.test_post_mortem_complex[0]>(3)make_ex_with_stack()
+    -> raise type_(*content) from from_
+    (Pdb) exceptions 1
+    > <doctest test.test_pdb.test_post_mortem_complex[0]>(3)make_ex_with_stack()
+    -> raise type_(*content) from from_
+    (Pdb) exceptions 2
+    > <doctest test.test_pdb.test_post_mortem_complex[0]>(3)make_ex_with_stack()
+    -> raise type_(*content) from from_
+    (Pdb) exceptions 3
+    > <doctest test.test_pdb.test_post_mortem_complex[5]>(9)main()
+    -> raise ValueError("With Context and With Cause") from ex
+    (Pdb) exit
+    """
+
+
 def test_post_mortem():
     """Test post mortem traceback debugging.
 
diff --git a/Misc/NEWS.d/next/Library/2023-07-12-10-59-08.gh-issue-106670.goQ2Sy.rst b/Misc/NEWS.d/next/Library/2023-07-12-10-59-08.gh-issue-106670.goQ2Sy.rst
new file mode 100644
index 0000000..0bb1831
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2023-07-12-10-59-08.gh-issue-106670.goQ2Sy.rst
@@ -0,0 +1 @@
+Add the new ``exceptions`` command to the Pdb debugger. It makes it possible to move between chained exceptions when using post mortem debugging.