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