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.
5
6"""
7Extract a structured traceback from an exception.
8
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"""
14
15from __future__ import annotations
16
17import os
18import os.path
19import sys
20
21from dataclasses import asdict, dataclass, field
22from traceback import walk_tb
23from types import ModuleType, TracebackType
24from typing import Any, Iterable, Sequence, Tuple, Union
25
26
27try:
28 import rich
29 import rich.pretty
30except ImportError:
31 rich = None # type: ignore[assignment]
32
33from .typing import ExcInfo
34
35
36__all__ = [
37 "ExceptionDictTransformer",
38 "Frame",
39 "Stack",
40 "SyntaxError_",
41 "Trace",
42 "extract",
43 "safe_str",
44 "to_repr",
45]
46
47
48SHOW_LOCALS = True
49LOCALS_MAX_LENGTH = 10
50LOCALS_MAX_STRING = 80
51MAX_FRAMES = 50
52
53OptExcInfo = Union[ExcInfo, Tuple[None, None, None]]
54
55
56@dataclass
57class Frame:
58 """
59 Represents a single stack frame.
60 """
61
62 filename: str
63 lineno: int
64 name: str
65 locals: dict[str, str] | None = None
66
67
68@dataclass
69class SyntaxError_: # noqa: N801
70 """
71 Contains detailed information about :exc:`SyntaxError` exceptions.
72 """
73
74 offset: int
75 filename: str
76 line: str
77 lineno: int
78 msg: str
79
80
81@dataclass
82class Stack:
83 """
84 Represents an exception and a list of stack frames.
85
86 .. versionchanged:: 25.2.0
87 Added the *exc_notes* field.
88
89 .. versionchanged:: 25.4.0
90 Added the *is_group* and *exceptions* fields.
91 """
92
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)
101
102
103@dataclass
104class Trace:
105 """
106 Container for a list of stack traces.
107 """
108
109 stacks: list[Stack]
110
111
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}>"
118
119
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.
128
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).
132
133 Args:
134 obj: Object to get a string representation for.
135
136 max_length: Maximum length of containers before abbreviating, or
137 ``None`` for no abbreviation.
138
139 max_string: Maximum length of string before truncating, or ``None`` to
140 disable truncating.
141
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.
145
146 Returns:
147 The string representation of *obj*.
148
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}>"
178
179 return obj_repr
180
181
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) -> Trace:
194 """
195 Extract traceback information.
196
197 Args:
198 exc_type: Exception type.
199
200 exc_value: Exception value.
201
202 traceback: Python Traceback object.
203
204 show_locals: Enable display of local variables. Defaults to False.
205
206 locals_max_length:
207 Maximum length of containers before abbreviating, or ``None`` for
208 no abbreviation.
209
210 locals_max_string:
211 Maximum length of string before truncating, or ``None`` to disable
212 truncating.
213
214 locals_hide_dunder:
215 Hide locals prefixed with double underscore.
216 Defaults to True.
217
218 locals_hide_sunder:
219 Hide locals prefixed with single underscore.
220 This implies hiding *locals_hide_dunder*.
221 Defaults to False.
222
223 use_rich: If ``True`` (the default), use rich_ to compute the repr.
224 If ``False`` or if rich_ is not installed, fall back to a simpler
225 algorithm.
226
227 Returns:
228 A Trace instance with structured information about all exceptions.
229
230 .. versionadded:: 22.1.0
231
232 .. versionchanged:: 24.3.0
233 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*
234 and *use_rich* arguments.
235
236 .. versionchanged:: 25.4.0
237 Handle exception groups.
238 """
239
240 stacks: list[Stack] = []
241 is_cause = False
242
243 while True:
244 stack = Stack(
245 exc_type=safe_str(exc_type.__name__),
246 exc_value=safe_str(exc_value),
247 exc_notes=[
248 safe_str(note) for note in getattr(exc_value, "__notes__", ())
249 ],
250 is_cause=is_cause,
251 )
252
253 if sys.version_info >= (3, 11):
254 if isinstance(exc_value, (BaseExceptionGroup, ExceptionGroup)): # noqa: F821
255 stack.is_group = True
256 for exception in exc_value.exceptions:
257 stack.exceptions.append(
258 extract(
259 type(exception),
260 exception,
261 exception.__traceback__,
262 show_locals=show_locals,
263 locals_max_length=locals_max_length,
264 locals_max_string=locals_max_string,
265 locals_hide_dunder=locals_hide_dunder,
266 locals_hide_sunder=locals_hide_sunder,
267 use_rich=use_rich,
268 )
269 )
270
271 if isinstance(exc_value, SyntaxError):
272 stack.syntax_error = SyntaxError_(
273 offset=exc_value.offset or 0,
274 filename=exc_value.filename or "?",
275 lineno=exc_value.lineno or 0,
276 line=exc_value.text or "",
277 msg=exc_value.msg,
278 )
279
280 stacks.append(stack)
281 append = stack.frames.append # pylint: disable=no-member
282
283 def get_locals(
284 iter_locals: Iterable[tuple[str, object]],
285 ) -> Iterable[tuple[str, object]]:
286 """Extract locals from an iterator of key pairs."""
287 if not (locals_hide_dunder or locals_hide_sunder):
288 yield from iter_locals
289 return
290 for key, value in iter_locals:
291 if locals_hide_dunder and key.startswith("__"):
292 continue
293 if locals_hide_sunder and key.startswith("_"):
294 continue
295 yield key, value
296
297 for frame_summary, line_no in walk_tb(traceback):
298 filename = frame_summary.f_code.co_filename
299 if filename and not filename.startswith("<"):
300 filename = os.path.abspath(filename)
301 # Rich has this, but we are not rich and like to keep all frames:
302 # if frame_summary.f_locals.get("_rich_traceback_omit", False):
303 # continue # noqa: ERA001
304
305 frame = Frame(
306 filename=filename or "?",
307 lineno=line_no,
308 name=frame_summary.f_code.co_name,
309 locals=(
310 {
311 key: to_repr(
312 value,
313 max_length=locals_max_length,
314 max_string=locals_max_string,
315 use_rich=use_rich,
316 )
317 for key, value in get_locals(
318 frame_summary.f_locals.items()
319 )
320 }
321 if show_locals
322 else None
323 ),
324 )
325 append(frame)
326
327 cause = getattr(exc_value, "__cause__", None)
328 if cause and cause.__traceback__:
329 exc_type = cause.__class__
330 exc_value = cause
331 traceback = cause.__traceback__
332 is_cause = True
333 continue
334
335 cause = exc_value.__context__
336 if (
337 cause
338 and cause.__traceback__
339 and not getattr(exc_value, "__suppress_context__", False)
340 ):
341 exc_type = cause.__class__
342 exc_value = cause
343 traceback = cause.__traceback__
344 is_cause = False
345 continue
346
347 # No cover, code is reached but coverage doesn't recognize it.
348 break # pragma: no cover
349
350 return Trace(stacks=stacks)
351
352
353class ExceptionDictTransformer:
354 """
355 Return a list of exception stack dictionaries for an exception.
356
357 These dictionaries are based on :class:`Stack` instances generated by
358 :func:`extract()` and can be dumped to JSON.
359
360 Args:
361 show_locals:
362 Whether or not to include the values of a stack frame's local
363 variables.
364
365 locals_max_length:
366 Maximum length of containers before abbreviating, or ``None`` for
367 no abbreviation.
368
369 locals_max_string:
370 Maximum length of string before truncating, or ``None`` to disable
371 truncating.
372
373 locals_hide_dunder:
374 Hide locals prefixed with double underscore.
375 Defaults to True.
376
377 locals_hide_sunder:
378 Hide locals prefixed with single underscore.
379 This implies hiding *locals_hide_dunder*.
380 Defaults to False.
381
382 suppress:
383 Optional sequence of modules or paths for which to suppress the
384 display of locals even if *show_locals* is ``True``.
385
386 max_frames:
387 Maximum number of frames in each stack. Frames are removed from
388 the inside out. The idea is, that the first frames represent your
389 code responsible for the exception and last frames the code where
390 the exception actually happened. With larger web frameworks, this
391 does not always work, so you should stick with the default.
392
393 use_rich: If ``True`` (the default), use rich_ to compute the repr of
394 locals. If ``False`` or if rich_ is not installed, fall back to
395 a simpler algorithm.
396
397 .. seealso::
398 :doc:`exceptions` for a broader explanation of *structlog*'s exception
399 features.
400
401 .. versionchanged:: 24.3.0
402 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*,
403 *suppress* and *use_rich* arguments.
404
405 .. versionchanged:: 25.1.0
406 *locals_max_length* and *locals_max_string* may be None to disable
407 truncation.
408
409 .. versionchanged:: 25.4.0
410 Handle exception groups.
411 """
412
413 def __init__(
414 self,
415 *,
416 show_locals: bool = SHOW_LOCALS,
417 locals_max_length: int = LOCALS_MAX_LENGTH,
418 locals_max_string: int = LOCALS_MAX_STRING,
419 locals_hide_dunder: bool = True,
420 locals_hide_sunder: bool = False,
421 suppress: Iterable[str | ModuleType] = (),
422 max_frames: int = MAX_FRAMES,
423 use_rich: bool = True,
424 ) -> None:
425 if locals_max_length is not None and locals_max_length < 0:
426 msg = f'"locals_max_length" must be >= 0: {locals_max_length}'
427 raise ValueError(msg)
428 if locals_max_string is not None and locals_max_string < 0:
429 msg = f'"locals_max_string" must be >= 0: {locals_max_string}'
430 raise ValueError(msg)
431 if max_frames < 2:
432 msg = f'"max_frames" must be >= 2: {max_frames}'
433 raise ValueError(msg)
434 self.show_locals = show_locals
435 self.locals_max_length = locals_max_length
436 self.locals_max_string = locals_max_string
437 self.locals_hide_dunder = locals_hide_dunder
438 self.locals_hide_sunder = locals_hide_sunder
439 self.suppress: Sequence[str] = []
440 for suppress_entity in suppress:
441 if not isinstance(suppress_entity, str):
442 if suppress_entity.__file__ is None:
443 msg = (
444 f'"suppress" item {suppress_entity!r} must be a '
445 f"module with '__file__' attribute"
446 )
447 raise ValueError(msg)
448 path = os.path.dirname(suppress_entity.__file__)
449 else:
450 path = suppress_entity
451 path = os.path.normpath(os.path.abspath(path))
452 self.suppress.append(path)
453 self.max_frames = max_frames
454 self.use_rich = use_rich
455
456 def __call__(self, exc_info: ExcInfo) -> list[dict[str, Any]]:
457 trace = extract(
458 *exc_info,
459 show_locals=self.show_locals,
460 locals_max_length=self.locals_max_length,
461 locals_max_string=self.locals_max_string,
462 locals_hide_dunder=self.locals_hide_dunder,
463 locals_hide_sunder=self.locals_hide_sunder,
464 use_rich=self.use_rich,
465 )
466
467 for stack in trace.stacks:
468 if len(stack.frames) <= self.max_frames:
469 continue
470
471 half = (
472 self.max_frames // 2
473 ) # Force int division to handle odd numbers correctly
474 fake_frame = Frame(
475 filename="",
476 lineno=-1,
477 name=f"Skipped frames: {len(stack.frames) - (2 * half)}",
478 )
479 stack.frames[:] = [
480 *stack.frames[:half],
481 fake_frame,
482 *stack.frames[-half:],
483 ]
484
485 return self._as_dict(trace)
486
487 def _as_dict(self, trace: Trace) -> list[dict[str, Any]]:
488 stack_dicts = []
489 for stack in trace.stacks:
490 stack_dict = asdict(stack)
491 for frame_dict in stack_dict["frames"]:
492 if frame_dict["locals"] is None or any(
493 frame_dict["filename"].startswith(path)
494 for path in self.suppress
495 ):
496 del frame_dict["locals"]
497 if stack.is_group:
498 stack_dict["exceptions"] = [
499 self._as_dict(t) for t in stack.exceptions
500 ]
501 stack_dicts.append(stack_dict)
502 return stack_dicts