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

168 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 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 _seen: set[int] | None = None, 

194) -> Trace: 

195 """ 

196 Extract traceback information. 

197 

198 Args: 

199 exc_type: Exception type. 

200 

201 exc_value: Exception value. 

202 

203 traceback: Python Traceback object. 

204 

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

206 

207 locals_max_length: 

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

209 no abbreviation. 

210 

211 locals_max_string: 

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

213 truncating. 

214 

215 locals_hide_dunder: 

216 Hide locals prefixed with double underscore. 

217 Defaults to True. 

218 

219 locals_hide_sunder: 

220 Hide locals prefixed with single underscore. 

221 This implies hiding *locals_hide_dunder*. 

222 Defaults to False. 

223 

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. 

227 

228 Returns: 

229 A Trace instance with structured information about all exceptions. 

230 

231 .. versionadded:: 22.1.0 

232 

233 .. versionchanged:: 24.3.0 

234 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder* 

235 and *use_rich* arguments. 

236 

237 .. versionchanged:: 25.4.0 

238 Handle exception groups. 

239 

240 .. versionchanged:: 25.5.0 

241 Handle loops in exception cause chain. 

242 """ 

243 

244 stacks: list[Stack] = [] 

245 is_cause = False 

246 

247 if _seen is None: 

248 _seen = set() 

249 

250 while True: 

251 exc_id = id(exc_value) 

252 if exc_id in _seen: 

253 break 

254 _seen.add(exc_id) 

255 

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 ) 

264 

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 ) 

283 

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 ) 

292 

293 stacks.append(stack) 

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

295 

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 

309 

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 

317 

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) 

339 

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 

347 

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 

359 

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

361 break # pragma: no cover 

362 

363 return Trace(stacks=stacks) 

364 

365 

366class ExceptionDictTransformer: 

367 """ 

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

369 

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

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

372 

373 Args: 

374 show_locals: 

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

376 variables. 

377 

378 locals_max_length: 

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

380 no abbreviation. 

381 

382 locals_max_string: 

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

384 truncating. 

385 

386 locals_hide_dunder: 

387 Hide locals prefixed with double underscore. 

388 Defaults to True. 

389 

390 locals_hide_sunder: 

391 Hide locals prefixed with single underscore. 

392 This implies hiding *locals_hide_dunder*. 

393 Defaults to False. 

394 

395 suppress: 

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

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

398 

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. 

405 

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. 

409 

410 .. seealso:: 

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

412 features. 

413 

414 .. versionchanged:: 24.3.0 

415 Added *locals_max_length*, *locals_hide_sunder*, *locals_hide_dunder*, 

416 *suppress* and *use_rich* arguments. 

417 

418 .. versionchanged:: 25.1.0 

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

420 truncation. 

421 

422 .. versionchanged:: 25.4.0 

423 Handle exception groups. 

424 """ 

425 

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 

468 

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 ) 

479 

480 for stack in trace.stacks: 

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

482 continue 

483 

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 ] 

497 

498 return self._as_dict(trace) 

499 

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