Skip to content
Open
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
47 changes: 32 additions & 15 deletions src/structlog/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from __future__ import annotations

import inspect
import sys
import warnings

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
48 changes: 28 additions & 20 deletions src/structlog/tracebacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<repr-error {str(error)!r}>"
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"<repr-error {str(error)!r}>"

return obj_repr

Expand Down
30 changes: 30 additions & 0 deletions tests/test_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
20 changes: 20 additions & 0 deletions tests/test_tracebacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,26 @@ def __repr__(self) -> str:
assert "<repr-error 'BAAM!'>" == 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.
Expand Down