Source code for structlog.tracebacks
# SPDX-License-Identifier: MIT OR Apache-2.0
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the MIT License. See the LICENSE file in the root of this
# repository for complete details.
"""
Extract a structured traceback from an exception.
Based on work by Will McGugan
<https://github.com/hynek/structlog/pull/407#issuecomment-1150926246>`_ from
`rich.traceback
<https://github.com/Textualize/rich/blob/972dedff/rich/traceback.py>`_.
"""
from __future__ import annotations
import os
import os.path
from dataclasses import asdict, dataclass, field
from traceback import walk_tb
from types import ModuleType, TracebackType
from typing import Any, Iterable, Sequence, Tuple, Union
try:
import rich
import rich.pretty
except ImportError:
rich = None # type: ignore[assignment]
from .typing import ExcInfo
__all__ = [
"ExceptionDictTransformer",
"Frame",
"Stack",
"SyntaxError_",
"Trace",
"extract",
"safe_str",
"to_repr",
]
SHOW_LOCALS = True
LOCALS_MAX_LENGTH = 10
LOCALS_MAX_STRING = 80
MAX_FRAMES = 50
OptExcInfo = Union[ExcInfo, Tuple[None, None, None]]
[docs]
@dataclass
class Frame:
"""
Represents a single stack frame.
"""
filename: str
lineno: int
name: str
locals: dict[str, str] | None = None
[docs]
@dataclass
class SyntaxError_: # noqa: N801
"""
Contains detailed information about :exc:`SyntaxError` exceptions.
"""
offset: int
filename: str
line: str
lineno: int
msg: str
[docs]
@dataclass
class Stack:
"""
Represents an exception and a list of stack frames.
.. versionchanged:: 25.2.0
Added the *exc_notes* field.
"""
exc_type: str
exc_value: str
exc_notes: list[str] = field(default_factory=list)
syntax_error: SyntaxError_ | None = None
is_cause: bool = False
frames: list[Frame] = field(default_factory=list)
def safe_str(_object: Any) -> str:
"""Don't allow exceptions from __str__ to propagate."""
try:
return str(_object)
except Exception as error: # noqa: BLE001
return f"<str-error {str(error)!r}>"
def to_repr(
obj: Any,
max_length: int | None = None,
max_string: int | None = None,
use_rich: bool = True,
) -> str:
"""
Get repr string for an object, but catch errors.
:func:`repr()` is used for strings, too, so that secret wrappers that
inherit from :func:`str` and overwrite ``__repr__()`` are handled correctly
(i.e. secrets are not logged in plain text).
Args:
obj: Object to get a string representation for.
max_length: Maximum length of containers before abbreviating, or
``None`` for no abbreviation.
max_string: Maximum length of string before truncating, or ``None`` to
disable truncating.
use_rich: If ``True`` (the default), use rich_ to compute the repr.
If ``False`` or if rich_ is not installed, fall back to a simpler
algorithm.
Returns:
The string representation of *obj*.
.. versionchanged:: 24.3.0
Added *max_length* argument. Use :program:`rich` to render locals if it
is available. Call :func:`repr()` on strings in fallback
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)
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
[docs]
def extract(
exc_type: type[BaseException],
exc_value: BaseException,
traceback: TracebackType | None,
*,
show_locals: bool = False,
locals_max_length: int = LOCALS_MAX_LENGTH,
locals_max_string: int = LOCALS_MAX_STRING,
locals_hide_dunder: bool = True,
locals_hide_sunder: bool = False,
use_rich: bool = True,
) -> Trace:
"""
Extract traceback information.
Args:
exc_type: Exception type.
exc_value: Exception value.
traceback: Python Traceback object.
show_locals: Enable display of local variables. Defaults to False.
locals_max_length:
Maximum length of containers before abbreviating, or ``None`` for
no abbreviation.
locals_max_string:
Maximum length of string before truncating, or ``None`` to disable
truncating.
locals_hide_dunder:
Hide locals prefixed with double underscore.
Defaults to True.
locals_hide_sunder:
Hide locals prefixed with single underscore.
This implies hiding *locals_hide_dunder*.
Defaults to False.
use_rich: If ``True`` (the default), use rich_ to compute the repr.
If ``False`` or if rich_ is not installed, fall back to a simpler
algorithm.
Returns:
A Trace instance with structured information about all exceptions.
.. versionadded:: 22.1.0
.. versionchanged:: 24.3.0
Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
and *use_rich* arguments.
"""
stacks: list[Stack] = []
is_cause = False
while True:
stack = Stack(
exc_type=safe_str(exc_type.__name__),
exc_value=safe_str(exc_value),
exc_notes=[
safe_str(note) for note in getattr(exc_value, "__notes__", ())
],
is_cause=is_cause,
)
if isinstance(exc_value, SyntaxError):
stack.syntax_error = SyntaxError_(
offset=exc_value.offset or 0,
filename=exc_value.filename or "?",
lineno=exc_value.lineno or 0,
line=exc_value.text or "",
msg=exc_value.msg,
)
stacks.append(stack)
append = stack.frames.append # pylint: disable=no-member
def get_locals(
iter_locals: Iterable[tuple[str, object]],
) -> Iterable[tuple[str, object]]:
"""Extract locals from an iterator of key pairs."""
if not (locals_hide_dunder or locals_hide_sunder):
yield from iter_locals
return
for key, value in iter_locals:
if locals_hide_dunder and key.startswith("__"):
continue
if locals_hide_sunder and key.startswith("_"):
continue
yield key, value
for frame_summary, line_no in walk_tb(traceback):
filename = frame_summary.f_code.co_filename
if filename and not filename.startswith("<"):
filename = os.path.abspath(filename)
# Rich has this, but we are not rich and like to keep all frames:
# if frame_summary.f_locals.get("_rich_traceback_omit", False):
# continue # noqa: ERA001
frame = Frame(
filename=filename or "?",
lineno=line_no,
name=frame_summary.f_code.co_name,
locals=(
{
key: to_repr(
value,
max_length=locals_max_length,
max_string=locals_max_string,
use_rich=use_rich,
)
for key, value in get_locals(
frame_summary.f_locals.items()
)
}
if show_locals
else None
),
)
append(frame)
cause = getattr(exc_value, "__cause__", None)
if cause and cause.__traceback__:
exc_type = cause.__class__
exc_value = cause
traceback = cause.__traceback__
is_cause = True
continue
cause = exc_value.__context__
if (
cause
and cause.__traceback__
and not getattr(exc_value, "__suppress_context__", False)
):
exc_type = cause.__class__
exc_value = cause
traceback = cause.__traceback__
is_cause = False
continue
# No cover, code is reached but coverage doesn't recognize it.
break # pragma: no cover
return Trace(stacks=stacks)
[docs]
class ExceptionDictTransformer:
"""
Return a list of exception stack dictionaries for an exception.
These dictionaries are based on :class:`Stack` instances generated by
:func:`extract()` and can be dumped to JSON.
Args:
show_locals:
Whether or not to include the values of a stack frame's local
variables.
locals_max_length:
Maximum length of containers before abbreviating, or ``None`` for
no abbreviation.
locals_max_string:
Maximum length of string before truncating, or ``None`` to disable
truncating.
locals_hide_dunder:
Hide locals prefixed with double underscore.
Defaults to True.
locals_hide_sunder:
Hide locals prefixed with single underscore.
This implies hiding *locals_hide_dunder*.
Defaults to False.
suppress:
Optional sequence of modules or paths for which to suppress the
display of locals even if *show_locals* is ``True``.
max_frames:
Maximum number of frames in each stack. Frames are removed from
the inside out. The idea is, that the first frames represent your
code responsible for the exception and last frames the code where
the exception actually happened. With larger web frameworks, this
does not always work, so you should stick with the default.
use_rich: If ``True`` (the default), use rich_ to compute the repr of
locals. If ``False`` or if rich_ is not installed, fall back to
a simpler algorithm.
.. seealso::
:doc:`exceptions` for a broader explanation of *structlog*'s exception
features.
.. versionchanged:: 24.3.0
Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*,
*suppress* and *use_rich* arguments.
.. versionchanged:: 25.1.0
*locals_max_length* and *locals_max_string* may be None to disable
truncation.
"""
def __init__(
self,
*,
show_locals: bool = SHOW_LOCALS,
locals_max_length: int = LOCALS_MAX_LENGTH,
locals_max_string: int = LOCALS_MAX_STRING,
locals_hide_dunder: bool = True,
locals_hide_sunder: bool = False,
suppress: Iterable[str | ModuleType] = (),
max_frames: int = MAX_FRAMES,
use_rich: bool = True,
) -> None:
if locals_max_length is not None and locals_max_length < 0:
msg = f'"locals_max_length" must be >= 0: {locals_max_length}'
raise ValueError(msg)
if locals_max_string is not None and locals_max_string < 0:
msg = f'"locals_max_string" must be >= 0: {locals_max_string}'
raise ValueError(msg)
if max_frames < 2:
msg = f'"max_frames" must be >= 2: {max_frames}'
raise ValueError(msg)
self.show_locals = show_locals
self.locals_max_length = locals_max_length
self.locals_max_string = locals_max_string
self.locals_hide_dunder = locals_hide_dunder
self.locals_hide_sunder = locals_hide_sunder
self.suppress: Sequence[str] = []
for suppress_entity in suppress:
if not isinstance(suppress_entity, str):
if suppress_entity.__file__ is None:
msg = (
f'"suppress" item {suppress_entity!r} must be a '
f"module with '__file__' attribute"
)
raise ValueError(msg)
path = os.path.dirname(suppress_entity.__file__)
else:
path = suppress_entity
path = os.path.normpath(os.path.abspath(path))
self.suppress.append(path)
self.max_frames = max_frames
self.use_rich = use_rich
def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
trace = extract(
*exc_info,
show_locals=self.show_locals,
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,
use_rich=self.use_rich,
)
for stack in trace.stacks:
if len(stack.frames) <= self.max_frames:
continue
half = (
self.max_frames // 2
) # Force int division to handle odd numbers correctly
fake_frame = Frame(
filename="",
lineno=-1,
name=f"Skipped frames: {len(stack.frames) - (2 * half)}",
)
stack.frames[:] = [
*stack.frames[:half],
fake_frame,
*stack.frames[-half:],
]
stacks = [asdict(stack) for stack in trace.stacks]
for stack_dict in stacks:
for frame_dict in stack_dict["frames"]:
if frame_dict["locals"] is None or any(
frame_dict["filename"].startswith(path)
for path in self.suppress
):
del frame_dict["locals"]
return stacks