From 95e0c37b98cc9076859d93816d9f70fc9a0db506 Mon Sep 17 00:00:00 2001 From: Matthias Bussonnier Date: Tue, 29 Aug 2023 11:56:03 +0200 Subject: [PATCH] Add support for chained exceptions. Closes #13982 This is a "backport" of python/cpython#106676 See documentation there --- IPython/core/debugger.py | 114 ++++++++++++++++++++- IPython/core/ultratb.py | 8 +- IPython/terminal/tests/test_debug_magic.py | 6 +- docs/source/whatsnew/version8.rst | 17 +++ 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/IPython/core/debugger.py b/IPython/core/debugger.py index 5964d0288cb..30be9fc0d19 100644 --- a/IPython/core/debugger.py +++ b/IPython/core/debugger.py @@ -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 @@ -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: @@ -185,6 +191,9 @@ class Pdb(OldPdb): """ + if CHAIN_EXCEPTIONS: + MAX_CHAINED_EXCEPTION_DEPTH = 999 + default_predicates = { "tbhide": True, "readonly": False, @@ -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.""" @@ -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()) diff --git a/IPython/core/ultratb.py b/IPython/core/ultratb.py index 6cd94eaf674..27d92dc5835 100644 --- a/IPython/core/ultratb.py +++ b/IPython/core/ultratb.py @@ -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 diff --git a/IPython/terminal/tests/test_debug_magic.py b/IPython/terminal/tests/test_debug_magic.py index faa3b7c4993..16ed53d31af 100644 --- a/IPython/terminal/tests/test_debug_magic.py +++ b/IPython/terminal/tests/test_debug_magic.py @@ -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') diff --git a/docs/source/whatsnew/version8.rst b/docs/source/whatsnew/version8.rst index 1ceb9f876f8..f3ff4752354 100644 --- a/docs/source/whatsnew/version8.rst +++ b/docs/source/whatsnew/version8.rst @@ -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 `_ for more details. + +I also want o thanks the `D.E. Shaw group `_ for +suggesting and funding this feature. + .. _version 8.14: IPython 8.14