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/context.py ADDED
@@ -0,0 +1,575 @@
1
+ """Context field type-coercion table.
2
+
3
+ Python port of `LogManager::$contextTypeRules` / `LogManager::contextTypeCorrector()`
4
+ and friends (reference `src/LogManager.php`, spec `docs/ContextFieldNaming.md`). This is
5
+ the field-type stability contract: OpenSearch gives a field its type from the first
6
+ document it appears in and has no `ignore_malformed` set, so if the same field name
7
+ ever emits two different JSON types across services, the SECOND document is silently
8
+ dropped whole — not just the offending field. The rule table below is what prevents
9
+ that: a context key's NAME decides its JSON type, deterministically, everywhere.
10
+
11
+ By default a field is a string. The key is split into `_`-delimited segments; the
12
+ LAST segment usually decides the type, matched as a whole segment, never a substring
13
+ (`report` does not see `port`, `datetime` does not see `time`). Resolution order,
14
+ first match wins (§1 of the spec):
15
+
16
+ 1. ``suffix`` — escape hatch: last segment ``int``/``float``/``bool``. Beats
17
+ everything else, including ``reserved``.
18
+ 2. ``reserved`` — whole key; fields this library writes itself.
19
+ 3. ``exact`` — whole key, lowercased; project override level.
20
+ 4. ``word`` — last segment.
21
+ 5. ``prefix`` — first segment, only for keys with 2+ segments.
22
+ 6. no rule — the default: string.
23
+
24
+ This module has zero third-party dependencies (stdlib only), per the project's
25
+ zero-runtime-dependency contract for the core.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import contextvars
31
+ import json
32
+ import math
33
+ import re
34
+ from collections.abc import Iterator, Mapping
35
+ from contextlib import contextmanager
36
+ from types import MappingProxyType
37
+ from typing import Final
38
+
39
+ from log_python.config import PylogConfig
40
+ from log_python.const import LOGGER_MCTIME, LOGGER_MEMORY, LOGGER_MEMORY_PEAK, LOGGER_SKIP_TRACE
41
+
42
+ _BOUND_CONTEXT: Final[contextvars.ContextVar[Mapping[str, object]]] = contextvars.ContextVar("pylog_bound_context", default=MappingProxyType({}))
43
+
44
+
45
+ def get_bound_context() -> dict[str, object]:
46
+ """Return a copy of context bound to the current thread/async task."""
47
+ return dict(_BOUND_CONTEXT.get())
48
+
49
+
50
+ def bind(**values: object) -> None:
51
+ """Merge values into the current logging context."""
52
+ current = get_bound_context()
53
+ current.update(values)
54
+ _BOUND_CONTEXT.set(MappingProxyType(current))
55
+
56
+
57
+ def clear() -> None:
58
+ """Clear all context bound to the current thread/async task."""
59
+ _BOUND_CONTEXT.set(MappingProxyType({}))
60
+
61
+
62
+ @contextmanager
63
+ def bound_context(**values: object) -> Iterator[None]:
64
+ """Temporarily merge values into the bound context."""
65
+ token = _BOUND_CONTEXT.set(MappingProxyType({**_BOUND_CONTEXT.get(), **values}))
66
+ try:
67
+ yield
68
+ finally:
69
+ _BOUND_CONTEXT.reset(token)
70
+
71
+
72
+ def remote_ip(value: str | None) -> None:
73
+ """Bind a non-empty remote address from framework middleware."""
74
+ if value:
75
+ bind(remote_ip=value)
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Type constants — same five values as LogManager::CONTEXT_TYPE_*.
80
+ # ---------------------------------------------------------------------------
81
+
82
+ CONTEXT_TYPE_INT: Final[str] = "int"
83
+ CONTEXT_TYPE_FLOAT: Final[str] = "float"
84
+ CONTEXT_TYPE_BOOL: Final[str] = "bool"
85
+ CONTEXT_TYPE_STRING: Final[str] = "string"
86
+ # "the corrector's caller (log()) consumes this itself, do not touch" — LogManager.php.
87
+ CONTEXT_TYPE_SKIP: Final[str] = "skip"
88
+
89
+ _VALID_TYPES: Final[frozenset[str]] = frozenset({CONTEXT_TYPE_INT, CONTEXT_TYPE_FLOAT, CONTEXT_TYPE_BOOL, CONTEXT_TYPE_STRING, CONTEXT_TYPE_SKIP})
90
+
91
+ # Lucene refuses to index a term longer than 32766 bytes; cap below that. BYTE count
92
+ # (UTF-8), not character count — matches PHP's `strlen()`/`mb_strcut()` pairing.
93
+ CONTEXT_MAX_STRING_BYTES: Final[int] = 32000
94
+
95
+ # Depth bound for the JSON-encoding of a nested value under a scalar rule (guards
96
+ # recursion / huge blobs). Reference: LogManager::CONTEXT_JSON_MAX_DEPTH.
97
+ _CONTEXT_JSON_MAX_DEPTH: Final[int] = 16
98
+
99
+ # Lowercased strings meaning boolean false. Legacy data really does ship 'F', and a
100
+ # blind `bool('F')` is truthy in both languages, so this is an explicit table, not a
101
+ # cast. Reference: LogManager::CONTEXT_FALSE_VALUES.
102
+ CONTEXT_FALSE_VALUES: Final[frozenset[str]] = frozenset({"", "0", "f", "false", "n", "no", "off"})
103
+
104
+ # PHP_INT_MAX (2**63 - 1, the 64-bit signed integer boundary). Python `int` has no such
105
+ # limit, but the coercion decision (int vs. string fallback) must land on the SAME side
106
+ # of the line as the PHP fleet for a given value, or the same field name emits two JSON
107
+ # types across services — the exact failure this whole module exists to prevent. So this
108
+ # threshold is carried over verbatim rather than dropped as "PHP-only cruft". The PHP
109
+ # source also compares against `(float) PHP_INT_MAX`, which rounds up to 2**63 in IEEE-754
110
+ # double precision; `float(_PHP_INT_MAX)` below reproduces the identical rounding, since
111
+ # Python floats are also IEEE-754 doubles.
112
+ _PHP_INT_MAX: Final[int] = 9223372036854775807
113
+ _PHP_INT_MIN: Final[int] = -9223372036854775808
114
+ _PHP_INT_MAX_AS_FLOAT: Final[float] = float(_PHP_INT_MAX)
115
+
116
+ # Strict decimal check — deliberately NOT a generic "is this numeric" check: '0x1A' /
117
+ # '1_000' must not be silently turned into a number. Reference: contextIsDecimal().
118
+ _DECIMAL_RE: Final[re.Pattern[str]] = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
119
+
120
+ # A bare integer literal (no '.', no exponent) — parsed as an exact Python int first, so
121
+ # a large-but-in-range integer never round-trips through float and loses precision.
122
+ # Reference: the `filter_var($str, FILTER_VALIDATE_INT)` branch of contextCastInt().
123
+ _INT_LITERAL_RE: Final[re.Pattern[str]] = re.compile(r"^[+-]?\d+$")
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # The rule table — ported 1:1 from LogManager::$contextTypeRules. Entries are matched
127
+ # lowercased (except 'reserved', see _resolve_type), so must be supplied lowercase.
128
+ # ---------------------------------------------------------------------------
129
+
130
+ # ESCAPE HATCH — top of the resolution chain, beats every other bucket including
131
+ # 'reserved'. LAST segment only. Name a key '*_int'/'*_float'/'*_bool' to force a type:
132
+ # it is the one rule nothing else can override.
133
+ _SUFFIX_RULES: Final[Mapping[str, str]] = MappingProxyType(
134
+ {
135
+ "int": CONTEXT_TYPE_INT,
136
+ "float": CONTEXT_TYPE_FLOAT,
137
+ "bool": CONTEXT_TYPE_BOOL,
138
+ }
139
+ )
140
+
141
+ # Fields this library writes itself — pinned so a string default can't regress them.
142
+ # Looked up by the RAW key first, then the lowercased key (see _resolve_type) — this is
143
+ # why LOGGER_SKIP_TRACE ('skipTrace', camelCase) is stored camelCase here rather than
144
+ # lowercased like every other entry: the raw-key pass is the only pass that can ever
145
+ # match it (the lowercased pass looks for the literal key 'skiptrace', not stored here).
146
+ _RESERVED_RULES: Final[Mapping[str, str]] = MappingProxyType(
147
+ {
148
+ "log_type": CONTEXT_TYPE_STRING,
149
+ "file": CONTEXT_TYPE_STRING,
150
+ "tag": CONTEXT_TYPE_STRING,
151
+ "trace": CONTEXT_TYPE_SKIP,
152
+ LOGGER_SKIP_TRACE: CONTEXT_TYPE_SKIP,
153
+ "exception": CONTEXT_TYPE_STRING,
154
+ "exception_code": CONTEXT_TYPE_INT,
155
+ "exception_prev": CONTEXT_TYPE_STRING,
156
+ "exception_prev_message": CONTEXT_TYPE_STRING,
157
+ "exception_prev_file": CONTEXT_TYPE_STRING,
158
+ "exception_prev_code": CONTEXT_TYPE_INT,
159
+ "exception_prev_trace": CONTEXT_TYPE_STRING,
160
+ # The one id that is NOT an auto-increment integer: a UUID (or the incoming
161
+ # X-Request-ID) is written here on every record. Pinned, not left to the "uncastable
162
+ # falls back to string" fallback — that fallback holds only until some caller passes
163
+ # an all-digit request id, at which point the field would diverge across records.
164
+ "request_id": CONTEXT_TYPE_STRING,
165
+ }
166
+ )
167
+
168
+ # Whole-key overrides — the only way to type a key whose segments carry no type word.
169
+ _EXACT_RULES: Final[Mapping[str, str]] = MappingProxyType(
170
+ {
171
+ # Last segment 'mctime' is not a word (would collide with the int 'time' rule).
172
+ LOGGER_MCTIME: CONTEXT_TYPE_FLOAT,
173
+ # memory_get_usage()-equivalents — both int; 'usage'/'peak' are too generic to be words.
174
+ LOGGER_MEMORY: CONTEXT_TYPE_INT,
175
+ LOGGER_MEMORY_PEAK: CONTEXT_TYPE_INT,
176
+ # Legacy key from the sibling logger. 'millisecond' is deliberately NOT a word —
177
+ # 'seconds' is the canonical unit.
178
+ "millisecond": CONTEXT_TYPE_INT,
179
+ }
180
+ )
181
+
182
+ # LAST segment => type. The type WORDS themselves live in 'suffix' above, which wins over
183
+ # this bucket: 'price_int' is an int, not a float.
184
+ _WORD_RULES: Final[Mapping[str, str]] = MappingProxyType(
185
+ {
186
+ # Covers the bare key 'id' and every '*_id'. An identifier is treated as an
187
+ # auto-increment integer, wanted for range queries and aggregations. A non-integer
188
+ # id (UUID, hash, hex) must be pinned to string via 'exact' — see 'request_id' above.
189
+ "id": CONTEXT_TYPE_INT,
190
+ "count": CONTEXT_TYPE_INT,
191
+ "cnt": CONTEXT_TYPE_INT,
192
+ "num": CONTEXT_TYPE_INT,
193
+ "qty": CONTEXT_TYPE_INT,
194
+ "total": CONTEXT_TYPE_INT,
195
+ # A counted sum, not money — money is 'money'/'amount'/'price' (float).
196
+ "sum": CONTEXT_TYPE_INT,
197
+ "size": CONTEXT_TYPE_INT,
198
+ "bytes": CONTEXT_TYPE_INT,
199
+ "len": CONTEXT_TYPE_INT,
200
+ "length": CONTEXT_TYPE_INT,
201
+ "limit": CONTEXT_TYPE_INT,
202
+ "offset": CONTEXT_TYPE_INT,
203
+ "page": CONTEXT_TYPE_INT,
204
+ "depth": CONTEXT_TYPE_INT,
205
+ "attempt": CONTEXT_TYPE_INT,
206
+ "retries": CONTEXT_TYPE_INT,
207
+ "level": CONTEXT_TYPE_INT,
208
+ "port": CONTEXT_TYPE_INT,
209
+ "status": CONTEXT_TYPE_INT,
210
+ # TRAP: '*_time' TRUNCATES a sub-second value — name it '*_duration'/'*_seconds'.
211
+ "time": CONTEXT_TYPE_INT,
212
+ "seconds": CONTEXT_TYPE_INT,
213
+ "ttl": CONTEXT_TYPE_INT,
214
+ # The money words. 'total'/'sum' are int — money must use one of these.
215
+ "money": CONTEXT_TYPE_FLOAT,
216
+ "amount": CONTEXT_TYPE_FLOAT,
217
+ "price": CONTEXT_TYPE_FLOAT,
218
+ "duration": CONTEXT_TYPE_FLOAT,
219
+ "rate": CONTEXT_TYPE_FLOAT,
220
+ "ratio": CONTEXT_TYPE_FLOAT,
221
+ "percent": CONTEXT_TYPE_FLOAT,
222
+ "avg": CONTEXT_TYPE_FLOAT,
223
+ "score": CONTEXT_TYPE_FLOAT,
224
+ "flag": CONTEXT_TYPE_BOOL,
225
+ "enabled": CONTEXT_TYPE_BOOL,
226
+ "success": CONTEXT_TYPE_BOOL,
227
+ # No "force string" list: string IS the default. NOTE: 'word' outranks 'prefix', so
228
+ # a bool prefix loses to a typed last segment — 'is_bot_id' is an INT, not a bool.
229
+ }
230
+ )
231
+
232
+ # FIRST segment => type, only for keys with more than one segment (a lone 'is' is a word,
233
+ # not a prefix), and only when the last segment resolved nothing.
234
+ _PREFIX_RULES: Final[Mapping[str, str]] = MappingProxyType(
235
+ {
236
+ "is": CONTEXT_TYPE_BOOL,
237
+ "has": CONTEXT_TYPE_BOOL,
238
+ "can": CONTEXT_TYPE_BOOL,
239
+ "should": CONTEXT_TYPE_BOOL,
240
+ "allow": CONTEXT_TYPE_BOOL,
241
+ "enable": CONTEXT_TYPE_BOOL,
242
+ "use": CONTEXT_TYPE_BOOL,
243
+ }
244
+ )
245
+
246
+ _KNOWN_BUCKETS: Final[frozenset[str]] = frozenset({"suffix", "reserved", "exact", "word", "prefix"})
247
+ _SEGMENT_BUCKETS: Final[frozenset[str]] = frozenset({"suffix", "word", "prefix"})
248
+
249
+ _SNAKE_ACRONYM_RE: Final[re.Pattern[str]] = re.compile(r"([A-Z]+)([A-Z][a-z])")
250
+ _SNAKE_BOUNDARY_RE: Final[re.Pattern[str]] = re.compile(r"([a-z0-9])([A-Z])")
251
+
252
+
253
+ def _to_snake_case(key: str) -> str:
254
+ """camelCase/PascalCase -> snake_case, then lowercase.
255
+
256
+ §4.7 opt-in key normalization (`PylogConfig.snake_case`), reference `Str::snake()`
257
+ (Laravel). This is a standard two-pass camelCase splitter, not a byte-for-byte port
258
+ of `Str::snake()`'s own per-character lookahead regex: for the tested/common case
259
+ (single hump, e.g. 'orderId' -> 'order_id') the two agree exactly; for a run of
260
+ consecutive capitals (an acronym, e.g. 'HTTPStatus') they diverge — this produces
261
+ 'http_status', while Laravel's `Str::snake()` produces 'h_t_t_p_status'. Deliberate:
262
+ `snake_case` is off by default and untested against acronym keys in the reference
263
+ suite, and porting `Str::snake()`'s exact algorithm (cache, custom delimiter, full
264
+ Unicode `ucwords` semantics) buys nothing for this contract that the common-case
265
+ conversion doesn't already cover. Flagged here rather than silently diverging.
266
+ """
267
+ s = _SNAKE_ACRONYM_RE.sub(r"\1_\2", key)
268
+ s = _SNAKE_BOUNDARY_RE.sub(r"\1_\2", s)
269
+ return s.lower()
270
+
271
+
272
+ def _merge_extra_rules(buckets: dict[str, dict[str, str]], extra: Mapping[str, Mapping[str, str]]) -> None:
273
+ """Merge project-declared rules on top of `buckets`, in place.
274
+
275
+ Reference: `LogManager::addContextTypeRules()`. An unknown bucket name or an
276
+ unrecognized type value is skipped silently — a config typo must not break logging.
277
+ A 'suffix'/'word'/'prefix' pattern is trimmed of leading/trailing underscores and
278
+ must reduce to a single segment (no embedded underscore) or it is dropped: those
279
+ buckets can only ever match one `_`-delimited segment, and 'exact' is what types a
280
+ whole key. 'reserved' patterns are kept in their given case (see _RESERVED_RULES);
281
+ every other bucket's pattern is lowercased, matching `contextResolveType()`'s
282
+ lowercased lookups.
283
+ """
284
+ for bucket, entries in extra.items():
285
+ if bucket not in _KNOWN_BUCKETS:
286
+ continue
287
+ for pattern, rtype in entries.items():
288
+ if rtype not in _VALID_TYPES:
289
+ continue
290
+ normalized = str(pattern)
291
+ if bucket != "reserved":
292
+ normalized = normalized.lower()
293
+ if bucket in _SEGMENT_BUCKETS:
294
+ normalized = normalized.strip("_")
295
+ if normalized == "" or "_" in normalized:
296
+ continue
297
+ buckets[bucket][normalized] = rtype
298
+
299
+
300
+ def _scalar_to_string(value: bool | int | float | str) -> str:
301
+ """Stringify a scalar the way PHP's `(string)` cast does — used only for the
302
+ "no rule matched" / plain-string-typed fallback, never for the JSON-encode path
303
+ (`_json_encode` below), which already gets Python's own float formatting for free.
304
+
305
+ - bool -> 'true'/'false' (`(string) false === ''` would look like a missing value).
306
+ - float -> PHP's `(string)` cast drops a trailing '.0' on a whole-number float
307
+ (`(string) 5.0 === '5'`); Python's `str()` always keeps it (`str(5.0) == '5.0'`).
308
+ Matched here for wire-CONTENT parity with the PHP fleet — both land in a string
309
+ field either way, so this is a content nuance, not a type-stability break.
310
+ - int -> `str()`, EXCEPT: CPython bounds base-10 int<->str conversion to ~4300
311
+ digits by default (a DoS guard added in 3.11+) and raises `ValueError` past it.
312
+ PHP has no such limit (and no native PHP int can even reach this magnitude —
313
+ it is bounded to 64 bits), so there is no reference behaviour to match; this
314
+ is a Python-only edge case. The contract that matters is "never raise out of
315
+ the logging path", so a bounded, deterministic placeholder is emitted instead.
316
+ """
317
+ if isinstance(value, bool):
318
+ return "true" if value else "false"
319
+ if isinstance(value, float):
320
+ return str(int(value)) if math.isfinite(value) and value.is_integer() else str(value)
321
+ if isinstance(value, int):
322
+ try:
323
+ return str(value)
324
+ except ValueError:
325
+ return f"<int:{value.bit_length()}-bit>"
326
+ return value
327
+
328
+
329
+ def _cap_string(value: str) -> str:
330
+ """Cap a string to `CONTEXT_MAX_STRING_BYTES` UTF-8 bytes, truncating on a character
331
+ boundary (never splitting a multi-byte sequence) and appending an ellipsis, mirroring
332
+ PHP's `strlen()`/`mb_strcut()` pairing (`contextCapString()`).
333
+ """
334
+ encoded = value.encode("utf-8", errors="replace")
335
+ if len(encoded) <= CONTEXT_MAX_STRING_BYTES:
336
+ return encoded.decode("utf-8")
337
+ truncated = encoded[: CONTEXT_MAX_STRING_BYTES - 1].decode("utf-8", errors="ignore")
338
+ return truncated + "…"
339
+
340
+
341
+ def _cap_depth(value: object, depth: int) -> object:
342
+ """Replace any container nested past `_CONTEXT_JSON_MAX_DEPTH` with a placeholder.
343
+
344
+ Pre-processing pass for `_json_encode`: guards against unbounded recursion / huge
345
+ blobs the same way PHP's depth-bounded `json_encode()` does, but by truncating
346
+ BEFORE encoding rather than retrying encode-then-salvage — simpler, and it can never
347
+ itself raise `RecursionError`.
348
+ """
349
+ if depth >= _CONTEXT_JSON_MAX_DEPTH:
350
+ return "…" if isinstance(value, Mapping | list | tuple | set | frozenset) else value
351
+ if isinstance(value, Mapping):
352
+ return {str(k): _cap_depth(v, depth + 1) for k, v in value.items()}
353
+ if isinstance(value, list | tuple | set | frozenset):
354
+ return [_cap_depth(v, depth + 1) for v in value]
355
+ return value
356
+
357
+
358
+ def _json_encode(value: object) -> str:
359
+ """Best-effort, never-raising JSON encoding for a structure/object placed under a
360
+ scalar coercion rule (reference `contextJsonEncode()`; `(int) [1, 2] === 1` would
361
+ silently lose data, so the value is encoded instead of cast).
362
+
363
+ Compact separators (no spaces) to match PHP `json_encode()`'s default formatting;
364
+ `ensure_ascii=True` to match PHP's default of NOT setting `JSON_UNESCAPED_UNICODE`.
365
+ One accepted formatting non-parity: PHP additionally backslash-escapes `/` by
366
+ default (no `JSON_UNESCAPED_SLASHES`); Python's `json` module has no equivalent
367
+ knob and this is not worth a custom encoder for a value that is itself an opaque,
368
+ already-a-string context field, not the outer wire envelope.
369
+ """
370
+ try:
371
+ capped = _cap_depth(value, 0)
372
+ encoded = json.dumps(capped, ensure_ascii=True, separators=(",", ":"), default=str)
373
+ except Exception:
374
+ encoded = json.dumps({"_encode_error": type(value).__name__})
375
+ return _cap_string(encoded)
376
+
377
+
378
+ def _cast_int(value: bool | int | float | str) -> int | str:
379
+ """Reference `contextCastInt()`. A fractional value truncates rather than falling
380
+ back to string, so `retries => 1.5` and `retries => 3` don't diverge in type; a
381
+ value that would overflow the PHP int64 boundary falls back to string instead of
382
+ wrapping into garbage (see `_PHP_INT_MAX`).
383
+ """
384
+ if isinstance(value, bool):
385
+ return 1 if value else 0
386
+ if isinstance(value, int):
387
+ if _PHP_INT_MIN <= value <= _PHP_INT_MAX:
388
+ return value
389
+ return _cap_string(_scalar_to_string(value))
390
+ if isinstance(value, float):
391
+ if not math.isfinite(value) or value >= _PHP_INT_MAX_AS_FLOAT or value <= -_PHP_INT_MAX_AS_FLOAT:
392
+ return _cap_string(_scalar_to_string(value))
393
+ return int(value) # truncates toward zero, like PHP's (int) cast
394
+ stripped = value.strip()
395
+ if not _DECIMAL_RE.match(stripped):
396
+ # (int) 'RU' === 0 would destroy the value — fall back to the default type.
397
+ return _cap_string(value)
398
+ if _INT_LITERAL_RE.match(stripped):
399
+ try:
400
+ as_int = int(stripped)
401
+ except ValueError:
402
+ # CPython bounds base-10 str->int conversion to ~4300 digits by default
403
+ # (a DoS guard, 3.11+) and raises past it. PHP's `filter_var` has no such
404
+ # limit, but it already returns false for anything past PHP_INT_MAX — a
405
+ # 4300+ digit literal is WAY past that too, so both languages converge on
406
+ # the same float-then-range-check fallback below either way. Fall through.
407
+ pass
408
+ else:
409
+ if _PHP_INT_MIN <= as_int <= _PHP_INT_MAX:
410
+ # Exact for every integer literal in range — no float round-trip to lose bits.
411
+ return as_int
412
+ # Out of PHP-int range as a bare literal: fall through to the float path
413
+ # below, mirroring `filter_var(..., FILTER_VALIDATE_INT)` returning false.
414
+ # Fractional ('19.99'), exponent ('1e5'), or out of int range. `float()` has no
415
+ # digit-count limit (unlike `int()`) — it overflows cleanly to +-inf instead.
416
+ num = float(stripped)
417
+ if not math.isfinite(num) or num >= _PHP_INT_MAX_AS_FLOAT or num <= -_PHP_INT_MAX_AS_FLOAT:
418
+ return _cap_string(value)
419
+ return int(num)
420
+
421
+
422
+ def _cast_float(value: bool | int | float | str) -> float | str:
423
+ """Reference `contextCastFloat()`."""
424
+ if isinstance(value, bool):
425
+ return 1.0 if value else 0.0
426
+ if isinstance(value, float):
427
+ return value if math.isfinite(value) else _cap_string(_scalar_to_string(value))
428
+ if isinstance(value, int):
429
+ try:
430
+ return float(value)
431
+ except OverflowError:
432
+ return _cap_string(_scalar_to_string(value))
433
+ stripped = value.strip()
434
+ if not _DECIMAL_RE.match(stripped):
435
+ return _cap_string(value)
436
+ num = float(stripped)
437
+ return num if math.isfinite(num) else _cap_string(value)
438
+
439
+
440
+ def _cast_bool(value: bool | int | float | str) -> bool:
441
+ """Reference `contextCastBool()`."""
442
+ if isinstance(value, bool):
443
+ return value
444
+ if isinstance(value, int | float):
445
+ return value != 0
446
+ return value.strip().lower() not in CONTEXT_FALSE_VALUES
447
+
448
+
449
+ def _cast_value(value: object, rtype: str | None) -> object:
450
+ """Reference `contextCastValue()`. `None` always stays `None`. A structure
451
+ (dict/list/tuple/set/frozenset — the Python analogue of a PHP array) is left
452
+ untouched when no rule matched (flattening it would break nested-path queries) and
453
+ JSON-encoded when a scalar rule matched (a scalar cast would silently drop data).
454
+ Any other non-primitive object (custom class instance, Decimal, Enum, ...) is
455
+ treated the same way as a structure: PHP's own `contextCastValue()` routes every
456
+ `is_object()` value through the identical array/object branch (its narrower
457
+ `Stringable`-under-an-explicit-`string`-rule shortcut has no clean Python analogue —
458
+ Python has no `Stringable`-equivalent formal marker distinct from "every object has
459
+ `__str__`" — so it is not replicated; documented here rather than silently dropped).
460
+ """
461
+ if value is None:
462
+ return None
463
+ if isinstance(value, Mapping | list | tuple | set | frozenset):
464
+ return value if rtype is None else _json_encode(value)
465
+ if isinstance(value, bool | int | float | str):
466
+ if rtype == CONTEXT_TYPE_INT:
467
+ return _cast_int(value)
468
+ if rtype == CONTEXT_TYPE_FLOAT:
469
+ return _cast_float(value)
470
+ if rtype == CONTEXT_TYPE_BOOL:
471
+ return _cast_bool(value)
472
+ # No rule, or an explicit 'string' rule: both stringify — string is the default.
473
+ return _cap_string(_scalar_to_string(value))
474
+ return value if rtype is None else _json_encode(value)
475
+
476
+
477
+ class ContextTypeCorrector:
478
+ """Precompiled context field type-coercion table.
479
+
480
+ Construct once per process (or once per `PylogConfig`, via `from_config`) — the
481
+ merged rule buckets are built at construction, not per call, since this sits on the
482
+ hot logging path. `correct()` returns a NEW dict; it never mutates its input.
483
+
484
+ Does not perform credential redaction — that's `log_python.redactor.Redactor`, wired in
485
+ together with this at the `manager.py` orchestration layer (a later phase), exactly
486
+ as the PHP reference's `contextTypeCorrector()` calls `Redactor::shouldRedactKey()`
487
+ inline. Kept separate here so the coercion table has no dependency on the redactor.
488
+ """
489
+
490
+ __slots__ = ("_buckets", "_snake_case")
491
+
492
+ def __init__(self, extra_rules: Mapping[str, Mapping[str, str]] = MappingProxyType({}), *, snake_case: bool = False) -> None:
493
+ buckets: dict[str, dict[str, str]] = {
494
+ "suffix": dict(_SUFFIX_RULES),
495
+ "reserved": dict(_RESERVED_RULES),
496
+ "exact": dict(_EXACT_RULES),
497
+ "word": dict(_WORD_RULES),
498
+ "prefix": dict(_PREFIX_RULES),
499
+ }
500
+ _merge_extra_rules(buckets, extra_rules)
501
+ self._buckets: Mapping[str, Mapping[str, str]] = MappingProxyType({bucket: MappingProxyType(entries) for bucket, entries in buckets.items()})
502
+ self._snake_case = snake_case
503
+
504
+ @classmethod
505
+ def from_config(cls, config: PylogConfig) -> ContextTypeCorrector:
506
+ """Build a corrector whose extra rules and snake_case flag come from `PylogConfig`.
507
+
508
+ `config.context_type_rules` merges ON TOP of the built-in table (never
509
+ replaces it) — same semantics as `LogManager::addContextTypeRules()`.
510
+ """
511
+ return cls(config.context_type_rules, snake_case=config.snake_case)
512
+
513
+ @property
514
+ def rules(self) -> Mapping[str, Mapping[str, str]]:
515
+ """The merged rule buckets (`suffix`/`reserved`/`exact`/`word`/`prefix`), read-only."""
516
+ return self._buckets
517
+
518
+ def resolve_type(self, key: str) -> str | None:
519
+ """Resolve `key` to a type constant, or `None` when nothing matched (the caller
520
+ then defaults to string). Reference `contextResolveType()`; first match wins:
521
+ suffix > reserved (raw key, then lowercased) > exact > word > prefix.
522
+ """
523
+ low = key.lower()
524
+ # Whole segments only: 'report' splits into ['report'], never matches 'port'.
525
+ parts = low.split("_")
526
+ last = parts[-1]
527
+
528
+ suffix = self._buckets["suffix"]
529
+ if last in suffix:
530
+ return suffix[last]
531
+ reserved = self._buckets["reserved"]
532
+ if key in reserved:
533
+ return reserved[key]
534
+ if low in reserved:
535
+ return reserved[low]
536
+ exact = self._buckets["exact"]
537
+ if low in exact:
538
+ return exact[low]
539
+ word = self._buckets["word"]
540
+ if last in word:
541
+ return word[last]
542
+ # A single-segment key has no prefix: a lone 'is' is a word, not a flag.
543
+ if len(parts) > 1:
544
+ prefix = self._buckets["prefix"]
545
+ if parts[0] in prefix:
546
+ return prefix[parts[0]]
547
+ return None
548
+
549
+ def correct(self, context: Mapping[str, object]) -> dict[str, object]:
550
+ """Return a new dict with every value coerced per the resolved type of its key.
551
+
552
+ §4.7: when `snake_case` is on, keys are normalized BEFORE rule resolution (same
553
+ order as the reference) — the whole mapping is renamed in one pass, then each
554
+ (possibly renamed) key is resolved and its value cast independently.
555
+
556
+ Every key is coerced with `str(key)` before it is used, for lookup AND as the
557
+ output key — mirroring the reference's own `(string) $k` cast at each of its
558
+ three key-consuming call sites (`shouldRedactKey`, `contextResolveType`,
559
+ `Str::snake`). The declared signature already commits to string keys; this is
560
+ defensive fidelity for an untyped caller (mypy protects typed ones), not a
561
+ widened contract — a non-string key is coerced, never a crash.
562
+ """
563
+ working: Mapping[str, object] = context
564
+ if self._snake_case:
565
+ working = {_to_snake_case(str(k)): v for k, v in context.items()}
566
+
567
+ result: dict[str, object] = {}
568
+ for raw_key, value in working.items():
569
+ key = raw_key if isinstance(raw_key, str) else str(raw_key)
570
+ rtype = self.resolve_type(key)
571
+ if rtype == CONTEXT_TYPE_SKIP:
572
+ result[key] = value
573
+ continue
574
+ result[key] = _cast_value(value, rtype)
575
+ return result