logging-objects-with-schema 0.1.3__tar.gz → 0.1.4__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.1.4}/PKG-INFO +1 -1
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/pyproject.toml +21 -1
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/src/logging_objects_with_schema/errors.py +11 -3
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/src/logging_objects_with_schema/schema_applier.py +24 -24
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/src/logging_objects_with_schema/schema_loader.py +79 -62
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/src/logging_objects_with_schema/schema_logger.py +11 -11
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/LICENSE +0 -0
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/README.md +0 -0
- {logging_objects_with_schema-0.1.3 → logging_objects_with_schema-0.1.4}/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.1.
|
|
3
|
+
Version: 0.1.4
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "logging-objects-with-schema"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.4"
|
|
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] = {
|
|
@@ -101,9 +110,9 @@ _TYPE_MAP: Mapping[str, type] = {
|
|
|
101
110
|
# use double-checked locking to avoid race conditions.
|
|
102
111
|
|
|
103
112
|
# Compiled schema cache: Key is absolute schema_path, Value is tuple of
|
|
104
|
-
# (
|
|
113
|
+
# (_CompiledSchema, list[_SchemaProblem]). This cache stores both successful
|
|
105
114
|
# compilations and failures (with problems list).
|
|
106
|
-
_SCHEMA_CACHE: dict[Path, tuple[
|
|
115
|
+
_SCHEMA_CACHE: dict[Path, tuple[_CompiledSchema, list[_SchemaProblem]]] = {}
|
|
107
116
|
|
|
108
117
|
_cache_lock = threading.RLock()
|
|
109
118
|
|
|
@@ -134,7 +143,7 @@ def _find_schema_file() -> Path | None:
|
|
|
134
143
|
current = start_path
|
|
135
144
|
|
|
136
145
|
while True:
|
|
137
|
-
schema_path = current /
|
|
146
|
+
schema_path = current / _SCHEMA_FILE_NAME
|
|
138
147
|
if schema_path.exists():
|
|
139
148
|
# Use resolve() to get an absolute path and resolve any symbolic links.
|
|
140
149
|
# This ensures we have a canonical path that can be used as a cache key
|
|
@@ -238,7 +247,7 @@ def _cache_and_return_missing_path() -> Path:
|
|
|
238
247
|
global _resolved_schema_path, _cached_cwd
|
|
239
248
|
|
|
240
249
|
current_cwd = _get_current_working_directory()
|
|
241
|
-
schema_path = (current_cwd /
|
|
250
|
+
schema_path = (current_cwd / _SCHEMA_FILE_NAME).resolve()
|
|
242
251
|
_resolved_schema_path = schema_path
|
|
243
252
|
_cached_cwd = current_cwd # Track CWD since path depends on it
|
|
244
253
|
return schema_path
|
|
@@ -247,7 +256,7 @@ def _cache_and_return_missing_path() -> Path:
|
|
|
247
256
|
def _get_schema_path() -> Path:
|
|
248
257
|
"""Resolve the absolute path to the JSON schema file with caching semantics.
|
|
249
258
|
|
|
250
|
-
The function searches for ``
|
|
259
|
+
The function searches for ``_SCHEMA_FILE_NAME`` by walking upward from the
|
|
251
260
|
current working directory and caches the result:
|
|
252
261
|
|
|
253
262
|
- When the schema file is found, its absolute path is cached as
|
|
@@ -265,16 +274,20 @@ def _get_schema_path() -> Path:
|
|
|
265
274
|
Absolute path where the schema file is located or expected to be.
|
|
266
275
|
"""
|
|
267
276
|
with _path_cache_lock:
|
|
277
|
+
# Check cached path for missing file first (CWD-dependent)
|
|
278
|
+
# This must be checked before found file cache, because
|
|
279
|
+
# _check_cached_found_file_path would invalidate the cache if file
|
|
280
|
+
# doesn't exist, even for missing file cache.
|
|
281
|
+
if _cached_cwd is not None:
|
|
282
|
+
cached_path = _check_cached_missing_file_path()
|
|
283
|
+
if cached_path is not None:
|
|
284
|
+
return cached_path
|
|
285
|
+
|
|
268
286
|
# Check cached path for found file (CWD-independent)
|
|
269
287
|
cached_path = _check_cached_found_file_path()
|
|
270
288
|
if cached_path is not None:
|
|
271
289
|
return cached_path
|
|
272
290
|
|
|
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
291
|
# Search for schema file
|
|
279
292
|
found_path = _find_schema_file()
|
|
280
293
|
if found_path is not None:
|
|
@@ -291,7 +304,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
291
304
|
This function attempts to read and parse the schema file. If any problems
|
|
292
305
|
occur (file not found, I/O errors, invalid JSON, wrong top-level type),
|
|
293
306
|
it raises exceptions (FileNotFoundError or ValueError). These exceptions
|
|
294
|
-
are then converted to :class:`
|
|
307
|
+
are then converted to :class:`_SchemaProblem` instances by the caller
|
|
295
308
|
(_compile_schema_internal).
|
|
296
309
|
|
|
297
310
|
Args:
|
|
@@ -319,7 +332,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
319
332
|
except OSError as exc:
|
|
320
333
|
# Normalise I/O errors when reading the schema file (e.g., permission
|
|
321
334
|
# denied, file not found) to ValueError so that _compile_schema_internal()
|
|
322
|
-
# can report them as
|
|
335
|
+
# can report them as _SchemaProblem instances instead of leaking raw
|
|
323
336
|
# OSError to callers. Note: System-level OSError (e.g., from os.getcwd())
|
|
324
337
|
# is not caught here and propagates directly.
|
|
325
338
|
raise ValueError(
|
|
@@ -330,7 +343,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
|
|
|
330
343
|
|
|
331
344
|
if not isinstance(data, dict):
|
|
332
345
|
# Normalise non-object top-level schemas into a ValueError so that the
|
|
333
|
-
# caller can report it as a
|
|
346
|
+
# caller can report it as a _SchemaProblem while keeping type safety.
|
|
334
347
|
raise ValueError("Top-level schema must be a JSON object")
|
|
335
348
|
|
|
336
349
|
return data, schema_path
|
|
@@ -392,9 +405,9 @@ def _validate_and_create_leaf(
|
|
|
392
405
|
value_dict: dict[str, Any],
|
|
393
406
|
path: tuple[str, ...],
|
|
394
407
|
key: str,
|
|
395
|
-
problems: list[
|
|
396
|
-
) ->
|
|
397
|
-
"""Validate a leaf node and create
|
|
408
|
+
problems: list[_SchemaProblem],
|
|
409
|
+
) -> _SchemaLeaf | None:
|
|
410
|
+
"""Validate a leaf node and create _SchemaLeaf if valid.
|
|
398
411
|
|
|
399
412
|
Args:
|
|
400
413
|
value_dict: Dictionary containing leaf node data.
|
|
@@ -403,7 +416,7 @@ def _validate_and_create_leaf(
|
|
|
403
416
|
problems: List to collect validation problems.
|
|
404
417
|
|
|
405
418
|
Returns:
|
|
406
|
-
|
|
419
|
+
_SchemaLeaf if validation passes, None otherwise.
|
|
407
420
|
"""
|
|
408
421
|
leaf_type = value_dict.get("type")
|
|
409
422
|
leaf_source = value_dict.get("source")
|
|
@@ -414,7 +427,7 @@ def _validate_and_create_leaf(
|
|
|
414
427
|
|
|
415
428
|
if type_invalid:
|
|
416
429
|
problems.append(
|
|
417
|
-
|
|
430
|
+
_SchemaProblem(
|
|
418
431
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
419
432
|
f"type cannot be None or empty",
|
|
420
433
|
),
|
|
@@ -422,7 +435,7 @@ def _validate_and_create_leaf(
|
|
|
422
435
|
|
|
423
436
|
if source_invalid:
|
|
424
437
|
problems.append(
|
|
425
|
-
|
|
438
|
+
_SchemaProblem(
|
|
426
439
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
427
440
|
f"source cannot be None or empty",
|
|
428
441
|
),
|
|
@@ -437,7 +450,7 @@ def _validate_and_create_leaf(
|
|
|
437
450
|
expected_type = _TYPE_MAP.get(str(leaf_type))
|
|
438
451
|
if expected_type is None:
|
|
439
452
|
problems.append(
|
|
440
|
-
|
|
453
|
+
_SchemaProblem(
|
|
441
454
|
f"Unknown type '{leaf_type}' at {_format_path(path, key)}",
|
|
442
455
|
),
|
|
443
456
|
)
|
|
@@ -451,7 +464,7 @@ def _validate_and_create_leaf(
|
|
|
451
464
|
item_type_invalid = _is_empty_or_none(item_type_name)
|
|
452
465
|
if item_type_invalid:
|
|
453
466
|
problems.append(
|
|
454
|
-
|
|
467
|
+
_SchemaProblem(
|
|
455
468
|
f"Incomplete leaf at {_format_path(path, key)}: "
|
|
456
469
|
f"item_type is required for list type and "
|
|
457
470
|
f"cannot be None or empty",
|
|
@@ -463,7 +476,7 @@ def _validate_and_create_leaf(
|
|
|
463
476
|
# Item type must be a primitive (str, int, float, bool), not list
|
|
464
477
|
if item_expected_type is None or item_expected_type is list:
|
|
465
478
|
problems.append(
|
|
466
|
-
|
|
479
|
+
_SchemaProblem(
|
|
467
480
|
f"Invalid item_type '{item_type_name}' at "
|
|
468
481
|
f"{_format_path(path, key)}: only primitive item types "
|
|
469
482
|
f"('str', 'int', 'float', 'bool') are allowed for lists",
|
|
@@ -471,11 +484,11 @@ def _validate_and_create_leaf(
|
|
|
471
484
|
)
|
|
472
485
|
return None
|
|
473
486
|
|
|
474
|
-
return
|
|
487
|
+
return _SchemaLeaf(
|
|
475
488
|
path=path + (key,),
|
|
476
489
|
# Convert to string to ensure type consistency. Even though source should
|
|
477
490
|
# be a string from JSON, this guards against unexpected types and ensures
|
|
478
|
-
# the
|
|
491
|
+
# the _SchemaLeaf always has a string source.
|
|
479
492
|
source=str(leaf_source),
|
|
480
493
|
expected_type=expected_type,
|
|
481
494
|
item_expected_type=item_expected_type,
|
|
@@ -485,14 +498,14 @@ def _validate_and_create_leaf(
|
|
|
485
498
|
def _compile_schema_tree(
|
|
486
499
|
node: MutableMapping[str, Any],
|
|
487
500
|
path: tuple[str, ...],
|
|
488
|
-
problems: list[
|
|
489
|
-
) -> Iterable[
|
|
490
|
-
"""Recursively compile a schema node into
|
|
501
|
+
problems: list[_SchemaProblem],
|
|
502
|
+
) -> Iterable[_SchemaLeaf]:
|
|
503
|
+
"""Recursively compile a schema node into _SchemaLeaf objects.
|
|
491
504
|
|
|
492
505
|
This function recursively walks the schema tree structure, identifying leaf
|
|
493
506
|
nodes (those with ``type`` and ``source`` fields) and inner nodes (those
|
|
494
507
|
without these fields). Leaf nodes are validated and converted to
|
|
495
|
-
:class:`
|
|
508
|
+
:class:`_SchemaLeaf` objects, while inner nodes are recursively processed.
|
|
496
509
|
|
|
497
510
|
Performance considerations:
|
|
498
511
|
Time complexity is O(n) where n is the total number of nodes in the
|
|
@@ -512,7 +525,7 @@ def _compile_schema_tree(
|
|
|
512
525
|
problems: List to collect validation problems.
|
|
513
526
|
|
|
514
527
|
Yields:
|
|
515
|
-
|
|
528
|
+
_SchemaLeaf objects found in the tree.
|
|
516
529
|
"""
|
|
517
530
|
# Check for excessive nesting depth (DoS protection: prevent deeply nested
|
|
518
531
|
# schemas that could cause stack overflow or excessive memory usage).
|
|
@@ -520,7 +533,7 @@ def _compile_schema_tree(
|
|
|
520
533
|
# and to provide clear error messages about the problematic path.
|
|
521
534
|
if len(path) > MAX_SCHEMA_DEPTH:
|
|
522
535
|
problems.append(
|
|
523
|
-
|
|
536
|
+
_SchemaProblem(
|
|
524
537
|
f"Schema nesting depth exceeds maximum allowed depth of "
|
|
525
538
|
f"{MAX_SCHEMA_DEPTH} at path {_format_path(path)}"
|
|
526
539
|
),
|
|
@@ -530,7 +543,7 @@ def _compile_schema_tree(
|
|
|
530
543
|
for key, value in node.items():
|
|
531
544
|
if not isinstance(value, Mapping):
|
|
532
545
|
problems.append(
|
|
533
|
-
|
|
546
|
+
_SchemaProblem(
|
|
534
547
|
f"Invalid schema at {_format_path(path, key)}: expected object"
|
|
535
548
|
),
|
|
536
549
|
)
|
|
@@ -552,9 +565,13 @@ def _compile_schema_tree(
|
|
|
552
565
|
|
|
553
566
|
|
|
554
567
|
@functools.lru_cache(maxsize=1)
|
|
555
|
-
def
|
|
568
|
+
def _get_builtin_logrecord_attributes() -> set[str]:
|
|
556
569
|
"""Get set of standard LogRecord attribute names.
|
|
557
570
|
|
|
571
|
+
This function is part of the internal implementation and is not considered
|
|
572
|
+
a public API. Its signature and behaviour may change between releases
|
|
573
|
+
without preserving backward compatibility.
|
|
574
|
+
|
|
558
575
|
This function extracts attribute names from LogRecord that represent
|
|
559
576
|
system fields and should not be used as root keys in the schema or
|
|
560
577
|
treated as user-provided extra fields.
|
|
@@ -573,7 +590,7 @@ def get_builtin_logrecord_attributes() -> set[str]:
|
|
|
573
590
|
Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc.
|
|
574
591
|
|
|
575
592
|
Example:
|
|
576
|
-
>>> forbidden =
|
|
593
|
+
>>> forbidden = _get_builtin_logrecord_attributes()
|
|
577
594
|
>>> "name" in forbidden
|
|
578
595
|
True
|
|
579
596
|
>>> "ServicePayload" in forbidden
|
|
@@ -608,32 +625,32 @@ def get_builtin_logrecord_attributes() -> set[str]:
|
|
|
608
625
|
|
|
609
626
|
|
|
610
627
|
def _check_root_conflicts(
|
|
611
|
-
schema_dict: Mapping[str, Any], problems: list[
|
|
628
|
+
schema_dict: Mapping[str, Any], problems: list[_SchemaProblem]
|
|
612
629
|
) -> None:
|
|
613
630
|
"""Check schema root keys for conflicts with reserved logging fields."""
|
|
614
631
|
|
|
615
|
-
forbidden_root_keys =
|
|
632
|
+
forbidden_root_keys = _get_builtin_logrecord_attributes()
|
|
616
633
|
|
|
617
634
|
for key in schema_dict.keys():
|
|
618
635
|
if key in forbidden_root_keys:
|
|
619
636
|
problems.append(
|
|
620
|
-
|
|
637
|
+
_SchemaProblem(
|
|
621
638
|
f"Root key '{key}' conflicts with reserved logging fields",
|
|
622
639
|
),
|
|
623
640
|
)
|
|
624
641
|
|
|
625
642
|
|
|
626
|
-
def _compile_schema_internal() -> tuple[
|
|
627
|
-
"""Compile JSON schema into ``
|
|
643
|
+
def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
644
|
+
"""Compile JSON schema into ``_CompiledSchema`` and collect all problems.
|
|
628
645
|
|
|
629
646
|
The function loads the raw JSON schema, validates its structure, checks
|
|
630
647
|
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:`
|
|
648
|
+
and compiles all valid leaves into a :class:`_CompiledSchema`. All issues
|
|
649
|
+
discovered during this process are reported as :class:`_SchemaProblem`
|
|
633
650
|
instances.
|
|
634
651
|
|
|
635
652
|
Results are cached process-wide: the cache key is the absolute schema
|
|
636
|
-
file path and the value is a tuple ``(
|
|
653
|
+
file path and the value is a tuple ``(_CompiledSchema, list[_SchemaProblem])``.
|
|
637
654
|
Once a schema for a given path has been observed (including the cases when
|
|
638
655
|
it is missing or invalid), subsequent calls always return the cached result
|
|
639
656
|
without re-reading or re-compiling the schema. To pick up on-disk changes
|
|
@@ -642,7 +659,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
642
659
|
|
|
643
660
|
This function never raises exceptions. It always returns the best-effort
|
|
644
661
|
compiled schema together with a list of problems detected during processing
|
|
645
|
-
(an empty ``
|
|
662
|
+
(an empty ``_CompiledSchema`` when the schema is missing or invalid).
|
|
646
663
|
|
|
647
664
|
Performance considerations:
|
|
648
665
|
First compilation of a schema involves file I/O, JSON parsing, and tree
|
|
@@ -665,13 +682,13 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
665
682
|
between releases without preserving backward compatibility.
|
|
666
683
|
|
|
667
684
|
Returns:
|
|
668
|
-
Tuple of (
|
|
685
|
+
Tuple of (_CompiledSchema, list[_SchemaProblem]).
|
|
669
686
|
"""
|
|
670
687
|
schema_path = _get_schema_path()
|
|
671
688
|
|
|
672
|
-
# Fast-path: First check (
|
|
673
|
-
# to compile schema for this path. This
|
|
674
|
-
# case when the schema is already cached.
|
|
689
|
+
# Fast-path: First check (with lock for thread-safety) if we have already attempted
|
|
690
|
+
# to compile schema for this path. This provides thread-safe cache access
|
|
691
|
+
# in the common case when the schema is already cached.
|
|
675
692
|
with _cache_lock:
|
|
676
693
|
cached = _SCHEMA_CACHE.get(schema_path)
|
|
677
694
|
if cached is not None:
|
|
@@ -684,12 +701,12 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
684
701
|
# while holding the lock. If another thread already compiled it, we use that
|
|
685
702
|
# result instead of storing our own (which might be different if the file changed).
|
|
686
703
|
|
|
687
|
-
problems: list[
|
|
704
|
+
problems: list[_SchemaProblem] = []
|
|
688
705
|
|
|
689
706
|
try:
|
|
690
707
|
raw_schema, _ = _load_raw_schema(schema_path)
|
|
691
708
|
except (FileNotFoundError, ValueError) as exc:
|
|
692
|
-
problems.append(
|
|
709
|
+
problems.append(_SchemaProblem(str(exc)))
|
|
693
710
|
result = _create_empty_compiled_schema_with_problems(problems)
|
|
694
711
|
# Double-checked locking: Check cache again while holding lock. Another
|
|
695
712
|
# thread might have compiled the schema (or handled the same error) while
|
|
@@ -708,11 +725,11 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
708
725
|
|
|
709
726
|
# Compile the schema tree into leaves. Each root key becomes a separate
|
|
710
727
|
# tree that we compile recursively. Problems are collected as we go.
|
|
711
|
-
leaves: list[
|
|
728
|
+
leaves: list[_SchemaLeaf] = []
|
|
712
729
|
for key, value in raw_schema.items():
|
|
713
730
|
if not isinstance(value, Mapping):
|
|
714
731
|
problems.append(
|
|
715
|
-
|
|
732
|
+
_SchemaProblem(f"Invalid schema at {key}: expected object"),
|
|
716
733
|
)
|
|
717
734
|
continue
|
|
718
735
|
|
|
@@ -722,7 +739,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
|
|
|
722
739
|
for leaf in _compile_schema_tree(dict(value), (key,), problems):
|
|
723
740
|
leaves.append(leaf)
|
|
724
741
|
|
|
725
|
-
compiled =
|
|
742
|
+
compiled = _CompiledSchema(leaves=leaves)
|
|
726
743
|
result = (compiled, problems)
|
|
727
744
|
|
|
728
745
|
# Double-checked locking: Check cache again while holding lock. Another thread
|
|
@@ -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
|
|
@@ -87,22 +87,22 @@ class SchemaLogger(logging.Logger):
|
|
|
87
87
|
try:
|
|
88
88
|
compiled, problems = _compile_schema_internal()
|
|
89
89
|
except (OSError, ValueError, RuntimeError) as exc:
|
|
90
|
-
# Convert system-level exceptions to
|
|
90
|
+
# Convert system-level exceptions to _SchemaProblem so they can be
|
|
91
91
|
# handled the same way as schema validation problems.
|
|
92
92
|
# - OSError: system-level file system issues (e.g., os.getcwd() failures
|
|
93
93
|
# when the current working directory is inaccessible or deleted).
|
|
94
94
|
# Note: OSError that occurs when reading the schema file (e.g., permission
|
|
95
|
-
# denied, I/O errors) is converted to
|
|
95
|
+
# denied, I/O errors) is converted to _SchemaProblem in _load_raw_schema()
|
|
96
96
|
# and does not reach this exception handler.
|
|
97
97
|
# - ValueError: path resolution issues (e.g., invalid path characters,
|
|
98
98
|
# malformed paths during schema file discovery)
|
|
99
99
|
# - RuntimeError: threading issues (e.g., lock acquisition problems)
|
|
100
100
|
# Note: JSON parsing and schema structure validation errors are
|
|
101
|
-
# converted to
|
|
101
|
+
# converted to _SchemaProblem instances and do not raise ValueError here.
|
|
102
102
|
# Note: System exceptions (KeyboardInterrupt, SystemExit) are not
|
|
103
103
|
# caught, which is the correct behavior.
|
|
104
|
-
problems = [
|
|
105
|
-
compiled =
|
|
104
|
+
problems = [_SchemaProblem(f"Schema compilation failed: {exc}")]
|
|
105
|
+
compiled = _CompiledSchema(leaves=[])
|
|
106
106
|
|
|
107
107
|
if problems:
|
|
108
108
|
# Schema is invalid; log problems and terminate without creating
|
|
@@ -111,7 +111,7 @@ class SchemaLogger(logging.Logger):
|
|
|
111
111
|
|
|
112
112
|
# Schema is valid; create the logger instance.
|
|
113
113
|
super().__init__(name, level)
|
|
114
|
-
self._schema:
|
|
114
|
+
self._schema: _CompiledSchema = compiled
|
|
115
115
|
|
|
116
116
|
def _log(
|
|
117
117
|
self,
|
|
@@ -203,7 +203,7 @@ class SchemaLogger(logging.Logger):
|
|
|
203
203
|
stack_info=False, stacklevel=stacklevel + 1
|
|
204
204
|
)
|
|
205
205
|
# Format error message as JSON for machine processing.
|
|
206
|
-
# Each
|
|
206
|
+
# Each _DataProblem.message is already a JSON string (created by
|
|
207
207
|
# _create_validation_error_json) with structure:
|
|
208
208
|
# {"field": "...", "error": "...", "value": "..."}
|
|
209
209
|
# We parse them back to dicts and combine into a single JSON object
|
|
@@ -226,7 +226,7 @@ class SchemaLogger(logging.Logger):
|
|
|
226
226
|
# create a fallback error object. This should never happen in
|
|
227
227
|
# normal operation since problem.message is always created via
|
|
228
228
|
# _create_validation_error_json, but protects against unexpected
|
|
229
|
-
# data corruption or manual
|
|
229
|
+
# data corruption or manual _DataProblem creation. The fallback
|
|
230
230
|
# preserves the same structure (field, error, value) for
|
|
231
231
|
# consistency.
|
|
232
232
|
validation_errors.append(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|