logging-objects-with-schema 0.1.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.
- logging_objects_with_schema/__init__.py +17 -0
- logging_objects_with_schema/errors.py +85 -0
- logging_objects_with_schema/schema_applier.py +184 -0
- logging_objects_with_schema/schema_loader.py +474 -0
- logging_objects_with_schema/schema_logger.py +118 -0
- logging_objects_with_schema-0.1.0.dist-info/METADATA +440 -0
- logging_objects_with_schema-0.1.0.dist-info/RECORD +9 -0
- logging_objects_with_schema-0.1.0.dist-info/WHEEL +4 -0
- logging_objects_with_schema-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Schema loading and validation for logging_objects_with_schema.
|
|
2
|
+
|
|
3
|
+
The schema is expected to live in a JSON file named
|
|
4
|
+
``logging_objects_with_schema.json`` in the application root
|
|
5
|
+
directory. The schema defines which extra fields are allowed to be
|
|
6
|
+
emitted and which Python types they must have.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import threading
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Tuple
|
|
18
|
+
|
|
19
|
+
from .errors import SchemaProblem
|
|
20
|
+
|
|
21
|
+
SCHEMA_FILE_NAME = "logging_objects_with_schema.json"
|
|
22
|
+
|
|
23
|
+
# Maximum allowed depth for schema nesting (protection against DoS)
|
|
24
|
+
MAX_SCHEMA_DEPTH = 100
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SchemaLeaf:
|
|
29
|
+
"""Represents a single leaf in the schema tree.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
path: Full path of keys from the schema root to this leaf.
|
|
33
|
+
source: Name of the field in the ``extra`` mapping.
|
|
34
|
+
expected_type: Expected Python type of the value (str, int, float, bool, list).
|
|
35
|
+
item_expected_type: Expected Python type of list elements when
|
|
36
|
+
``expected_type`` is ``list``. For non-list leaves this is ``None``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
path: Tuple[str, ...]
|
|
40
|
+
source: str
|
|
41
|
+
expected_type: type
|
|
42
|
+
item_expected_type: type | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class CompiledSchema:
|
|
47
|
+
"""Internal representation of a compiled schema."""
|
|
48
|
+
|
|
49
|
+
leaves: List[SchemaLeaf]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_empty(self) -> bool:
|
|
53
|
+
"""Return True if there are no valid leaves."""
|
|
54
|
+
|
|
55
|
+
return not self.leaves
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_TYPE_MAP: Mapping[str, type] = {
|
|
59
|
+
"str": str,
|
|
60
|
+
"int": int,
|
|
61
|
+
"float": float,
|
|
62
|
+
"bool": bool,
|
|
63
|
+
"list": list,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Module-level cache for compiled schemas.
|
|
67
|
+
# Key: absolute schema_path, Value: (CompiledSchema, List[SchemaProblem])
|
|
68
|
+
# This cache is thread-safe: all read and write operations are protected by
|
|
69
|
+
# _cache_lock.
|
|
70
|
+
_SCHEMA_CACHE: Dict[Path, Tuple[CompiledSchema, List[SchemaProblem]]] = {}
|
|
71
|
+
|
|
72
|
+
_cache_lock = threading.RLock()
|
|
73
|
+
|
|
74
|
+
# Cache for resolved schema path (to avoid re-searching when CWD changes)
|
|
75
|
+
_resolved_schema_path: Path | None = None
|
|
76
|
+
# CWD that was used when caching a path for a file that was not found
|
|
77
|
+
# None means the cached path is an absolute path to a found file (CWD-independent)
|
|
78
|
+
# Non-None means the cached path is an absolute path based on CWD
|
|
79
|
+
# when file was not found
|
|
80
|
+
_cached_cwd: Path | None = None
|
|
81
|
+
_path_cache_lock = threading.Lock()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _find_schema_file() -> Path | None:
|
|
85
|
+
"""Search for schema file by walking up the directory tree.
|
|
86
|
+
|
|
87
|
+
Starts from current working directory and searches upward for the schema file.
|
|
88
|
+
If found, returns the absolute path to the file. If not found, returns None.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Absolute path to schema file if found, None otherwise.
|
|
92
|
+
"""
|
|
93
|
+
start_path = Path(os.getcwd()).resolve()
|
|
94
|
+
current = start_path
|
|
95
|
+
|
|
96
|
+
while True:
|
|
97
|
+
schema_path = current / SCHEMA_FILE_NAME
|
|
98
|
+
if schema_path.exists():
|
|
99
|
+
return schema_path.resolve()
|
|
100
|
+
|
|
101
|
+
# Move to parent directory
|
|
102
|
+
parent = current.parent
|
|
103
|
+
if parent == current:
|
|
104
|
+
# Reached filesystem root, stop searching
|
|
105
|
+
break
|
|
106
|
+
current = parent
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_schema_path() -> Path:
|
|
112
|
+
"""Resolve the absolute path to the JSON schema file with caching semantics.
|
|
113
|
+
|
|
114
|
+
The function searches for ``SCHEMA_FILE_NAME`` by walking upward from the
|
|
115
|
+
current working directory and caches the result:
|
|
116
|
+
|
|
117
|
+
- When the schema file is found, its absolute path is cached as
|
|
118
|
+
CWD-independent. Subsequent calls reuse this path as long as the file
|
|
119
|
+
still exists on disk.
|
|
120
|
+
- When the schema file is not found, an absolute path based on the current
|
|
121
|
+
CWD is cached (the directory where the file was expected but missing).
|
|
122
|
+
This cache entry is tied to the CWD and is invalidated automatically
|
|
123
|
+
if the process changes CWD, so that a new search can be performed.
|
|
124
|
+
|
|
125
|
+
In both cases the returned path may or may not exist and is suitable for
|
|
126
|
+
reporting clear error messages to the caller.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Absolute path where the schema file is located or expected to be.
|
|
130
|
+
"""
|
|
131
|
+
global _resolved_schema_path, _cached_cwd
|
|
132
|
+
|
|
133
|
+
with _path_cache_lock:
|
|
134
|
+
if _resolved_schema_path is not None:
|
|
135
|
+
# If cached path is an absolute path to a found file (CWD-independent)
|
|
136
|
+
if _cached_cwd is None:
|
|
137
|
+
# Schema file was found, absolute path doesn't depend on CWD
|
|
138
|
+
# Return it if it still exists
|
|
139
|
+
if _resolved_schema_path.exists():
|
|
140
|
+
return _resolved_schema_path
|
|
141
|
+
# If cached path doesn't exist, re-search (schema might have been moved)
|
|
142
|
+
_resolved_schema_path = None
|
|
143
|
+
else:
|
|
144
|
+
# Cached path is based on CWD when file was not found,
|
|
145
|
+
# check if CWD changed
|
|
146
|
+
current_cwd = Path(os.getcwd()).resolve()
|
|
147
|
+
if current_cwd != _cached_cwd:
|
|
148
|
+
# CWD changed, invalidate cache and re-search from new CWD
|
|
149
|
+
_resolved_schema_path = None
|
|
150
|
+
_cached_cwd = None
|
|
151
|
+
else:
|
|
152
|
+
# CWD unchanged, return cached path
|
|
153
|
+
return _resolved_schema_path
|
|
154
|
+
|
|
155
|
+
# Search for schema file
|
|
156
|
+
found_path = _find_schema_file()
|
|
157
|
+
if found_path is not None:
|
|
158
|
+
_resolved_schema_path = found_path
|
|
159
|
+
_cached_cwd = None # Absolute path doesn't depend on CWD
|
|
160
|
+
return found_path
|
|
161
|
+
|
|
162
|
+
# Schema file not found, return absolute path in current working directory
|
|
163
|
+
# (this path may not exist, but allows caller to report proper error)
|
|
164
|
+
current_cwd = Path(os.getcwd()).resolve()
|
|
165
|
+
schema_path = (current_cwd / SCHEMA_FILE_NAME).resolve()
|
|
166
|
+
_resolved_schema_path = schema_path
|
|
167
|
+
_cached_cwd = current_cwd # Track CWD since path depends on it
|
|
168
|
+
return schema_path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _load_raw_schema() -> Tuple[Dict[str, Any], Path]:
|
|
172
|
+
"""Load raw JSON schema from the application root.
|
|
173
|
+
|
|
174
|
+
This function always attempts to read the schema file and records
|
|
175
|
+
any problems as :class:`SchemaProblem` instances.
|
|
176
|
+
|
|
177
|
+
Note:
|
|
178
|
+
This helper is part of the internal implementation and is not
|
|
179
|
+
considered a public API.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple of (schema data, schema file path).
|
|
183
|
+
"""
|
|
184
|
+
schema_path = _get_schema_path()
|
|
185
|
+
|
|
186
|
+
if not schema_path.exists():
|
|
187
|
+
# Let the caller decide how to report this.
|
|
188
|
+
raise FileNotFoundError(f"Schema file not found: {schema_path}")
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
with schema_path.open("r", encoding="utf-8") as f:
|
|
192
|
+
data = json.load(f)
|
|
193
|
+
except OSError as exc:
|
|
194
|
+
# Normalise I/O errors to ValueError so that _compile_schema_internal()
|
|
195
|
+
# can report them as SchemaProblem instances instead of leaking raw
|
|
196
|
+
# OSError to callers.
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Failed to read schema file {schema_path}: {exc}",
|
|
199
|
+
) from exc
|
|
200
|
+
except json.JSONDecodeError as exc:
|
|
201
|
+
raise ValueError(f"Failed to parse JSON schema: {exc}") from exc
|
|
202
|
+
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
# Normalise non-object top-level schemas into a ValueError so that the
|
|
205
|
+
# caller can report it as a SchemaProblem while keeping type safety.
|
|
206
|
+
raise ValueError("Top-level schema must be a JSON object")
|
|
207
|
+
|
|
208
|
+
return data, schema_path
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _compile_schema_tree(
|
|
212
|
+
node: MutableMapping[str, Any],
|
|
213
|
+
path: Tuple[str, ...],
|
|
214
|
+
problems: List[SchemaProblem],
|
|
215
|
+
) -> Iterable[SchemaLeaf]:
|
|
216
|
+
"""Recursively compile a schema node into SchemaLeaf objects.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
node: The schema node to compile.
|
|
220
|
+
path: Current path in the schema tree.
|
|
221
|
+
problems: List to collect validation problems.
|
|
222
|
+
|
|
223
|
+
Yields:
|
|
224
|
+
SchemaLeaf objects found in the tree.
|
|
225
|
+
"""
|
|
226
|
+
# Check for excessive nesting depth
|
|
227
|
+
if len(path) > MAX_SCHEMA_DEPTH:
|
|
228
|
+
problems.append(
|
|
229
|
+
SchemaProblem(
|
|
230
|
+
f"Schema nesting depth exceeds maximum allowed depth of "
|
|
231
|
+
f"{MAX_SCHEMA_DEPTH} at path {'.'.join(path)}"
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
for key, value in node.items():
|
|
237
|
+
if not isinstance(value, Mapping):
|
|
238
|
+
problems.append(
|
|
239
|
+
SchemaProblem(
|
|
240
|
+
f"Invalid schema at {'.'.join(path + (key,))}: expected object"
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
value_dict = dict(value)
|
|
246
|
+
leaf_type = value_dict.get("type")
|
|
247
|
+
leaf_source = value_dict.get("source")
|
|
248
|
+
|
|
249
|
+
if leaf_type is not None or leaf_source is not None:
|
|
250
|
+
# This is supposed to be a leaf - validate required fields first.
|
|
251
|
+
type_invalid = leaf_type is None or (
|
|
252
|
+
isinstance(leaf_type, str) and leaf_type.strip() == ""
|
|
253
|
+
)
|
|
254
|
+
source_invalid = leaf_source is None or (
|
|
255
|
+
isinstance(leaf_source, str) and leaf_source.strip() == ""
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if type_invalid:
|
|
259
|
+
problems.append(
|
|
260
|
+
SchemaProblem(
|
|
261
|
+
f"Incomplete leaf at {'.'.join(path + (key,))}: "
|
|
262
|
+
f"type cannot be None or empty",
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if source_invalid:
|
|
267
|
+
problems.append(
|
|
268
|
+
SchemaProblem(
|
|
269
|
+
f"Incomplete leaf at {'.'.join(path + (key,))}: "
|
|
270
|
+
f"source cannot be None or empty",
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if type_invalid or source_invalid:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
expected_type = _TYPE_MAP.get(str(leaf_type))
|
|
278
|
+
if expected_type is None:
|
|
279
|
+
problems.append(
|
|
280
|
+
SchemaProblem(
|
|
281
|
+
f"Unknown type '{leaf_type}' at {'.'.join(path + (key,))}",
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
item_expected_type: type | None = None
|
|
287
|
+
# For list-typed leaves we require an explicit, primitive item_type
|
|
288
|
+
# to ensure element homogeneity (e.g. list[str], list[int]).
|
|
289
|
+
if expected_type is list:
|
|
290
|
+
item_type_name = value_dict.get("item_type")
|
|
291
|
+
item_type_invalid = item_type_name is None or (
|
|
292
|
+
isinstance(item_type_name, str) and item_type_name.strip() == ""
|
|
293
|
+
)
|
|
294
|
+
if item_type_invalid:
|
|
295
|
+
problems.append(
|
|
296
|
+
SchemaProblem(
|
|
297
|
+
f"Incomplete leaf at {'.'.join(path + (key,))}: "
|
|
298
|
+
f"item_type is required for list type and "
|
|
299
|
+
f"cannot be None or empty",
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
item_expected_type = _TYPE_MAP.get(str(item_type_name))
|
|
305
|
+
# Item type must be a primitive (str, int, float, bool), not list
|
|
306
|
+
if item_expected_type is None or item_expected_type is list:
|
|
307
|
+
problems.append(
|
|
308
|
+
SchemaProblem(
|
|
309
|
+
f"Invalid item_type '{item_type_name}' at "
|
|
310
|
+
f"{'.'.join(path + (key,))}: only primitive item types "
|
|
311
|
+
f"('str', 'int', 'float', 'bool') are allowed for lists",
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
yield SchemaLeaf(
|
|
317
|
+
path=path + (key,),
|
|
318
|
+
source=str(leaf_source),
|
|
319
|
+
expected_type=expected_type,
|
|
320
|
+
item_expected_type=item_expected_type,
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
# This is an inner node; recurse into children.
|
|
324
|
+
for child_leaf in _compile_schema_tree(value_dict, path + (key,), problems):
|
|
325
|
+
yield child_leaf
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_builtin_logrecord_attributes() -> set[str]:
|
|
329
|
+
"""Get set of standard LogRecord attribute names.
|
|
330
|
+
|
|
331
|
+
This function extracts attribute names from LogRecord that represent
|
|
332
|
+
system fields and should not be used as root keys in the schema or
|
|
333
|
+
treated as user-provided extra fields.
|
|
334
|
+
|
|
335
|
+
The function creates a minimal LogRecord instance and uses introspection
|
|
336
|
+
to discover all non-callable, non-private attributes. This is the standard
|
|
337
|
+
way to get attribute names from a class instance in Python.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Set of attribute names that are reserved by the logging system.
|
|
341
|
+
Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc.
|
|
342
|
+
|
|
343
|
+
Example:
|
|
344
|
+
>>> forbidden = get_builtin_logrecord_attributes()
|
|
345
|
+
>>> "name" in forbidden
|
|
346
|
+
True
|
|
347
|
+
>>> "ServicePayload" in forbidden
|
|
348
|
+
False
|
|
349
|
+
"""
|
|
350
|
+
# Create a minimal LogRecord instance to inspect its attributes
|
|
351
|
+
# This is necessary because LogRecord attributes are not defined as
|
|
352
|
+
# class attributes but are set in __init__
|
|
353
|
+
record = logging.LogRecord(
|
|
354
|
+
name="",
|
|
355
|
+
level=0,
|
|
356
|
+
pathname="",
|
|
357
|
+
lineno=0,
|
|
358
|
+
msg="",
|
|
359
|
+
args=(),
|
|
360
|
+
exc_info=None,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Use dir() to get all attributes, then filter out:
|
|
364
|
+
# - Private attributes (starting with _)
|
|
365
|
+
# - Callable attributes (methods)
|
|
366
|
+
# This leaves only data fields that represent actual LogRecord attributes
|
|
367
|
+
forbidden = set()
|
|
368
|
+
for attr_name in dir(record):
|
|
369
|
+
if attr_name.startswith("_"):
|
|
370
|
+
continue
|
|
371
|
+
attr_value = getattr(record, attr_name, None)
|
|
372
|
+
if not callable(attr_value):
|
|
373
|
+
forbidden.add(attr_name)
|
|
374
|
+
|
|
375
|
+
return forbidden
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _check_root_conflicts(
|
|
379
|
+
schema_dict: Mapping[str, Any], problems: List[SchemaProblem]
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Check schema root keys for conflicts with reserved logging fields."""
|
|
382
|
+
|
|
383
|
+
forbidden_root_keys = get_builtin_logrecord_attributes()
|
|
384
|
+
|
|
385
|
+
for key in schema_dict.keys():
|
|
386
|
+
if key in forbidden_root_keys:
|
|
387
|
+
problems.append(
|
|
388
|
+
SchemaProblem(
|
|
389
|
+
f"Root key '{key}' conflicts with reserved logging fields",
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _compile_schema_internal() -> Tuple[CompiledSchema, List[SchemaProblem]]:
|
|
395
|
+
"""Compile JSON schema into ``CompiledSchema`` and collect all problems.
|
|
396
|
+
|
|
397
|
+
The function loads the raw JSON schema, validates its structure, checks
|
|
398
|
+
root keys for conflicts with reserved ``logging.LogRecord`` attributes
|
|
399
|
+
and compiles all valid leaves into a :class:`CompiledSchema`. All issues
|
|
400
|
+
discovered during this process are reported as :class:`SchemaProblem`
|
|
401
|
+
instances.
|
|
402
|
+
|
|
403
|
+
Results are cached process-wide: the cache key is the absolute schema
|
|
404
|
+
file path and the value is a tuple ``(CompiledSchema, List[SchemaProblem])``.
|
|
405
|
+
Once a schema for a given path has been observed (including the cases when
|
|
406
|
+
it is missing or invalid), subsequent calls always return the cached result
|
|
407
|
+
without re-reading or re-compiling the schema. To pick up on-disk changes
|
|
408
|
+
to the schema, the application must restart the process. See the README
|
|
409
|
+
section \"Schema caching and thread safety\" for more details.
|
|
410
|
+
|
|
411
|
+
This function never raises ``SchemaValidationError``. It always returns
|
|
412
|
+
the best-effort compiled schema together with a list of problems detected
|
|
413
|
+
during processing (an empty ``CompiledSchema`` when the schema is missing
|
|
414
|
+
or invalid).
|
|
415
|
+
|
|
416
|
+
Note:
|
|
417
|
+
This function is used internally by :class:`SchemaLogger` and by the
|
|
418
|
+
test suite. It is not part of the public package API and may change
|
|
419
|
+
between releases without preserving backward compatibility.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Tuple of (CompiledSchema, List[SchemaProblem]).
|
|
423
|
+
"""
|
|
424
|
+
schema_path = _get_schema_path().resolve()
|
|
425
|
+
|
|
426
|
+
# Fast-path: if we have already attempted to compile schema for this path,
|
|
427
|
+
# return cached result (successful, missing-file, or invalid-schema).
|
|
428
|
+
with _cache_lock:
|
|
429
|
+
cached = _SCHEMA_CACHE.get(schema_path)
|
|
430
|
+
if cached is not None:
|
|
431
|
+
return cached
|
|
432
|
+
|
|
433
|
+
problems: List[SchemaProblem] = []
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
raw_schema, loaded_path = _load_raw_schema()
|
|
437
|
+
except FileNotFoundError as exc:
|
|
438
|
+
problems.append(SchemaProblem(str(exc)))
|
|
439
|
+
result = (CompiledSchema(leaves=[]), problems)
|
|
440
|
+
with _cache_lock:
|
|
441
|
+
_SCHEMA_CACHE[schema_path] = result
|
|
442
|
+
return result
|
|
443
|
+
except ValueError as exc:
|
|
444
|
+
problems.append(SchemaProblem(str(exc)))
|
|
445
|
+
result = (CompiledSchema(leaves=[]), problems)
|
|
446
|
+
with _cache_lock:
|
|
447
|
+
_SCHEMA_CACHE[schema_path] = result
|
|
448
|
+
return result
|
|
449
|
+
|
|
450
|
+
_check_root_conflicts(raw_schema, problems)
|
|
451
|
+
|
|
452
|
+
leaves: List[SchemaLeaf] = []
|
|
453
|
+
for key, value in raw_schema.items():
|
|
454
|
+
if not isinstance(value, Mapping):
|
|
455
|
+
problems.append(
|
|
456
|
+
SchemaProblem(f"Invalid schema at {key}: expected object"),
|
|
457
|
+
)
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
for leaf in _compile_schema_tree(dict(value), (key,), problems):
|
|
461
|
+
leaves.append(leaf)
|
|
462
|
+
|
|
463
|
+
compiled = CompiledSchema(leaves=leaves)
|
|
464
|
+
result = (compiled, problems)
|
|
465
|
+
|
|
466
|
+
# Cache the result for this schema path (thread-safe write).
|
|
467
|
+
# NOTE: ``loaded_path`` is expected to be identical to ``schema_path`` here
|
|
468
|
+
# because both are derived from ``_get_schema_path()``. We still use
|
|
469
|
+
# ``schema_path`` as the cache key consistently to avoid any confusion if
|
|
470
|
+
# ``_load_raw_schema()`` implementation changes in the future.
|
|
471
|
+
with _cache_lock:
|
|
472
|
+
_SCHEMA_CACHE[schema_path] = result
|
|
473
|
+
|
|
474
|
+
return result
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Logger subclass that applies a JSON schema to extra fields.
|
|
2
|
+
|
|
3
|
+
This class extends the standard ``logging.Logger`` to validate and filter
|
|
4
|
+
user-provided ``extra`` fields according to a compiled JSON schema. It raises
|
|
5
|
+
a DataValidationError *after* the log record has been emitted when problems
|
|
6
|
+
are detected.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import DataValidationError, SchemaValidationError
|
|
16
|
+
from .schema_applier import _apply_schema_internal
|
|
17
|
+
from .schema_loader import CompiledSchema, _compile_schema_internal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SchemaLogger(logging.Logger):
|
|
21
|
+
"""Logger subclass that enforces schema on extra fields.
|
|
22
|
+
|
|
23
|
+
This class extends :class:`logging.Logger` to add validation for
|
|
24
|
+
``extra`` fields according to a compiled JSON schema. It is designed
|
|
25
|
+
to be used as a drop-in replacement via :func:`logging.setLoggerClass`.
|
|
26
|
+
|
|
27
|
+
The schema is loaded from ``logging_objects_with_schema.json`` in the
|
|
28
|
+
application root directory during initialization. If the schema cannot
|
|
29
|
+
be loaded or validated, a :class:`SchemaValidationError` is raised.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
>>> import logging
|
|
33
|
+
>>> from logging_objects_with_schema import SchemaLogger
|
|
34
|
+
>>> logging.setLoggerClass(SchemaLogger)
|
|
35
|
+
>>> logger = logging.getLogger("my_service")
|
|
36
|
+
>>> logger.info("request processed", extra={"request_id": "abc-123"})
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, name: str, level: int = logging.NOTSET) -> None:
|
|
40
|
+
"""Initialise the schema-aware logger.
|
|
41
|
+
|
|
42
|
+
The schema is compiled once during construction. If any
|
|
43
|
+
problems are detected in the schema, a SchemaValidationError
|
|
44
|
+
will be raised and this logger instance is not usable.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
name: Logger name (same as :class:`logging.Logger`).
|
|
48
|
+
level: Logger level (same as :class:`logging.Logger`).
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(name, level)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
compiled, problems = _compile_schema_internal()
|
|
54
|
+
except Exception:
|
|
55
|
+
# Ensure that a partially initialised logger instance is not left
|
|
56
|
+
# registered in the logging manager if schema compilation fails.
|
|
57
|
+
# Otherwise, subsequent logging.getLogger(name) calls could return
|
|
58
|
+
# this broken instance and lead to AttributeError at runtime.
|
|
59
|
+
self.manager.loggerDict.pop(self.name, None)
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
if problems:
|
|
63
|
+
# Schema is invalid; remove this instance from the logging manager
|
|
64
|
+
# cache before raising, for the same reason as above.
|
|
65
|
+
self.manager.loggerDict.pop(self.name, None)
|
|
66
|
+
raise SchemaValidationError("Schema has problems", problems=problems)
|
|
67
|
+
|
|
68
|
+
self._schema: CompiledSchema = compiled
|
|
69
|
+
|
|
70
|
+
def _log(
|
|
71
|
+
self,
|
|
72
|
+
level: int,
|
|
73
|
+
msg: object,
|
|
74
|
+
args: tuple[object, ...] | Mapping[str, object],
|
|
75
|
+
exc_info: Any = None,
|
|
76
|
+
extra: Mapping[str, object] | None = None,
|
|
77
|
+
stack_info: bool = False,
|
|
78
|
+
stacklevel: int = 1,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Log a message with the specified level and schema-validated extra.
|
|
81
|
+
|
|
82
|
+
This method validates and filters the ``extra`` parameter according
|
|
83
|
+
to the compiled schema before delegating to the parent class.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
level: Logging level.
|
|
87
|
+
msg: Message format string.
|
|
88
|
+
args: Arguments for message formatting.
|
|
89
|
+
exc_info: Exception information.
|
|
90
|
+
extra: Extra fields to include in the log record.
|
|
91
|
+
stack_info: Whether to include stack information.
|
|
92
|
+
stacklevel: Stack level for caller information.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
DataValidationError: If the ``extra`` data does not match the schema.
|
|
96
|
+
"""
|
|
97
|
+
structured_extra, data_problems = _apply_schema_internal(
|
|
98
|
+
self._schema,
|
|
99
|
+
extra or {},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
super()._log(
|
|
103
|
+
level,
|
|
104
|
+
msg,
|
|
105
|
+
args,
|
|
106
|
+
exc_info=exc_info,
|
|
107
|
+
extra=structured_extra,
|
|
108
|
+
stack_info=stack_info,
|
|
109
|
+
# Increment stacklevel to account for this override frame so that
|
|
110
|
+
# caller information points to user code instead of SchemaLogger._log.
|
|
111
|
+
stacklevel=stacklevel + 1,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if data_problems:
|
|
115
|
+
raise DataValidationError(
|
|
116
|
+
"Log data does not match schema",
|
|
117
|
+
problems=data_problems,
|
|
118
|
+
)
|