Skip to content

Commit 8467472

Browse files
committed
Add support for chained exceptions.
Closes ipython#13982 This is a "backport" of python/cpython#106676 See documentation there
1 parent 46c7ccf commit 8467472

File tree

4 files changed

+139
-5
lines changed

4 files changed

+139
-5
lines changed

IPython/core/debugger.py

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
import os
109109

110110
from IPython import get_ipython
111+
from contextlib import contextmanager
111112
from IPython.utils import PyColorize
112113
from IPython.utils import coloransi, py3compat
113114
from IPython.core.excolors import exception_colors
@@ -127,6 +128,11 @@
127128
DEBUGGERSKIP = "__debuggerskip__"
128129

129130

131+
# this has been implemented in Pdb in Python 3.13 (https://github.com/python/cpython/pull/106676
132+
# on lower python versions, we backported the feature.
133+
CHAIN_EXCEPTIONS = sys.version_info < (3, 13)
134+
135+
130136
def make_arrow(pad):
131137
"""generate the leading arrow in front of traceback or debugger"""
132138
if pad >= 2:
@@ -185,6 +191,9 @@ class Pdb(OldPdb):
185191
186192
"""
187193

194+
if CHAIN_EXCEPTIONS:
195+
MAX_CHAINED_EXCEPTION_DEPTH = 999
196+
188197
default_predicates = {
189198
"tbhide": True,
190199
"readonly": False,
@@ -281,6 +290,10 @@ def __init__(self, completekey=None, stdin=None, stdout=None, context=5, **kwarg
281290
# list of predicates we use to skip frames
282291
self._predicates = self.default_predicates
283292

293+
if CHAIN_EXCEPTIONS:
294+
self._chained_exceptions = tuple()
295+
self._chained_exception_index = 0
296+
284297
#
285298
def set_colors(self, scheme):
286299
"""Shorthand access to the color table scheme selector method."""
@@ -330,12 +343,110 @@ def hidden_frames(self, stack):
330343
ip_hide = [h if i > ip_start[0] else True for (i, h) in enumerate(ip_hide)]
331344
return ip_hide
332345

333-
def interaction(self, frame, traceback):
346+
if CHAIN_EXCEPTIONS:
347+
348+
def _get_tb_and_exceptions(self, tb_or_exc):
349+
"""
350+
Given a tracecack or an exception, return a tuple of chained exceptions
351+
and current traceback to inspect.
352+
This will deal with selecting the right ``__cause__`` or ``__context__``
353+
as well as handling cycles, and return a flattened list of exceptions we
354+
can jump to with do_exceptions.
355+
"""
356+
_exceptions = []
357+
if isinstance(tb_or_exc, BaseException):
358+
traceback, current = tb_or_exc.__traceback__, tb_or_exc
359+
360+
while current is not None:
361+
if current in _exceptions:
362+
break
363+
_exceptions.append(current)
364+
if current.__cause__ is not None:
365+
current = current.__cause__
366+
elif (
367+
current.__context__ is not None
368+
and not current.__suppress_context__
369+
):
370+
current = current.__context__
371+
372+
if len(_exceptions) >= self.MAX_CHAINED_EXCEPTION_DEPTH:
373+
self.message(
374+
f"More than {self.MAX_CHAINED_EXCEPTION_DEPTH}"
375+
" chained exceptions found, not all exceptions"
376+
"will be browsable with `exceptions`."
377+
)
378+
break
379+
else:
380+
traceback = tb_or_exc
381+
return tuple(reversed(_exceptions)), traceback
382+
383+
@contextmanager
384+
def _hold_exceptions(self, exceptions):
385+
"""
386+
Context manager to ensure proper cleaning of exceptions references
387+
When given a chained exception instead of a traceback,
388+
pdb may hold references to many objects which may leak memory.
389+
We use this context manager to make sure everything is properly cleaned
390+
"""
391+
try:
392+
self._chained_exceptions = exceptions
393+
self._chained_exception_index = len(exceptions) - 1
394+
yield
395+
finally:
396+
# we can't put those in forget as otherwise they would
397+
# be cleared on exception change
398+
self._chained_exceptions = tuple()
399+
self._chained_exception_index = 0
400+
401+
def do_exceptions(self, arg):
402+
"""exceptions [number]
403+
List or change current exception in an exception chain.
404+
Without arguments, list all the current exception in the exception
405+
chain. Exceptions will be numbered, with the current exception indicated
406+
with an arrow.
407+
If given an integer as argument, switch to the exception at that index.
408+
"""
409+
if not self._chained_exceptions:
410+
self.message(
411+
"Did not find chained exceptions. To move between"
412+
" exceptions, pdb/post_mortem must be given an exception"
413+
" object rather than a traceback."
414+
)
415+
return
416+
if not arg:
417+
for ix, exc in enumerate(self._chained_exceptions):
418+
prompt = ">" if ix == self._chained_exception_index else " "
419+
rep = repr(exc)
420+
if len(rep) > 80:
421+
rep = rep[:77] + "..."
422+
self.message(f"{prompt} {ix:>3} {rep}")
423+
else:
424+
try:
425+
number = int(arg)
426+
except ValueError:
427+
self.error("Argument must be an integer")
428+
return
429+
if 0 <= number < len(self._chained_exceptions):
430+
self._chained_exception_index = number
431+
self.setup(None, self._chained_exceptions[number].__traceback__)
432+
self.print_stack_entry(self.stack[self.curindex])
433+
else:
434+
self.error("No exception with that number")
435+
436+
def interaction(self, frame, tb_or_exc):
334437
try:
335-
OldPdb.interaction(self, frame, traceback)
438+
if CHAIN_EXCEPTIONS:
439+
# this context manager is part of interaction in 3.13
440+
_chained_exceptions, tb = self._get_tb_and_exceptions(tb_or_exc)
441+
with self._hold_exceptions(_chained_exceptions):
442+
OldPdb.interaction(self, frame, tb)
443+
else:
444+
OldPdb.interaction(self, frame, traceback)
445+
336446
except KeyboardInterrupt:
337447
self.stdout.write("\n" + self.shell.get_exception_only())
338448

449+
339450
def precmd(self, line):
340451
"""Perform useful escapes on the command before it is executed."""
341452

IPython/core/ultratb.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1246,7 +1246,11 @@ def debugger(self, force: bool = False):
12461246
if etb and etb.tb_next:
12471247
etb = etb.tb_next
12481248
self.pdb.botframe = etb.tb_frame
1249-
self.pdb.interaction(None, etb)
1249+
exc = sys.last_value if sys.version_info < (3, 12) else sys.last_exc
1250+
if exc:
1251+
self.pdb.interaction(None, exc)
1252+
else:
1253+
self.pdb.interaction(None, etb)
12501254

12511255
if hasattr(self, 'tb'):
12521256
del self.tb

IPython/terminal/tests/test_debug_magic.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ def test_debug_magic_passes_through_generators():
6868
child.expect_exact('----> 1 for x in gen:')
6969

7070
child.expect(ipdb_prompt)
71-
child.sendline('u')
72-
child.expect_exact('*** Oldest frame')
71+
child.sendline("u")
72+
child.expect_exact(
73+
"*** all frames above hidden, use `skip_hidden False` to get get into those."
74+
)
7375

7476
child.expect(ipdb_prompt)
7577
child.sendline('exit')

docs/source/whatsnew/version8.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
============
22
8.x Series
33
============
4+
5+
.. _version 8.15:
6+
7+
IPython 8.15
8+
------------
9+
10+
Medium release of IPython after a couple of month hiatus, and a bit off-schedule.
11+
12+
The main change is the addition of the ability to move between chained
13+
exceptions when using IPdb, this feature was also contributed to upstream Pdb
14+
and is thus native to CPython in Python 3.13+ Though ipdb should support this
15+
feature in older version of Python. I invite you to look at the `CPython changes
16+
and docs <https://github.com/python/cpython/pull/106676>`_ for more details.
17+
18+
I also want o thanks the `D.E. Shaw group <https://www.deshaw.com/>`_ for
19+
suggesting and funding this feature.
20+
421
.. _version 8.14:
522

623
IPython 8.14

0 commit comments

Comments
 (0)