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 dataclasses import asdict, dataclass, field
22from traceback import walk_tb
23from types import ModuleType, TracebackType
24from typing import Any, Iterable, Sequence, Tuple, Union
27try:
28 import rich
29 import rich.pretty
30except ImportError:
31 rich = None # type: ignore[assignment]
33from .typing import ExcInfo
36__all__ = [
37 "ExceptionDictTransformer",
38 "Frame",
39 "Stack",
40 "SyntaxError_",
41 "Trace",
42 "extract",
43 "safe_str",
44 "to_repr",
45]
48SHOW_LOCALS = True
49LOCALS_MAX_LENGTH = 10
50LOCALS_MAX_STRING = 80
51MAX_FRAMES = 50
53OptExcInfo = Union[ExcInfo, Tuple[None, None, None]]
56@dataclass
57class Frame:
58 """
59 Represents a single stack frame.
60 """
62 filename: str
63 lineno: int
64 name: str
65 locals: dict[str, str] | None = None
68@dataclass
69class SyntaxError_: # noqa: N801
70 """
71 Contains detailed information about :exc:`SyntaxError` exceptions.
72 """
74 offset: int
75 filename: str
76 line: str
77 lineno: int
78 msg: str
81@dataclass
82class Stack:
83 """
84 Represents an exception and a list of stack frames.
86 .. versionchanged:: 25.2.0
87 Added the *exc_notes* field.
89 .. versionchanged:: 25.4.0
90 Added the *is_group* and *exceptions* fields.
91 """
93 exc_type: str
94 exc_value: str
95 exc_notes: list[str] = field(default_factory=list)
96 syntax_error: SyntaxError_ | None = None
97 is_cause: bool = False
98 frames: list[Frame] = field(default_factory=list)
99 is_group: bool = False
100 exceptions: list[Trace] = field(default_factory=list)
103@dataclass
104class Trace:
105 """
106 Container for a list of stack traces.
107 """
109 stacks: list[Stack]
112def safe_str(_object: Any) -> str:
113 """Don't allow exceptions from __str__ to propagate."""
114 try:
115 return str(_object)
116 except Exception as error: # noqa: BLE001
117 return f"<str-error {str(error)!r}>"
120def to_repr(
121 obj: Any,
122 max_length: int | None = None,
123 max_string: int | None = None,
124 use_rich: bool = True,
125) -> str:
126 """
127 Get repr string for an object, but catch errors.
129 :func:`repr()` is used for strings, too, so that secret wrappers that
130 inherit from :func:`str` and overwrite ``__repr__()`` are handled correctly
131 (i.e. secrets are not logged in plain text).
133 Args:
134 obj: Object to get a string representation for.
136 max_length: Maximum length of containers before abbreviating, or
137 ``None`` for no abbreviation.
139 max_string: Maximum length of string before truncating, or ``None`` to
140 disable truncating.
142 use_rich: If ``True`` (the default), use rich_ to compute the repr.
143 If ``False`` or if rich_ is not installed, fall back to a simpler
144 algorithm.
146 Returns:
147 The string representation of *obj*.
149 .. versionchanged:: 24.3.0
150 Added *max_length* argument. Use :program:`rich` to render locals if it
151 is available. Call :func:`repr()` on strings in fallback
152 implementation.
153 """
154 if use_rich and rich is not None:
155 # Let rich render the repr if it is available.
156 # It produces much better results for containers and dataclasses/attrs.
157 obj_repr = rich.pretty.traverse(
158 obj, max_length=max_length, max_string=max_string
159 ).render()
160 else:
161 # Generate a (truncated) repr if rich is not available.
162 # Handle str/bytes differently to get better results for truncated
163 # representations. Also catch all errors, similarly to "safe_str()".
164 try:
165 if isinstance(obj, (str, bytes)):
166 if max_string is not None and len(obj) > max_string:
167 truncated = len(obj) - max_string
168 obj_repr = f"{obj[:max_string]!r}+{truncated}"
169 else:
170 obj_repr = repr(obj)
171 else:
172 obj_repr = repr(obj)
173 if max_string is not None and len(obj_repr) > max_string:
174 truncated = len(obj_repr) - max_string
175 obj_repr = f"{obj_repr[:max_string]!r}+{truncated}"
176 except Exception as error: # noqa: BLE001
177 obj_repr = f"<repr-error {str(error)!r}>"
179 return obj_repr
182def extract(
183 exc_type: type[BaseException],
184 exc_value: BaseException,
185 traceback: TracebackType | None,
186 *,
187 show_locals: bool = False,
188 locals_max_length: int = LOCALS_MAX_LENGTH,
189 locals_max_string: int = LOCALS_MAX_STRING,
190 locals_hide_dunder: bool = True,
191 locals_hide_sunder: bool = False,
192 use_rich: bool = True,
193 _seen: set[int] | None = None,
194) -> Trace:
195 """
196 Extract traceback information.
198 Args:
199 exc_type: Exception type.
201 exc_value: Exception value.
203 traceback: Python Traceback object.
205 show_locals: Enable display of local variables. Defaults to False.
207 locals_max_length:
208 Maximum length of containers before abbreviating, or ``None`` for
209 no abbreviation.
211 locals_max_string:
212 Maximum length of string before truncating, or ``None`` to disable
213 truncating.
215 locals_hide_dunder:
216 Hide locals prefixed with double underscore.
217 Defaults to True.
219 locals_hide_sunder:
220 Hide locals prefixed with single underscore.
221 This implies hiding *locals_hide_dunder*.
222 Defaults to False.
224 use_rich: If ``True`` (the default), use rich_ to compute the repr.
225 If ``False`` or if rich_ is not installed, fall back to a simpler
226 algorithm.
228 Returns:
229 A Trace instance with structured information about all exceptions.
231 .. versionadded:: 22.1.0
233 .. versionchanged:: 24.3.0
234 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
235 and *use_rich* arguments.
237 .. versionchanged:: 25.4.0
238 Handle exception groups.
240 .. versionchanged:: 25.5.0
241 Handle loops in exception cause chain.
242 """
244 stacks: list[Stack] = []
245 is_cause = False
247 if _seen is None:
248 _seen = set()
250 while True:
251 exc_id = id(exc_value)
252 if exc_id in _seen:
253 break
254 _seen.add(exc_id)
256 stack = Stack(
257 exc_type=safe_str(exc_type.__name__),
258 exc_value=safe_str(exc_value),
259 exc_notes=[
260 safe_str(note) for note in getattr(exc_value, "__notes__", ())
261 ],
262 is_cause=is_cause,
263 )
265 if sys.version_info >= (3, 11):
266 if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): # noqa: F821
267 stack.is_group = True
268 for exception in exc_value.exceptions:
269 stack.exceptions.append(
270 extract(
271 type(exception),
272 exception,
273 exception.__traceback__,
274 show_locals=show_locals,
275 locals_max_length=locals_max_length,
276 locals_max_string=locals_max_string,
277 locals_hide_dunder=locals_hide_dunder,
278 locals_hide_sunder=locals_hide_sunder,
279 use_rich=use_rich,
280 _seen=_seen,
281 )
282 )
284 if isinstance(exc_value, SyntaxError):
285 stack.syntax_error = SyntaxError_(
286 offset=exc_value.offset or 0,
287 filename=exc_value.filename or "?",
288 lineno=exc_value.lineno or 0,
289 line=exc_value.text or "",
290 msg=exc_value.msg,
291 )
293 stacks.append(stack)
294 append = stack.frames.append # pylint: disable=no-member
296 def get_locals(
297 iter_locals: Iterable[tuple[str, object]],
298 ) -> Iterable[tuple[str, object]]:
299 """Extract locals from an iterator of key pairs."""
300 if not (locals_hide_dunder or locals_hide_sunder):
301 yield from iter_locals
302 return
303 for key, value in iter_locals:
304 if locals_hide_dunder and key.startswith("__"):
305 continue
306 if locals_hide_sunder and key.startswith("_"):
307 continue
308 yield key, value
310 for frame_summary, line_no in walk_tb(traceback):
311 filename = frame_summary.f_code.co_filename
312 if filename and not filename.startswith("<"):
313 filename = os.path.abspath(filename)
314 # Rich has this, but we are not rich and like to keep all frames:
315 # if frame_summary.f_locals.get("_rich_traceback_omit", False):
316 # continue # noqa: ERA001
318 frame = Frame(
319 filename=filename or "?",
320 lineno=line_no,
321 name=frame_summary.f_code.co_name,
322 locals=(
323 {
324 key: to_repr(
325 value,
326 max_length=locals_max_length,
327 max_string=locals_max_string,
328 use_rich=use_rich,
329 )
330 for key, value in get_locals(
331 frame_summary.f_locals.items()
332 )
333 }
334 if show_locals
335 else None
336 ),
337 )
338 append(frame)
340 cause = getattr(exc_value, "__cause__", None)
341 if cause and cause.__traceback__:
342 exc_type = cause.__class__
343 exc_value = cause
344 traceback = cause.__traceback__
345 is_cause = True
346 continue
348 cause = exc_value.__context__
349 if (
350 cause
351 and cause.__traceback__
352 and not getattr(exc_value, "__suppress_context__", False)
353 ):
354 exc_type = cause.__class__
355 exc_value = cause
356 traceback = cause.__traceback__
357 is_cause = False
358 continue
360 # No cover, code is reached but coverage doesn't recognize it.
361 break # pragma: no cover
363 return Trace(stacks=stacks)
366class ExceptionDictTransformer:
367 """
368 Return a list of exception stack dictionaries for an exception.
370 These dictionaries are based on :class:`Stack` instances generated by
371 :func:`extract()` and can be dumped to JSON.
373 Args:
374 show_locals:
375 Whether or not to include the values of a stack frame's local
376 variables.
378 locals_max_length:
379 Maximum length of containers before abbreviating, or ``None`` for
380 no abbreviation.
382 locals_max_string:
383 Maximum length of string before truncating, or ``None`` to disable
384 truncating.
386 locals_hide_dunder:
387 Hide locals prefixed with double underscore.
388 Defaults to True.
390 locals_hide_sunder:
391 Hide locals prefixed with single underscore.
392 This implies hiding *locals_hide_dunder*.
393 Defaults to False.
395 suppress:
396 Optional sequence of modules or paths for which to suppress the
397 display of locals even if *show_locals* is ``True``.
399 max_frames:
400 Maximum number of frames in each stack. Frames are removed from
401 the inside out. The idea is, that the first frames represent your
402 code responsible for the exception and last frames the code where
403 the exception actually happened. With larger web frameworks, this
404 does not always work, so you should stick with the default.
406 use_rich: If ``True`` (the default), use rich_ to compute the repr of
407 locals. If ``False`` or if rich_ is not installed, fall back to
408 a simpler algorithm.
410 .. seealso::
411 :doc:`exceptions` for a broader explanation of *structlog*'s exception
412 features.
414 .. versionchanged:: 24.3.0
415 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*,
416 *suppress* and *use_rich* arguments.
418 .. versionchanged:: 25.1.0
419 *locals_max_length* and *locals_max_string* may be None to disable
420 truncation.
422 .. versionchanged:: 25.4.0
423 Handle exception groups.
424 """
426 def __init__(
427 self,
428 *,
429 show_locals: bool = SHOW_LOCALS,
430 locals_max_length: int = LOCALS_MAX_LENGTH,
431 locals_max_string: int = LOCALS_MAX_STRING,
432 locals_hide_dunder: bool = True,
433 locals_hide_sunder: bool = False,
434 suppress: Iterable[str | ModuleType] = (),
435 max_frames: int = MAX_FRAMES,
436 use_rich: bool = True,
437 ) -> None:
438 if locals_max_length is not None and locals_max_length < 0:
439 msg = f'"locals_max_length" must be >= 0: {locals_max_length}'
440 raise ValueError(msg)
441 if locals_max_string is not None and locals_max_string < 0:
442 msg = f'"locals_max_string" must be >= 0: {locals_max_string}'
443 raise ValueError(msg)
444 if max_frames < 2:
445 msg = f'"max_frames" must be >= 2: {max_frames}'
446 raise ValueError(msg)
447 self.show_locals = show_locals
448 self.locals_max_length = locals_max_length
449 self.locals_max_string = locals_max_string
450 self.locals_hide_dunder = locals_hide_dunder
451 self.locals_hide_sunder = locals_hide_sunder
452 self.suppress: Sequence[str] = []
453 for suppress_entity in suppress:
454 if not isinstance(suppress_entity, str):
455 if suppress_entity.__file__ is None:
456 msg = (
457 f'"suppress" item {suppress_entity!r} must be a '
458 f"module with '__file__' attribute"
459 )
460 raise ValueError(msg)
461 path = os.path.dirname(suppress_entity.__file__)
462 else:
463 path = suppress_entity
464 path = os.path.normpath(os.path.abspath(path))
465 self.suppress.append(path)
466 self.max_frames = max_frames
467 self.use_rich = use_rich
469 def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
470 trace = extract(
471 *exc_info,
472 show_locals=self.show_locals,
473 locals_max_length=self.locals_max_length,
474 locals_max_string=self.locals_max_string,
475 locals_hide_dunder=self.locals_hide_dunder,
476 locals_hide_sunder=self.locals_hide_sunder,
477 use_rich=self.use_rich,
478 )
480 for stack in trace.stacks:
481 if len(stack.frames) <= self.max_frames:
482 continue
484 half = (
485 self.max_frames // 2
486 ) # Force int division to handle odd numbers correctly
487 fake_frame = Frame(
488 filename="",
489 lineno=-1,
490 name=f"Skipped frames: {len(stack.frames) - (2 * half)}",
491 )
492 stack.frames[:] = [
493 *stack.frames[:half],
494 fake_frame,
495 *stack.frames[-half:],
496 ]
498 return self._as_dict(trace)
500 def _as_dict(self, trace: Trace) -> list[dict[str, Any]]:
501 stack_dicts = []
502 for stack in trace.stacks:
503 stack_dict = asdict(stack)
504 for frame_dict in stack_dict["frames"]:
505 if frame_dict["locals"] is None or any(
506 frame_dict["filename"].startswith(path)
507 for path in self.suppress
508 ):
509 del frame_dict["locals"]
510 if stack.is_group:
511 stack_dict["exceptions"] = [
512 self._as_dict(t) for t in stack.exceptions
513 ]
514 stack_dicts.append(stack_dict)
515 return stack_dicts