Skip to content

bpo-31299: Make it possible to filter out frames from tracebacks #26772

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Doc/library/traceback.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a code example, in particular, class SkipG from the test, which skips frames for a function named 'g'.

Note that when locals are captured, they are also shown in the traceback.

.. attribute:: __cause__
Expand Down Expand Up @@ -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
-----------------------------
Expand Down Expand Up @@ -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

Expand Down
206 changes: 206 additions & 0 deletions Lib/test/test_traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -1443,6 +1443,34 @@ 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)

stack = Skip_G.extract(
traceback.walk_tb(exc_info[2])).format()

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):

Expand Down Expand Up @@ -1752,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):
Expand Down
46 changes: 26 additions & 20 deletions Lib/traceback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -509,23 +510,25 @@ def format(self):
last_name = None
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):
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:
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
result.append(self.format_frame(frame))
continue
result.append(formatted_frame)

if count > _RECURSIVE_CUTOFF:
count -= _RECURSIVE_CUTOFF
Expand Down Expand Up @@ -618,7 +621,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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a good approach to customizing the behavior while reusing most of the exception printing code. There's precedent for this style of expansion with JSONEncoder and cls in the json module. Any thoughts @terryjreedy?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See general review comment.

# 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.
Expand All @@ -628,8 +631,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)
Expand Down Expand Up @@ -665,6 +669,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
Expand All @@ -684,6 +689,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.