Repository: hynek/structlog

Issue:
Allow passing arguments to Rich in structlog.dev.rich_traceback
I'm trying to figure out how to turn off locals when printing out stack traces with the rich logger (but also with the JSONRenderer). Can't seem to find a way to do that in the documentation, aside from just extracting it from the final `dict` log record.

Here's the the important bits of (still exploring things, some variables are undefined):

```python
    shared_processors = [
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.stdlib.add_log_level,
        structlog.contextvars.merge_contextvars,
        structlog.processors.StackInfoRenderer(),
        structlog.processors.UnicodeDecoder(),
        structlog.processors.CallsiteParameterAdder(
            parameters=[
                CallsiteParameter.MODULE,
                CallsiteParameter.FUNC_NAME,
                CallsiteParameter.LINENO,
                CallsiteParameter.THREAD,
                CallsiteParameter.THREAD_NAME,
            ],
        ),
    ]

    # Here, we are prepping structlog to deal with logging from the stdlib
    structlog.configure(
        processors=shared_processors
        + [
            structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
        ],
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.make_filtering_bound_logger(level),
        cache_logger_on_first_use=cache_logger,
    )

    # These are processors that are shared for the formatter, used later in the root logger.
    shared_formatter_processors = [
        structlog.stdlib.ProcessorFormatter.remove_processors_meta,
    ]
    # If we're connected to a tty-like device (like a user shell), or we've disabled json logging, render to the console.
    if sys.stderr.isatty() and json_disabled:
        processor_renderers = shared_formatter_processors + [
            structlog.dev.ConsoleRenderer(),
        ]

    # Otherwise, use the json renderer.
    else:
        processor_renderers = shared_formatter_processors + [
            structlog.processors.dict_tracebacks,
            structlog.processors.StackInfoRenderer(),
            structlog.processors.JSONRenderer(),
        ]

    formatter = structlog.stdlib.ProcessorFormatter(
        # These run ONLY on `logging` entries that do NOT originate within structlog.
        foreign_pre_chain=shared_processors,
        # These run on ALL entries after the pre_chain is done.
        processors=processor_renderers,
    )

    # Make sure uncaught exceptions are formatted properly
    sys.excepthook = _log_uncaught_exceptions

    # Use OUR `ProcessorFormatter` to format all `logging` entries.
    handler = logging.StreamHandler(stream=sys.stdout)
    handler.setFormatter(formatter)
    root_logger = logging.getLogger()
    root_logger.addHandler(handler)
    root_logger.setLevel(level)
```

Any help would be appreciated, thanks!

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3dd3029..858080b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,11 +24,16 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/
 
 - Official support for Python 3.12.
   [#515](https://github.com/hynek/structlog/issues/515)
+
 - `structlog.processors.MaybeTimeStamper` that only adds a timestamp if there isn't one already.
   [#81](https://github.com/hynek/structlog/issues/81)
+
 - `structlog.dev.ConsoleRenderer` now supports renamed timestamp keys using the *timestamp_key* parameter.
   [#541](https://github.com/hynek/structlog/issues/541)
 
+- `structlog.dev.RichTracebackFormatter` that allows to configure the traceback formatting.
+  [#542](https://github.com/hynek/structlog/issues/542)
+
 
 ### Fixed
 
diff --git a/docs/api.rst b/docs/api.rst
index 5286276..f58f50f 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -79,6 +79,7 @@ API Reference
    :members: get_default_level_styles
 
 .. autofunction:: plain_traceback
+.. autoclass:: RichTracebackFormatter
 .. autofunction:: rich_traceback
 .. autofunction:: better_traceback
 
diff --git a/docs/conf.py b/docs/conf.py
index 8334f67..5948b03 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -160,4 +160,7 @@ linkcheck_ignore = [
 # Twisted's trac tends to be slow
 linkcheck_timeout = 300
 
-intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/3", None),
+    "rich": ("https://rich.readthedocs.io/en/stable/", None),
+}
diff --git a/src/structlog/dev.py b/src/structlog/dev.py
index c8d7387..1bc9bbf 100644
--- a/src/structlog/dev.py
+++ b/src/structlog/dev.py
@@ -11,11 +11,23 @@ See also the narrative documentation in `development`.
 
 from __future__ import annotations
 
+import shutil
 import sys
 import warnings
 
+from dataclasses import dataclass
 from io import StringIO
-from typing import Any, Iterable, Protocol, TextIO, Type, Union
+from types import ModuleType
+from typing import (
+    Any,
+    Iterable,
+    Literal,
+    Protocol,
+    Sequence,
+    TextIO,
+    Type,
+    Union,
+)
 
 from ._frames import _format_exception
 from .processors import _figure_out_exc_info
@@ -160,25 +172,78 @@ def plain_traceback(sio: TextIO, exc_info: ExcInfo) -> None:
 
     Used by default if neither Rich nor *better-exceptions* are present.
 
-    .. versionadded:: 21.2
+    .. versionadded:: 21.2.0
     """
     sio.write("\n" + _format_exception(exc_info))
 
 
-def rich_traceback(sio: TextIO, exc_info: ExcInfo) -> None:
+@dataclass
+class RichTracebackFormatter:
     """
-    Pretty-print *exc_info* to *sio* using the Rich package.
+    A Rich traceback renderer with the given options.
 
-    To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument.
+    Pass an instance as `ConsoleRenderer`'s ``exception_formatter`` argument.
 
-    Used by default if Rich is installed.
+    See :class:`rich.traceback.Traceback` for details on the arguments.
 
-    .. versionadded:: 21.2
+    If a *width* of -1 is passed, the terminal width is used. If the width
+    can't be determined, fall back to 80.
+
+    .. versionadded:: 23.2.0
     """
-    sio.write("\n")
-    Console(file=sio, color_system="truecolor").print(
-        Traceback.from_exception(*exc_info, show_locals=True)
-    )
+
+    color_system: Literal[
+        "auto", "standard", "256", "truecolor", "windows"
+    ] = "truecolor"
+    show_locals: bool = True
+    max_frames: int = 100
+    theme: str | None = None
+    word_wrap: bool = False
+    extra_lines: int = 3
+    width: int = 100
+    indent_guides: bool = True
+    locals_max_length: int = 10
+    locals_max_string: int = 80
+    locals_hide_dunder: bool = True
+    locals_hide_sunder: bool = False
+    suppress: Sequence[str | ModuleType] = ()
+
+    def __call__(self, sio: TextIO, exc_info: ExcInfo) -> None:
+        if self.width == -1:
+            self.width, _ = shutil.get_terminal_size((80, 0))
+
+        sio.write("\n")
+
+        Console(file=sio, color_system=self.color_system).print(
+            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,
+            )
+        )
+
+
+rich_traceback = RichTracebackFormatter()
+"""
+Pretty-print *exc_info* to *sio* using the Rich package.
+
+To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument.
+
+This is a `RichTracebackFormatter` with default arguments and used by default
+if Rich is installed.
+
+.. versionadded:: 21.2.0
+"""
 
 
 def better_traceback(sio: TextIO, exc_info: ExcInfo) -> None:


Test patch:
diff --git a/tests/test_dev.py b/tests/test_dev.py
index 2d84557..531e76d 100644
--- a/tests/test_dev.py
+++ b/tests/test_dev.py
@@ -7,6 +7,7 @@ import pickle
 import sys
 
 from io import StringIO
+from unittest import mock
 
 import pytest
 
@@ -560,20 +561,19 @@ class TestSetExcInfo:
         assert {"exc_info": True} == dev.set_exc_info(None, "exception", {})
 
 
-@pytest.mark.skipif(dev.rich is None, reason="Needs rich.")
-class TestRichTraceback:
+@pytest.mark.skipif(dev.rich is None, reason="Needs Rich.")
+class TestRichTracebackFormatter:
     def test_default(self):
         """
         If Rich is present, it's the default.
         """
         assert dev.default_exception_formatter is dev.rich_traceback
 
-    def test_does_not_blow_up(self):
+    def test_does_not_blow_up(self, sio):
         """
         We trust Rich to do the right thing, so we just exercise the function
         and check the first new line that we add manually is present.
         """
-        sio = StringIO()
         try:
             0 / 0
         except ZeroDivisionError:
@@ -581,6 +581,20 @@ class TestRichTraceback:
 
         assert sio.getvalue().startswith("\n")
 
+    def test_width_minus_one(self, sio):
+        """
+        If width is -1, it's replaced by the terminal width on first use.
+        """
+        rtf = dev.RichTracebackFormatter(width=-1)
+
+        with mock.patch("shutil.get_terminal_size", return_value=(42, 0)):
+            try:
+                0 / 0
+            except ZeroDivisionError:
+                rtf(sio, sys.exc_info())
+
+        assert 42 == rtf.width
+
 
 @pytest.mark.skipif(
     dev.better_exceptions is None, reason="Needs better-exceptions."