summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Doc/library/pdb.rst53
-rw-r--r--Doc/whatsnew/3.13.rst7
-rwxr-xr-xLib/pdb.py142
-rw-r--r--Lib/test/test_pdb.py343
-rw-r--r--Misc/NEWS.d/next/Library/2023-07-12-10-59-08.gh-issue-106670.goQ2Sy.rst1
5 files changed, 526 insertions, 20 deletions
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 @@ 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
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 @@ 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
-------
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 functools
import traceback
import linecache
+from contextlib import contextmanager
from typing import Union
@@ -205,10 +206,15 @@ class _ModuleTarget(str):
# 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 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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 @@ class Pdb(bdb.Bdb, cmd.Cmd):
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.