Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/structlog/tracebacks.py: 40%

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

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