xakki-log-python 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- log_python/__init__.py +160 -0
- log_python/config.py +144 -0
- log_python/const.py +41 -0
- log_python/context.py +575 -0
- log_python/formatters.py +217 -0
- log_python/handlers/__init__.py +1 -0
- log_python/handlers/redis.py +42 -0
- log_python/heartbeat.py +52 -0
- log_python/integrations/__init__.py +1 -0
- log_python/integrations/sqlalchemy.py +87 -0
- log_python/logtype.py +171 -0
- log_python/manager.py +394 -0
- log_python/py.typed +0 -0
- log_python/redactor.py +261 -0
- log_python/trace.py +504 -0
- xakki_log_python-0.4.0.dist-info/METADATA +275 -0
- xakki_log_python-0.4.0.dist-info/RECORD +19 -0
- xakki_log_python-0.4.0.dist-info/WHEEL +4 -0
- xakki_log_python-0.4.0.dist-info/licenses/LICENSE +674 -0
log_python/trace.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""Stack-trace capture ported from `TraitFileTrace.php` (`src/TraitFileTrace.php`), wired
|
|
2
|
+
the way `LogManager::appendContext()` (`src/LogManager.php`) actually calls it (spec
|
|
3
|
+
`docs/LoggingRules.md` §3.7 / §6.1). Both reference files re-verified 2026-08-13.
|
|
4
|
+
|
|
5
|
+
Two rendering modes, both feeding the SAME `#N file:line | qualname(args)` line format and
|
|
6
|
+
the SAME consecutive-excluded-frame collapsing, but with different call-site semantics —
|
|
7
|
+
this mirrors a real asymmetry in the PHP reference, not a simplification:
|
|
8
|
+
|
|
9
|
+
- **Call-site trace** (`TraceCapture.capture()`) — what the reference builds from
|
|
10
|
+
`debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40)` for an ordinary `warning`+ log call.
|
|
11
|
+
`DEBUG_BACKTRACE_IGNORE_ARGS` means the PHP trace carries NO call arguments at all, so
|
|
12
|
+
`renderTraceWithClass()`'s argument-redaction branch is simply never reached on this path.
|
|
13
|
+
Excluded frames (`excluded_partials`) are dropped and collapsed into a `.` run; only
|
|
14
|
+
non-excluded frames count against the per-level depth budget (5/10/20). No trace at all
|
|
15
|
+
below `WARNING` (`appendContext()`'s `$level->value >= Level::Warning->value` guard).
|
|
16
|
+
- **Exception trace** (`TraceCapture.format_exception_chain()`) — what the reference builds
|
|
17
|
+
from `$e->getTrace()` (`traceToString($e->getTrace(), 30, false)`), which DOES carry call
|
|
18
|
+
arguments (no `IGNORE_ARGS` flag), so THIS is the path where `Redactor::redactValue()`
|
|
19
|
+
actually runs over trace arguments in the reference. Filtering is OFF
|
|
20
|
+
(`checkExcludePart=false`): excluded frames are still listed (file:line only, no
|
|
21
|
+
class/function/args) and DO count against the fixed 30-frame limit — nothing is collapsed
|
|
22
|
+
to dots here. Python has no "call arguments" concept distinct from a frame's locals; the
|
|
23
|
+
closest analogue (`inspect.getargvalues()`, no source read, no `inspect.stack()`) is used
|
|
24
|
+
to reconstruct the parameter values for exception frames only, which is exactly where the
|
|
25
|
+
reference needs them. Unlike PHP's positional-only backtrace arrays, `getargvalues()` also
|
|
26
|
+
hands back the PARAMETER NAME each value is bound to — this port uses that to apply
|
|
27
|
+
key-aware masking (`Redactor.should_redact_key()`) before the value-shape pass
|
|
28
|
+
(`_render_arg(value, name=...)`), a Python-only addition with no PHP analogue: PHP
|
|
29
|
+
structurally cannot know a positional arg's name, this port can and does, so a
|
|
30
|
+
`def login(password: str)` frame does not leak its argument here the way it would in the
|
|
31
|
+
reference.
|
|
32
|
+
|
|
33
|
+
Ordering divergence (documented, not a bug): the reference's frame 0 is the THROW point
|
|
34
|
+
(deepest frame first, PHP `getTrace()` order). This port keeps Python's own `tb_next`
|
|
35
|
+
order (shallowest/catch-adjacent frame first, raise point last) — "most recent call last",
|
|
36
|
+
matching every other Python traceback a reader will have seen.
|
|
37
|
+
|
|
38
|
+
`LOGGER_SKIP_TRACE` ('skipTrace'): historically, this key was registered ONLY as a
|
|
39
|
+
context-type-coercion skip entry in the PHP reference
|
|
40
|
+
(`LogManager::$contextTypeRules['reserved'][LOGGER_SKIP_TRACE] = CONTEXT_TYPE_SKIP`,
|
|
41
|
+
already ported in `context.py`) — nothing in `LogManager.php` ever READ
|
|
42
|
+
`$context[LOGGER_SKIP_TRACE]` to actually suppress capture, despite what the name implied.
|
|
43
|
+
`should_skip_trace()` below was originally this port's own completion of that evident
|
|
44
|
+
intent. As of this phase, a matching fix has landed on the PHP side too (uncommitted at
|
|
45
|
+
the time of writing — `LogManager::appendContext()` now reads the marker via
|
|
46
|
+
`contextCastBool()`, pops it before coercion, and gates the call-site AND exception
|
|
47
|
+
traces on it), so the two libraries now agree on both the wiring AND the truthiness
|
|
48
|
+
convention: `should_skip_trace()` casts through the SAME `CONTEXT_FALSE_VALUES` bool
|
|
49
|
+
convention `context.py`'s `_cast_bool()` uses, not raw Python truthiness (`bool("false")`
|
|
50
|
+
is `True` in Python, which would silently invert a caller's intent). See `mapping.md`.
|
|
51
|
+
`manager.py` (phase 5) is the orchestrator that calls this before `.capture()` /
|
|
52
|
+
`.format_exception_chain()` / `.render_exception_trace()`, and pops the marker key so it
|
|
53
|
+
never reaches the wire.
|
|
54
|
+
|
|
55
|
+
Performance (this sits on the hot logging path): frames are walked via `sys._getframe()` /
|
|
56
|
+
raw `TracebackType.tb_next`, never `inspect.stack()` (which reads source context per frame
|
|
57
|
+
via `linecache` — pathologically slow). No source lines are read anywhere in this module.
|
|
58
|
+
Both raw walks are capped (`_MAX_RAW_CALL_SITE_FRAMES` / `_EXCEPTION_TRACE_LIMIT`) so a
|
|
59
|
+
pathologically deep recursion (e.g. a `RecursionError` traceback, ~1000 frames) costs the
|
|
60
|
+
same handful of frame reads as a shallow one — the cap is applied to the WALK itself, not as
|
|
61
|
+
a post-hoc slice of an already-fully-materialized frame list.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import inspect
|
|
67
|
+
import logging
|
|
68
|
+
import sys
|
|
69
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
70
|
+
from dataclasses import dataclass
|
|
71
|
+
from types import FrameType, TracebackType
|
|
72
|
+
from typing import TYPE_CHECKING, Final
|
|
73
|
+
|
|
74
|
+
from log_python.const import LOGGER_SKIP_TRACE
|
|
75
|
+
from log_python.context import CONTEXT_FALSE_VALUES
|
|
76
|
+
from log_python.redactor import Redactor
|
|
77
|
+
|
|
78
|
+
if TYPE_CHECKING:
|
|
79
|
+
from log_python.config import PylogConfig, TraceConfig
|
|
80
|
+
|
|
81
|
+
# Same bool-truthiness convention as `context.py::_cast_bool()` — a string 'false'/'0'/
|
|
82
|
+
# 'off'/'no' must NOT be treated as a truthy skip-trace marker. Bound to a module constant
|
|
83
|
+
# here (rather than referenced only inline in `should_skip_trace()`) so the import is a
|
|
84
|
+
# real, direct dependency of this module, not a re-export.
|
|
85
|
+
_SKIP_TRACE_FALSE_VALUES: Final[frozenset[str]] = CONTEXT_FALSE_VALUES
|
|
86
|
+
|
|
87
|
+
_UNRENDERABLE: Final[str] = "<unrenderable>"
|
|
88
|
+
_UNREADABLE_FRAME: Final[str] = "<unreadable frame>"
|
|
89
|
+
_TRUNCATION_MARKER: Final[str] = "***"
|
|
90
|
+
|
|
91
|
+
# PHP call-site cap: `debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 40)`.
|
|
92
|
+
_MAX_RAW_CALL_SITE_FRAMES: Final[int] = 40
|
|
93
|
+
# PHP exception-trace fixed limit: `traceToString($e->getTrace(), 30, false)`.
|
|
94
|
+
_EXCEPTION_TRACE_LIMIT: Final[int] = 30
|
|
95
|
+
# Cause/context chain depth guard. The PHP reference only ever follows ONE
|
|
96
|
+
# `getPrevious()` level (see the module-level report to the team-lead / SKILL.md for this
|
|
97
|
+
# drift against its own spec text "full chain via getPrevious()") — there is no reference
|
|
98
|
+
# cap to match. Bounded here purely as a defensive ceiling against a pathological chain.
|
|
99
|
+
_MAX_CHAIN_DEPTH: Final[int] = 10
|
|
100
|
+
|
|
101
|
+
_CAUSE_SEPARATOR: Final[str] = "\nThe above exception was the direct cause of the following exception:\n"
|
|
102
|
+
_CONTEXT_SEPARATOR: Final[str] = "\nDuring handling of the above exception, another exception occurred:\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class _RawFrame:
|
|
107
|
+
"""One normalized stack frame. `args` is populated only for exception frames — the
|
|
108
|
+
call-site path never has arguments to offer (see module docstring). Each entry is a
|
|
109
|
+
`(param_name, value)` pair, not a bare value: unlike PHP's `debug_backtrace()`/
|
|
110
|
+
`getTrace()` (a positional array with no parameter names attached), Python's
|
|
111
|
+
`inspect.getargvalues()` DOES give us the parameter name each value is bound to — see
|
|
112
|
+
`_render_arg()` for why that matters for redaction.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
file: str | None
|
|
116
|
+
line: int | None
|
|
117
|
+
qualname: str | None
|
|
118
|
+
args: tuple[tuple[str, object], ...] | None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True, slots=True)
|
|
122
|
+
class CapturedTrace:
|
|
123
|
+
"""Result of `TraceCapture.capture()` — mirrors the reference's `getFileLine()` (`file`)
|
|
124
|
+
and `traceToString()` (`trace`) over one `debug_backtrace()` call.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
file: str
|
|
128
|
+
trace: str | None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_excluded(path: str, excluded_partials: tuple[str, ...]) -> bool:
|
|
132
|
+
return any(partial in path for partial in excluded_partials)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _frame_info(frame: FrameType) -> _RawFrame:
|
|
136
|
+
try:
|
|
137
|
+
code = frame.f_code
|
|
138
|
+
return _RawFrame(file=code.co_filename, line=frame.f_lineno, qualname=code.co_qualname, args=None)
|
|
139
|
+
except Exception:
|
|
140
|
+
# A logging library must never raise from the logging path over a merely
|
|
141
|
+
# unusual/broken frame — degrade to an unreadable placeholder instead.
|
|
142
|
+
return _RawFrame(file=None, line=None, qualname=None, args=None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _walk_live_stack(*, skip: int, max_frames: int) -> list[_RawFrame]:
|
|
146
|
+
"""Walk the CURRENTLY EXECUTING stack via `sys._getframe().f_back`, capped at
|
|
147
|
+
`max_frames` — never `inspect.stack()` (see module docstring).
|
|
148
|
+
"""
|
|
149
|
+
frames: list[_RawFrame] = []
|
|
150
|
+
try:
|
|
151
|
+
frame: FrameType | None = sys._getframe(skip)
|
|
152
|
+
except ValueError:
|
|
153
|
+
# Fewer frames on the stack than `skip` (e.g. called near the top of the program).
|
|
154
|
+
frame = None
|
|
155
|
+
count = 0
|
|
156
|
+
while frame is not None and count < max_frames:
|
|
157
|
+
frames.append(_frame_info(frame))
|
|
158
|
+
frame = frame.f_back
|
|
159
|
+
count += 1
|
|
160
|
+
return frames
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _exception_frame_info(tb: TracebackType) -> _RawFrame:
|
|
164
|
+
try:
|
|
165
|
+
frame = tb.tb_frame
|
|
166
|
+
code = frame.f_code
|
|
167
|
+
file = code.co_filename
|
|
168
|
+
line = tb.tb_lineno
|
|
169
|
+
qualname = code.co_qualname
|
|
170
|
+
except Exception:
|
|
171
|
+
return _RawFrame(file=None, line=None, qualname=None, args=None)
|
|
172
|
+
|
|
173
|
+
args: tuple[tuple[str, object], ...] | None
|
|
174
|
+
try:
|
|
175
|
+
# Closest Python analogue of PHP's per-call `args` array: the values bound to this
|
|
176
|
+
# frame's OWN parameters (not its full locals). No source read (unlike
|
|
177
|
+
# `inspect.stack()` / `inspect.getframeinfo()`) — `getargvalues()` only introspects
|
|
178
|
+
# `frame.f_code` and `frame.f_locals`. Kept as (name, value) pairs, not bare values —
|
|
179
|
+
# the name is what lets `_render_arg()` apply the SAME key-aware redaction the rest
|
|
180
|
+
# of the library uses for named context fields, which PHP structurally cannot do
|
|
181
|
+
# here (its backtrace arrays carry no parameter names at all).
|
|
182
|
+
arg_info = inspect.getargvalues(frame)
|
|
183
|
+
names = [*arg_info.args]
|
|
184
|
+
if arg_info.varargs:
|
|
185
|
+
names.append(arg_info.varargs)
|
|
186
|
+
if arg_info.keywords:
|
|
187
|
+
names.append(arg_info.keywords)
|
|
188
|
+
args = tuple((name, arg_info.locals.get(name)) for name in names)
|
|
189
|
+
except Exception:
|
|
190
|
+
args = None
|
|
191
|
+
|
|
192
|
+
return _RawFrame(file=file, line=line, qualname=qualname, args=args)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _walk_traceback(tb: TracebackType | None, *, max_frames: int) -> list[_RawFrame]:
|
|
196
|
+
frames: list[_RawFrame] = []
|
|
197
|
+
count = 0
|
|
198
|
+
while tb is not None and count < max_frames:
|
|
199
|
+
frames.append(_exception_frame_info(tb))
|
|
200
|
+
tb = tb.tb_next
|
|
201
|
+
count += 1
|
|
202
|
+
return frames
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _first_file_line(frames: Iterable[_RawFrame], excluded_partials: tuple[str, ...]) -> str:
|
|
206
|
+
"""Port of `TraitFileTrace::getFileLine()`: first frame whose file is not excluded,
|
|
207
|
+
rendered `file:line`; `''` if every frame is excluded or none carry a file.
|
|
208
|
+
|
|
209
|
+
No Laravel `app()->basePath()` equivalent exists in a framework-agnostic Python library,
|
|
210
|
+
so the file path is left as-is (absolute, as CPython reports it) rather than PHP's
|
|
211
|
+
`getRelativeFilePath()` — a documented Python-side simplification, not a gap.
|
|
212
|
+
"""
|
|
213
|
+
for item in frames:
|
|
214
|
+
if not item.file:
|
|
215
|
+
continue
|
|
216
|
+
if _is_excluded(item.file, excluded_partials):
|
|
217
|
+
continue
|
|
218
|
+
return f"{item.file}:{item.line if item.line is not None else ''}"
|
|
219
|
+
return ""
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def exception_origin(exc: BaseException) -> str:
|
|
223
|
+
"""The file:line where `exc` was ultimately raised (the deepest traceback frame) —
|
|
224
|
+
mirrors the reference's separate `context['file'] = getRelativeFilePath($e->getFile()) .
|
|
225
|
+
':' . $e->getLine()` assignment for exception logging (`LogManager::appendContext()`,
|
|
226
|
+
distinct from the general call-site `getFileLine()` fallback).
|
|
227
|
+
"""
|
|
228
|
+
tb = exc.__traceback__
|
|
229
|
+
if tb is None:
|
|
230
|
+
return ""
|
|
231
|
+
while tb.tb_next is not None:
|
|
232
|
+
tb = tb.tb_next
|
|
233
|
+
try:
|
|
234
|
+
return f"{tb.tb_frame.f_code.co_filename}:{tb.tb_lineno}"
|
|
235
|
+
except Exception:
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def exception_class_name(exc: BaseException) -> str:
|
|
240
|
+
"""Fully-qualified exception class name, mirroring PHP's `get_class($e)` (always a
|
|
241
|
+
namespaced FQCN there). `builtins`/`__main__` are stripped as module qualifiers since
|
|
242
|
+
Python's own `traceback` module does the same — `ValueError`, not
|
|
243
|
+
`builtins.ValueError`. Used both for the `exception`/`exception_prev` context fields
|
|
244
|
+
(`manager.py`, phase 5) and the per-link header in `format_exception_chain()` below, so
|
|
245
|
+
the two never drift apart on how a class name is rendered.
|
|
246
|
+
"""
|
|
247
|
+
try:
|
|
248
|
+
module = type(exc).__module__
|
|
249
|
+
qualname = type(exc).__qualname__
|
|
250
|
+
except Exception:
|
|
251
|
+
return _UNRENDERABLE
|
|
252
|
+
else:
|
|
253
|
+
return qualname if module in ("builtins", "__main__") else f"{module}.{qualname}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def should_skip_trace(context: Mapping[str, object]) -> bool:
|
|
257
|
+
"""True if `context` carries a truthy `LOGGER_SKIP_TRACE` ('skipTrace') flag.
|
|
258
|
+
|
|
259
|
+
Truthiness is decided the same way every other bool-shaped context field in this
|
|
260
|
+
library is decided — `context.py`'s `_cast_bool()` / `CONTEXT_FALSE_VALUES` convention
|
|
261
|
+
— NOT raw Python truthiness. `bool("false")` is `True` in Python, which would silently
|
|
262
|
+
invert a caller's intent for a value arriving as a string (env var, HTTP header, JSON
|
|
263
|
+
payload): `{"skipTrace": "false"}` must NOT suppress the trace. This mirrors a matching
|
|
264
|
+
fix landed concurrently on the PHP side (`LogManager::appendContext()`, using its own
|
|
265
|
+
`contextCastBool()`) — the two libraries must agree on this, see mapping.md.
|
|
266
|
+
|
|
267
|
+
A non-scalar value (dict/list/object/...) has no bool-cast convention to apply — same
|
|
268
|
+
as PHP's `is_scalar()` guard — and is treated as not-set (does not suppress).
|
|
269
|
+
|
|
270
|
+
See the module docstring: the PHP reference historically never read this key at all to
|
|
271
|
+
suppress capture (it only registered it in the type-coercion skip table); this is this
|
|
272
|
+
port's completion of that evidently-intended opt-out, for a phase-5 orchestrator to
|
|
273
|
+
call before `.capture()` / `.format_exception_chain()` / `.render_exception_trace()`.
|
|
274
|
+
"""
|
|
275
|
+
value = context.get(LOGGER_SKIP_TRACE)
|
|
276
|
+
if value is None:
|
|
277
|
+
return False
|
|
278
|
+
if isinstance(value, bool):
|
|
279
|
+
return value
|
|
280
|
+
if isinstance(value, int | float):
|
|
281
|
+
return value != 0
|
|
282
|
+
if isinstance(value, str):
|
|
283
|
+
return value.strip().lower() not in _SKIP_TRACE_FALSE_VALUES
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TraceCapture:
|
|
288
|
+
"""Precompiled, reusable trace-capture helper (mirrors `Redactor` / `ContextTypeCorrector`
|
|
289
|
+
— build once per process, reuse for its lifetime). Takes a `Redactor` for trace argument
|
|
290
|
+
redaction: value-shape matching (`Redactor.redact_value()`, the reference's own
|
|
291
|
+
mechanism — PHP's backtrace arrays are positional/key-less) PLUS, for exception frames
|
|
292
|
+
where a parameter name is available, key-aware masking (`Redactor.should_redact_key()`)
|
|
293
|
+
that the PHP reference structurally cannot do (see `_render_arg()`).
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
__slots__ = ("_arg_limit", "_config", "_redactor")
|
|
297
|
+
|
|
298
|
+
def __init__(self, config: TraceConfig, redactor: Redactor) -> None:
|
|
299
|
+
self._config = config
|
|
300
|
+
self._redactor = redactor
|
|
301
|
+
self._arg_limit = config.arg_limit
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_config(cls, config: PylogConfig, redactor: Redactor) -> TraceCapture:
|
|
305
|
+
"""Build a TraceCapture from `PylogConfig.trace`, reusing a shared `Redactor`."""
|
|
306
|
+
return cls(config.trace, redactor)
|
|
307
|
+
|
|
308
|
+
def depth_for_level(self, level: int) -> int:
|
|
309
|
+
"""Frames to capture for a stdlib `logging` level. `0` = no trace at all — the
|
|
310
|
+
reference never builds a trace below `Warning` (`appendContext()`'s
|
|
311
|
+
`$level->value >= Level::Warning->value` guard); `logging.CRITICAL` and above also
|
|
312
|
+
covers any custom higher level a caller defines, matching the reference comment
|
|
313
|
+
"critical => 20 // also alert/emergency" (stdlib `logging` has no separate
|
|
314
|
+
alert/emergency levels).
|
|
315
|
+
"""
|
|
316
|
+
if level < logging.WARNING:
|
|
317
|
+
return 0
|
|
318
|
+
if level >= logging.CRITICAL:
|
|
319
|
+
return self._config.depth.critical
|
|
320
|
+
if level >= logging.ERROR:
|
|
321
|
+
return self._config.depth.error
|
|
322
|
+
return self._config.depth.warning
|
|
323
|
+
|
|
324
|
+
def capture(self, level: int, *, skip: int = 2) -> CapturedTrace:
|
|
325
|
+
"""Capture the current call site. `file` is always resolved (mirrors the reference
|
|
326
|
+
computing `context['file']` unconditionally); `trace` is `None` below the warning
|
|
327
|
+
depth cutoff.
|
|
328
|
+
|
|
329
|
+
`skip` is how many of THIS function's own frames to discard before starting the walk
|
|
330
|
+
(default 2: `_walk_live_stack`'s own frame + `capture()`'s own frame, landing on
|
|
331
|
+
`capture()`'s caller) — mirrors the reference's `array_slice(debug_backtrace(...), 1)`
|
|
332
|
+
dropping `appendContext()`'s own entry. A caller one layer further removed (e.g. a
|
|
333
|
+
thin logging wrapper) should pass a larger `skip`.
|
|
334
|
+
"""
|
|
335
|
+
depth = self.depth_for_level(level)
|
|
336
|
+
raw = _walk_live_stack(skip=skip, max_frames=_MAX_RAW_CALL_SITE_FRAMES)
|
|
337
|
+
file_line = _first_file_line(raw, self._config.excluded_partials)
|
|
338
|
+
if depth <= 0:
|
|
339
|
+
return CapturedTrace(file=file_line, trace=None)
|
|
340
|
+
trace = self._render_frames(raw, limit=depth, filter_excluded=True)
|
|
341
|
+
return CapturedTrace(file=file_line, trace=trace)
|
|
342
|
+
|
|
343
|
+
def format_exception_chain(self, exc: BaseException) -> str:
|
|
344
|
+
"""Render `exc` and its full `__cause__`/`__context__` chain into ONE coalesced
|
|
345
|
+
string (spec §3.7/§6.1 — never one record per traceback line, never one log call per
|
|
346
|
+
frame). Each link renders like the reference's own-exception trace
|
|
347
|
+
(`traceToString($e->getTrace(), 30, false)` — no filtering, fixed 30-frame limit,
|
|
348
|
+
arguments rendered+redacted); links are joined with the same separator text Python's
|
|
349
|
+
own `traceback` module uses for chained exceptions.
|
|
350
|
+
|
|
351
|
+
The reference (`LogManager::appendContext()`) only ever follows ONE `getPrevious()`
|
|
352
|
+
level into separate `exception_prev*` fields, despite its own spec text (§6.1) saying
|
|
353
|
+
"full chain via getPrevious()" — a drift between spec and implementation, reported
|
|
354
|
+
rather than silently resolved. This method follows the SPEC's stated intent (full
|
|
355
|
+
chain) using Python's native chain-walking idiom; field-name assembly (whether/how a
|
|
356
|
+
multi-level chain maps onto `exception_prev*`-shaped fields) is phase 5's job, not
|
|
357
|
+
this module's.
|
|
358
|
+
"""
|
|
359
|
+
chain: list[tuple[BaseException, str | None]] = []
|
|
360
|
+
seen: set[int] = set()
|
|
361
|
+
current: BaseException | None = exc
|
|
362
|
+
depth = 0
|
|
363
|
+
while current is not None and depth < _MAX_CHAIN_DEPTH:
|
|
364
|
+
current_id = id(current)
|
|
365
|
+
if current_id in seen:
|
|
366
|
+
break
|
|
367
|
+
seen.add(current_id)
|
|
368
|
+
try:
|
|
369
|
+
cause = current.__cause__
|
|
370
|
+
context = current.__context__
|
|
371
|
+
suppressed = current.__suppress_context__
|
|
372
|
+
except Exception:
|
|
373
|
+
# A hostile exception subclass whose __cause__/__context__/
|
|
374
|
+
# __suppress_context__ access itself raises (e.g. overridden as a raising
|
|
375
|
+
# property) must not blow up the logging path — stop walking the chain
|
|
376
|
+
# here rather than propagate.
|
|
377
|
+
next_exception = None
|
|
378
|
+
separator = None
|
|
379
|
+
else:
|
|
380
|
+
if cause is not None:
|
|
381
|
+
next_exception = cause
|
|
382
|
+
separator = _CAUSE_SEPARATOR
|
|
383
|
+
elif context is not None and not suppressed:
|
|
384
|
+
next_exception = context
|
|
385
|
+
separator = _CONTEXT_SEPARATOR
|
|
386
|
+
else:
|
|
387
|
+
next_exception = None
|
|
388
|
+
separator = None
|
|
389
|
+
chain.append((current, separator))
|
|
390
|
+
current = next_exception
|
|
391
|
+
depth += 1
|
|
392
|
+
|
|
393
|
+
parts: list[str] = []
|
|
394
|
+
for index in range(len(chain) - 1, -1, -1):
|
|
395
|
+
parts.append(self._format_single_exception(chain[index][0]))
|
|
396
|
+
if index > 0:
|
|
397
|
+
separator = chain[index - 1][1]
|
|
398
|
+
if separator is not None:
|
|
399
|
+
parts.append(separator)
|
|
400
|
+
return "\n".join(parts)
|
|
401
|
+
|
|
402
|
+
def render_exception_trace(self, exc: BaseException) -> str:
|
|
403
|
+
"""Single-exception frame-list rendering, mirroring
|
|
404
|
+
`traceToString($e->getTrace(), 30, false)` exactly: no header (class name and
|
|
405
|
+
message are separate context fields already — `manager.py` phase 5), filtering
|
|
406
|
+
OFF, fixed 30-frame limit. This is the shared building block behind both
|
|
407
|
+
`_format_single_exception()`'s per-link body below and the flat `trace` /
|
|
408
|
+
`exception_prev_trace` fields `manager.py` assembles for a single exception (the
|
|
409
|
+
owner-decided ONE-level `exception_prev*` shape, distinct from the full coalesced
|
|
410
|
+
chain `format_exception_chain()` produces).
|
|
411
|
+
"""
|
|
412
|
+
frames = _walk_traceback(exc.__traceback__, max_frames=_EXCEPTION_TRACE_LIMIT)
|
|
413
|
+
return self._render_frames(frames, limit=_EXCEPTION_TRACE_LIMIT, filter_excluded=False)
|
|
414
|
+
|
|
415
|
+
def _format_single_exception(self, exc: BaseException) -> str:
|
|
416
|
+
try:
|
|
417
|
+
message = self._redactor.redact_value(str(exc))
|
|
418
|
+
except Exception:
|
|
419
|
+
header = _UNRENDERABLE
|
|
420
|
+
else:
|
|
421
|
+
header = f"{exception_class_name(exc)}: {message}"
|
|
422
|
+
body = self.render_exception_trace(exc)
|
|
423
|
+
return f"{header}\n{body}" if body else header
|
|
424
|
+
|
|
425
|
+
def _render_frames(self, frames: Sequence[_RawFrame], *, limit: int, filter_excluded: bool) -> str:
|
|
426
|
+
"""Port of `TraitFileTrace::traceToString()`. `filter_excluded=True` (call-site mode)
|
|
427
|
+
drops excluded frames and collapses consecutive runs into a single `'.' * N` marker,
|
|
428
|
+
counting only kept frames against `limit`; `filter_excluded=False` (exception mode)
|
|
429
|
+
keeps every frame (file:line only, no class/function/args for excluded ones) and
|
|
430
|
+
counts all of them against `limit`.
|
|
431
|
+
"""
|
|
432
|
+
out: list[str] = []
|
|
433
|
+
skipped = 0
|
|
434
|
+
i = 0
|
|
435
|
+
for item in frames:
|
|
436
|
+
excluded = item.file is not None and _is_excluded(item.file, self._config.excluded_partials)
|
|
437
|
+
if excluded and filter_excluded:
|
|
438
|
+
skipped += 1
|
|
439
|
+
continue
|
|
440
|
+
if skipped:
|
|
441
|
+
out.append("." * skipped)
|
|
442
|
+
skipped = 0
|
|
443
|
+
|
|
444
|
+
line = f"#{i}"
|
|
445
|
+
if item.file:
|
|
446
|
+
line += f" {item.file}:{item.line if item.line is not None else ''}"
|
|
447
|
+
elif item.qualname is None:
|
|
448
|
+
line += f" {_UNREADABLE_FRAME}"
|
|
449
|
+
if not excluded and item.qualname:
|
|
450
|
+
line += self._render_call_suffix(item)
|
|
451
|
+
out.append(line)
|
|
452
|
+
|
|
453
|
+
i += 1
|
|
454
|
+
if i >= limit:
|
|
455
|
+
out.append(_TRUNCATION_MARKER)
|
|
456
|
+
break
|
|
457
|
+
|
|
458
|
+
if skipped:
|
|
459
|
+
out.append("." * skipped)
|
|
460
|
+
return "\n".join(out)
|
|
461
|
+
|
|
462
|
+
def _render_call_suffix(self, item: _RawFrame) -> str:
|
|
463
|
+
args_str = ""
|
|
464
|
+
if item.args:
|
|
465
|
+
args_str = ", ".join(self._render_arg(value, name=name) for name, value in item.args)
|
|
466
|
+
return f" | {item.qualname}({args_str})"
|
|
467
|
+
|
|
468
|
+
def _render_arg(self, value: object, *, name: str | None = None) -> str:
|
|
469
|
+
"""Render+redact one trace argument.
|
|
470
|
+
|
|
471
|
+
`name` is the parameter name this value is bound to (exception frames only, via
|
|
472
|
+
`inspect.getargvalues()` — see `_RawFrame`). When it looks sensitive
|
|
473
|
+
(`Redactor.should_redact_key()`), the whole argument is masked outright, BEFORE any
|
|
474
|
+
type dispatch — this is a Python-only addition with no PHP analogue: PHP's
|
|
475
|
+
`debug_backtrace()`/`getTrace()` arrays carry no parameter names, so
|
|
476
|
+
`renderTraceWithClass()` can only ever apply VALUE-shape redaction
|
|
477
|
+
(`Redactor::redactValue()`). A `def login(password: str)` frame would leak `password`
|
|
478
|
+
verbatim in the PHP original; this port does not have that gap and does not
|
|
479
|
+
reproduce it. Unnamed data (call-site trace has no args at all; elements inside a
|
|
480
|
+
dict/list/tuple argument) still only gets value-shape redaction — those already
|
|
481
|
+
render as `[...N]` with no content exposed, so nothing there regresses.
|
|
482
|
+
|
|
483
|
+
Ported branch-for-branch from `renderTraceWithClass()` for the type dispatch: object
|
|
484
|
+
-> class name only (no redaction/truncation, same as the reference's bare
|
|
485
|
+
`get_class($arg)`); array/list-like -> `[...N]`; bool -> `T`/`F`; str -> quoted,
|
|
486
|
+
redacted THEN truncated to `arg_limit`; everything else (None/int/float) ->
|
|
487
|
+
stringified, redacted THEN truncated. Every branch is wrapped: a broken
|
|
488
|
+
`__class__`/`__repr__`/`__str__` degrades to a placeholder rather than propagating —
|
|
489
|
+
a logging library must never raise from the logging path.
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
if name is not None and self._redactor.should_redact_key(name):
|
|
493
|
+
return self._redactor.MASK
|
|
494
|
+
if isinstance(value, bool):
|
|
495
|
+
return "T" if value else "F"
|
|
496
|
+
if isinstance(value, dict | list | tuple | set | frozenset):
|
|
497
|
+
return f"[...{len(value)}]"
|
|
498
|
+
if isinstance(value, str):
|
|
499
|
+
return '"' + self._redactor.redact_value(value)[: self._arg_limit] + '"'
|
|
500
|
+
if value is None or isinstance(value, int | float):
|
|
501
|
+
return self._redactor.redact_value(str(value))[: self._arg_limit]
|
|
502
|
+
return type(value).__name__
|
|
503
|
+
except Exception:
|
|
504
|
+
return _UNRENDERABLE
|