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..b451c870 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: dict[str, Any] = { + "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.