Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/structlog/tracebacks.py: 38%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# SPDX-License-Identifier: MIT OR Apache-2.0
2# This file is dual licensed under the terms of the Apache License, Version
3# 2.0, and the MIT License. See the LICENSE file in the root of this
4# repository for complete details.
6"""
7Extract a structured traceback from an exception.
9Based on work by Will McGugan
10<https://github.com/hynek/structlog/pull/407#issuecomment-1150926246>`_ from
11`rich.traceback
12<https://github.com/Textualize/rich/blob/972dedff/rich/traceback.py>`_.
13"""
15from __future__ import annotations
17import os
18import os.path
19import sys
21from collections.abc import Iterable, Sequence
22from dataclasses import asdict, dataclass, field
23from traceback import walk_tb
24from types import ModuleType, TracebackType
25from typing import Any, TypeAlias
28try:
29 import rich
30 import rich.pretty
31except ImportError:
32 rich = None # type: ignore[assignment]
34from .typing import ExcInfo
37__all__ = [
38 "ExceptionDictTransformer",
39 "Frame",
40 "Stack",
41 "SyntaxError_",
42 "Trace",
43 "extract",
44 "safe_str",
45 "to_repr",
46]
49SHOW_LOCALS = True
50LOCALS_MAX_LENGTH = 10
51LOCALS_MAX_STRING = 80
52MAX_FRAMES = 50
54OptExcInfo: TypeAlias = ExcInfo | tuple[None, None, None]
57@dataclass
58class Frame:
59 """
60 Represents a single stack frame.
61 """
63 filename: str
64 lineno: int
65 name: str
66 locals: dict[str, str] | None = None
69@dataclass
70class SyntaxError_: # noqa: N801
71 """
72 Contains detailed information about :exc:`SyntaxError` exceptions.
73 """
75 offset: int
76 filename: str
77 line: str
78 lineno: int
79 msg: str
82@dataclass
83class Stack:
84 """
85 Represents an exception and a list of stack frames.
87 .. versionchanged:: 25.2.0
88 Added the *exc_notes* field.
90 .. versionchanged:: 25.4.0
91 Added the *is_group* and *exceptions* fields.
92 """
94 exc_type: str
95 exc_value: str
96 exc_notes: list[str] = field(default_factory=list)
97 syntax_error: SyntaxError_ | None = None
98 is_cause: bool = False
99 frames: list[Frame] = field(default_factory=list)
100 is_group: bool = False
101 exceptions: list[Trace] = field(default_factory=list)
104@dataclass
105class Trace:
106 """
107 Container for a list of stack traces.
108 """
110 stacks: list[Stack]
113def safe_str(_object: Any) -> str:
114 """Don't allow exceptions from __str__ to propagate."""
115 try:
116 return str(_object)
117 except Exception as error: # noqa: BLE001
118 return f"<str-error {str(error)!r}>"
121def to_repr(
122 obj: Any,
123 max_length: int | None = None,
124 max_string: int | None = None,
125 use_rich: bool = True,
126) -> str:
127 """
128 Get repr string for an object, but catch errors.
130 :func:`repr()` is used for strings, too, so that secret wrappers that
131 inherit from :func:`str` and overwrite ``__repr__()`` are handled correctly
132 (i.e. secrets are not logged in plain text).
134 Args:
135 obj: Object to get a string representation for.
137 max_length: Maximum length of containers before abbreviating, or
138 ``None`` for no abbreviation.
140 max_string: Maximum length of string before truncating, or ``None`` to
141 disable truncating.
143 use_rich: If ``True`` (the default), use rich_ to compute the repr.
144 If ``False`` or if rich_ is not installed, fall back to a simpler
145 algorithm.
147 Returns:
148 The string representation of *obj*.
150 .. versionchanged:: 24.3.0
151 Added *max_length* argument. Use :program:`rich` to render locals if it
152 is available. Call :func:`repr()` on strings in fallback
153 implementation.
154 """
155 if use_rich and rich is not None:
156 # Let rich render the repr if it is available.
157 # It produces much better results for containers and dataclasses/attrs.
158 obj_repr = rich.pretty.traverse(
159 obj, max_length=max_length, max_string=max_string
160 ).render()
161 else:
162 # Generate a (truncated) repr if rich is not available.
163 # Handle str/bytes differently to get better results for truncated
164 # representations. Also catch all errors, similarly to "safe_str()".
165 try:
166 if isinstance(obj, (str, bytes)):
167 if max_string is not None and len(obj) > max_string:
168 truncated = len(obj) - max_string
169 obj_repr = f"{obj[:max_string]!r}+{truncated}"
170 else:
171 obj_repr = repr(obj)
172 else:
173 obj_repr = repr(obj)
174 if max_string is not None and len(obj_repr) > max_string:
175 truncated = len(obj_repr) - max_string
176 obj_repr = f"{obj_repr[:max_string]!r}+{truncated}"
177 except Exception as error: # noqa: BLE001
178 obj_repr = f"<repr-error {str(error)!r}>"
180 return obj_repr
183def extract(
184 exc_type: type[BaseException],
185 exc_value: BaseException,
186 traceback: TracebackType | None,
187 *,
188 show_locals: bool = False,
189 locals_max_length: int = LOCALS_MAX_LENGTH,
190 locals_max_string: int = LOCALS_MAX_STRING,
191 locals_hide_dunder: bool = True,
192 locals_hide_sunder: bool = False,
193 use_rich: bool = True,
194 _seen: set[int] | None = None,
195) -> Trace:
196 """
197 Extract traceback information.
199 Args:
200 exc_type: Exception type.
202 exc_value: Exception value.
204 traceback: Python Traceback object.
206 show_locals: Enable display of local variables. Defaults to False.
208 locals_max_length:
209 Maximum length of containers before abbreviating, or ``None`` for
210 no abbreviation.
212 locals_max_string:
213 Maximum length of string before truncating, or ``None`` to disable
214 truncating.
216 locals_hide_dunder:
217 Hide locals prefixed with double underscore.
218 Defaults to True.
220 locals_hide_sunder:
221 Hide locals prefixed with single underscore.
222 This implies hiding *locals_hide_dunder*.
223 Defaults to False.
225 use_rich: If ``True`` (the default), use rich_ to compute the repr.
226 If ``False`` or if rich_ is not installed, fall back to a simpler
227 algorithm.
229 Returns:
230 A Trace instance with structured information about all exceptions.
232 .. versionadded:: 22.1.0
234 .. versionchanged:: 24.3.0
235 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
236 and *use_rich* arguments.
238 .. versionchanged:: 25.4.0
239 Handle exception groups.
241 .. versionchanged:: 25.5.0
242 Handle loops in exception cause chain.
243 """
245 stacks: list[Stack] = []
246 is_cause = False
248 if _seen is None:
249 _seen = set()
251 while True:
252 exc_id = id(exc_value)
253 if exc_id in _seen:
254 break
255 _seen.add(exc_id)
257 stack = Stack(
258 exc_type=safe_str(exc_type.__name__),
259 exc_value=safe_str(exc_value),
260 exc_notes=[
261 safe_str(note) for note in getattr(exc_value, "__notes__", ())
262 ],
263 is_cause=is_cause,
264 )
266 if sys.version_info >= (3, 11):
267 if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): # noqa: F821
268 stack.is_group = True
269 for exception in exc_value.exceptions:
270 stack.exceptions.append(
271 extract(
272 type(exception),
273 exception,
274 exception.__traceback__,
275 show_locals=show_locals,
276 locals_max_length=locals_max_length,
277 locals_max_string=locals_max_string,
278 locals_hide_dunder=locals_hide_dunder,
279 locals_hide_sunder=locals_hide_sunder,
280 use_rich=use_rich,
281 _seen=_seen,
282 )
283 )
285 if isinstance(exc_value, SyntaxError):
286 stack.syntax_error = SyntaxError_(
287 offset=exc_value.offset or 0,
288 filename=exc_value.filename or "?",
289 lineno=exc_value.lineno or 0,
290 line=exc_value.text or "",
291 msg=exc_value.msg,
292 )
294 stacks.append(stack)
295 append = stack.frames.append # pylint: disable=no-member
297 def get_locals(
298 iter_locals: Iterable[tuple[str, object]],
299 ) -> Iterable[tuple[str, object]]:
300 """Extract locals from an iterator of key pairs."""
301 if not (locals_hide_dunder or locals_hide_sunder):
302 yield from iter_locals
303 return
304 for key, value in iter_locals:
305 if locals_hide_dunder and key.startswith("__"):
306 continue
307 if locals_hide_sunder and key.startswith("_"):
308 continue
309 yield key, value
311 for frame_summary, line_no in walk_tb(traceback):
312 filename = frame_summary.f_code.co_filename
313 if filename and not filename.startswith("<"):
314 filename = os.path.abspath(filename)
315 # Rich has this, but we are not rich and like to keep all frames:
316 # if frame_summary.f_locals.get("_rich_traceback_omit", False):
317 # continue # noqa: ERA001
319 frame = Frame(
320 filename=filename or "?",
321 lineno=line_no,
322 name=frame_summary.f_code.co_name,
323 locals=(
324 {
325 key: to_repr(
326 value,
327 max_length=locals_max_length,
328 max_string=locals_max_string,
329 use_rich=use_rich,
330 )
331 for key, value in get_locals(
332 frame_summary.f_locals.items()
333 )
334 }
335 if show_locals
336 else None
337 ),
338 )
339 append(frame)
341 cause = getattr(exc_value, "__cause__", None)
342 if cause and cause.__traceback__:
343 exc_type = cause.__class__
344 exc_value = cause
345 traceback = cause.__traceback__
346 is_cause = True
347 continue
349 cause = exc_value.__context__
350 if (
351 cause
352 and cause.__traceback__
353 and not getattr(exc_value, "__suppress_context__", False)
354 ):
355 exc_type = cause.__class__
356 exc_value = cause
357 traceback = cause.__traceback__
358 is_cause = False
359 continue
361 # No cover, code is reached but coverage doesn't recognize it.
362 break # pragma: no cover
364 return Trace(stacks=stacks)
367class ExceptionDictTransformer:
368 """
369 Return a list of exception stack dictionaries for an exception.
371 These dictionaries are based on :class:`Stack` instances generated by
372 :func:`extract()` and can be dumped to JSON.
374 Args:
375 show_locals:
376 Whether or not to include the values of a stack frame's local
377 variables.
379 locals_max_length:
380 Maximum length of containers before abbreviating, or ``None`` for
381 no abbreviation.
383 locals_max_string:
384 Maximum length of string before truncating, or ``None`` to disable
385 truncating.
387 locals_hide_dunder:
388 Hide locals prefixed with double underscore.
389 Defaults to True.
391 locals_hide_sunder:
392 Hide locals prefixed with single underscore.
393 This implies hiding *locals_hide_dunder*.
394 Defaults to False.
396 suppress:
397 Optional sequence of modules or paths for which to suppress the
398 display of locals even if *show_locals* is ``True``.
400 max_frames:
401 Maximum number of frames in each stack. Frames are removed from
402 the inside out. The idea is, that the first frames represent your
403 code responsible for the exception and last frames the code where
404 the exception actually happened. With larger web frameworks, this
405 does not always work, so you should stick with the default.
407 use_rich: If ``True`` (the default), use rich_ to compute the repr of
408 locals. If ``False`` or if rich_ is not installed, fall back to
409 a simpler algorithm.
411 .. seealso::
412 :doc:`exceptions` for a broader explanation of *structlog*'s exception
413 features.
415 .. versionchanged:: 24.3.0
416 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*,
417 *suppress* and *use_rich* arguments.
419 .. versionchanged:: 25.1.0
420 *locals_max_length* and *locals_max_string* may be None to disable
421 truncation.
423 .. versionchanged:: 25.4.0
424 Handle exception groups.
425 """
427 def __init__(
428 self,
429 *,
430 show_locals: bool = SHOW_LOCALS,
431 locals_max_length: int = LOCALS_MAX_LENGTH,
432 locals_max_string: int = LOCALS_MAX_STRING,
433 locals_hide_dunder: bool = True,
434 locals_hide_sunder: bool = False,
435 suppress: Iterable[str | ModuleType] = (),
436 max_frames: int = MAX_FRAMES,
437 use_rich: bool = True,
438 ) -> None:
439 if locals_max_length is not None and locals_max_length < 0:
440 msg = f'"locals_max_length" must be >= 0: {locals_max_length}'
441 raise ValueError(msg)
442 if locals_max_string is not None and locals_max_string < 0:
443 msg = f'"locals_max_string" must be >= 0: {locals_max_string}'
444 raise ValueError(msg)
445 if max_frames < 2:
446 msg = f'"max_frames" must be >= 2: {max_frames}'
447 raise ValueError(msg)
448 self.show_locals = show_locals
449 self.locals_max_length = locals_max_length
450 self.locals_max_string = locals_max_string
451 self.locals_hide_dunder = locals_hide_dunder
452 self.locals_hide_sunder = locals_hide_sunder
453 self.suppress: Sequence[str] = []
454 for suppress_entity in suppress:
455 if not isinstance(suppress_entity, str):
456 if suppress_entity.__file__ is None:
457 msg = (
458 f'"suppress" item {suppress_entity!r} must be a '
459 f"module with '__file__' attribute"
460 )
461 raise ValueError(msg)
462 path = os.path.dirname(suppress_entity.__file__)
463 else:
464 path = suppress_entity
465 path = os.path.normpath(os.path.abspath(path))
466 self.suppress.append(path)
467 self.max_frames = max_frames
468 self.use_rich = use_rich
470 def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
471 trace = extract(
472 *exc_info,
473 show_locals=self.show_locals,
474 locals_max_length=self.locals_max_length,
475 locals_max_string=self.locals_max_string,
476 locals_hide_dunder=self.locals_hide_dunder,
477 locals_hide_sunder=self.locals_hide_sunder,
478 use_rich=self.use_rich,
479 )
481 for stack in trace.stacks:
482 if len(stack.frames) <= self.max_frames:
483 continue
485 half = (
486 self.max_frames // 2
487 ) # Force int division to handle odd numbers correctly
488 fake_frame = Frame(
489 filename="",
490 lineno=-1,
491 name=f"Skipped frames: {len(stack.frames) - (2 * half)}",
492 )
493 stack.frames[:] = [
494 *stack.frames[:half],
495 fake_frame,
496 *stack.frames[-half:],
497 ]
499 return self._as_dict(trace)
501 def _as_dict(self, trace: Trace) -> list[dict[str, Any]]:
502 stack_dicts = []
503 for stack in trace.stacks:
504 stack_dict = asdict(stack)
505 for frame_dict in stack_dict["frames"]:
506 if frame_dict["locals"] is None or any(
507 frame_dict["filename"].startswith(path)
508 for path in self.suppress
509 ):
510 del frame_dict["locals"]
511 if stack.is_group:
512 stack_dict["exceptions"] = [
513 self._as_dict(t) for t in stack.exceptions
514 ]
515 stack_dicts.append(stack_dict)
516 return stack_dicts