From 1a2c13b492c75069d6bc670750394efd2e98a6ba Mon Sep 17 00:00:00 2001 From: Pranjal Date: Sat, 8 Aug 2026 12:41:31 +0530 Subject: [PATCH 1/2] Fix two crashes caused by unguarded calls into the optional Rich dependency - tracebacks.to_repr(): rich.pretty.traverse() was called with no error handling, unlike the non-Rich fallback right below it which explicitly catches everything. Any exception raised during Rich's own object introspection (e.g. from unusual attribute access) would propagate and break exception logging entirely, at exactly the moment it's needed most. Now falls back to the manual repr algorithm on failure. (#655) - dev.RichTracebackFormatter: always passed locals_hide_dunder and locals_hide_sunder to Traceback.from_exception(), but those arguments were only added in Rich 13.1.0. On older Rich versions this raised TypeError: unexpected keyword argument, crashing the console renderer. Now inspects Traceback.from_exception()'s signature once and only passes the kwargs the installed Rich version actually supports, mirroring the existing hasattr(tb, "code_width") compatibility pattern already used a few lines below for the same reason. (#576) Added regression tests for both and a changelog entry. --- CHANGELOG.md | 8 +++++++ src/structlog/dev.py | 47 ++++++++++++++++++++++++------------ src/structlog/tracebacks.py | 48 +++++++++++++++++++++---------------- tests/test_dev.py | 30 +++++++++++++++++++++++ tests/test_tracebacks.py | 20 ++++++++++++++++ 5 files changed, 118 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b23d71a7..549963a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ## [Unreleased](https://github.com/hynek/structlog/compare/26.1.0...HEAD) +### Fixed + +- `structlog.tracebacks.to_repr()` no longer lets exceptions raised by Rich's own object introspection propagate and break exception logging; it now falls back to the non-Rich algorithm instead. + [#655](https://github.com/hynek/structlog/issues/655) + +- `structlog.dev.RichTracebackFormatter` no longer crashes with a `TypeError` on older versions of Rich (before 13.1.0) that don't support the *locals_hide_dunder* / *locals_hide_sunder* arguments. + [#576](https://github.com/hynek/structlog/issues/576) + ## [26.1.0](https://github.com/hynek/structlog/compare/25.5.0...26.1.0) - 2026-06-06 diff --git a/src/structlog/dev.py b/src/structlog/dev.py index 8ab25d15..74ace9ad 100644 --- a/src/structlog/dev.py +++ b/src/structlog/dev.py @@ -11,6 +11,7 @@ from __future__ import annotations +import inspect import sys import warnings @@ -52,6 +53,15 @@ from rich.traceback import Traceback except ImportError: rich = None # type: ignore[assignment] +else: + # Older versions of Rich don't support all keyword arguments we'd like to + # pass to Traceback.from_exception() (e.g., locals_hide_dunder / + # locals_hide_sunder were only added in Rich 13.1.0). Only pass the ones + # that are actually supported by the installed version so that structlog + # keeps working with a wide range of Rich versions. + _RICH_TRACEBACK_FROM_EXCEPTION_PARAMS = frozenset( + inspect.signature(Traceback.from_exception).parameters + ) __all__ = [ "ConsoleRenderer", @@ -445,21 +455,28 @@ def __call__(self, sio: TextIO, exc_info: ExcInfo) -> None: console = Console( file=sio, color_system=self.color_system, width=self.width ) - tb = Traceback.from_exception( - *exc_info, - show_locals=self.show_locals, - max_frames=self.max_frames, - theme=self.theme, - word_wrap=self.word_wrap, - extra_lines=self.extra_lines, - width=self.width, - indent_guides=self.indent_guides, - locals_max_length=self.locals_max_length, - locals_max_string=self.locals_max_string, - locals_hide_dunder=self.locals_hide_dunder, - locals_hide_sunder=self.locals_hide_sunder, - suppress=self.suppress, - ) + kwargs = { + "show_locals": self.show_locals, + "max_frames": self.max_frames, + "theme": self.theme, + "word_wrap": self.word_wrap, + "extra_lines": self.extra_lines, + "width": self.width, + "indent_guides": self.indent_guides, + "locals_max_length": self.locals_max_length, + "locals_max_string": self.locals_max_string, + "locals_hide_dunder": self.locals_hide_dunder, + "locals_hide_sunder": self.locals_hide_sunder, + "suppress": self.suppress, + } + # Drop kwargs that the installed Rich version doesn't support yet + # (see _RICH_TRACEBACK_FROM_EXCEPTION_PARAMS above). + kwargs = { + k: v + for k, v in kwargs.items() + if k in _RICH_TRACEBACK_FROM_EXCEPTION_PARAMS + } + tb = Traceback.from_exception(*exc_info, **kwargs) if hasattr(tb, "code_width"): # `code_width` requires `rich>=13.8.0` tb.code_width = self.code_width diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py index 5b01ad99..8731caa8 100644 --- a/src/structlog/tracebacks.py +++ b/src/structlog/tracebacks.py @@ -153,29 +153,37 @@ def to_repr( implementation. """ if use_rich and rich is not None: - # Let rich render the repr if it is available. - # It produces much better results for containers and dataclasses/attrs. - obj_repr = rich.pretty.traverse( - obj, max_length=max_length, max_string=max_string - ).render() - else: - # Generate a (truncated) repr if rich is not available. - # Handle str/bytes differently to get better results for truncated - # representations. Also catch all errors, similarly to "safe_str()". try: - if isinstance(obj, (str, bytes)): - if max_string is not None and len(obj) > max_string: - truncated = len(obj) - max_string - obj_repr = f"{obj[:max_string]!r}+{truncated}" - else: - obj_repr = repr(obj) + # Let rich render the repr if it is available. + # It produces much better results for containers and + # dataclasses/attrs. + return rich.pretty.traverse( + obj, max_length=max_length, max_string=max_string + ).render() + except Exception: # noqa: BLE001, S110 + # Rich's introspection can raise on objects it doesn't expect + # (e.g. ones with unusual attribute access), which would + # otherwise break exception logging entirely. Fall back to the + # manual algorithm below instead. + pass + + # Generate a (truncated) repr if rich is not available, or failed above. + # Handle str/bytes differently to get better results for truncated + # representations. Also catch all errors, similarly to "safe_str()". + try: + if isinstance(obj, (str, bytes)): + if max_string is not None and len(obj) > max_string: + truncated = len(obj) - max_string + obj_repr = f"{obj[:max_string]!r}+{truncated}" else: obj_repr = repr(obj) - if max_string is not None and len(obj_repr) > max_string: - truncated = len(obj_repr) - max_string - obj_repr = f"{obj_repr[:max_string]!r}+{truncated}" - except Exception as error: # noqa: BLE001 - obj_repr = f"" + else: + obj_repr = repr(obj) + if max_string is not None and len(obj_repr) > max_string: + truncated = len(obj_repr) - max_string + obj_repr = f"{obj_repr[:max_string]!r}+{truncated}" + except Exception as error: # noqa: BLE001 + obj_repr = f"" return obj_repr diff --git a/tests/test_dev.py b/tests/test_dev.py index 2c11b2f0..20af2b94 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -841,6 +841,36 @@ def test_code_width_support(self, sio, code_width_support): if code_width_support: assert tb.code_width == 88 + def test_old_rich_missing_locals_hide_params(self, sio, monkeypatch): + """ + If the installed Rich version doesn't support the + locals_hide_dunder / locals_hide_sunder keyword arguments (added in + Rich 13.1.0), they are silently omitted from the call to + Traceback.from_exception() instead of raising a TypeError (#576). + """ + from rich.traceback import Trace + + monkeypatch.setattr( + dev, + "_RICH_TRACEBACK_FROM_EXCEPTION_PARAMS", + dev._RICH_TRACEBACK_FROM_EXCEPTION_PARAMS + - {"locals_hide_dunder", "locals_hide_sunder"}, + ) + + tb = mock.Mock(spec=dev.Traceback(Trace([]))) + tb.__rich_console__.return_value = "for Python 3.8 compatibility" + + with mock.patch.object( + dev.Traceback, "from_exception", return_value=tb + ) as factory: + try: + 0 / 0 + except ZeroDivisionError: + dev.rich_traceback(sio, sys.exc_info()) + + assert "locals_hide_dunder" not in factory.call_args.kwargs + assert "locals_hide_sunder" not in factory.call_args.kwargs + @pytest.mark.skipif( dev.better_exceptions is None, reason="Needs better-exceptions." diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index 667c7871..7b299120 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -133,6 +133,26 @@ def __repr__(self) -> str: assert "" == tracebacks.to_repr(Baam()) +def test_to_repr_rich_error(monkeypatch: pytest.MonkeyPatch) -> None: + """ + "to_repr()" falls back to the non-Rich algorithm if Rich's own + introspection raises an exception instead of propagating it (#655). + """ + try: + import rich + import rich.pretty + except ImportError: + pytest.skip(reason="rich not installed") + + def boom(*args: Any, **kwargs: Any) -> Any: + raise AttributeError("'Baam' object has no attribute 'x'") + + monkeypatch.setattr(tracebacks, "rich", rich) + monkeypatch.setattr(rich.pretty, "traverse", boom) + + assert "'spam'" == tracebacks.to_repr("spam") + + def test_simple_exception(): """ Tracebacks are parsed for simple, single exceptions. From e9d9f213d31275228490fd60f645032462d0d719 Mon Sep 17 00:00:00 2001 From: Pranjal Date: Sat, 8 Aug 2026 13:00:19 +0530 Subject: [PATCH 2/2] Fix mypy: annotate kwargs dict as dict[str, Any] Without an explicit annotation, mypy inferred a narrow union type from the dict literal, which it then rejected when unpacked into Traceback.from_exception()'s specifically-typed parameters. CI caught this. --- src/structlog/dev.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/structlog/dev.py b/src/structlog/dev.py index 74ace9ad..b451c870 100644 --- a/src/structlog/dev.py +++ b/src/structlog/dev.py @@ -455,7 +455,7 @@ def __call__(self, sio: TextIO, exc_info: ExcInfo) -> None: console = Console( file=sio, color_system=self.color_system, width=self.width ) - kwargs = { + kwargs: dict[str, Any] = { "show_locals": self.show_locals, "max_frames": self.max_frames, "theme": self.theme,