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