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.
`Contributed 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
from dataclasses import asdict, dataclass, field
from traceback import walk_tb
from types import TracebackType
from typing import Any, Tuple, Union
from .typing import ExcInfo
__all__ = [
"ExceptionDictTransformer",
"Frame",
"Stack",
"SyntaxError_",
"Trace",
"extract",
"safe_str",
"to_repr",
]
SHOW_LOCALS = True
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
line: 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.
"""
exc_type: str
exc_value: str
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 propegate."""
try:
return str(_object)
except Exception as error: # noqa: BLE001
return f"<str-error {str(error)!r}>"
def to_repr(obj: Any, max_string: int | None = None) -> str:
"""Get repr string for an object, but catch errors."""
if isinstance(obj, str):
obj_repr = obj
else:
try:
obj_repr = repr(obj)
except Exception as error: # noqa: BLE001
obj_repr = f"<repr-error {str(error)!r}>"
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}"
return obj_repr
[docs]
def extract(
exc_type: type[BaseException],
exc_value: BaseException,
traceback: TracebackType | None,
*,
show_locals: bool = False,
locals_max_string: int = LOCALS_MAX_STRING,
) -> 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_string:
Maximum length of string before truncating, or ``None`` to disable.
max_frames: Maximum number of frames in each stack
Returns:
A Trace instance with structured information about all exceptions.
.. versionadded:: 22.1.0
"""
stacks: list[Stack] = []
is_cause = False
while True:
stack = Stack(
exc_type=safe_str(exc_type.__name__),
exc_value=safe_str(exc_value),
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
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)
frame = Frame(
filename=filename or "?",
lineno=line_no,
name=frame_summary.f_code.co_name,
locals=(
{
key: to_repr(value, max_string=locals_max_string)
for key, value in 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_string:
The maximum length after which long string representations are
truncated.
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.
.. seealso::
:doc:`exceptions` for a broader explanation of *structlog*'s exception
features.
"""
def __init__(
self,
show_locals: bool = True,
locals_max_string: int = LOCALS_MAX_STRING,
max_frames: int = MAX_FRAMES,
) -> None:
if 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_string = locals_max_string
self.max_frames = max_frames
def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
trace = extract(
*exc_info,
show_locals=self.show_locals,
locals_max_string=self.locals_max_string,
)
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:],
]
return [asdict(stack) for stack in trace.stacks]