Skip to content

Commit

Permalink
Allow Pdb to move between chained exception.
Browse files Browse the repository at this point in the history
This lets Pdb receive an exception, instead of a traceback, and when
this is the case and the exception are chained, the new `exceptions` command
allows to both list (no arguments) and move between the chained exceptions.

That is to say if you have something like

    def out():
        try:
            middle()                                # B
        except Exception as e:
            raise ValueError("foo(): bar failed")   # A

    def middle():
        try:
            return inner(0)                         # D
        except Exception as e:
            raise ValueError("Middle fail") from e  # C

    def inner(x):
        1 / x                                       # E

Only A was reachable after calling `out()` and doing post mortem debug.

With this all A-E points are reachable with a combination of up/down,
and ``exceptions <number>``.

This also change the default behavior of ``pdb.pm()``, as well as
`python -m pdb <script.py>` to receive `sys.last_exc` so that chained
exception navigation is enabled.

We do follow the logic of the ``traceback`` module and handle the
``_context__`` and ``__cause__`` in the same way. That is to say, we try
``__cause__`` first, and if not present chain with ``__context__``. In
the same vein, if we encounter an exception that has
``__suppress_context__`` (like when ``raise ... from None``), we do stop
walking the chain.

Some implementation notes:

 - We do handle cycle in exceptions
 - cleanup of references to tracebacks are not cleared in ``forget()``, as
   ``setup()`` and ``forget()`` are both for setting a single
   exception.
 - We do not handle sub-exceptions of exception groups.
 - We ensure we do not hold references to exceptions too long with a new
   context manager.
 - Have the MAX_CHAINED_EXCEPTION_DEPTH class variable to control the
   maximum number we allow

Co-authored-by: Irit Katriel <1055913+iritkatriel@users.noreply.github.com>
  • Loading branch information
Carreau and iritkatriel committed Aug 28, 2023
1 parent 47d7eba commit 14a3b06
Show file tree
Hide file tree
Showing 5 changed files with 527 additions and 20 deletions.
53 changes: 51 additions & 2 deletions Doc/library/pdb.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ slightly different way:

.. 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
Expand Down Expand Up @@ -639,6 +639,55 @@ can be overridden by the local file.

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
Expand Down
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.13.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ pathlib
: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:`pm()` using
the new ``exceptions [exc_number]`` command for Pdb. (Contributed by Matthias
Bussonnier in :gh:`106676`.)

sqlite3
-------

Expand Down Expand Up @@ -189,6 +196,7 @@ typing
check whether a class is a :class:`typing.Protocol`. (Contributed by Jelle Zijlstra in
:gh:`104873`.)


Optimizations
=============

Expand Down
142 changes: 124 additions & 18 deletions Lib/pdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
import traceback
import linecache

from contextlib import contextmanager
from typing import Union


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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")

Expand Down
Loading

0 comments on commit 14a3b06

Please sign in to comment.