From d26b1772e23892935c165714dc1b40e62f537b50 Mon Sep 17 00:00:00 2001 From: Tor Pedersen Date: Fri, 30 Jan 2026 12:41:10 +0100 Subject: [PATCH] Implemented an option truncate_suppressed_frames for format_stack() which truncates any sequence of stack frames matching suppressed_paths into a minimal formatted entry for the first and last frame in the contiguous sequence of "boring"/suppressed frames, with a count of how many frames were suppressed inbetween. Created tests/source_boring.py and source_interesting.py to enable both a demo and tests of the new functionality. --- demo_truncate_boring.py | 13 ++ stackprinter/formatting.py | 243 +++++++++++++++++++--------------- tests/source_boring.py | 31 +++++ tests/source_interesting.py | 17 +++ tests/test_hide_suppressed.py | 62 +++++++++ 5 files changed, 262 insertions(+), 104 deletions(-) create mode 100644 demo_truncate_boring.py create mode 100644 tests/source_boring.py create mode 100644 tests/source_interesting.py create mode 100644 tests/test_hide_suppressed.py diff --git a/demo_truncate_boring.py b/demo_truncate_boring.py new file mode 100644 index 0000000..62421d9 --- /dev/null +++ b/demo_truncate_boring.py @@ -0,0 +1,13 @@ +import stackprinter +from tests.source_boring import BoringClass + +try: + boring = BoringClass() + boring.do_something_boring(for_num_of_frames=10, boring_sequences=3) +except Exception as e: + print("Oops!", e) + print( + stackprinter.format( + e, suppressed_paths=[r"source_boring\.py"], truncate_suppressed_frames=True, add_summary=False + ) + ) diff --git a/stackprinter/formatting.py b/stackprinter/formatting.py index 25f5fd8..57ef431 100644 --- a/stackprinter/formatting.py +++ b/stackprinter/formatting.py @@ -1,98 +1,132 @@ """ Various convnenience methods to walk stacks and concatenate formatted frames """ -import types + import traceback -import stackprinter.extraction as ex import stackprinter.colorschemes as colorschemes -from stackprinter.utils import match, get_ansi_tpl -from stackprinter.frame_formatting import FrameFormatter, ColorfulFrameFormatter +import stackprinter.extraction as ex +from stackprinter.frame_formatting import ColorfulFrameFormatter, FrameFormatter +from stackprinter.utils import get_ansi_tpl, match def get_formatter(style, **kwargs): - if style in ['plaintext', 'plain']: + if style in ["plaintext", "plain"]: return FrameFormatter(**kwargs) else: return ColorfulFrameFormatter(style, **kwargs) -def format_summary(frames, style='plaintext', source_lines=1, reverse=False, - **kwargs): +def format_summary(frames, style="plaintext", source_lines=1, reverse=False, **kwargs): """ Render a list of frames with 1 line of source context, no variable values. keyword args like stackprinter.format() """ min_src_lines = 0 if source_lines == 0 else 1 - minimal_formatter = get_formatter(style=style, - source_lines=min_src_lines, - show_signature=False, - show_vals=False) + minimal_formatter = get_formatter(style=style, source_lines=min_src_lines, show_signature=False, show_vals=False) frame_msgs = [minimal_formatter(frame) for frame in frames] if reverse: frame_msgs = reversed(frame_msgs) - return ''.join(frame_msgs) - - -def format_stack(frames, style='plaintext', source_lines=5, - show_signature=True, show_vals='like_source', - truncate_vals=500, line_wrap=60, reverse=False, - suppressed_paths=None, suppressed_vars=[]): + return "".join(frame_msgs) + + +def format_stack( + frames, + style="plaintext", + source_lines=5, + show_signature=True, + show_vals="like_source", + truncate_vals=500, + line_wrap=60, + reverse=False, + suppressed_paths=None, + suppressed_vars=[], + truncate_suppressed_frames=False, # TODO: check variable name! +): """ Render a list of frames (or FrameInfo tuples) keyword args like stackprinter.format() """ - min_src_lines = 0 if source_lines == 0 else 1 - minimal_formatter = get_formatter(style=style, - source_lines=min_src_lines, - show_signature=False, - show_vals=False) - - reduced_formatter = get_formatter(style=style, - source_lines=min_src_lines, - show_signature=show_signature, - show_vals=show_vals, - truncate_vals=truncate_vals, - line_wrap=line_wrap, - suppressed_paths=suppressed_paths, - suppressed_vars=suppressed_vars) - - verbose_formatter = get_formatter(style=style, - source_lines=source_lines, - show_signature=show_signature, - show_vals=show_vals, - truncate_vals=truncate_vals, - line_wrap=line_wrap, - suppressed_paths=suppressed_paths, - suppressed_vars=suppressed_vars) + minimal_formatter = get_formatter(style=style, source_lines=min_src_lines, show_signature=False, show_vals=False) + + reduced_formatter = get_formatter( + style=style, + source_lines=min_src_lines, + show_signature=show_signature, + show_vals=show_vals, + truncate_vals=truncate_vals, + line_wrap=line_wrap, + suppressed_paths=suppressed_paths, + suppressed_vars=suppressed_vars, + ) + + verbose_formatter = get_formatter( + style=style, + source_lines=source_lines, + show_signature=show_signature, + show_vals=show_vals, + truncate_vals=truncate_vals, + line_wrap=line_wrap, + suppressed_paths=suppressed_paths, + suppressed_vars=suppressed_vars, + ) frame_msgs = [] parent_is_boring = True + last_boring_frame_fi = None + boring_frames_count: int = 0 for frame in frames: fi = ex.get_info(frame, suppressed_vars=suppressed_vars) is_boring = match(fi.filename, suppressed_paths) if is_boring: - if parent_is_boring: - formatter = minimal_formatter + if truncate_suppressed_frames and last_boring_frame_fi is not None: + # Already in a boring sequence and set to hide => skip frame + formatter = None else: - formatter = reduced_formatter + # Not set to hide, or this is the first boring frame in sequence => display frame + if parent_is_boring: + formatter = minimal_formatter + elif truncate_suppressed_frames: + # Use minimal format for first boring frame + formatter = minimal_formatter + else: + formatter = reduced_formatter + + # Remember last boring frame in case it is the last in the sequence of boring frames and + # should be displayed after the '... suppressed frames ...' message. + # Also increment count of boring frames in the sequence. + last_boring_frame_fi = fi + boring_frames_count += 1 else: + if last_boring_frame_fi: + # We are coming out of a boring sequence, print 'suppressed frames' line if sequence count > 2 (first and + # last boring frames are shown), then print last boring frame. + if boring_frames_count > 2: + frame_msgs.append(f"[--- {boring_frames_count - 2} suppressed frames truncated ---]\r\n") + frame_msgs.append(minimal_formatter(last_boring_frame_fi)) + formatter = verbose_formatter + # Reset boring sequence tracking variables + last_boring_frame_fi = None + boring_frames_count = 0 + parent_is_boring = is_boring - frame_msgs.append(formatter(fi)) + if formatter is not None: + # formatter is None when we are hiding boring frames, else append normally + frame_msgs.append(formatter(fi)) if reverse: frame_msgs = reversed(frame_msgs) - return ''.join(frame_msgs) + return "".join(frame_msgs) def format_stack_from_frame(fr, add_summary=False, **kwargs): @@ -112,9 +146,17 @@ def format_stack_from_frame(fr, add_summary=False, **kwargs): return format_stack(stack, **kwargs) -def format_exc_info(etype, evalue, tb, style='plaintext', add_summary='auto', - reverse=False, suppressed_exceptions=[KeyboardInterrupt], - suppressed_vars=[], **kwargs): +def format_exc_info( + etype, + evalue, + tb, + style="plaintext", + add_summary="auto", + reverse=False, + suppressed_exceptions=[KeyboardInterrupt], + suppressed_vars=[], + **kwargs, +): """ Format an exception traceback, including the exception message @@ -123,12 +165,12 @@ def format_exc_info(etype, evalue, tb, style='plaintext', add_summary='auto', if etype is None: etype = type(None) - if etype.__name__ == 'ExceptionGroup': + if etype.__name__ == "ExceptionGroup": # Exception groups (new in py 3.11) aren't supported so far, # but at least we fall back on the default message. - return ''.join(traceback.format_exception(etype, evalue, tb)) + return "".join(traceback.format_exception(etype, evalue, tb)) - msg = '' + msg = "" try: # First, recursively format any chained exceptions (exceptions # during whose handling the given one happened). @@ -136,108 +178,101 @@ def format_exc_info(etype, evalue, tb, style='plaintext', add_summary='auto', # more... structured datastructure before assembling a string, # so that e.g. a summary of the whole chain can be shown at # the end. - context = getattr(evalue, '__context__', None) - cause = getattr(evalue, '__cause__', None) - suppress_context = getattr(evalue, '__suppress_context__', False) + context = getattr(evalue, "__context__", None) + cause = getattr(evalue, "__cause__", None) + suppress_context = getattr(evalue, "__suppress_context__", False) if cause: chained_exc = cause - chain_hint = ("\n\nThe above exception was the direct cause " - "of the following exception:\n\n") + chain_hint = "\n\nThe above exception was the direct cause " "of the following exception:\n\n" elif context and not suppress_context: chained_exc = context - chain_hint = ("\n\nWhile handling the above exception, " - "another exception occurred:\n\n") + chain_hint = "\n\nWhile handling the above exception, " "another exception occurred:\n\n" else: chained_exc = None if chained_exc: - msg += format_exc_info(chained_exc.__class__, - chained_exc, - chained_exc.__traceback__, - style=style, - add_summary=add_summary, - reverse=reverse, - suppressed_vars=suppressed_vars, - **kwargs) - - if style == 'plaintext': - msg += chain_hint + msg += format_exc_info( + chained_exc.__class__, + chained_exc, + chained_exc.__traceback__, + style=style, + add_summary=add_summary, + reverse=reverse, + suppressed_vars=suppressed_vars, + **kwargs, + ) + + if style == "plaintext": + msg += chain_hint else: sc = getattr(colorschemes, style) - clr = get_ansi_tpl(*sc.colors['exception_type']) + clr = get_ansi_tpl(*sc.colors["exception_type"]) msg += clr % chain_hint # Now, actually do some formatting: parts = [] if tb: - frameinfos = [ex.get_info(tb_, suppressed_vars=suppressed_vars) - for tb_ in _walk_traceback(tb)] - if (suppressed_exceptions and - issubclass(etype, tuple(suppressed_exceptions))): - summary = format_summary(frameinfos, style=style, - reverse=reverse, **kwargs) + frameinfos = [ex.get_info(tb_, suppressed_vars=suppressed_vars) for tb_ in _walk_traceback(tb)] + if suppressed_exceptions and issubclass(etype, tuple(suppressed_exceptions)): + summary = format_summary(frameinfos, style=style, reverse=reverse, **kwargs) parts = [summary] else: - whole_stack = format_stack(frameinfos, style=style, - reverse=reverse, **kwargs) + whole_stack = format_stack(frameinfos, style=style, reverse=reverse, **kwargs) parts.append(whole_stack) - if add_summary == 'auto': - add_summary = whole_stack.count('\n') > 50 + if add_summary == "auto": + add_summary = whole_stack.count("\n") > 50 if add_summary: - summary = format_summary(frameinfos, style=style, - reverse=reverse, **kwargs) - summary += '\n' - parts.append('---- (full traceback below) ----\n\n' if reverse else - '---- (full traceback above) ----\n') + summary = format_summary(frameinfos, style=style, reverse=reverse, **kwargs) + summary += "\n" + parts.append( + "---- (full traceback below) ----\n\n" if reverse else "---- (full traceback above) ----\n" + ) parts.append(summary) exc = format_exception_message(etype, evalue, style=style) - parts.append('\n\n' if reverse else '') + parts.append("\n\n" if reverse else "") parts.append(exc) if reverse: parts = reversed(parts) - msg += ''.join(parts) + msg += "".join(parts) except Exception as exc: import os - if 'PY_STACKPRINTER_DEBUG' in os.environ: - raise - our_tb = traceback.format_exception(exc.__class__, - exc, - exc.__traceback__, - chain=False) - where = getattr(exc, 'where', None) - context = " while formatting " + str(where) if where else '' - msg = 'Stackprinter failed%s:\n%s\n' % (context, ''.join(our_tb[-2:])) - msg += 'So here is your original traceback at least:\n\n' - msg += ''.join(traceback.format_exception(etype, evalue, tb)) + if "PY_STACKPRINTER_DEBUG" in os.environ: + raise + our_tb = traceback.format_exception(exc.__class__, exc, exc.__traceback__, chain=False) + where = getattr(exc, "where", None) + context = " while formatting " + str(where) if where else "" + msg = "Stackprinter failed%s:\n%s\n" % (context, "".join(our_tb[-2:])) + msg += "So here is your original traceback at least:\n\n" + msg += "".join(traceback.format_exception(etype, evalue, tb)) return msg -def format_exception_message(etype, evalue, tb=None, style='plaintext'): +def format_exception_message(etype, evalue, tb=None, style="plaintext"): type_str = etype.__name__ val_str = str(evalue) if etype == SyntaxError and evalue.text: - val_str += '\n %s\n %s^' % (evalue.text.rstrip(), ' '*evalue.offset) + val_str += "\n %s\n %s^" % (evalue.text.rstrip(), " " * evalue.offset) if val_str: type_str += ": " - if style == 'plaintext': + if style == "plaintext": return type_str + val_str else: sc = getattr(colorschemes, style) - clr_head = get_ansi_tpl(*sc.colors['exception_type']) - clr_msg = get_ansi_tpl(*sc.colors['exception_msg']) + clr_head = get_ansi_tpl(*sc.colors["exception_type"]) + clr_msg = get_ansi_tpl(*sc.colors["exception_msg"]) return clr_head % type_str + clr_msg % val_str diff --git a/tests/source_boring.py b/tests/source_boring.py new file mode 100644 index 0000000..e289adc --- /dev/null +++ b/tests/source_boring.py @@ -0,0 +1,31 @@ +from tests.source_interesting import ( + InterestingButFlawedClass, + do_something_interesting_then_callback, +) + + +class BoringClass: + """ + Test class that calls itself recursively to create one or more sequences of "boring" + (suppressed) stack frames in order to test and demo printing a stack with suppressed_paths=[r"source_boring\.py"] + and truncate_suppressed_frames=True. + """ + + def __init__(self): + self._initial_num_frames = 0 + pass + + def do_something_boring(self, for_num_of_frames: int, boring_sequences: int = 1): + self._initial_num_frames = max(self._initial_num_frames, for_num_of_frames) + if for_num_of_frames <= 1: + if boring_sequences > 1: + do_something_interesting_then_callback( + self.do_something_boring, + for_num_of_frames=self._initial_num_frames, + boring_sequences=boring_sequences - 1, + ) + else: + interesting = InterestingButFlawedClass() + interesting.would_you_like_to_come_to_my_place() + else: + self.do_something_boring(for_num_of_frames=for_num_of_frames - 1, boring_sequences=boring_sequences) diff --git a/tests/source_interesting.py b/tests/source_interesting.py new file mode 100644 index 0000000..b788261 --- /dev/null +++ b/tests/source_interesting.py @@ -0,0 +1,17 @@ +from typing import Any + + +class InterestingButFlawedClass: + def __init__(self): + pass + + def would_you_like_to_come_to_my_place(self): + self.bouncy_bouncy() + + def bouncy_bouncy(self): + raise Exception("I will not buy this record, it is scratched!") + + +def do_something_interesting_then_callback(callback, **kwargs: Any): + this = "interesting" # noqa: F841 + callback(**kwargs) diff --git a/tests/test_hide_suppressed.py b/tests/test_hide_suppressed.py new file mode 100644 index 0000000..ffa15de --- /dev/null +++ b/tests/test_hide_suppressed.py @@ -0,0 +1,62 @@ +import re +from re import RegexFlag + +import stackprinter +from tests.source_boring import BoringClass + + +def test_hide_suppressed_frames(): + # Arrange + boring = BoringClass() + + # Act: + try: + boring.do_something_boring(for_num_of_frames=10) + except Exception as e: + fmt_stacktrace: str = stackprinter.format( + e, suppressed_paths=["source_boring"], truncate_suppressed_frames=True, add_summary=False + ) + + # Assert + print(fmt_stacktrace) + matches = re.findall("source_boring\.py", fmt_stacktrace, RegexFlag.MULTILINE) + assert len(matches) == 2 + assert "8 suppressed frames truncated" in fmt_stacktrace + + +def test_hide_suppressed_frames_multiple_sequences(): + # Arrange + boring = BoringClass() + + # Act + try: + boring.do_something_boring(for_num_of_frames=10, boring_sequences=3) + except Exception as e: + fmt_stacktrace: str = stackprinter.format( + e, suppressed_paths=["source_boring"], truncate_suppressed_frames=True, add_summary=False + ) + + # Assert + matches_frames = re.findall("source_boring\.py", fmt_stacktrace, RegexFlag.MULTILINE) + assert len(matches_frames) == 2 * 3 # 1 before 1 after for each of 3 sequences + matches_hidden_label = re.findall("8 suppressed frames truncated", fmt_stacktrace, RegexFlag.MULTILINE) + assert len(matches_hidden_label) == 3 # 3 sequences that will be truncated + + +def test_hide_suppressed_frames_only_two_boring_frames(): + # Arrange + boring = BoringClass() + + # Act: + try: + boring.do_something_boring(for_num_of_frames=2) + except Exception as e: + fmt_stacktrace: str = stackprinter.format( + e, suppressed_paths=["source_boring"], truncate_suppressed_frames=True, add_summary=False + ) + + # Assert + print(fmt_stacktrace) + matches = re.findall(r"source_boring\.py", fmt_stacktrace, RegexFlag.MULTILINE) + assert len(matches) == 2 + assert "suppressed frames truncated" not in fmt_stacktrace