From 1b49c9fd92421df084d5e28dd0edbc69f38a81cb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 23 Sep 2021 22:00:19 +0100 Subject: [PATCH 1/4] add suppress option to tracebacks --- examples/suppress.py | 23 +++++++++++++++++++++++ rich/console.py | 4 +++- rich/traceback.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 examples/suppress.py diff --git a/examples/suppress.py b/examples/suppress.py new file mode 100644 index 000000000..d0bfaf4ec --- /dev/null +++ b/examples/suppress.py @@ -0,0 +1,23 @@ +try: + import click +except ImportError: + print("Please install click for this example") + print(" pip install click") + exit() + +from rich.traceback import install + +install(suppress=[click]) + + +@click.command() +@click.option("--count", default=1, help="Number of greetings.") +def hello(count): + """Simple program that greets NAME for a total of COUNT times.""" + 1 / 0 + for x in range(count): + click.echo(f"Hello {name}!") + + +if __name__ == "__main__": + hello() diff --git a/rich/console.py b/rich/console.py index d4534240d..f9e22cad8 100644 --- a/rich/console.py +++ b/rich/console.py @@ -13,7 +13,7 @@ from inspect import isclass from itertools import islice from time import monotonic -from types import FrameType, TracebackType +from types import FrameType, TracebackType, ModuleType from typing import ( IO, TYPE_CHECKING, @@ -1704,6 +1704,7 @@ def print_exception( theme: Optional[str] = None, word_wrap: bool = False, show_locals: bool = False, + suppress: Iterable[Union[str, ModuleType]] = (), ) -> None: """Prints a rich render of the last exception and traceback. @@ -1722,6 +1723,7 @@ def print_exception( theme=theme, word_wrap=word_wrap, show_locals=show_locals, + suppress=suppress, ) self.print(traceback) diff --git a/rich/traceback.py b/rich/traceback.py index 4f9012d73..32b2970c7 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -5,8 +5,8 @@ import sys from dataclasses import dataclass, field from traceback import walk_tb -from types import TracebackType -from typing import Any, Callable, Dict, Iterable, List, Optional, Type +from types import ModuleType, TracebackType +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Type, Union from pygments.lexers import guess_lexer_for_filename from pygments.token import Comment, Keyword, Name, Number, Operator, String @@ -47,6 +47,7 @@ def install( word_wrap: bool = False, show_locals: bool = False, indent_guides: bool = True, + suppress: Iterable[Union[str, ModuleType]] = (), ) -> Callable[[Type[BaseException], BaseException, Optional[TracebackType]], Any]: """Install a rich traceback handler. @@ -62,6 +63,8 @@ def install( word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. show_locals (bool, optional): Enable display of local variables. Defaults to False. indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. + Returns: Callable: The previous exception handler that was replaced. @@ -85,6 +88,7 @@ def excepthook( word_wrap=word_wrap, show_locals=show_locals, indent_guides=indent_guides, + suppress=suppress, ) ) @@ -192,6 +196,8 @@ class Traceback: locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. + """ LEXERS = { @@ -213,6 +219,7 @@ def __init__( indent_guides: bool = True, locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, + suppress: Iterable[Union[str, ModuleType]] = (), ): if trace is None: exc_type, exc_value, traceback = sys.exc_info() @@ -233,6 +240,15 @@ def __init__( self.locals_max_length = locals_max_length self.locals_max_string = locals_max_string + self.suppress: Sequence[str] = [] + for suppress_entity in suppress: + if not isinstance(suppress_entity, str): + path = os.path.dirname(suppress_entity.__file__) + else: + path = suppress_entity + path = os.path.normpath(os.path.abspath(path)) + self.suppress.append(path) + @classmethod def from_exception( cls, @@ -247,6 +263,7 @@ def from_exception( indent_guides: bool = True, locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, + suppress: Iterable[Union[str, ModuleType]] = (), ) -> "Traceback": """Create a traceback from exception info @@ -263,6 +280,7 @@ def from_exception( locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. + suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. Returns: Traceback: A Traceback instance that may be printed. @@ -280,6 +298,7 @@ def from_exception( indent_guides=indent_guides, locals_max_length=locals_max_length, locals_max_string=locals_max_string, + suppress=suppress, ) @classmethod @@ -539,6 +558,9 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: ) for first, frame in loop_first(stack.frames): + frame_filename = frame.filename + suppressed = any(frame_filename.startswith(path) for path in self.suppress) + text = Text.assemble( path_highlighter(Text(frame.filename, style="pygments.string")), (":", "pygments.text"), @@ -553,6 +575,8 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: if frame.filename.startswith("<"): yield from render_locals(frame) continue + if suppressed: + continue try: code = read_code(frame.filename) lexer_name = self._guess_lexer(frame.filename, code) @@ -592,6 +616,7 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: if __name__ == "__main__": # pragma: no cover + import rich from .console import Console console = Console() @@ -622,6 +647,6 @@ def error() -> None: except: slfkjsldkfj # type: ignore except: - console.print_exception(show_locals=True) + console.print_exception(show_locals=True, suppress=[rich]) error() From e96d889beb045042a04fade20e3386a37115e505 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Sep 2021 09:41:23 +0100 Subject: [PATCH 2/4] tests --- CHANGELOG.md | 7 +++ docs/source/traceback.rst | 39 +++++++++++++ examples/recursive_error.py | 25 ++++++++ pyproject.toml | 2 +- rich/console.py | 4 ++ rich/traceback.py | 111 +++++++++++++++++++++++------------- tests/test_traceback.py | 28 +++++++++ 7 files changed, 174 insertions(+), 42 deletions(-) create mode 100644 examples/recursive_error.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef9e50ae3..cb3d8426d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.11.0] - Unreleased + +### Added + +- Added `suppress` parameter to tracebacks +- Added `max_frames` parameter to tracebacks + ## [10.10.0] - 2021-09-18 ### Added diff --git a/docs/source/traceback.rst b/docs/source/traceback.rst index ee0d64bec..84c3375c4 100644 --- a/docs/source/traceback.rst +++ b/docs/source/traceback.rst @@ -35,3 +35,42 @@ Rich can be installed as the default traceback handler so that all uncaught exce install(show_locals=True) There are a few options to configure the traceback handler, see :func:`~rich.traceback.install` for details. + + +Suppressing Frames +------------------ + +If you are working with a framework (click, django etc), you may only be interested in displaying code in your own application. You can exclude frameworks by setting the `suppress` argument on `Traceback`, `install`, and `print_exception`, which may be a iterable of modules or str paths. + +Here's how you would exclude [click](https://click.palletsprojects.com/en/8.0.x/) from Rich exceptions:: + + import click + from rich.traceback import install + install(suppress=[click]) + +Suppressed frames will show the line and file only, without any code. + +Max Frames +---------- + +A recursion error can generate very large tracebacks that take a while to render and contain a lot of repetitive frames. Rich guards against this with a `max_frames` argument, which defaults to 100. If a traceback contains more than 100 frames then only the first 50, and last 50 will be shown. You can disable this feature by setting `max_frames` to 0. + +Here's an example of printing an recursive error:: + + from rich.console import Console + + + def foo(n): + return bar(n) + + + def bar(n): + return foo(n) + + + console = Console() + + try: + foo(1) + except Exception: + console.print_exception(max_frames=20) \ No newline at end of file diff --git a/examples/recursive_error.py b/examples/recursive_error.py new file mode 100644 index 000000000..12b06e6eb --- /dev/null +++ b/examples/recursive_error.py @@ -0,0 +1,25 @@ +""" + +Demonstrates Rich tracebacks for recursion errors. + +Rich can exclude frames in the middle to avoid huge tracebacks. + +""" + +from rich.console import Console + + +def foo(n): + return bar(n) + + +def bar(n): + return foo(n) + + +console = Console() + +try: + foo(1) +except Exception: + console.print_exception(max_frames=20) diff --git a/pyproject.toml b/pyproject.toml index 7f9329aaa..c9b4bbb9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "rich" homepage = "https://github.com/willmcgugan/rich" documentation = "https://rich.readthedocs.io/en/latest/" -version = "10.10.0" +version = "10.11.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" authors = ["Will McGugan "] license = "MIT" diff --git a/rich/console.py b/rich/console.py index f9e22cad8..d314d9bd8 100644 --- a/rich/console.py +++ b/rich/console.py @@ -1705,6 +1705,7 @@ def print_exception( word_wrap: bool = False, show_locals: bool = False, suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ) -> None: """Prints a rich render of the last exception and traceback. @@ -1714,6 +1715,8 @@ def print_exception( theme (str, optional): Override pygments theme used in traceback word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. show_locals (bool, optional): Enable display of local variables. Defaults to False. + suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. """ from .traceback import Traceback @@ -1724,6 +1727,7 @@ def print_exception( word_wrap=word_wrap, show_locals=show_locals, suppress=suppress, + max_frames=max_frames, ) self.print(traceback) diff --git a/rich/traceback.py b/rich/traceback.py index 32b2970c7..47af0cded 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -48,6 +48,7 @@ def install( show_locals: bool = False, indent_guides: bool = True, suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ) -> Callable[[Type[BaseException], BaseException, Optional[TracebackType]], Any]: """Install a rich traceback handler. @@ -63,8 +64,7 @@ def install( word_wrap (bool, optional): Enable word wrapping of long lines. Defaults to False. show_locals (bool, optional): Enable display of local variables. Defaults to False. indent_guides (bool, optional): Enable indent guides in code and locals. Defaults to True. - suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. - + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. Returns: Callable: The previous exception handler that was replaced. @@ -89,6 +89,7 @@ def excepthook( show_locals=show_locals, indent_guides=indent_guides, suppress=suppress, + max_frames=max_frames, ) ) @@ -196,7 +197,8 @@ class Traceback: locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. - suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. + suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. """ @@ -220,6 +222,7 @@ def __init__( locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ): if trace is None: exc_type, exc_value, traceback = sys.exc_info() @@ -248,6 +251,7 @@ def __init__( path = suppress_entity path = os.path.normpath(os.path.abspath(path)) self.suppress.append(path) + self.max_frames = max(4, max_frames) if max_frames > 0 else 0 @classmethod def from_exception( @@ -264,6 +268,7 @@ def from_exception( locals_max_length: int = LOCALS_MAX_LENGTH, locals_max_string: int = LOCALS_MAX_STRING, suppress: Iterable[Union[str, ModuleType]] = (), + max_frames: int = 100, ) -> "Traceback": """Create a traceback from exception info @@ -280,7 +285,8 @@ def from_exception( locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to 10. locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80. - suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traeback. + suppress (Iterable[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. Returns: Traceback: A Traceback instance that may be printed. @@ -299,6 +305,7 @@ def from_exception( locals_max_length=locals_max_length, locals_max_string=locals_max_string, suppress=suppress, + max_frames=max_frames, ) @classmethod @@ -557,7 +564,30 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: max_string=self.locals_max_string, ) - for first, frame in loop_first(stack.frames): + exclude_frames: Optional[range] = None + if self.max_frames != 0: + exclude_frames = range( + self.max_frames // 2, + len(stack.frames) - self.max_frames // 2, + ) + + excluded = False + for frame_index, frame in enumerate(stack.frames): + + if exclude_frames and frame_index in exclude_frames: + excluded = True + continue + + if excluded: + assert exclude_frames is not None + yield Text( + f"\n... {len(exclude_frames)} frames hidden ...", + justify="center", + style="traceback.error", + ) + excluded = False + + first = frame_index == 1 frame_filename = frame.filename suppressed = any(frame_filename.startswith(path) for path in self.suppress) @@ -575,43 +605,42 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: if frame.filename.startswith("<"): yield from render_locals(frame) continue - if suppressed: - continue - try: - code = read_code(frame.filename) - lexer_name = self._guess_lexer(frame.filename, code) - syntax = Syntax( - code, - lexer_name, - theme=theme, - line_numbers=True, - line_range=( - frame.lineno - self.extra_lines, - frame.lineno + self.extra_lines, - ), - highlight_lines={frame.lineno}, - word_wrap=self.word_wrap, - code_width=88, - indent_guides=self.indent_guides, - dedent=False, - ) - yield "" - except Exception as error: - yield Text.assemble( - (f"\n{error}", "traceback.error"), - ) - else: - yield ( - Columns( - [ - syntax, - *render_locals(frame), - ], - padding=1, + if not suppressed: + try: + code = read_code(frame.filename) + lexer_name = self._guess_lexer(frame.filename, code) + syntax = Syntax( + code, + lexer_name, + theme=theme, + line_numbers=True, + line_range=( + frame.lineno - self.extra_lines, + frame.lineno + self.extra_lines, + ), + highlight_lines={frame.lineno}, + word_wrap=self.word_wrap, + code_width=88, + indent_guides=self.indent_guides, + dedent=False, + ) + yield "" + except Exception as error: + yield Text.assemble( + (f"\n{error}", "traceback.error"), + ) + else: + yield ( + Columns( + [ + syntax, + *render_locals(frame), + ], + padding=1, + ) + if frame.locals + else syntax ) - if frame.locals - else syntax - ) if __name__ == "__main__": # pragma: no cover diff --git a/tests/test_traceback.py b/tests/test_traceback.py index 33ec880b4..3b3e8de65 100644 --- a/tests/test_traceback.py +++ b/tests/test_traceback.py @@ -212,6 +212,34 @@ def test_guess_lexer(): assert Traceback._guess_lexer("foo", "foo\nbnar") == "text" +def test_recursive(): + def foo(n): + return bar(n) + + def bar(n): + return foo(n) + + console = Console(width=100, file=io.StringIO()) + try: + foo(1) + except Exception: + console.print_exception(max_frames=6) + result = console.file.getvalue() + print(result) + assert "frames hidden" in result + assert result.count("in foo") < 4 + + +def test_suppress(): + try: + 1 / 0 + except Exception: + traceback = Traceback(suppress=[pytest, "foo"]) + assert len(traceback.suppress) == 2 + assert "pytest" in traceback.suppress[0] + assert "foo" in traceback.suppress[1] + + if __name__ == "__main__": # pragma: no cover expected = render(get_exception()) From 8fbc4f242333b7688ada878e4c6c3747dd73be91 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Sep 2021 09:45:01 +0100 Subject: [PATCH 3/4] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb3d8426d..987eba8c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [10.11.0] - Unreleased +## [10.11.0] - 2021-09-24 ### Added From b82d75671cabf24f11696133a197cee01f756d67 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 24 Sep 2021 09:56:05 +0100 Subject: [PATCH 4/4] docs --- rich/pretty.py | 3 ++- rich/traceback.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rich/pretty.py b/rich/pretty.py index 4997d5b8e..ecd60fbce 100644 --- a/rich/pretty.py +++ b/rich/pretty.py @@ -83,7 +83,8 @@ def install( max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation. Defaults to None. max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to None. - expand_all (bool, optional): Expand all containers. Defaults to False + expand_all (bool, optional): Expand all containers. Defaults to False. + max_frames (int): Maximum number of frames to show in a traceback, 0 for no maximum. Defaults to 100. """ from rich import get_console diff --git a/rich/traceback.py b/rich/traceback.py index 47af0cded..4315fe053 100644 --- a/rich/traceback.py +++ b/rich/traceback.py @@ -645,7 +645,6 @@ def render_locals(frame: Frame) -> Iterable[ConsoleRenderable]: if __name__ == "__main__": # pragma: no cover - import rich from .console import Console console = Console() @@ -676,6 +675,6 @@ def error() -> None: except: slfkjsldkfj # type: ignore except: - console.print_exception(show_locals=True, suppress=[rich]) + console.print_exception(show_locals=True) error()