Skip to content

Commit

Permalink
Add support for chained exceptions.
Browse files Browse the repository at this point in the history
Closes ipython#13982

This is a "backport" of python/cpython#106676

See documentation there
  • Loading branch information
Carreau committed Aug 29, 2023
1 parent 46c7ccf commit 95e0c37
Show file tree
Hide file tree
Showing 4 changed files with 140 additions and 5 deletions.
114 changes: 112 additions & 2 deletions IPython/core/debugger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -185,6 +191,9 @@ class Pdb(OldPdb):
"""

if CHAIN_EXCEPTIONS:
MAX_CHAINED_EXCEPTION_DEPTH = 999

default_predicates = {
"tbhide": True,
"readonly": False,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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())

Expand Down
8 changes: 7 additions & 1 deletion IPython/core/ultratb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions IPython/terminal/tests/test_debug_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
17 changes: 17 additions & 0 deletions docs/source/whatsnew/version8.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
============
8.x Series
============

.. _version 8.15:

IPython 8.15
------------

Medium release of IPython after a couple of month hiatus, and a bit off-schedule.

The main change is the addition of the ability to move between chained
exceptions when using IPdb, this feature was also contributed to upstream Pdb
and is thus native to CPython in Python 3.13+ Though ipdb should support this
feature in older version of Python. I invite you to look at the `CPython changes
and docs <https://github.com/python/cpython/pull/106676>`_ for more details.

I also want o thanks the `D.E. Shaw group <https://www.deshaw.com/>`_ for
suggesting and funding this feature.

.. _version 8.14:

IPython 8.14
Expand Down

0 comments on commit 95e0c37

Please sign in to comment.