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

This commit is contained in:
Matthias Bussonnier 2023-08-28 20:31:03 +02:00 committed by GitHub
parent 242bef459b
commit f75cefd402
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 526 additions and 20 deletions

View file

@ -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
@ -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

View file

@ -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:`~pdb.pm` using
the new ``exceptions [exc_number]`` command for Pdb. (Contributed by Matthias
Bussonnier in :gh:`106676`.)
sqlite3
-------

View file

@ -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")

View file

@ -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.

View file

@ -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.