logging-objects-with-schema 0.1.3__tar.gz → 0.2.0__tar.gz
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.
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/PKG-INFO +76 -1
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/README.md +75 -0
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/pyproject.toml +21 -1
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/errors.py +11 -3
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_applier.py +24 -24
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_loader.py +142 -83
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_logger.py +26 -13
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/LICENSE +0 -0
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: logging-objects-with-schema
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Proxy logging wrapper that validates extra fields against a JSON schema.
|
|
5
5
|
Keywords: logging,schema,validation,structured-logging,structured-logs
|
|
6
6
|
Author: Dmitrii Safronov
|
|
@@ -283,3 +283,78 @@ Example:
|
|
|
283
283
|
|
|
284
284
|
With `extra={"request_id": "abc-123"}`, the value appears in both
|
|
285
285
|
`ServicePayload.RequestID` and `ServicePayload.Metadata.ID`.
|
|
286
|
+
|
|
287
|
+
## Inheritance and custom forbidden root keys
|
|
288
|
+
|
|
289
|
+
`SchemaLogger` supports inheritance, allowing subclasses to add additional
|
|
290
|
+
forbidden root keys for schema validation. This is useful when you need to
|
|
291
|
+
prevent certain root keys from being used in your schema beyond the builtin
|
|
292
|
+
`logging.LogRecord` attributes.
|
|
293
|
+
|
|
294
|
+
### Basic inheritance
|
|
295
|
+
|
|
296
|
+
Each subclass can pass the `forbidden_keys` parameter to the parent's
|
|
297
|
+
`__init__()` method. The builtin set of forbidden keys (standard `logging.LogRecord`
|
|
298
|
+
attributes) is always present and cannot be replaced - additional keys are
|
|
299
|
+
merged with the builtin set.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
|
|
303
|
+
```python
|
|
304
|
+
from logging_objects_with_schema import SchemaLogger
|
|
305
|
+
import logging
|
|
306
|
+
|
|
307
|
+
class MyLogger(SchemaLogger):
|
|
308
|
+
def __init__(self, name: str, level: int = logging.NOTSET) -> None:
|
|
309
|
+
# Add custom forbidden keys
|
|
310
|
+
super().__init__(name, level, forbidden_keys={"custom_forbidden_key"})
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Multi-level inheritance
|
|
314
|
+
|
|
315
|
+
When creating a hierarchy of loggers, each subclass can pass `forbidden_keys`
|
|
316
|
+
from its own subclasses to the parent, merging them with its own set. The
|
|
317
|
+
library does not automatically propagate keys up the inheritance chain - each
|
|
318
|
+
subclass must implement this logic itself.
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from logging_objects_with_schema import SchemaLogger
|
|
324
|
+
import logging
|
|
325
|
+
|
|
326
|
+
class ParentLogger(SchemaLogger):
|
|
327
|
+
def __init__(
|
|
328
|
+
self, name: str, level: int = logging.NOTSET, forbidden_keys: set[str] | None = None
|
|
329
|
+
) -> None:
|
|
330
|
+
# Merge parent's keys with keys from child
|
|
331
|
+
parent_keys = {"parent_forbidden_key"}
|
|
332
|
+
if forbidden_keys:
|
|
333
|
+
parent_keys = parent_keys | forbidden_keys
|
|
334
|
+
super().__init__(name, level, forbidden_keys=parent_keys)
|
|
335
|
+
|
|
336
|
+
class ChildLogger(ParentLogger):
|
|
337
|
+
def __init__(self, name: str, level: int = logging.NOTSET) -> None:
|
|
338
|
+
# Pass child's keys to parent, which will merge them
|
|
339
|
+
super().__init__(name, level, forbidden_keys={"child_forbidden_key"})
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
In this example, the final set of forbidden keys will be:
|
|
343
|
+
|
|
344
|
+
- Builtin `logging.LogRecord` attributes (always present)
|
|
345
|
+
- `parent_forbidden_key` (from `ParentLogger`)
|
|
346
|
+
- `child_forbidden_key` (from `ChildLogger`)
|
|
347
|
+
|
|
348
|
+
All keys are merged together - they are not replaced, only supplemented.
|
|
349
|
+
|
|
350
|
+
### Important notes
|
|
351
|
+
|
|
352
|
+
- The builtin set of forbidden keys (standard `logging.LogRecord` attributes)
|
|
353
|
+
is always present and cannot be replaced or removed
|
|
354
|
+
- Additional forbidden keys are merged with the builtin set, not replaced
|
|
355
|
+
- Each subclass must implement the logic to pass `forbidden_keys` to its parent
|
|
356
|
+
if it wants to propagate keys from its own subclasses
|
|
357
|
+
- The `forbidden_keys` parameter is optional - if not provided, only builtin
|
|
358
|
+
keys are used, maintaining 100% backward compatibility
|
|
359
|
+
- `None` and empty `set()` are semantically equivalent for `forbidden_keys` -
|
|
360
|
+
both mean "no additional forbidden keys" and produce the same result
|
|
@@ -250,3 +250,78 @@ Example:
|
|
|
250
250
|
|
|
251
251
|
With `extra={"request_id": "abc-123"}`, the value appears in both
|
|
252
252
|
`ServicePayload.RequestID` and `ServicePayload.Metadata.ID`.
|
|
253
|
+
|
|
254
|
+
## Inheritance and custom forbidden root keys
|
|
255
|
+
|
|
256
|
+
`SchemaLogger` supports inheritance, allowing subclasses to add additional
|
|
257
|
+
forbidden root keys for schema validation. This is useful when you need to
|
|
258
|
+
prevent certain root keys from being used in your schema beyond the builtin
|
|
259
|
+
`logging.LogRecord` attributes.
|
|
260
|
+
|
|
261
|
+
### Basic inheritance
|
|
262
|
+
|
|
263
|
+
Each subclass can pass the `forbidden_keys` parameter to the parent's
|
|
264
|
+
`__init__()` method. The builtin set of forbidden keys (standard `logging.LogRecord`
|
|
265
|
+
attributes) is always present and cannot be replaced - additional keys are
|
|
266
|
+
merged with the builtin set.
|
|
267
|
+
|
|
268
|
+
Example:
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
from logging_objects_with_schema import SchemaLogger
|
|
272
|
+
import logging
|
|
273
|
+
|
|
274
|
+
class MyLogger(SchemaLogger):
|
|
275
|
+
def __init__(self, name: str, level: int = logging.NOTSET) -> None:
|
|
276
|
+
# Add custom forbidden keys
|
|
277
|
+
super().__init__(name, level, forbidden_keys={"custom_forbidden_key"})
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Multi-level inheritance
|
|
281
|
+
|
|
282
|
+
When creating a hierarchy of loggers, each subclass can pass `forbidden_keys`
|
|
283
|
+
from its own subclasses to the parent, merging them with its own set. The
|
|
284
|
+
library does not automatically propagate keys up the inheritance chain - each
|
|
285
|
+
subclass must implement this logic itself.
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from logging_objects_with_schema import SchemaLogger
|
|
291
|
+
import logging
|
|
292
|
+
|
|
293
|
+
class ParentLogger(SchemaLogger):
|
|
294
|
+
def __init__(
|
|
295
|
+
self, name: str, level: int = logging.NOTSET, forbidden_keys: set[str] | None = None
|
|
296
|
+
) -> None:
|
|
297
|
+
# Merge parent's keys with keys from child
|
|
298
|
+
parent_keys = {"parent_forbidden_key"}
|
|
299
|
+
if forbidden_keys:
|
|
300
|
+
parent_keys = parent_keys | forbidden_keys
|
|
301
|
+
super().__init__(name, level, forbidden_keys=parent_keys)
|
|
302
|
+
|
|
303
|
+
class ChildLogger(ParentLogger):
|
|
304
|
+
def __init__(self, name: str, level: int = logging.NOTSET) -> None:
|
|
305
|
+
# Pass child's keys to parent, which will merge them
|
|
306
|
+
super().__init__(name, level, forbidden_keys={"child_forbidden_key"})
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
In this example, the final set of forbidden keys will be:
|
|
310
|
+
|
|
311
|
+
- Builtin `logging.LogRecord` attributes (always present)
|
|
312
|
+
- `parent_forbidden_key` (from `ParentLogger`)
|
|
313
|
+
- `child_forbidden_key` (from `ChildLogger`)
|
|
314
|
+
|
|
315
|
+
All keys are merged together - they are not replaced, only supplemented.
|
|
316
|
+
|
|
317
|
+
### Important notes
|
|
318
|
+
|
|
319
|
+
- The builtin set of forbidden keys (standard `logging.LogRecord` attributes)
|
|
320
|
+
is always present and cannot be replaced or removed
|
|
321
|
+
- Additional forbidden keys are merged with the builtin set, not replaced
|
|
322
|
+
- Each subclass must implement the logic to pass `forbidden_keys` to its parent
|
|
323
|
+
if it wants to propagate keys from its own subclasses
|
|
324
|
+
- The `forbidden_keys` parameter is optional - if not provided, only builtin
|
|
325
|
+
keys are used, maintaining 100% backward compatibility
|
|
326
|
+
- `None` and empty `set()` are semantically equivalent for `forbidden_keys` -
|
|
327
|
+
both mean "no additional forbidden keys" and produce the same result
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "logging-objects-with-schema"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Proxy logging wrapper that validates extra fields against a JSON schema."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -95,3 +95,23 @@ strict_equality = true
|
|
|
95
95
|
[tool.bandit]
|
|
96
96
|
skips = [ "B101", "B601" ]
|
|
97
97
|
exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
|
|
98
|
+
|
|
99
|
+
[tool.coverage.run]
|
|
100
|
+
source = [ "src" ]
|
|
101
|
+
branch = true
|
|
102
|
+
|
|
103
|
+
[tool.coverage.report]
|
|
104
|
+
exclude_lines = [
|
|
105
|
+
"def __repr__",
|
|
106
|
+
"if self\\.debug",
|
|
107
|
+
"raise AssertionError",
|
|
108
|
+
"raise NotImplementedError",
|
|
109
|
+
"if 0:",
|
|
110
|
+
"if __name__ == .__main__.:",
|
|
111
|
+
"@(abc\\.)?abstractmethod"
|
|
112
|
+
]
|
|
113
|
+
show_missing = true
|
|
114
|
+
skip_covered = false
|
|
115
|
+
|
|
116
|
+
[tool.coverage.html]
|
|
117
|
+
directory = "htmlcov"
|
|
@@ -6,9 +6,13 @@ from dataclasses import dataclass
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@dataclass
|
|
9
|
-
class
|
|
9
|
+
class _SchemaProblem:
|
|
10
10
|
"""Describes a single problem encountered while loading the schema.
|
|
11
11
|
|
|
12
|
+
This class is part of the internal implementation and is not considered
|
|
13
|
+
a public API. Its signature and behaviour may change between releases
|
|
14
|
+
without preserving backward compatibility.
|
|
15
|
+
|
|
12
16
|
This class is used to report schema validation errors during schema
|
|
13
17
|
compilation. Schema problems are fatal: if any are detected during
|
|
14
18
|
logger initialization, the application is terminated after logging
|
|
@@ -36,12 +40,16 @@ class SchemaProblem:
|
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
@dataclass
|
|
39
|
-
class
|
|
43
|
+
class _DataProblem:
|
|
40
44
|
"""Describes a single problem encountered while validating log data.
|
|
41
45
|
|
|
46
|
+
This class is part of the internal implementation and is not considered
|
|
47
|
+
a public API. Its signature and behaviour may change between releases
|
|
48
|
+
without preserving backward compatibility.
|
|
49
|
+
|
|
42
50
|
This class is used to report validation errors when applying the compiled
|
|
43
51
|
schema to user-provided ``extra`` fields during logging. Unlike
|
|
44
|
-
:class:`
|
|
52
|
+
:class:`_SchemaProblem`, data problems are not fatal: they are collected
|
|
45
53
|
and logged as ERROR messages *after* the main log record has been emitted,
|
|
46
54
|
ensuring 100% compatibility with standard logger behavior.
|
|
47
55
|
|
|
@@ -11,8 +11,8 @@ from collections import defaultdict
|
|
|
11
11
|
from collections.abc import Mapping, MutableMapping
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from .errors import
|
|
15
|
-
from .schema_loader import
|
|
14
|
+
from .errors import _DataProblem
|
|
15
|
+
from .schema_loader import _CompiledSchema, _SchemaLeaf
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def _create_validation_error_json(field: str, error: str, value: Any) -> str:
|
|
@@ -45,7 +45,7 @@ def _validate_list_value(
|
|
|
45
45
|
value: list,
|
|
46
46
|
source: str,
|
|
47
47
|
item_expected_type: type | None,
|
|
48
|
-
) ->
|
|
48
|
+
) -> _DataProblem | None:
|
|
49
49
|
"""Validate that a list value matches the expected item type.
|
|
50
50
|
|
|
51
51
|
Validates that all elements in the list have the exact type declared by
|
|
@@ -58,11 +58,11 @@ def _validate_list_value(
|
|
|
58
58
|
for list-typed leaves.
|
|
59
59
|
|
|
60
60
|
Returns:
|
|
61
|
-
|
|
61
|
+
_DataProblem if validation fails, None if validation succeeds.
|
|
62
62
|
"""
|
|
63
63
|
if item_expected_type is None:
|
|
64
64
|
error_msg = "is a list but has no item type configured"
|
|
65
|
-
return
|
|
65
|
+
return _DataProblem(_create_validation_error_json(source, error_msg, value))
|
|
66
66
|
|
|
67
67
|
if len(value) == 0:
|
|
68
68
|
# Empty lists are always valid
|
|
@@ -83,7 +83,7 @@ def _validate_list_value(
|
|
|
83
83
|
f"expected all elements to be of type "
|
|
84
84
|
f"{item_expected_type.__name__}"
|
|
85
85
|
)
|
|
86
|
-
return
|
|
86
|
+
return _DataProblem(_create_validation_error_json(source, error_msg, value))
|
|
87
87
|
|
|
88
88
|
return None
|
|
89
89
|
|
|
@@ -124,11 +124,11 @@ def _set_nested_value(
|
|
|
124
124
|
|
|
125
125
|
|
|
126
126
|
def _validate_and_apply_leaf(
|
|
127
|
-
leaf:
|
|
127
|
+
leaf: _SchemaLeaf,
|
|
128
128
|
value: Any,
|
|
129
129
|
source: str,
|
|
130
130
|
extra: MutableMapping[str, Any],
|
|
131
|
-
problems: list[
|
|
131
|
+
problems: list[_DataProblem],
|
|
132
132
|
) -> None:
|
|
133
133
|
"""Validate a value against a schema leaf and apply it if valid.
|
|
134
134
|
|
|
@@ -152,7 +152,7 @@ def _validate_and_apply_leaf(
|
|
|
152
152
|
f"expected {leaf.expected_type.__name__}"
|
|
153
153
|
)
|
|
154
154
|
problems.append(
|
|
155
|
-
|
|
155
|
+
_DataProblem(_create_validation_error_json(source, error_msg, value))
|
|
156
156
|
)
|
|
157
157
|
return
|
|
158
158
|
|
|
@@ -203,27 +203,27 @@ def _strip_empty(node: Any) -> Any:
|
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
def _apply_schema_internal(
|
|
206
|
-
compiled:
|
|
206
|
+
compiled: _CompiledSchema,
|
|
207
207
|
extra_values: Mapping[str, Any],
|
|
208
|
-
) -> tuple[dict[str, Any], list[
|
|
208
|
+
) -> tuple[dict[str, Any], list[_DataProblem]]:
|
|
209
209
|
"""Internal function to build structured ``extra`` from compiled schema.
|
|
210
210
|
|
|
211
|
-
The function applies a :class:`
|
|
211
|
+
The function applies a :class:`_CompiledSchema` to user-provided ``extra``
|
|
212
212
|
values and returns a tuple ``(structured_extra, problems)`` where:
|
|
213
213
|
|
|
214
214
|
- ``structured_extra`` is a nested dictionary that follows the schema
|
|
215
215
|
structure and contains only fields that passed validation;
|
|
216
|
-
- ``problems`` is a list of :class:`
|
|
216
|
+
- ``problems`` is a list of :class:`_DataProblem` describing all data
|
|
217
217
|
issues observed during processing.
|
|
218
218
|
|
|
219
219
|
Behaviour summary:
|
|
220
220
|
|
|
221
221
|
- If the compiled schema is effectively empty (no valid leaves),
|
|
222
222
|
all fields from ``extra_values`` are treated as redundant: the returned
|
|
223
|
-
payload is empty, and a :class:`
|
|
223
|
+
payload is empty, and a :class:`_DataProblem` is created for each field.
|
|
224
224
|
- For each ``source`` mentioned in the schema when there are valid leaves:
|
|
225
225
|
- if the source is missing from ``extra_values``, it is silently skipped;
|
|
226
|
-
- if the corresponding value is ``None``, a ``
|
|
226
|
+
- if the corresponding value is ``None``, a ``_DataProblem`` is recorded
|
|
227
227
|
and the value is not written to the payload.
|
|
228
228
|
- Type checks are strict: the runtime type must exactly match the declared
|
|
229
229
|
Python type (``type(value) is leaf.expected_type``). This prevents
|
|
@@ -233,17 +233,17 @@ def _apply_schema_internal(
|
|
|
233
233
|
- all elements must have the exact type declared by the leaf
|
|
234
234
|
``item_expected_type`` (for example, list[str], list[int]);
|
|
235
235
|
- non-primitive elements and elements of a different primitive type are
|
|
236
|
-
rejected with a ``
|
|
236
|
+
rejected with a ``_DataProblem`` and the list value is not written.
|
|
237
237
|
- Redundant fields from ``extra_values`` (not referenced by any leaf
|
|
238
238
|
``source``) are always reported as problems: each such field generates
|
|
239
|
-
a :class:`
|
|
239
|
+
a :class:`_DataProblem` indicating that it is not defined in the schema.
|
|
240
240
|
- A single ``source`` may be used by multiple leaves. The value is
|
|
241
241
|
validated independently for each leaf and written only to locations
|
|
242
|
-
where the type matches; mismatched locations produce ``
|
|
242
|
+
where the type matches; mismatched locations produce ``_DataProblem``
|
|
243
243
|
entries, but do not affect successful locations.
|
|
244
244
|
|
|
245
245
|
The function itself does not raise exceptions; it only accumulates
|
|
246
|
-
:class:`
|
|
246
|
+
:class:`_DataProblem` instances for the caller to handle.
|
|
247
247
|
|
|
248
248
|
Performance considerations:
|
|
249
249
|
Time complexity is O(n + m) where n is the number of leaves in the
|
|
@@ -269,10 +269,10 @@ def _apply_schema_internal(
|
|
|
269
269
|
change between releases without preserving backward compatibility.
|
|
270
270
|
|
|
271
271
|
Returns:
|
|
272
|
-
Tuple of (structured_extra, list[
|
|
272
|
+
Tuple of (structured_extra, list[_DataProblem]).
|
|
273
273
|
"""
|
|
274
274
|
extra: dict[str, Any] = {}
|
|
275
|
-
problems: list[
|
|
275
|
+
problems: list[_DataProblem] = []
|
|
276
276
|
|
|
277
277
|
# Group leaves by source field name. This is necessary because a single source
|
|
278
278
|
# can be referenced by multiple leaves (allowing the same value to appear in
|
|
@@ -280,7 +280,7 @@ def _apply_schema_internal(
|
|
|
280
280
|
# all leaves for a given source together, which is more efficient and allows
|
|
281
281
|
# us to validate the value once per source (e.g., checking for None) rather
|
|
282
282
|
# than once per leaf.
|
|
283
|
-
source_to_leaves: dict[str, list[
|
|
283
|
+
source_to_leaves: dict[str, list[_SchemaLeaf]] = defaultdict(list)
|
|
284
284
|
for leaf in compiled.leaves:
|
|
285
285
|
source_to_leaves[leaf.source].append(leaf)
|
|
286
286
|
|
|
@@ -304,7 +304,7 @@ def _apply_schema_internal(
|
|
|
304
304
|
if value is None:
|
|
305
305
|
error_msg = "is None"
|
|
306
306
|
problems.append(
|
|
307
|
-
|
|
307
|
+
_DataProblem(_create_validation_error_json(source, error_msg, None))
|
|
308
308
|
)
|
|
309
309
|
continue
|
|
310
310
|
|
|
@@ -329,7 +329,7 @@ def _apply_schema_internal(
|
|
|
329
329
|
for key in redundant_keys:
|
|
330
330
|
error_msg = "is not defined in schema"
|
|
331
331
|
problems.append(
|
|
332
|
-
|
|
332
|
+
_DataProblem(
|
|
333
333
|
_create_validation_error_json(key, error_msg, extra_values[key])
|
|
334
334
|
)
|
|
335
335
|
)
|
|
@@ -18,18 +18,22 @@ from dataclasses import dataclass
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
|
-
from .errors import
|
|
21
|
+
from .errors import _SchemaProblem
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
_SCHEMA_FILE_NAME = "logging_objects_with_schema.json"
|
|
24
24
|
|
|
25
25
|
# Maximum allowed depth for schema nesting (protection against DoS)
|
|
26
26
|
MAX_SCHEMA_DEPTH = 100
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@dataclass
|
|
30
|
-
class
|
|
30
|
+
class _SchemaLeaf:
|
|
31
31
|
"""Represents a single leaf in the schema tree.
|
|
32
32
|
|
|
33
|
+
This class is part of the internal implementation and is not considered
|
|
34
|
+
a public API. Its signature and behaviour may change between releases
|
|
35
|
+
without preserving backward compatibility.
|
|
36
|
+
|
|
33
37
|
Attributes:
|
|
34
38
|
path: Full path of keys from the schema root to this leaf.
|
|
35
39
|
source: Name of the field in the ``extra`` mapping.
|
|
@@ -45,10 +49,15 @@ class SchemaLeaf:
|
|
|
45
49
|
|
|
46
50
|
|
|
47
51
|
@dataclass
|
|
48
|
-
class
|
|
49
|
-
"""Internal representation of a compiled schema.
|
|
52
|
+
class _CompiledSchema:
|
|
53
|
+
"""Internal representation of a compiled schema.
|
|
54
|
+
|
|
55
|
+
This class is part of the internal implementation and is not considered
|
|
56
|
+
a public API. Its signature and behaviour may change between releases
|
|
57
|
+
without preserving backward compatibility.
|
|
58
|
+
"""
|
|
50
59
|
|
|
51
|
-
leaves: list[
|
|
60
|
+
leaves: list[_SchemaLeaf]
|
|
52
61
|
|
|
53
62
|
@property
|
|
54
63
|
def is_empty(self) -> bool:
|
|
@@ -58,17 +67,17 @@ class CompiledSchema:
|
|
|
58
67
|
|
|
59
68
|
|
|
60
69
|
def _create_empty_compiled_schema_with_problems(
|
|
61
|
-
problems: list[
|
|
62
|
-
) -> tuple[
|
|
63
|
-
"""Create an empty
|
|
70
|
+
problems: list[_SchemaProblem],
|
|
71
|
+
) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
72
|
+
"""Create an empty _CompiledSchema with problems.
|
|
64
73
|
|
|
65
74
|
Args:
|
|
66
75
|
problems: List of schema problems.
|
|
67
76
|
|
|
68
77
|
Returns:
|
|
69
|
-
Tuple of (empty
|
|
78
|
+
Tuple of (empty _CompiledSchema, problems list).
|
|
70
79
|
"""
|
|
71
|
-
return (
|
|
80
|
+
return (_CompiledSchema(leaves=[]), problems)
|
|
72
81
|
|
|
73
82
|
|
|
74
83
|
_TYPE_MAP: Mapping[str, type] = {
|
|
@@ -100,10 +109,12 @@ _TYPE_MAP: Mapping[str, type] = {
|
|
|
100
109
|
# cache stores the result of compiling that file. Both are thread-safe and
|
|
101
110
|
# use double-checked locking to avoid race conditions.
|
|
102
111
|
|
|
103
|
-
# Compiled schema cache: Key is absolute schema_path,
|
|
104
|
-
# (
|
|
105
|
-
# compilations and failures (with problems list).
|
|
106
|
-
_SCHEMA_CACHE: dict[
|
|
112
|
+
# Compiled schema cache: Key is tuple of (absolute schema_path, frozenset of
|
|
113
|
+
# forbidden_keys), Value is tuple of (_CompiledSchema, list[_SchemaProblem]).
|
|
114
|
+
# This cache stores both successful compilations and failures (with problems list).
|
|
115
|
+
_SCHEMA_CACHE: dict[
|
|
116
|
+
tuple[Path, frozenset[str]], tuple[_CompiledSchema, list[_SchemaProblem]]
|
|
117
|
+
] = {}
|
|
107
118
|
|
|
108
119
|
_cache_lock = threading.RLock()
|
|
109
120
|
|
|
@@ -134,7 +145,7 @@ def _find_schema_file() -> Path | None:
|
|
|
134
145
|
current = start_path
|
|
135
146
|
|
|
136
147
|
while True:
|
|
137
|
-
schema_path = current /
|
|
148
|
+
schema_path = current / _SCHEMA_FILE_NAME
|
|
138
149
|
if schema_path.exists():
|
|
139
150
|
# Use resolve() to get an absolute path and resolve any symbolic links.
|
|
140
151
|
# This ensures we have a canonical path that can be used as a cache key
|
|
@@ -238,7 +249,7 @@ def _cache_and_return_missing_path() -> Path:
|
|
|
238
249
|
global _resolved_schema_path, _cached_cwd
|
|
239
250
|
|
|
240
251
|
current_cwd = _get_current_working_directory()
|
|
241
|
-
schema_path = (current_cwd /
|
|
252
|
+
schema_path = (current_cwd / _SCHEMA_FILE_NAME).resolve()
|
|
242
253
|
_resolved_schema_path = schema_path
|
|
243
254
|
_cached_cwd = current_cwd # Track CWD since path depends on it
|
|
244
255
|
return schema_path
|
|
@@ -247,7 +258,7 @@ def _cache_and_return_missing_path() -> Path:
|
|
|
247
258
|
def _get_schema_path() -> Path:
|
|
248
259
|
"""Resolve the absolute path to the JSON schema file with caching semantics.
|
|
249
260
|
|
|
250
|
-
The function searches for ``
|
|
261
|
+
The function searches for ``_SCHEMA_FILE_NAME`` by walking upward from the
|
|
251
262
|
current working directory and caches the result:
|
|
252
263
|
|
|
253
264
|
- When the schema file is found, its absolute path is cached as
|
|
@@ -265,16 +276,20 @@ def _get_schema_path() -> Path:
|
|
|
265
276
|
Absolute path where the schema file is located or expected to be.
|
|
266
277
|
"""
|
|
267
278
|
with _path_cache_lock:
|
|
279
|
+
# Check cached path for missing file first (CWD-dependent)
|
|
280
|
+
# This must be checked before found file cache, because
|
|
281
|
+
# _check_cached_found_file_path would invalidate the cache if file
|
|
282
|
+
# doesn't exist, even for missing file cache.
|
|
283
|
+
if _cached_cwd is not None:
|
|
284
|
+
cached_path = _check_cached_missing_file_path()
|
|
285
|
+
if cached_path is not None:
|
|
286
|
+
return cached_path
|
|
287
|
+
|
|
268
288
|
# Check cached path for found file (CWD-independent)
|
|
269
289
|
cached_path = _check_cached_found_file_path()
|
|
270
290
|
if cached_path is not None:
|
|
271
291
|
return cached_path
|
|
272
292
|
|
|
273
|
-
# Check cached path for missing file (CWD-dependent)
|
|
274
|
-
cached_path = _check_cached_missing_file_path()
|
|
275
|
-
if cached_path is not None:
|
|
276
|
-
return cached_path
|
|
277
|
-
|
|
278
293
|
# Search for schema file
|
|
279
294
|
found_path = _find_schema_file()
|
|
280
295
|
if found_path is not None:
|
|
@@ -291,7 +306,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
291
306
|
This function attempts to read and parse the schema file. If any problems
|
|
292
307
|
occur (file not found, I/O errors, invalid JSON, wrong top-level type),
|
|
293
308
|
it raises exceptions (FileNotFoundError or ValueError). These exceptions
|
|
294
|
-
are then converted to :class:`
|
|
309
|
+
are then converted to :class:`_SchemaProblem` instances by the caller
|
|
295
310
|
(_compile_schema_internal).
|
|
296
311
|
|
|
297
312
|
Args:
|
|
@@ -319,7 +334,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
319
334
|
except OSError as exc:
|
|
320
335
|
# Normalise I/O errors when reading the schema file (e.g., permission
|
|
321
336
|
# denied, file not found) to ValueError so that _compile_schema_internal()
|
|
322
|
-
# can report them as
|
|
337
|
+
# can report them as _SchemaProblem instances instead of leaking raw
|
|
323
338
|
# OSError to callers. Note: System-level OSError (e.g., from os.getcwd())
|
|
324
339
|
# is not caught here and propagates directly.
|
|
325
340
|
raise ValueError(
|
|
@@ -330,7 +345,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
330
345
|
|
|
331
346
|
if not isinstance(data, dict):
|
|
332
347
|
# Normalise non-object top-level schemas into a ValueError so that the
|
|
333
|
-
# caller can report it as a
|
|
348
|
+
# caller can report it as a _SchemaProblem while keeping type safety.
|
|
334
349
|
raise ValueError("Top-level schema must be a JSON object")
|
|
335
350
|
|
|
336
351
|
return data, schema_path
|
|
@@ -392,9 +407,9 @@ def _validate_and_create_leaf(
|
|
|
392
407
|
value_dict: dict[str, Any],
|
|
393
408
|
path: tuple[str, ...],
|
|
394
409
|
key: str,
|
|
395
|
-
problems: list[
|
|
396
|
-
) ->
|
|
397
|
-
"""Validate a leaf node and create
|
|
410
|
+
problems: list[_SchemaProblem],
|
|
411
|
+
) -> _SchemaLeaf | None:
|
|
412
|
+
"""Validate a leaf node and create _SchemaLeaf if valid.
|
|
398
413
|
|
|
399
414
|
Args:
|
|
400
415
|
value_dict: Dictionary containing leaf node data.
|
|
@@ -403,7 +418,7 @@ def _validate_and_create_leaf(
|
|
|
403
418
|
problems: List to collect validation problems.
|
|
404
419
|
|
|
405
420
|
Returns:
|
|
406
|
-
|
|
421
|
+
_SchemaLeaf if validation passes, None otherwise.
|
|
407
422
|
"""
|
|
408
423
|
leaf_type = value_dict.get("type")
|
|
409
424
|
leaf_source = value_dict.get("source")
|
|
@@ -414,7 +429,7 @@ def _validate_and_create_leaf(
|
|
|
414
429
|
|
|
415
430
|
if type_invalid:
|
|
416
431
|
problems.append(
|
|
417
|
-
|
|
432
|
+
_SchemaProblem(
|
|
418
433
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
419
434
|
f"type cannot be None or empty",
|
|
420
435
|
),
|
|
@@ -422,7 +437,7 @@ def _validate_and_create_leaf(
|
|
|
422
437
|
|
|
423
438
|
if source_invalid:
|
|
424
439
|
problems.append(
|
|
425
|
-
|
|
440
|
+
_SchemaProblem(
|
|
426
441
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
427
442
|
f"source cannot be None or empty",
|
|
428
443
|
),
|
|
@@ -437,7 +452,7 @@ def _validate_and_create_leaf(
|
|
|
437
452
|
expected_type = _TYPE_MAP.get(str(leaf_type))
|
|
438
453
|
if expected_type is None:
|
|
439
454
|
problems.append(
|
|
440
|
-
|
|
455
|
+
_SchemaProblem(
|
|
441
456
|
f"Unknown type '{leaf_type}' at {_format_path(path, key)}",
|
|
442
457
|
),
|
|
443
458
|
)
|
|
@@ -451,7 +466,7 @@ def _validate_and_create_leaf(
|
|
|
451
466
|
item_type_invalid = _is_empty_or_none(item_type_name)
|
|
452
467
|
if item_type_invalid:
|
|
453
468
|
problems.append(
|
|
454
|
-
|
|
469
|
+
_SchemaProblem(
|
|
455
470
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
456
471
|
f"item_type is required for list type and "
|
|
457
472
|
f"cannot be None or empty",
|
|
@@ -463,7 +478,7 @@ def _validate_and_create_leaf(
|
|
|
463
478
|
# Item type must be a primitive (str, int, float, bool), not list
|
|
464
479
|
if item_expected_type is None or item_expected_type is list:
|
|
465
480
|
problems.append(
|
|
466
|
-
|
|
481
|
+
_SchemaProblem(
|
|
467
482
|
f"Invalid item_type '{item_type_name}' at "
|
|
468
483
|
f"{_format_path(path, key)}: only primitive item types "
|
|
469
484
|
f"('str', 'int', 'float', 'bool') are allowed for lists",
|
|
@@ -471,11 +486,11 @@ def _validate_and_create_leaf(
|
|
|
471
486
|
)
|
|
472
487
|
return None
|
|
473
488
|
|
|
474
|
-
return
|
|
489
|
+
return _SchemaLeaf(
|
|
475
490
|
path=path + (key,),
|
|
476
491
|
# Convert to string to ensure type consistency. Even though source should
|
|
477
492
|
# be a string from JSON, this guards against unexpected types and ensures
|
|
478
|
-
# the
|
|
493
|
+
# the _SchemaLeaf always has a string source.
|
|
479
494
|
source=str(leaf_source),
|
|
480
495
|
expected_type=expected_type,
|
|
481
496
|
item_expected_type=item_expected_type,
|
|
@@ -485,14 +500,14 @@ def _validate_and_create_leaf(
|
|
|
485
500
|
def _compile_schema_tree(
|
|
486
501
|
node: MutableMapping[str, Any],
|
|
487
502
|
path: tuple[str, ...],
|
|
488
|
-
problems: list[
|
|
489
|
-
) -> Iterable[
|
|
490
|
-
"""Recursively compile a schema node into
|
|
503
|
+
problems: list[_SchemaProblem],
|
|
504
|
+
) -> Iterable[_SchemaLeaf]:
|
|
505
|
+
"""Recursively compile a schema node into _SchemaLeaf objects.
|
|
491
506
|
|
|
492
507
|
This function recursively walks the schema tree structure, identifying leaf
|
|
493
508
|
nodes (those with ``type`` and ``source`` fields) and inner nodes (those
|
|
494
509
|
without these fields). Leaf nodes are validated and converted to
|
|
495
|
-
:class:`
|
|
510
|
+
:class:`_SchemaLeaf` objects, while inner nodes are recursively processed.
|
|
496
511
|
|
|
497
512
|
Performance considerations:
|
|
498
513
|
Time complexity is O(n) where n is the total number of nodes in the
|
|
@@ -512,7 +527,7 @@ def _compile_schema_tree(
|
|
|
512
527
|
problems: List to collect validation problems.
|
|
513
528
|
|
|
514
529
|
Yields:
|
|
515
|
-
|
|
530
|
+
_SchemaLeaf objects found in the tree.
|
|
516
531
|
"""
|
|
517
532
|
# Check for excessive nesting depth (DoS protection: prevent deeply nested
|
|
518
533
|
# schemas that could cause stack overflow or excessive memory usage).
|
|
@@ -520,7 +535,7 @@ def _compile_schema_tree(
|
|
|
520
535
|
# and to provide clear error messages about the problematic path.
|
|
521
536
|
if len(path) > MAX_SCHEMA_DEPTH:
|
|
522
537
|
problems.append(
|
|
523
|
-
|
|
538
|
+
_SchemaProblem(
|
|
524
539
|
f"Schema nesting depth exceeds maximum allowed depth of "
|
|
525
540
|
f"{MAX_SCHEMA_DEPTH} at path {_format_path(path)}"
|
|
526
541
|
),
|
|
@@ -530,7 +545,7 @@ def _compile_schema_tree(
|
|
|
530
545
|
for key, value in node.items():
|
|
531
546
|
if not isinstance(value, Mapping):
|
|
532
547
|
problems.append(
|
|
533
|
-
|
|
548
|
+
_SchemaProblem(
|
|
534
549
|
f"Invalid schema at {_format_path(path, key)}: expected object"
|
|
535
550
|
),
|
|
536
551
|
)
|
|
@@ -552,9 +567,13 @@ def _compile_schema_tree(
|
|
|
552
567
|
|
|
553
568
|
|
|
554
569
|
@functools.lru_cache(maxsize=1)
|
|
555
|
-
def
|
|
570
|
+
def _get_builtin_logrecord_attributes() -> set[str]:
|
|
556
571
|
"""Get set of standard LogRecord attribute names.
|
|
557
572
|
|
|
573
|
+
This function is part of the internal implementation and is not considered
|
|
574
|
+
a public API. Its signature and behaviour may change between releases
|
|
575
|
+
without preserving backward compatibility.
|
|
576
|
+
|
|
558
577
|
This function extracts attribute names from LogRecord that represent
|
|
559
578
|
system fields and should not be used as root keys in the schema or
|
|
560
579
|
treated as user-provided extra fields.
|
|
@@ -573,7 +592,7 @@ def get_builtin_logrecord_attributes() -> set[str]:
|
|
|
573
592
|
Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc.
|
|
574
593
|
|
|
575
594
|
Example:
|
|
576
|
-
>>> forbidden =
|
|
595
|
+
>>> forbidden = _get_builtin_logrecord_attributes()
|
|
577
596
|
>>> "name" in forbidden
|
|
578
597
|
True
|
|
579
598
|
>>> "ServicePayload" in forbidden
|
|
@@ -608,56 +627,87 @@ def get_builtin_logrecord_attributes() -> set[str]:
|
|
|
608
627
|
|
|
609
628
|
|
|
610
629
|
def _check_root_conflicts(
|
|
611
|
-
schema_dict: Mapping[str, Any],
|
|
630
|
+
schema_dict: Mapping[str, Any],
|
|
631
|
+
problems: list[_SchemaProblem],
|
|
632
|
+
forbidden_keys: set[str] | None = None,
|
|
612
633
|
) -> None:
|
|
613
|
-
"""Check schema root keys for conflicts with reserved logging fields.
|
|
634
|
+
"""Check schema root keys for conflicts with reserved logging fields.
|
|
614
635
|
|
|
615
|
-
|
|
636
|
+
Args:
|
|
637
|
+
schema_dict: The schema dictionary to check.
|
|
638
|
+
problems: List to collect validation problems.
|
|
639
|
+
forbidden_keys: Additional forbidden root keys to check against.
|
|
640
|
+
These keys are merged with builtin LogRecord attributes.
|
|
641
|
+
Builtin keys cannot be replaced, only supplemented.
|
|
642
|
+
Note: None and empty set() are semantically equivalent - both
|
|
643
|
+
mean "no additional forbidden keys" and produce the same result.
|
|
644
|
+
"""
|
|
645
|
+
# Builtin keys always present, cannot be replaced
|
|
646
|
+
forbidden_root_keys = _get_builtin_logrecord_attributes()
|
|
647
|
+
# Merge with additional forbidden keys if provided.
|
|
648
|
+
# We use `if forbidden_keys:` instead of `if forbidden_keys is not None:` because
|
|
649
|
+
# both None and empty set() semantically mean "no additional forbidden keys".
|
|
650
|
+
# This check treats them equivalently: None is falsy, and empty set() is also
|
|
651
|
+
# falsy, so both cases skip the merge operation, which is the correct behavior.
|
|
652
|
+
if forbidden_keys:
|
|
653
|
+
forbidden_root_keys = forbidden_root_keys | forbidden_keys
|
|
616
654
|
|
|
617
655
|
for key in schema_dict.keys():
|
|
618
656
|
if key in forbidden_root_keys:
|
|
619
657
|
problems.append(
|
|
620
|
-
|
|
658
|
+
_SchemaProblem(
|
|
621
659
|
f"Root key '{key}' conflicts with reserved logging fields",
|
|
622
660
|
),
|
|
623
661
|
)
|
|
624
662
|
|
|
625
663
|
|
|
626
|
-
def _compile_schema_internal(
|
|
627
|
-
|
|
664
|
+
def _compile_schema_internal(
|
|
665
|
+
forbidden_keys: set[str] | None = None,
|
|
666
|
+
) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
667
|
+
"""Compile JSON schema into ``_CompiledSchema`` and collect all problems.
|
|
628
668
|
|
|
629
669
|
The function loads the raw JSON schema, validates its structure, checks
|
|
630
670
|
root keys for conflicts with reserved ``logging.LogRecord`` attributes
|
|
631
|
-
and compiles all valid leaves into a :class:`
|
|
632
|
-
discovered during this process are reported as :class:`
|
|
671
|
+
and compiles all valid leaves into a :class:`_CompiledSchema`. All issues
|
|
672
|
+
discovered during this process are reported as :class:`_SchemaProblem`
|
|
633
673
|
instances.
|
|
634
674
|
|
|
635
|
-
Results are cached process-wide: the cache key is the absolute
|
|
636
|
-
file path and the value is a
|
|
637
|
-
Once a schema for a
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
675
|
+
Results are cached process-wide: the cache key is a tuple of the absolute
|
|
676
|
+
schema file path and the set of additional forbidden keys. The value is a
|
|
677
|
+
tuple ``(_CompiledSchema, list[_SchemaProblem])``. Once a schema for a
|
|
678
|
+
given path and forbidden keys set has been observed (including the cases
|
|
679
|
+
when it is missing or invalid), subsequent calls always return the cached
|
|
680
|
+
result without re-reading or re-compiling the schema. To pick up on-disk
|
|
681
|
+
changes to the schema, the application must restart the process. See the
|
|
682
|
+
README section \"Schema caching and thread safety\" for more details.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
forbidden_keys: Additional forbidden root keys to check against.
|
|
686
|
+
These keys are merged with builtin LogRecord attributes.
|
|
687
|
+
Builtin keys cannot be replaced, only supplemented.
|
|
688
|
+
Note: None and empty set() are semantically equivalent - both
|
|
689
|
+
mean "no additional forbidden keys" and produce the same result.
|
|
690
|
+
They also produce the same cache key, so cached results are shared.
|
|
642
691
|
|
|
643
692
|
This function never raises exceptions. It always returns the best-effort
|
|
644
693
|
compiled schema together with a list of problems detected during processing
|
|
645
|
-
(an empty ``
|
|
694
|
+
(an empty ``_CompiledSchema`` when the schema is missing or invalid).
|
|
646
695
|
|
|
647
696
|
Performance considerations:
|
|
648
697
|
First compilation of a schema involves file I/O, JSON parsing, and tree
|
|
649
698
|
traversal. For typical schemas (< 1000 nodes), this takes < 10ms. All
|
|
650
|
-
subsequent calls for the same schema path
|
|
651
|
-
(< 0.1ms). The cache is process-wide and persists
|
|
652
|
-
lifetime.
|
|
699
|
+
subsequent calls for the same schema path and forbidden keys return
|
|
700
|
+
immediately from cache (< 0.1ms). The cache is process-wide and persists
|
|
701
|
+
for the application lifetime.
|
|
653
702
|
|
|
654
703
|
Limitations:
|
|
655
704
|
- Schema changes on disk are not automatically reloaded; the application
|
|
656
705
|
must be restarted to pick up changes
|
|
657
706
|
- Very large schemas (> 10,000 nodes) may cause noticeable compilation
|
|
658
707
|
overhead on first load, but this is uncommon in practice
|
|
659
|
-
- The cache uses absolute file paths as keys, so
|
|
660
|
-
accessible via the same path throughout the
|
|
708
|
+
- The cache uses absolute file paths and forbidden keys sets as keys, so
|
|
709
|
+
schema files must be accessible via the same path throughout the
|
|
710
|
+
application lifetime
|
|
661
711
|
|
|
662
712
|
Note:
|
|
663
713
|
This function is used internally by :class:`SchemaLogger` and by the
|
|
@@ -665,15 +715,24 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
665
715
|
between releases without preserving backward compatibility.
|
|
666
716
|
|
|
667
717
|
Returns:
|
|
668
|
-
Tuple of (
|
|
718
|
+
Tuple of (_CompiledSchema, list[_SchemaProblem]).
|
|
669
719
|
"""
|
|
670
720
|
schema_path = _get_schema_path()
|
|
671
|
-
|
|
672
|
-
#
|
|
673
|
-
#
|
|
674
|
-
#
|
|
721
|
+
# Create cache key that includes forbidden keys.
|
|
722
|
+
# We use `frozenset(forbidden_keys or ())` instead of checking for None explicitly
|
|
723
|
+
# because both None and empty set() should produce the same cache key (frozenset()).
|
|
724
|
+
# This is semantically correct: both None and set() mean "no additional forbidden
|
|
725
|
+
# keys", so they should share the same cached compilation result. The `or ()`
|
|
726
|
+
# operator converts None to empty tuple, which frozenset() then converts to empty
|
|
727
|
+
# frozenset, and empty set() also becomes empty frozenset, ensuring cache key
|
|
728
|
+
# consistency.
|
|
729
|
+
cache_key = (schema_path, frozenset(forbidden_keys or ()))
|
|
730
|
+
|
|
731
|
+
# Fast-path: First check (with lock for thread-safety) if we have already attempted
|
|
732
|
+
# to compile schema for this path and forbidden keys set. This provides thread-safe
|
|
733
|
+
# cache access in the common case when the schema is already cached.
|
|
675
734
|
with _cache_lock:
|
|
676
|
-
cached = _SCHEMA_CACHE.get(
|
|
735
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
677
736
|
if cached is not None:
|
|
678
737
|
return cached
|
|
679
738
|
|
|
@@ -684,35 +743,35 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
684
743
|
# while holding the lock. If another thread already compiled it, we use that
|
|
685
744
|
# result instead of storing our own (which might be different if the file changed).
|
|
686
745
|
|
|
687
|
-
problems: list[
|
|
746
|
+
problems: list[_SchemaProblem] = []
|
|
688
747
|
|
|
689
748
|
try:
|
|
690
749
|
raw_schema, _ = _load_raw_schema(schema_path)
|
|
691
750
|
except (FileNotFoundError, ValueError) as exc:
|
|
692
|
-
problems.append(
|
|
751
|
+
problems.append(_SchemaProblem(str(exc)))
|
|
693
752
|
result = _create_empty_compiled_schema_with_problems(problems)
|
|
694
753
|
# Double-checked locking: Check cache again while holding lock. Another
|
|
695
754
|
# thread might have compiled the schema (or handled the same error) while
|
|
696
755
|
# we were processing the exception. If so, use the cached result.
|
|
697
756
|
with _cache_lock:
|
|
698
|
-
cached = _SCHEMA_CACHE.get(
|
|
757
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
699
758
|
if cached is not None:
|
|
700
759
|
return cached
|
|
701
|
-
_SCHEMA_CACHE[
|
|
760
|
+
_SCHEMA_CACHE[cache_key] = result
|
|
702
761
|
return result
|
|
703
762
|
|
|
704
763
|
# Check root key conflicts before compiling the tree. This allows us to
|
|
705
764
|
# catch conflicts early and report them as schema problems. We do this
|
|
706
765
|
# before tree compilation to avoid unnecessary work if there are conflicts.
|
|
707
|
-
_check_root_conflicts(raw_schema, problems)
|
|
766
|
+
_check_root_conflicts(raw_schema, problems, forbidden_keys)
|
|
708
767
|
|
|
709
768
|
# Compile the schema tree into leaves. Each root key becomes a separate
|
|
710
769
|
# tree that we compile recursively. Problems are collected as we go.
|
|
711
|
-
leaves: list[
|
|
770
|
+
leaves: list[_SchemaLeaf] = []
|
|
712
771
|
for key, value in raw_schema.items():
|
|
713
772
|
if not isinstance(value, Mapping):
|
|
714
773
|
problems.append(
|
|
715
|
-
|
|
774
|
+
_SchemaProblem(f"Invalid schema at {key}: expected object"),
|
|
716
775
|
)
|
|
717
776
|
continue
|
|
718
777
|
|
|
@@ -722,7 +781,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
722
781
|
for leaf in _compile_schema_tree(dict(value), (key,), problems):
|
|
723
782
|
leaves.append(leaf)
|
|
724
783
|
|
|
725
|
-
compiled =
|
|
784
|
+
compiled = _CompiledSchema(leaves=leaves)
|
|
726
785
|
result = (compiled, problems)
|
|
727
786
|
|
|
728
787
|
# Double-checked locking: Check cache again while holding lock. Another thread
|
|
@@ -731,9 +790,9 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
731
790
|
# instead of overwriting it with our own (which might be different if the file
|
|
732
791
|
# was modified between reads).
|
|
733
792
|
with _cache_lock:
|
|
734
|
-
cached = _SCHEMA_CACHE.get(
|
|
793
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
735
794
|
if cached is not None:
|
|
736
795
|
return cached
|
|
737
|
-
_SCHEMA_CACHE[
|
|
796
|
+
_SCHEMA_CACHE[cache_key] = result
|
|
738
797
|
|
|
739
798
|
return result
|
|
@@ -17,9 +17,9 @@ import sys
|
|
|
17
17
|
from collections.abc import Mapping
|
|
18
18
|
from typing import Any
|
|
19
19
|
|
|
20
|
-
from .errors import
|
|
20
|
+
from .errors import _SchemaProblem
|
|
21
21
|
from .schema_applier import _apply_schema_internal
|
|
22
|
-
from .schema_loader import
|
|
22
|
+
from .schema_loader import _compile_schema_internal, _CompiledSchema
|
|
23
23
|
|
|
24
24
|
# Python 3.11+ has improved findCaller() implementation with proper stacklevel support.
|
|
25
25
|
# For Python < 3.11, we use inspect.stack() as a fallback due to known issues with
|
|
@@ -27,7 +27,7 @@ from .schema_loader import CompiledSchema, _compile_schema_internal
|
|
|
27
27
|
_USE_FINDCALLER = sys.version_info >= (3, 11)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def _log_schema_problems_and_exit(problems: list[
|
|
30
|
+
def _log_schema_problems_and_exit(problems: list[_SchemaProblem]) -> None:
|
|
31
31
|
"""Log schema problems to stderr and terminate the application.
|
|
32
32
|
|
|
33
33
|
Uses os._exit(1) instead of sys.exit(1) to ensure immediate termination
|
|
@@ -69,7 +69,12 @@ class SchemaLogger(logging.Logger):
|
|
|
69
69
|
>>> logger.info("request processed", extra={"request_id": "abc-123"})
|
|
70
70
|
"""
|
|
71
71
|
|
|
72
|
-
def __init__(
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
name: str,
|
|
75
|
+
level: int = logging.NOTSET,
|
|
76
|
+
forbidden_keys: set[str] | None = None,
|
|
77
|
+
) -> None:
|
|
73
78
|
"""Initialise the schema-aware logger.
|
|
74
79
|
|
|
75
80
|
The schema is compiled once during construction. If any
|
|
@@ -80,29 +85,37 @@ class SchemaLogger(logging.Logger):
|
|
|
80
85
|
Args:
|
|
81
86
|
name: Logger name (same as :class:`logging.Logger`).
|
|
82
87
|
level: Logger level (same as :class:`logging.Logger`).
|
|
88
|
+
forbidden_keys: Additional forbidden root keys to check against.
|
|
89
|
+
These keys are merged with builtin LogRecord attributes.
|
|
90
|
+
Builtin keys cannot be replaced, only supplemented.
|
|
91
|
+
Subclasses can override this method and pass their own
|
|
92
|
+
forbidden keys to the parent, merging them with keys from
|
|
93
|
+
their own subclasses if needed.
|
|
94
|
+
Note: None and empty set() are semantically equivalent - both
|
|
95
|
+
mean "no additional forbidden keys" and produce the same result.
|
|
83
96
|
"""
|
|
84
97
|
# Validate schema before creating the logger instance to avoid
|
|
85
98
|
# registering a broken logger in the logging manager cache.
|
|
86
99
|
# Schema is compiled and cached first, then problems are checked.
|
|
87
100
|
try:
|
|
88
|
-
compiled, problems = _compile_schema_internal()
|
|
101
|
+
compiled, problems = _compile_schema_internal(forbidden_keys)
|
|
89
102
|
except (OSError, ValueError, RuntimeError) as exc:
|
|
90
|
-
# Convert system-level exceptions to
|
|
103
|
+
# Convert system-level exceptions to _SchemaProblem so they can be
|
|
91
104
|
# handled the same way as schema validation problems.
|
|
92
105
|
# - OSError: system-level file system issues (e.g., os.getcwd() failures
|
|
93
106
|
# when the current working directory is inaccessible or deleted).
|
|
94
107
|
# Note: OSError that occurs when reading the schema file (e.g., permission
|
|
95
|
-
# denied, I/O errors) is converted to
|
|
108
|
+
# denied, I/O errors) is converted to _SchemaProblem in _load_raw_schema()
|
|
96
109
|
# and does not reach this exception handler.
|
|
97
110
|
# - ValueError: path resolution issues (e.g., invalid path characters,
|
|
98
111
|
# malformed paths during schema file discovery)
|
|
99
112
|
# - RuntimeError: threading issues (e.g., lock acquisition problems)
|
|
100
113
|
# Note: JSON parsing and schema structure validation errors are
|
|
101
|
-
# converted to
|
|
114
|
+
# converted to _SchemaProblem instances and do not raise ValueError here.
|
|
102
115
|
# Note: System exceptions (KeyboardInterrupt, SystemExit) are not
|
|
103
116
|
# caught, which is the correct behavior.
|
|
104
|
-
problems = [
|
|
105
|
-
compiled =
|
|
117
|
+
problems = [_SchemaProblem(f"Schema compilation failed: {exc}")]
|
|
118
|
+
compiled = _CompiledSchema(leaves=[])
|
|
106
119
|
|
|
107
120
|
if problems:
|
|
108
121
|
# Schema is invalid; log problems and terminate without creating
|
|
@@ -111,7 +124,7 @@ class SchemaLogger(logging.Logger):
|
|
|
111
124
|
|
|
112
125
|
# Schema is valid; create the logger instance.
|
|
113
126
|
super().__init__(name, level)
|
|
114
|
-
self._schema:
|
|
127
|
+
self._schema: _CompiledSchema = compiled
|
|
115
128
|
|
|
116
129
|
def _log(
|
|
117
130
|
self,
|
|
@@ -203,7 +216,7 @@ class SchemaLogger(logging.Logger):
|
|
|
203
216
|
stack_info=False, stacklevel=stacklevel + 1
|
|
204
217
|
)
|
|
205
218
|
# Format error message as JSON for machine processing.
|
|
206
|
-
# Each
|
|
219
|
+
# Each _DataProblem.message is already a JSON string (created by
|
|
207
220
|
# _create_validation_error_json) with structure:
|
|
208
221
|
# {"field": "...", "error": "...", "value": "..."}
|
|
209
222
|
# We parse them back to dicts and combine into a single JSON object
|
|
@@ -226,7 +239,7 @@ class SchemaLogger(logging.Logger):
|
|
|
226
239
|
# create a fallback error object. This should never happen in
|
|
227
240
|
# normal operation since problem.message is always created via
|
|
228
241
|
# _create_validation_error_json, but protects against unexpected
|
|
229
|
-
# data corruption or manual
|
|
242
|
+
# data corruption or manual _DataProblem creation. The fallback
|
|
230
243
|
# preserves the same structure (field, error, value) for
|
|
231
244
|
# consistency.
|
|
232
245
|
validation_errors.append(
|
|
File without changes
|
|
File without changes
|