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

169 statements  

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 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 

26 

27 

28try: 

29 import rich 

30 import rich.pretty 

31except ImportError: 

32 rich = None # type: ignore[assignment] 

33 

34from .typing import ExcInfo 

35 

36 

37__all__ = [ 

38 "ExceptionDictTransformer", 

39 "Frame", 

40 "Stack", 

41 "SyntaxError_", 

42 "Trace", 

43 "extract", 

44 "safe_str", 

45 "to_repr", 

46] 

47 

48 

49SHOW_LOCALS = True 

50LOCALS_MAX_LENGTH = 10 

51LOCALS_MAX_STRING = 80 

52MAX_FRAMES = 50 

53 

54OptExcInfo: TypeAlias = ExcInfo | tuple[None, None, None] 

55 

56 

57@dataclass 

58class Frame: 

59 """ 

60 Represents a single stack frame. 

61 """ 

62 

63 filename: str 

64 lineno: int 

65 name: str 

66 locals: dict[str, str] | None = None 

67 

68 

69@dataclass 

70class SyntaxError_: # noqa: N801 

71 """ 

72 Contains detailed information about :exc:`SyntaxError` exceptions. 

73 """ 

74 

75 offset: int 

76 filename: str 

77 line: str 

78 lineno: int 

79 msg: str 

80 

81 

82@dataclass 

83class Stack: 

84 """ 

85 Represents an exception and a list of stack frames. 

86 

87 .. versionchanged:: 25.2.0 

88 Added the *exc_notes* field. 

89 

90 .. versionchanged:: 25.4.0 

91 Added the *is_group* and *exceptions* fields. 

92 """ 

93 

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) 

102 

103 

104@dataclass 

105class Trace: 

106 """ 

107 Container for a list of stack traces. 

108 """ 

109 

110 stacks: list[Stack] 

111 

112 

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}>" 

119 

120 

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. 

129 

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). 

133 

134 Args: 

135 obj: Object to get a string representation for. 

136 

137 max_length: Maximum length of containers before abbreviating, or 

138 ``None`` for no abbreviation. 

139 

140 max_string: Maximum length of string before truncating, or ``None`` to 

141 disable truncating. 

142 

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. 

146 

147 Returns: 

148 The string representation of *obj*. 

149 

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}>" 

179 

180 return obj_repr 

181 

182 

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. 

198 

199 Args: 

200 exc_type: Exception type. 

201 

202 exc_value: Exception value. 

203 

204 traceback: Python Traceback object. 

205 

206 show_locals: Enable display of local variables. Defaults to False. 

207 

208 locals_max_length: 

209 Maximum length of containers before abbreviating, or ``None`` for 

210 no abbreviation. 

211 

212 locals_max_string: 

213 Maximum length of string before truncating, or ``None`` to disable 

214 truncating. 

215 

216 locals_hide_dunder: 

217 Hide locals prefixed with double underscore. 

218 Defaults to True. 

219 

220 locals_hide_sunder: 

221 Hide locals prefixed with single underscore. 

222 This implies hiding *locals_hide_dunder*. 

223 Defaults to False. 

224 

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. 

228 

229 Returns: 

230 A Trace instance with structured information about all exceptions. 

231 

232 .. versionadded:: 22.1.0 

233 

234 .. versionchanged:: 24.3.0 

235 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder* 

236 and *use_rich* arguments. 

237 

238 .. versionchanged:: 25.4.0 

239 Handle exception groups. 

240 

241 .. versionchanged:: 25.5.0 

242 Handle loops in exception cause chain. 

243 """ 

244 

245 stacks: list[Stack] = [] 

246 is_cause = False 

247 

248 if _seen is None: 

249 _seen = set() 

250 

251 while True: 

252 exc_id = id(exc_value) 

253 if exc_id in _seen: 

254 break 

255 _seen.add(exc_id) 

256 

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 ) 

265 

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 ) 

284 

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 ) 

293 

294 stacks.append(stack) 

295 append = stack.frames.append # pylint: disable=no-member 

296 

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 

310 

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 

318 

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) 

340 

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 

348 

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 

360 

361 # No cover, code is reached but coverage doesn't recognize it. 

362 break # pragma: no cover 

363 

364 return Trace(stacks=stacks) 

365 

366 

367class ExceptionDictTransformer: 

368 """ 

369 Return a list of exception stack dictionaries for an exception. 

370 

371 These dictionaries are based on :class:`Stack` instances generated by 

372 :func:`extract()` and can be dumped to JSON. 

373 

374 Args: 

375 show_locals: 

376 Whether or not to include the values of a stack frame's local 

377 variables. 

378 

379 locals_max_length: 

380 Maximum length of containers before abbreviating, or ``None`` for 

381 no abbreviation. 

382 

383 locals_max_string: 

384 Maximum length of string before truncating, or ``None`` to disable 

385 truncating. 

386 

387 locals_hide_dunder: 

388 Hide locals prefixed with double underscore. 

389 Defaults to True. 

390 

391 locals_hide_sunder: 

392 Hide locals prefixed with single underscore. 

393 This implies hiding *locals_hide_dunder*. 

394 Defaults to False. 

395 

396 suppress: 

397 Optional sequence of modules or paths for which to suppress the 

398 display of locals even if *show_locals* is ``True``. 

399 

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. 

406 

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. 

410 

411 .. seealso:: 

412 :doc:`exceptions` for a broader explanation of *structlog*'s exception 

413 features. 

414 

415 .. versionchanged:: 24.3.0 

416 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*, 

417 *suppress* and *use_rich* arguments. 

418 

419 .. versionchanged:: 25.1.0 

420 *locals_max_length* and *locals_max_string* may be None to disable 

421 truncation. 

422 

423 .. versionchanged:: 25.4.0 

424 Handle exception groups. 

425 """ 

426 

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 

469 

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 ) 

480 

481 for stack in trace.stacks: 

482 if len(stack.frames) <= self.max_frames: 

483 continue 

484 

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 ] 

498 

499 return self._as_dict(trace) 

500 

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