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/manager.py ADDED
@@ -0,0 +1,394 @@
1
+ """Core record-enrichment orchestration, ported from `LogManager::appendContext()`
2
+ (reference `src/LogManager.php`, spec `docs/LoggingRules.md` / `docs/ContextFieldNaming.md`,
3
+ both re-verified 2026-08-13). This is the single behavioural hook every other phase feeds
4
+ into: `LogManager.append_context()` takes a level/message/context triple and returns an
5
+ enriched context dict plus the (possibly truncated) message, ready for a formatter
6
+ to render onto the wire. Handler/formatter wiring lives in `log_python.setup()`; this
7
+ module remains framework-agnostic.
8
+
9
+ ## Operation order (verified against `LogManager::appendContext()`, line-by-line)
10
+
11
+ 1. Extract the `LOGGER_SKIP_TRACE` ('skipTrace') marker, cast it through the SAME
12
+ bool-truthiness convention as every other bool-shaped context field
13
+ (`context.py`'s `_cast_bool()` / `CONTEXT_FALSE_VALUES`, wired via
14
+ `trace.should_skip_trace()`), and POP the marker key so it never reaches the wire —
15
+ this mirrors a fix landed concurrently on the PHP side (uncommitted as of this port,
16
+ `LogManager::appendContext()` using `contextCastBool()`); see `mapping.md`.
17
+ 2. Extract `context['exception']` when it is a real exception instance (and only then —
18
+ a non-exception value under that key is left alone, exactly like the reference's
19
+ `instanceof \\Throwable` guard).
20
+ 3. `log_type` classification: only when the caller did not already supply one — `logger`
21
+ by default, promoted to `exception` when an exception is present and the ambient
22
+ `LogType.current()` origin is still the default.
23
+ 4. Context type-coercion (`ContextTypeCorrector`) THEN key-based redaction
24
+ (`Redactor.should_redact_key`) — in that order, and ONLY in that order. Reference
25
+ `contextTypeCorrector()` checks `shouldRedactKey()` BEFORE casting a given key, so a
26
+ sensitive key is masked outright and never cast; running redaction first and coercion
27
+ second here would be a no-op for THAT ordering, but running coercion first, redaction
28
+ AFTER (this port's approach), and never re-coercing the masked `"***"` string produces
29
+ the identical final result — because coercion and (key-based) redaction are mutually
30
+ exclusive per key, "coerce every key, then overwrite the sensitive ones with the mask"
31
+ equals "check-then-branch per key". The one order that is WRONG is masking a value
32
+ BEFORE coercion could see it: `contextCastBool("***")` on a `password_bool` key would
33
+ come out `True` — a masked value turned back into a coerced one, a real type-stability
34
+ break the tests in `test_manager.py` pin explicitly (a key that is both sensitive AND
35
+ suffix-typed must serialize as the string `"***"`, never a cast value).
36
+ 5. Exception-derived fields, added to the ALREADY-coerced-and-redacted dict — so these
37
+ fields bypass coercion and (key-based) redaction entirely, exactly like the reference
38
+ (`appendContext()` populates them AFTER its own `contextTypeCorrector()` call). See
39
+ "Exception chain wire shape" below for the field list.
40
+ 6. `message_len` — the length of the ORIGINAL, untruncated message. Order matters: the
41
+ reference computes this BEFORE the message is ever truncated (truncation only happens
42
+ at the very end, on the RETURNED string, never stored back into `context`).
43
+ 7. Caller `file:line` (+ conditionally, call-site `trace`) — ONLY when `'file'` is not
44
+ already set (i.e., no exception path ran, or it left `'file'` falsy). Delegates to
45
+ `TraceCapture.capture()`, which already embeds the "no trace below WARNING" cutoff.
46
+ 8. Memory fields, only when `allow_memory` is on.
47
+ 9. `request_id` — always (re)stamped, unconditionally, from the ambient context var.
48
+ 10. Return `(message[:message_limit], enriched_context)`.
49
+
50
+ ## Public-API divergence from the reference (documented, not an oversight)
51
+
52
+ PHP's `appendContext(Level $level, string|Stringable $message, array &$context): string`
53
+ mutates `$context` by reference and returns only the truncated message. This port instead
54
+ returns `(message, new_context)` — the SAME "never mutate the input, return a new dict"
55
+ convention `ContextTypeCorrector.correct()` and `Redactor.redact()` already established in
56
+ phases 2/3, kept consistent rather than reintroducing PHP's by-reference out-param pattern
57
+ into idiomatic Python.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import contextvars
63
+ import math
64
+ import sys
65
+ import tracemalloc
66
+ import uuid
67
+ from collections.abc import Mapping
68
+ from typing import Final
69
+
70
+ from log_python.config import PylogConfig
71
+ from log_python.const import LOGGER_MEMORY, LOGGER_MEMORY_PEAK, LOGGER_SKIP_TRACE
72
+ from log_python.context import ContextTypeCorrector
73
+ from log_python.logtype import LogType
74
+ from log_python.redactor import Redactor
75
+ from log_python.trace import TraceCapture, exception_class_name, exception_origin, should_skip_trace
76
+
77
+ # The full `__cause__`/`__context__` chain, coalesced into ONE string field by
78
+ # `TraceCapture.format_exception_chain()` (phase 4) — satisfies spec §6.1's stated intent
79
+ # ("full chain via getPrevious()") alongside the flat, ONE-level `exception_prev*` fields
80
+ # below, which reproduce the reference's ACTUAL (narrower) behaviour 1:1. Deliberately not
81
+ # a `LOGGER_*` constant in `const.py`: that module's contract is "byte-for-byte identical to
82
+ # the PHP reference" (its own docstring, and `test_const.py` asserts each entry against
83
+ # `src/const.php` verbatim) — there is nothing in the PHP source for this field to match,
84
+ # since it has no PHP counterpart at all. Chosen not to collide with any of the seven
85
+ # PHP-defined exception field names.
86
+ EXCEPTION_CHAIN_FIELD: Final[str] = "exception_chain"
87
+
88
+ _REQUEST_ID: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar("pylog_request_id", default=None)
89
+
90
+
91
+ def get_or_create_request_id() -> str:
92
+ """Return the current context's request id, generating one on first use.
93
+
94
+ Reference `LogManager::getOrCreateRequestId()`: a UUID (or an incoming
95
+ `X-Request-ID`), pinned to string in the type-coercion table because it may be
96
+ all-digits and must never flip to int (see `context.py::_RESERVED_RULES`).
97
+
98
+ Format: `str(uuid.uuid4())` — a standard RFC 4122 v4 UUID string (lowercase,
99
+ hyphenated), the same textual shape as Laravel's `Str::uuid()`. This port does not
100
+ read an inbound `X-Request-ID` header itself (there is no framework-agnostic Python
101
+ equivalent of PHP's `$_SERVER` superglobal to read from) — a web/worker integration
102
+ calls `set_request_id()` explicitly instead, e.g. from middleware.
103
+
104
+ Scoping divergence (deliberate): the reference's id lives in `$_SERVER`, a single
105
+ process-global value — correct for PHP-FPM's one-request-per-process model, but a
106
+ Python process may serve many concurrent requests/tasks on the SAME process (threads,
107
+ asyncio). A bare global here would leak one request's id into another's log lines
108
+ running concurrently. `contextvars.ContextVar` gives each thread/async-task context
109
+ its own value instead, which is a closer match to the spec's own description of
110
+ `request_id` as "stable within one request" than a literal process-global port would
111
+ have been.
112
+ """
113
+ current = _REQUEST_ID.get()
114
+ if current:
115
+ return current
116
+ new_id = str(uuid.uuid4())
117
+ _REQUEST_ID.set(new_id)
118
+ return new_id
119
+
120
+
121
+ def set_request_id(request_id: str) -> None:
122
+ """Seed the current context's request id explicitly — e.g. from an inbound
123
+ `X-Request-ID` header in a web middleware. No PHP method maps onto this directly;
124
+ the reference reads `$_SERVER['HTTP_X_REQUEST_ID']` inline inside
125
+ `getOrCreateRequestId()`, which has no framework-agnostic Python equivalent — this is
126
+ the explicit seam a caller uses instead.
127
+ """
128
+ _REQUEST_ID.set(request_id)
129
+
130
+
131
+ def reset_request_id() -> None:
132
+ """Clear the current context's request id so the next unit of work gets a fresh one.
133
+
134
+ Reference `LogManager::resetRequestId()` — the Laravel `JobProcessing` listener
135
+ (`LaraLogServiceProvider`) becomes this explicit function, which a worker calls
136
+ between jobs so a long-running process doesn't stamp every job with the same id.
137
+ """
138
+ _REQUEST_ID.set(None)
139
+
140
+
141
+ def _safe_str(value: object) -> str:
142
+ """Best-effort `str()`, never raising — used for the log `message` itself, which the
143
+ public API accepts as `object` specifically so a hostile value (broken `__str__`)
144
+ degrades to a placeholder rather than blowing up the logging path.
145
+ """
146
+ if isinstance(value, str):
147
+ return value
148
+ try:
149
+ return str(value)
150
+ except Exception:
151
+ return "<unrenderable message>"
152
+
153
+
154
+ def _safe_copy_context(context: Mapping[str, object] | None) -> dict[str, object]:
155
+ """Shallow-copy the caller's context into a fresh dict this function owns — never
156
+ mutates the caller's mapping. Wrapped because a hostile custom `Mapping` (broken
157
+ `__iter__`/`.items()`) must not be able to raise out of the logging path.
158
+ """
159
+ if context is None:
160
+ return {}
161
+ try:
162
+ return dict(context)
163
+ except Exception:
164
+ return {}
165
+
166
+
167
+ def _exception_code(exc: BaseException) -> int:
168
+ """Python-side derivation of PHP's `(int) $e->getCode()`. Every PHP `Throwable` has a
169
+ `getCode()` (default `0`); Python's `BaseException` has no universal numeric-code
170
+ attribute. This reads an optional `.code` attribute (several stdlib exceptions —
171
+ `SystemExit`, some driver exceptions — already use that name) and coerces it the same
172
+ tolerant way the reference's `(int)` cast would, falling back to `0` for anything
173
+ absent, non-numeric, or a raising property.
174
+ """
175
+ try:
176
+ code = getattr(exc, "code", None)
177
+ except Exception:
178
+ return 0
179
+ if isinstance(code, bool):
180
+ return int(code)
181
+ if isinstance(code, int):
182
+ return code
183
+ if isinstance(code, float):
184
+ return int(code) if math.isfinite(code) else 0
185
+ if isinstance(code, str):
186
+ try:
187
+ return int(code.strip())
188
+ except ValueError:
189
+ return 0
190
+ return 0
191
+
192
+
193
+ def _previous_exception(exc: BaseException) -> BaseException | None:
194
+ """The exception this port treats as PHP's `$e->getPrevious()` — owner decision:
195
+ `__cause__` (explicit `raise X from Y`) takes priority over `__context__` (implicit
196
+ chaining), and an explicit `raise X from None` (`__suppress_context__`) means no
197
+ previous exception at all, matching Python's own traceback-printing rule and
198
+ `TraceCapture.format_exception_chain()`'s identical priority (phase 4, `trace.py`).
199
+ Wrapped because reading dunder attributes off a hostile custom exception subclass
200
+ (e.g. `__cause__`/`__suppress_context__` as raising properties) must not raise out of
201
+ the logging path.
202
+ """
203
+ try:
204
+ cause = exc.__cause__
205
+ if cause is not None:
206
+ return cause
207
+ context = exc.__context__
208
+ if context is not None and not exc.__suppress_context__:
209
+ return context
210
+ except Exception:
211
+ return None
212
+ return None
213
+
214
+
215
+ def _peak_memory_bytes() -> int | None:
216
+ """Process-wide peak resident set size, in bytes — the closest POSIX analogue of PHP's
217
+ `memory_get_peak_usage()`, but NOT the same quantity: PHP's figure is the Zend engine's
218
+ own allocator peak (userland PHP object/array memory), while `ru_maxrss` is the OS's
219
+ peak RSS for the whole process (interpreter, C extensions, everything). Same field
220
+ name (`LOGGER_MEMORY_PEAK`), same JSON type (int), genuinely different semantics —
221
+ documented here and in `mapping.md`, not papered over.
222
+
223
+ `resource` is POSIX-only (no Windows build) and imported lazily, inside the `try`,
224
+ specifically so there is nothing to reassign in an `except` branch — the
225
+ strict no-suppression rule in `AGENTS.md` rules out the usual conditional
226
+ import plus assignment-suppression shape,
227
+ and this local-import form needs no such fallback assignment at all.
228
+
229
+ `ru_maxrss` unit differs by platform: kibibytes on Linux, bytes on macOS (`darwin`) —
230
+ normalized to bytes here so the field means the same thing regardless of host OS,
231
+ matching the PHP fleet's byte-denominated field.
232
+ """
233
+ try:
234
+ import resource
235
+ except ImportError:
236
+ return None
237
+ try:
238
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
239
+ except Exception:
240
+ return None
241
+ if sys.platform == "darwin":
242
+ return int(peak)
243
+ return int(peak) * 1024
244
+
245
+
246
+ def _current_traced_memory_bytes() -> int | None:
247
+ """Current Python-allocated memory, in bytes, from `tracemalloc` — ONLY when tracing
248
+ is already active. This library never calls `tracemalloc.start()` itself (a real,
249
+ measurable hot-path tax for every allocation in the process); when tracing is off the
250
+ `LOGGER_MEMORY` field is simply omitted from the record rather than emitted as a
251
+ placeholder, which is fine for the field-type-stability contract (an ABSENT key is not
252
+ a second JSON type for the same key, only a present-with-wrong-type value would be).
253
+ """
254
+ if not tracemalloc.is_tracing():
255
+ return None
256
+ try:
257
+ current, _peak = tracemalloc.get_traced_memory()
258
+ except Exception:
259
+ return None
260
+ return current
261
+
262
+
263
+ class LogManager:
264
+ """Precompiled, reusable enrichment orchestrator (mirrors `Redactor` /
265
+ `ContextTypeCorrector` / `TraceCapture` — build once per process from a `PylogConfig`,
266
+ reuse for its lifetime; no per-call recompilation or env re-reads on the hot path).
267
+
268
+ `append_context()` is the single public entry point, faithfully porting
269
+ `LogManager::appendContext()` — see the module docstring for the verified operation
270
+ order. This class does not touch handler/formatter wiring or install process
271
+ hooks; it is a pure enrichment function.
272
+ """
273
+
274
+ __slots__ = ("_allow_memory", "_corrector", "_message_limit", "_redactor", "_trace")
275
+
276
+ def __init__(self, config: PylogConfig) -> None:
277
+ self._redactor = Redactor.from_config(config)
278
+ self._corrector = ContextTypeCorrector.from_config(config)
279
+ self._trace = TraceCapture.from_config(config, self._redactor)
280
+ self._message_limit = config.message_limit
281
+ self._allow_memory = config.allow_memory
282
+
283
+ def append_context(self, level: int, message: object, context: Mapping[str, object] | None = None) -> tuple[str, dict[str, object]]:
284
+ """Enrich `context` for a record at `level` (a stdlib `logging` level int) with
285
+ `message`. Returns `(message_truncated_to_message_limit, enriched_context)` — see
286
+ the module docstring for exactly what gets added and in what order.
287
+
288
+ Must never raise: `message` and `context` are both treated as untrusted input
289
+ (hostile `__str__`/`__repr__`, a `Mapping` whose `.items()` raises, a broken
290
+ exception object, ...). Every raise-prone step ported into this method is wrapped
291
+ individually (see the private helpers above); this is the one OUTER last-resort
292
+ net for anything those miss — it still returns a well-formed, minimally-enriched
293
+ result rather than letting an exception escape the logging path.
294
+ """
295
+ safe_message = _safe_str(message)
296
+ try:
297
+ return self._append_context(level, safe_message, context)
298
+ except Exception:
299
+ return safe_message[: self._message_limit], {
300
+ "log_type": LogType.LOGGER,
301
+ "request_id": get_or_create_request_id(),
302
+ }
303
+
304
+ def _append_context(self, level: int, message: str, context: Mapping[str, object] | None) -> tuple[str, dict[str, object]]:
305
+ working = _safe_copy_context(context)
306
+
307
+ # 1. LOGGER_SKIP_TRACE: read, bool-cast, then POP so the marker key never reaches
308
+ # coercion/redaction/the wire — mirrors the reference's `unset()` of the same key.
309
+ skip_trace = should_skip_trace(working)
310
+ working.pop(LOGGER_SKIP_TRACE, None)
311
+
312
+ # 2. Extract a real exception only — a non-Throwable-shaped value under
313
+ # 'exception' is left in place, exactly like `isset(...) && ... instanceof`.
314
+ raw_exc = working.get("exception")
315
+ exc: BaseException | None = raw_exc if isinstance(raw_exc, BaseException) else None
316
+ if exc is not None:
317
+ del working["exception"]
318
+
319
+ # 3. log_type classification — only when the caller did not already supply one.
320
+ if working.get("log_type") is None:
321
+ log_type = LogType.current()
322
+ if exc is not None and log_type == LogType.LOGGER:
323
+ log_type = LogType.EXCEPTION
324
+ working["log_type"] = log_type
325
+
326
+ # 4. Coercion, THEN key-based redaction — see the module docstring for why this
327
+ # order (and not the reverse) is the one that matches the reference.
328
+ enriched = self._coerce_and_redact(working)
329
+
330
+ # 5. Exception-derived fields, added AFTER coercion/redaction — bypass both,
331
+ # exactly like the reference (these are library-computed, already-typed values).
332
+ if exc is not None:
333
+ enriched["exception"] = exception_class_name(exc)
334
+ enriched["exception_message"] = self._redactor.redact_value(_safe_str(exc))
335
+ enriched["exception_code"] = _exception_code(exc)
336
+ enriched["file"] = exception_origin(exc)
337
+ if not skip_trace:
338
+ enriched["trace"] = self._trace.render_exception_trace(exc)
339
+ enriched[EXCEPTION_CHAIN_FIELD] = self._trace.format_exception_chain(exc)
340
+ prev = _previous_exception(exc)
341
+ if prev is not None:
342
+ enriched["exception_prev"] = exception_class_name(prev)
343
+ # Owner-flagged content divergence from the reference: PHP writes
344
+ # `$e->getPrevious()->getMessage()` verbatim, unredacted. Spec §6.1 itself
345
+ # calls the exception message "suspect" (PII/log-injection) and offers
346
+ # "apply the same redactor as for the request body" as an option; this
347
+ # port takes that option (value-shape `redact_value()`, best-effort, same
348
+ # mechanism `_render_arg()` already applies to trace arguments) rather
349
+ # than reproducing the unredacted PHP behaviour. Field NAME and TYPE stay
350
+ # 1:1 with PHP (still a plain string) — only string CONTENT can differ,
351
+ # and only when the message happens to match a Bearer/JWT/URL-credential/
352
+ # key=value shape. Reported to the team-lead as vetoable.
353
+ enriched["exception_prev_message"] = self._redactor.redact_value(_safe_str(prev))
354
+ enriched["exception_prev_file"] = exception_origin(prev)
355
+ enriched["exception_prev_code"] = _exception_code(prev)
356
+ if not skip_trace:
357
+ enriched["exception_prev_trace"] = self._trace.render_exception_trace(prev)
358
+
359
+ # 6. message_len: the length of the message BEFORE truncation — computed here,
360
+ # truncation happens only in the tuple this method returns, at the very end.
361
+ enriched["message_len"] = len(message)
362
+
363
+ # 7. Caller file:line (+ conditionally, call-site trace) — only when 'file' is not
364
+ # already set (exception path already set it above, when exc is not None).
365
+ if not enriched.get("file"):
366
+ captured = self._trace.capture(level)
367
+ enriched["file"] = captured.file
368
+ if not skip_trace and not enriched.get("trace") and captured.trace is not None:
369
+ enriched["trace"] = captured.trace
370
+
371
+ # 8. Memory fields, opt-in only.
372
+ if self._allow_memory:
373
+ peak = _peak_memory_bytes()
374
+ if peak is not None:
375
+ enriched[LOGGER_MEMORY_PEAK] = peak
376
+ current = _current_traced_memory_bytes()
377
+ if current is not None:
378
+ enriched[LOGGER_MEMORY] = current
379
+
380
+ # 9. request_id — always (re)stamped, unconditionally.
381
+ enriched["request_id"] = get_or_create_request_id()
382
+
383
+ # 10. Truncate the RETURNED message only; message_len above already reflects the
384
+ # untruncated length.
385
+ return message[: self._message_limit], enriched
386
+
387
+ def _coerce_and_redact(self, context: Mapping[str, object]) -> dict[str, object]:
388
+ """`ContextTypeCorrector.correct()` THEN a key-based redaction overlay. See the
389
+ module docstring's point 4 for why "coerce everything, then overwrite sensitive
390
+ keys with the mask" is equivalent to (and not "backwards" from) the reference's
391
+ per-key "check redaction before casting" order.
392
+ """
393
+ coerced = self._corrector.correct(context)
394
+ return {key: (Redactor.MASK if self._redactor.should_redact_key(key) else value) for key, value in coerced.items()}
log_python/py.typed ADDED
File without changes
log_python/redactor.py ADDED
@@ -0,0 +1,261 @@
1
+ """Credential / secret redaction at the process boundary.
2
+
3
+ Python port of the reference `src/Redactor.php` (xakki/laralog, spec
4
+ `docs/LoggingRules.md` §2 / §2.1). Two complementary modes:
5
+
6
+ - **by key**: for keyed data (context fields, dict entries) — match the FIELD
7
+ NAME against a needle list, case-insensitive substring.
8
+ - **by value**: for positional / free-form data (stack-trace arguments,
9
+ exception args, log messages) where no field name is available — match the
10
+ VALUE itself against Bearer/Basic-auth headers, JWTs, and
11
+ ``key=value``/``key: value`` assignments whose key looks sensitive.
12
+
13
+ Built-in needles are always active; extra needles (e.g. from
14
+ ``PylogConfig.redact``) are merged ON TOP of them and never replace them.
15
+
16
+ This module has zero third-party dependencies (stdlib ``re`` only), per the
17
+ project's zero-runtime-dependency contract for the core.
18
+
19
+ Deliberate divergences from ``Redactor.php`` (the reference has neither):
20
+
21
+ - ``redact_value`` also matches a needle key wrapped in a quote (``"key":
22
+ "value"`` JSON, ``'key' => 'value'`` PHP-array-style) — logging a raw
23
+ request/response body is routine, so leaving quoted values unredacted was a
24
+ live gap worth closing in the port even though the reference doesn't.
25
+ - ``redact_value`` also masks the password component of a
26
+ ``scheme://user:password@host`` URL (host and user are left intact — they
27
+ are diagnostically useful and not secret).
28
+
29
+ Known, accepted limitation (same in both languages, not a bug to "discover"
30
+ later): a bare token with no key and no Bearer/Basic/JWT shape — e.g.
31
+ ``["Authorization", "Bearer", raw_token]`` split across list elements — is
32
+ never redacted. Catching that would need heuristics over unrelated sibling
33
+ values with unacceptable false-positive risk.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import re
39
+ from collections.abc import Iterable, Mapping
40
+ from typing import ClassVar, Final
41
+
42
+ from log_python.config import PylogConfig
43
+
44
+ MASK: Final[str] = "***"
45
+
46
+ # High-signal needles only — substring match, so overly-broad tokens (`auth`, `card`,
47
+ # `pin`, `session`) are deliberately excluded to avoid masking legit fields like
48
+ # `cardinality` / `shipping` / `author`. Add narrow project needles via
49
+ # `PylogConfig.redact`. Ported 1:1 from `Redactor.php::BUILTIN` (14 needles,
50
+ # verified against the reference 2026-08-13).
51
+ BUILTIN_NEEDLES: Final[tuple[str, ...]] = (
52
+ "password",
53
+ "passwd",
54
+ "secret",
55
+ "token",
56
+ "authorization",
57
+ "api_key",
58
+ "apikey",
59
+ "access_key",
60
+ "private_key",
61
+ "credential",
62
+ "cookie",
63
+ "cvv",
64
+ "card_number",
65
+ "cardnumber",
66
+ )
67
+
68
+ # Bearer / Basic auth header values. PHP: `/\b(bearer|basic)\s+[A-Za-z0-9._\-\/+=]{8,}/i`.
69
+ # The token-body quantifier is bounded (PHP's `{8,}` is not) as a defensive cap against
70
+ # pathological input sizes. This is safe specifically because it is a TRAILING atom with
71
+ # nothing after it in the pattern: a bound here can only shorten what gets matched for the
72
+ # `.search()` boolean check, never prevent the match outright (as long as >=8 eligible chars
73
+ # exist anywhere, some prefix of them up to the cap still matches). And since a match masks
74
+ # the WHOLE value (see `redact_value` below), a truncated match still fully masks the value
75
+ # — there is no truncation-leak and no false-non-match. Verified 2026-08-13.
76
+ _BEARER_RE: Final[re.Pattern[str]] = re.compile(r"\b(?:bearer|basic)\s+[A-Za-z0-9._\-/+=]{8,4096}", re.IGNORECASE)
77
+
78
+ # JWT: three base64url segments. PHP: `/\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/`.
79
+ # Unlike _BEARER_RE, each segment quantifier here is immediately followed by a required
80
+ # literal ('.' or end of string) — so a bound is NOT safe: a payload segment longer than the
81
+ # bound (real JWTs can carry many claims/roles well past 4096 chars) has no position within
82
+ # the bound where the next char is the required '.', so the WHOLE regex silently fails to
83
+ # match — a false non-match that leaks the entire token, worse than any truncation. Verified
84
+ # 2026-08-13 (a >4096-char payload segment was confirmed to defeat the bounded version).
85
+ # Restored unbounded to match the PHP reference exactly.
86
+ _JWT_RE: Final[re.Pattern[str]] = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
87
+
88
+ # URL-embedded credentials: `scheme://user:password@host`. Not present in Redactor.php (see
89
+ # module docstring). Only the password is masked; scheme/user/host stay intact for
90
+ # diagnostics.
91
+ _URL_CREDENTIALS_RE: Final[re.Pattern[str]] = re.compile(r"(?P<scheme>[A-Za-z][A-Za-z0-9+.-]*://)(?P<user>[^:/?#\s@]+):(?P<password>[^@/?#\s]+)@")
92
+
93
+ # Recursion guard for Redactor.redact(). No realistic log payload is this deep; hitting the
94
+ # limit means either a pathological or self-referential structure, so we fail safe (mask)
95
+ # rather than risk leaking an un-inspected branch.
96
+ _MAX_DEPTH: Final[int] = 50
97
+ _CIRCULAR_PLACEHOLDER: Final[str] = "<circular>"
98
+
99
+ # Hot-path guard for Redactor._redact(): these four types can never carry a secret, so the
100
+ # generic str()-scan fallback is skipped for them entirely. Deliberately a small explicit
101
+ # allow-list (not "isinstance of a numeric ABC" or similar) — Decimal/complex/etc. are NOT
102
+ # listed here on purpose and still go through the str() scan, per project instruction.
103
+ _SKIP_STR_SCAN_TYPES: Final[tuple[type, ...]] = (type(None), bool, int, float)
104
+
105
+
106
+ class Redactor:
107
+ """Precompiled, reusable redaction helper.
108
+
109
+ Construct once (needle list + value-pattern regexes are compiled at
110
+ construction, not per call — this sits on the hot logging path) and reuse
111
+ for the process lifetime.
112
+ """
113
+
114
+ MASK: ClassVar[str] = MASK
115
+
116
+ __slots__ = ("_kv_sub_re", "_needles")
117
+
118
+ def __init__(self, needles: Iterable[str] = ()) -> None:
119
+ """Build the merged, deduplicated, lowercased needle list and precompile regexes.
120
+
121
+ Built-in needles always win a place; ``needles`` (e.g. config-supplied
122
+ extras) are merged ON TOP, never replacing them.
123
+ """
124
+ merged: list[str] = []
125
+ seen: set[str] = set()
126
+ for needle in (*BUILTIN_NEEDLES, *needles):
127
+ lowered = needle.strip().lower()
128
+ if lowered and lowered not in seen:
129
+ seen.add(lowered)
130
+ merged.append(lowered)
131
+ self._needles: tuple[str, ...] = tuple(merged)
132
+
133
+ # key=value / key: value / key=>value where key is one of the needles (built-in or
134
+ # config-supplied). PHP: match `/(needles)\s*[=:]\s*\S+/i` then replace
135
+ # `/((?:needles)\s*[=:]\s*)\S+/i` with `$1***`. Python `re.sub` already no-ops when
136
+ # there is no match, so a single substitution call covers both PHP steps.
137
+ #
138
+ # The trailing value atom is intentionally UNBOUNDED (`\S+`, not `\S{1,N}`): it is a
139
+ # simple greedy atom at the end of the pattern with nothing after it to fail on, so it
140
+ # cannot backtrack catastrophically — a bound here only truncated the capture, leaving
141
+ # the tail of any secret longer than the bound as literal, unmasked characters right
142
+ # after the mask (a real leak, not just a theoretical one — verified 2026-08-13).
143
+ #
144
+ # Divergence from Redactor.php (documented in the module docstring): an optional quote
145
+ # right after the needle (closing quote of a JSON/PHP-array key, e.g. `"password":` /
146
+ # `'token' =>`) and an optional opening quote at the start of the value are both
147
+ # tolerated, and a quoted value is matched — and replaced, together with its closing
148
+ # quote — as a single unit, so no dangling quote is ever left behind.
149
+ alternation = "|".join(re.escape(needle) for needle in self._needles)
150
+ self._kv_sub_re: re.Pattern[str] = re.compile(
151
+ rf"((?:{alternation})[\"']?\s*(?:=>|[=:])\s*)(?:\"[^\"]*\"|'[^']*'|\S+)",
152
+ re.IGNORECASE,
153
+ )
154
+
155
+ @classmethod
156
+ def from_config(cls, config: PylogConfig) -> Redactor:
157
+ """Build a Redactor whose extra needles come from `PylogConfig.redact`."""
158
+ return cls(config.redact)
159
+
160
+ @property
161
+ def needles(self) -> tuple[str, ...]:
162
+ """The merged, deduplicated, lowercased needle list (built-ins + config extras)."""
163
+ return self._needles
164
+
165
+ def should_redact_key(self, key: str) -> bool:
166
+ """True if `key` (case-insensitive substring match) contains any needle."""
167
+ lowered = key.lower()
168
+ return any(needle in lowered for needle in self._needles)
169
+
170
+ def redact_value(self, value: str) -> str:
171
+ """Best-effort redaction of a positional (key-less) string value.
172
+
173
+ Masks the whole value when it looks like a Bearer/Basic auth header or a JWT;
174
+ otherwise masks the password component of any `scheme://user:password@host` URL
175
+ (Python-only addition, see module docstring) and the value part of any
176
+ `needle=...`/`needle: ...`/`needle=>...` assignment (optionally quoted) found
177
+ inside it, leaving the rest of the string untouched.
178
+ """
179
+ if _BEARER_RE.search(value):
180
+ return MASK
181
+ if _JWT_RE.search(value):
182
+ return MASK
183
+ value = _URL_CREDENTIALS_RE.sub(rf"\g<scheme>\g<user>:{MASK}@", value)
184
+ return self._kv_sub_re.sub(r"\1" + MASK, value)
185
+
186
+ def redact_pair(self, key: str, value: object) -> object:
187
+ """Redact a single key/value pair: mask outright on a sensitive key, else walk the value."""
188
+ if self.should_redact_key(key):
189
+ return MASK
190
+ return self.redact(value)
191
+
192
+ def redact(self, value: object) -> object:
193
+ """Recursively redact a nested structure (dict/list/tuple/set/scalar/arbitrary object).
194
+
195
+ - Mapping: keys matching a needle are masked outright; other values recurse.
196
+ - list/tuple: every element recurses (list stays a list, tuple stays a tuple).
197
+ - set/frozenset: every element recurses (type preserved); redacted elements stay
198
+ hashable (str/tuple/frozenset), so this can never raise.
199
+ - str: scanned with `redact_value` (Bearer/JWT/URL-credentials/key=value patterns).
200
+ - bytes/bytearray: decoded (`errors="replace"`, never raises) and scanned the same
201
+ way; returned as the redacted `str` if anything changed, else as the original
202
+ object unchanged (callers keep their type when there was nothing to redact).
203
+ - None/bool/int/float: returned unchanged — these can never carry a secret, so the
204
+ `str()` scan below is skipped for them as a hot-path guard.
205
+ - anything else (dataclass, exception, custom class, ...): `str(obj)` is scanned the
206
+ same way; the redacted **string** is returned only if redaction actually changed
207
+ it, otherwise the original object is returned unchanged so structure/type survive
208
+ the overwhelmingly common non-secret case. `str(obj)` is allowed to raise (a
209
+ broken `__str__`) — caught and failed safe to MASK, since a logging library must
210
+ never raise from the logging path.
211
+
212
+ Guards against unbounded recursion (depth cap, fails safe to MASK) and
213
+ self-referential containers (identity-tracked, fails safe to a circular marker).
214
+ """
215
+ return self._redact(value, seen=frozenset(), depth=0)
216
+
217
+ def _redact(self, value: object, *, seen: frozenset[int], depth: int) -> object:
218
+ if depth >= _MAX_DEPTH:
219
+ return MASK
220
+ if isinstance(value, str):
221
+ return self.redact_value(value)
222
+ if isinstance(value, bytes | bytearray):
223
+ decoded = value.decode("utf-8", errors="replace")
224
+ redacted_text = self.redact_value(decoded)
225
+ return redacted_text if redacted_text != decoded else value
226
+ if isinstance(value, Mapping):
227
+ obj_id = id(value)
228
+ if obj_id in seen:
229
+ return _CIRCULAR_PLACEHOLDER
230
+ next_seen = seen | {obj_id}
231
+ return {
232
+ key: (MASK if isinstance(key, str) and self.should_redact_key(key) else self._redact(item, seen=next_seen, depth=depth + 1))
233
+ for key, item in value.items()
234
+ }
235
+ if isinstance(value, list | tuple):
236
+ obj_id = id(value)
237
+ if obj_id in seen:
238
+ return _CIRCULAR_PLACEHOLDER
239
+ next_seen = seen | {obj_id}
240
+ items = [self._redact(item, seen=next_seen, depth=depth + 1) for item in value]
241
+ return items if isinstance(value, list) else tuple(items)
242
+ if isinstance(value, set | frozenset):
243
+ obj_id = id(value)
244
+ if obj_id in seen:
245
+ return _CIRCULAR_PLACEHOLDER
246
+ next_seen = seen | {obj_id}
247
+ # Elements of a set/frozenset are already hashable by construction, and every
248
+ # branch above returns a hashable type (str, tuple, frozenset, scalar) for a
249
+ # hashable input, so the rebuilt set/frozenset can never raise TypeError here.
250
+ redacted_items = {self._redact(item, seen=next_seen, depth=depth + 1) for item in value}
251
+ return redacted_items if isinstance(value, set) else frozenset(redacted_items)
252
+ if isinstance(value, _SKIP_STR_SCAN_TYPES):
253
+ return value
254
+ try:
255
+ text = str(value)
256
+ except Exception:
257
+ # A logging library must not raise from the logging path just because some
258
+ # object's __str__ is broken — fail safe to MASK rather than propagate.
259
+ return MASK
260
+ redacted_text = self.redact_value(text)
261
+ return redacted_text if redacted_text != text else value