From 3830400d1d0789f65fd59b05db2f61ef4d7a30f2 Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Fri, 16 Jul 2021 19:49:09 +0100 Subject: [PATCH 1/7] use StackSummary subclasses to filter frames --- Lib/test/test_traceback.py | 34 +++++++++++++++++++ Lib/traceback.py | 24 ++++++++----- .../2021-06-17-16-16-33.bpo-31299.d4WDz7.rst | 1 + 3 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2021-06-17-16-16-33.bpo-31299.d4WDz7.rst diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 4742eb1d2309b4..01354a2177a784 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -1443,6 +1443,40 @@ def some_inner(): s.format(), [f'{__file__}:{some_inner.__code__.co_firstlineno + 1}']) + def test_dropping_frames(self): + def f(): + 1/0 + def g(): + try: + f() + except: + return sys.exc_info() + exc_info = g() + + class Skip_G(traceback.StackSummary): + def format_frame(self, frame): + if frame.name == 'g': + return None + return super().format_frame(frame) + + def get_output(stack_summary_cls=None): + output = StringIO() + traceback.TracebackException( + *exc_info, stack_summary_cls=stack_summary_cls, + ).print(file=output) + return output.getvalue().split('\n') + + default = get_output() + skip_g = get_output(Skip_G) + + for l in skip_g: + default.remove(l) + # Only the lines for g's frame should remain: + self.assertEqual(len(default), 3) + self.assertRegex(default[0], ', line [0-9]*, in g') + self.assertEqual(default[1], ' f()') + self.assertEqual(default[2], ' ^^^') + class TestTracebackException(unittest.TestCase): diff --git a/Lib/traceback.py b/Lib/traceback.py index ae5775d2f3bdae..4d6c570a7f8a1e 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -507,6 +507,7 @@ def format(self): last_file = None last_line = None last_name = None + last_line_displayed = False count = 0 for frame in self: if (last_file is None or last_file != frame.filename or @@ -514,10 +515,11 @@ def format(self): last_name is None or last_name != frame.name): if count > _RECURSIVE_CUTOFF: count -= _RECURSIVE_CUTOFF - result.append( - f' [Previous line repeated {count} more ' - f'time{"s" if count > 1 else ""}]\n' - ) + if last_line_displayed: + result.append( + f' [Previous line repeated {count} more ' + f'time{"s" if count > 1 else ""}]\n' + ) last_file = frame.filename last_line = frame.lineno last_name = frame.name @@ -525,7 +527,12 @@ def format(self): count += 1 if count > _RECURSIVE_CUTOFF: continue - result.append(self.format_frame(frame)) + formatted_frame = self.format_frame(frame) + if formatted_frame is not None: + last_line_displayed = True + result.append(formatted_frame) + else: + last_line_displayed = False if count > _RECURSIVE_CUTOFF: count -= _RECURSIVE_CUTOFF @@ -618,7 +625,7 @@ class TracebackException: def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, lookup_lines=True, capture_locals=False, compact=False, - _seen=None): + stack_summary_cls=None, _seen=None): # NB: we need to accept exc_traceback, exc_value, exc_traceback to # permit backwards compat with the existing API, otherwise we # need stub thunk objects just to glue it together. @@ -628,8 +635,9 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, _seen = set() _seen.add(id(exc_value)) - # TODO: locals. - self.stack = StackSummary._extract_from_extended_frame_gen( + if stack_summary_cls is None: + stack_summary_cls = StackSummary + self.stack = stack_summary_cls._extract_from_extended_frame_gen( _walk_tb_with_full_positions(exc_traceback), limit=limit, lookup_lines=lookup_lines, capture_locals=capture_locals) diff --git a/Misc/NEWS.d/next/Library/2021-06-17-16-16-33.bpo-31299.d4WDz7.rst b/Misc/NEWS.d/next/Library/2021-06-17-16-16-33.bpo-31299.d4WDz7.rst new file mode 100644 index 00000000000000..7a53f3276fc7cc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-06-17-16-16-33.bpo-31299.d4WDz7.rst @@ -0,0 +1 @@ +Added the ``stack_summary_cls`` parameter to :class:`TracebackException`, to allow fine-grained control over the content of a formatted traceback. Added option to completely drop frames from the output by returning ``None`` from a :meth:`~StackSummary.format_frame` override. From 4debb1dcf39617ec4c44bb9d877cb86b3c2bd592 Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Fri, 16 Jul 2021 20:06:40 +0100 Subject: [PATCH 2/7] added documentation --- Doc/library/traceback.rst | 11 +++++++++-- Lib/traceback.py | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Doc/library/traceback.rst b/Doc/library/traceback.rst index 83d5c8c6fcbd32..894ab8b33a4754 100644 --- a/Doc/library/traceback.rst +++ b/Doc/library/traceback.rst @@ -212,7 +212,7 @@ The module also defines the following classes: :class:`TracebackException` objects are created from actual exceptions to capture data for later printing in a lightweight fashion. -.. class:: TracebackException(exc_type, exc_value, exc_traceback, *, limit=None, lookup_lines=True, capture_locals=False, compact=False) +.. class:: TracebackException(exc_type, exc_value, exc_traceback, *, limit=None, lookup_lines=True, capture_locals=False, compact=False, stack_summary_cls=None) Capture an exception for later rendering. *limit*, *lookup_lines* and *capture_locals* are as for the :class:`StackSummary` class. @@ -222,6 +222,10 @@ capture data for later printing in a lightweight fashion. ``__context__`` field is calculated only if ``__cause__`` is ``None`` and ``__suppress_context__`` is false. + If *stack_summary_cls* is not ``None``, it is a class to be used instead of + the default :class:`~traceback.StackSummary` to format the stack (typically + a subclass that overrides :meth:`~traceback.StackSummary.format_frame`). + Note that when locals are captured, they are also shown in the traceback. .. attribute:: __cause__ @@ -309,6 +313,8 @@ capture data for later printing in a lightweight fashion. .. versionchanged:: 3.10 Added the *compact* parameter. + .. versionchanged:: 3.11 + Added the *stack_summary_cls* paramter. :class:`StackSummary` Objects ----------------------------- @@ -357,7 +363,8 @@ capture data for later printing in a lightweight fashion. Returns a string for printing one of the frames involved in the stack. This method gets called for each frame object to be printed in the - :class:`StackSummary`. + :class:`StackSummary`. If it returns ``none``, the frame is omitted + from the output. .. versionadded:: 3.11 diff --git a/Lib/traceback.py b/Lib/traceback.py index 4d6c570a7f8a1e..f35589d19a7553 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -453,7 +453,8 @@ def format_frame(self, frame): """Format the lines for a single frame. Returns a string representing one frame involved in the stack. This - gets called for every frame to be printed in the stack summary. + gets called for every frame to be printed in the stack summary. If + it returns ``None``, the frame is omitted from the output. """ row = [] row.append(' File "{}", line {}, in {}\n'.format( From caef03f1a3c149de3dfb02e09dc5fc0023cdd76b Mon Sep 17 00:00:00 2001 From: Terry Jan Reedy Date: Fri, 16 Jul 2021 16:31:52 -0400 Subject: [PATCH 3/7] Update Doc/library/traceback.rst Co-authored-by: Ammar Askar --- Doc/library/traceback.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/traceback.rst b/Doc/library/traceback.rst index 894ab8b33a4754..8341d03576ac9a 100644 --- a/Doc/library/traceback.rst +++ b/Doc/library/traceback.rst @@ -363,7 +363,7 @@ capture data for later printing in a lightweight fashion. Returns a string for printing one of the frames involved in the stack. This method gets called for each frame object to be printed in the - :class:`StackSummary`. If it returns ``none``, the frame is omitted + :class:`StackSummary`. If it returns ``None``, the frame is omitted from the output. .. versionadded:: 3.11 From 50fcdccf8b7f2929b4c99655e594df51744b04a4 Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Sun, 18 Jul 2021 20:06:53 +0100 Subject: [PATCH 4/7] more precise comparison in test --- Lib/test/test_traceback.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 01354a2177a784..43d070da6928bd 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -1473,7 +1473,8 @@ def get_output(stack_summary_cls=None): default.remove(l) # Only the lines for g's frame should remain: self.assertEqual(len(default), 3) - self.assertRegex(default[0], ', line [0-9]*, in g') + lno = g.__code__.co_firstlineno + 2 + self.assertEqual(default[0], f' File "{__file__}", line {lno}, in g') self.assertEqual(default[1], ' f()') self.assertEqual(default[2], ' ^^^') From ac09f43fd3ea02dcd1dc73a2b451581fb9ba5559 Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Mon, 19 Jul 2021 01:02:12 +0100 Subject: [PATCH 5/7] add more tests and fix a couple of bugs --- Lib/test/test_traceback.py | 205 ++++++++++++++++++++++++++++++++++--- Lib/traceback.py | 11 +- 2 files changed, 195 insertions(+), 21 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 43d070da6928bd..56589ae1a35454 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -1446,11 +1446,13 @@ def some_inner(): def test_dropping_frames(self): def f(): 1/0 + def g(): try: f() except: return sys.exc_info() + exc_info = g() class Skip_G(traceback.StackSummary): @@ -1459,24 +1461,15 @@ def format_frame(self, frame): return None return super().format_frame(frame) - def get_output(stack_summary_cls=None): - output = StringIO() - traceback.TracebackException( - *exc_info, stack_summary_cls=stack_summary_cls, - ).print(file=output) - return output.getvalue().split('\n') - - default = get_output() - skip_g = get_output(Skip_G) + stack = Skip_G.extract( + traceback.walk_tb(exc_info[2])).format() - for l in skip_g: - default.remove(l) - # Only the lines for g's frame should remain: - self.assertEqual(len(default), 3) - lno = g.__code__.co_firstlineno + 2 - self.assertEqual(default[0], f' File "{__file__}", line {lno}, in g') - self.assertEqual(default[1], ' f()') - self.assertEqual(default[2], ' ^^^') + self.assertEqual(len(stack), 1) + lno = f.__code__.co_firstlineno + 1 + self.assertEqual( + stack[0], + f' File "{__file__}", line {lno}, in f\n 1/0\n' + ) class TestTracebackException(unittest.TestCase): @@ -1787,6 +1780,184 @@ def f(): '']) +class TestTracebackException_CustomStackSummary(unittest.TestCase): + def _get_output(self, *exc_info, stack_summary_cls=None): + output = StringIO() + traceback.TracebackException( + *exc_info, stack_summary_cls=stack_summary_cls, + ).print(file=output) + return output.getvalue().split('\n') + + class MyStackSummary(traceback.StackSummary): + def format_frame(self, frame): + return f'{frame.filename}:{frame.lineno}\n' + + class SkipG(traceback.StackSummary): + def format_frame(self, frame): + if frame.name == 'g': + return None + return super().format_frame(frame) + + def test_custom_stack_summary(self): + def f(): + 1/0 + + def g(): + try: + f() + except: + return sys.exc_info() + + exc_info = g() + stack = self._get_output( + *exc_info, + stack_summary_cls=self.MyStackSummary) + self.assertEqual( + stack, + ['Traceback (most recent call last):', + f'{__file__}:{g.__code__.co_firstlineno+2}', + f'{__file__}:{f.__code__.co_firstlineno+1}', + 'ZeroDivisionError: division by zero', + '']) + + def test_custom_stack_summary_with_context(self): + def f(): + try: + 1/0 + except ZeroDivisionError as e: + raise ValueError('bad value') + + def g(): + try: + f() + except: + return sys.exc_info() + + exc_info = g() + stack = self._get_output( + *exc_info, + stack_summary_cls=self.MyStackSummary) + self.assertEqual( + stack, + ['Traceback (most recent call last):', + f'{__file__}:{f.__code__.co_firstlineno+2}', + 'ZeroDivisionError: division by zero', + '', + context_message.replace('\n', ''), + '', + 'Traceback (most recent call last):', + f'{__file__}:{g.__code__.co_firstlineno+2}', + f'{__file__}:{f.__code__.co_firstlineno+4}', + 'ValueError: bad value', + '']) + + def test_custom_stack_summary_with_cause(self): + def f(): + try: + 1/0 + except ZeroDivisionError as e: + raise ValueError('bad value') from e + + def g(): + try: + f() + except: + return sys.exc_info() + + exc_info = g() + stack = self._get_output( + *exc_info, + stack_summary_cls=self.MyStackSummary) + self.assertEqual( + stack, + ['Traceback (most recent call last):', + f'{__file__}:{f.__code__.co_firstlineno+2}', + 'ZeroDivisionError: division by zero', + '', + cause_message.replace('\n', ''), + '', + 'Traceback (most recent call last):', + f'{__file__}:{g.__code__.co_firstlineno+2}', + f'{__file__}:{f.__code__.co_firstlineno+4}', + 'ValueError: bad value', + '']) + + @requires_debug_ranges + def test_dropping_frames(self): + def f(): + 1/0 + + def g(): + try: + f() + except: + return sys.exc_info() + + exc_info = g() + full = self._get_output(*exc_info) + skipped = self._get_output( + *exc_info, + stack_summary_cls=self.SkipG) + + for l in skipped: + full.remove(l) + # Only the lines for g's frame should remain: + self.assertEqual(len(full), 3) + lno = g.__code__.co_firstlineno + 2 + self.assertEqual( + full, + [f' File "{__file__}", line {lno}, in g', + ' f()', + ' ^^^']) + + def test_dropping_frames_recursion_limit_msg1(self): + # recursion at bottom of the stack + def g(): + g() + + def h(): + g() + + try: + h() + except: + exc_info = sys.exc_info() + + full = self._get_output(*exc_info) + skipped = self._get_output( + *exc_info, + stack_summary_cls=self.SkipG) + + rep_txt_regex = 'Previous line repeated (\\d+) more times' + self.assertRegex(''.join(full), rep_txt_regex) + self.assertNotRegex(''.join(skipped), rep_txt_regex) + + def test_dropping_frames_recursion_limit_msg2(self): + # recursion in the middle of the stack + def f(): + 1/0 + + def g(i): + if i < 10: + g(i+1) + else: + f() + + try: + g(0) + except: + exc_info = sys.exc_info() + + full = self._get_output(*exc_info) + skipped = self._get_output( + *exc_info, + stack_summary_cls=self.SkipG) + + rep_txt_regex = 'Previous line repeated (\\d+) more times' + self.assertRegex(''.join(full), rep_txt_regex) + self.assertNotRegex(''.join(skipped), rep_txt_regex) + + class MiscTest(unittest.TestCase): def test_all(self): diff --git a/Lib/traceback.py b/Lib/traceback.py index f35589d19a7553..05402f4229c58a 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -537,10 +537,11 @@ def format(self): if count > _RECURSIVE_CUTOFF: count -= _RECURSIVE_CUTOFF - result.append( - f' [Previous line repeated {count} more ' - f'time{"s" if count > 1 else ""}]\n' - ) + if last_line_displayed: + result.append( + f' [Previous line repeated {count} more ' + f'time{"s" if count > 1 else ""}]\n' + ) return result @@ -674,6 +675,7 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, limit=limit, lookup_lines=lookup_lines, capture_locals=capture_locals, + stack_summary_cls=stack_summary_cls, _seen=_seen) else: cause = None @@ -693,6 +695,7 @@ def __init__(self, exc_type, exc_value, exc_traceback, *, limit=None, limit=limit, lookup_lines=lookup_lines, capture_locals=capture_locals, + stack_summary_cls=stack_summary_cls, _seen=_seen) else: context = None From 68afed958c997db8816edad3b37fa4911984530f Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Mon, 19 Jul 2021 21:31:42 +0100 Subject: [PATCH 6/7] fix indent --- Lib/test/test_traceback.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_traceback.py b/Lib/test/test_traceback.py index 56589ae1a35454..ccc1f7c27f79cb 100644 --- a/Lib/test/test_traceback.py +++ b/Lib/test/test_traceback.py @@ -1782,11 +1782,11 @@ def f(): class TestTracebackException_CustomStackSummary(unittest.TestCase): def _get_output(self, *exc_info, stack_summary_cls=None): - output = StringIO() - traceback.TracebackException( - *exc_info, stack_summary_cls=stack_summary_cls, - ).print(file=output) - return output.getvalue().split('\n') + output = StringIO() + traceback.TracebackException( + *exc_info, stack_summary_cls=stack_summary_cls, + ).print(file=output) + return output.getvalue().split('\n') class MyStackSummary(traceback.StackSummary): def format_frame(self, frame): From b025ee077f1d0b71df652b4c61099e3d91905bde Mon Sep 17 00:00:00 2001 From: Irit Katriel Date: Mon, 19 Jul 2021 22:14:13 +0100 Subject: [PATCH 7/7] put all the recursive-cutoff logic under 'if formatted_frame is not None' and then we don't need the last_line_displayed flag --- Lib/traceback.py | 42 ++++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/Lib/traceback.py b/Lib/traceback.py index 05402f4229c58a..4a3890f1a09fec 100644 --- a/Lib/traceback.py +++ b/Lib/traceback.py @@ -508,40 +508,34 @@ def format(self): last_file = None last_line = None last_name = None - last_line_displayed = False count = 0 for frame in self: - if (last_file is None or last_file != frame.filename or - last_line is None or last_line != frame.lineno or - last_name is None or last_name != frame.name): - if count > _RECURSIVE_CUTOFF: - count -= _RECURSIVE_CUTOFF - if last_line_displayed: + formatted_frame = self.format_frame(frame) + if formatted_frame is not None: + if (last_file is None or last_file != frame.filename or + last_line is None or last_line != frame.lineno or + last_name is None or last_name != frame.name): + if count > _RECURSIVE_CUTOFF: + count -= _RECURSIVE_CUTOFF result.append( f' [Previous line repeated {count} more ' f'time{"s" if count > 1 else ""}]\n' ) - last_file = frame.filename - last_line = frame.lineno - last_name = frame.name - count = 0 - count += 1 - if count > _RECURSIVE_CUTOFF: - continue - formatted_frame = self.format_frame(frame) - if formatted_frame is not None: - last_line_displayed = True + last_file = frame.filename + last_line = frame.lineno + last_name = frame.name + count = 0 + count += 1 + if count > _RECURSIVE_CUTOFF: + continue result.append(formatted_frame) - else: - last_line_displayed = False if count > _RECURSIVE_CUTOFF: count -= _RECURSIVE_CUTOFF - if last_line_displayed: - result.append( - f' [Previous line repeated {count} more ' - f'time{"s" if count > 1 else ""}]\n' - ) + result.append( + f' [Previous line repeated {count} more ' + f'time{"s" if count > 1 else ""}]\n' + ) return result