logging-objects-with-schema 0.1.4__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.4 → logging_objects_with_schema-0.2.0}/PKG-INFO +76 -1
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/README.md +75 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/pyproject.toml +1 -1
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_loader.py +69 -27
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_logger.py +15 -2
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/LICENSE +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/__init__.py +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/errors.py +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.2.0}/src/logging_objects_with_schema/schema_applier.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
|
|
@@ -109,10 +109,12 @@ _TYPE_MAP: Mapping[str, type] = {
|
|
|
109
109
|
# cache stores the result of compiling that file. Both are thread-safe and
|
|
110
110
|
# use double-checked locking to avoid race conditions.
|
|
111
111
|
|
|
112
|
-
# Compiled schema cache: Key is absolute schema_path,
|
|
113
|
-
# (_CompiledSchema, list[_SchemaProblem]).
|
|
114
|
-
# compilations and failures (with problems list).
|
|
115
|
-
_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
|
+
] = {}
|
|
116
118
|
|
|
117
119
|
_cache_lock = threading.RLock()
|
|
118
120
|
|
|
@@ -625,11 +627,30 @@ def _get_builtin_logrecord_attributes() -> set[str]:
|
|
|
625
627
|
|
|
626
628
|
|
|
627
629
|
def _check_root_conflicts(
|
|
628
|
-
schema_dict: Mapping[str, Any],
|
|
630
|
+
schema_dict: Mapping[str, Any],
|
|
631
|
+
problems: list[_SchemaProblem],
|
|
632
|
+
forbidden_keys: set[str] | None = None,
|
|
629
633
|
) -> None:
|
|
630
|
-
"""Check schema root keys for conflicts with reserved logging fields.
|
|
634
|
+
"""Check schema root keys for conflicts with reserved logging fields.
|
|
631
635
|
|
|
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
|
|
632
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
|
|
633
654
|
|
|
634
655
|
for key in schema_dict.keys():
|
|
635
656
|
if key in forbidden_root_keys:
|
|
@@ -640,7 +661,9 @@ def _check_root_conflicts(
|
|
|
640
661
|
)
|
|
641
662
|
|
|
642
663
|
|
|
643
|
-
def _compile_schema_internal(
|
|
664
|
+
def _compile_schema_internal(
|
|
665
|
+
forbidden_keys: set[str] | None = None,
|
|
666
|
+
) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
644
667
|
"""Compile JSON schema into ``_CompiledSchema`` and collect all problems.
|
|
645
668
|
|
|
646
669
|
The function loads the raw JSON schema, validates its structure, checks
|
|
@@ -649,13 +672,22 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
|
649
672
|
discovered during this process are reported as :class:`_SchemaProblem`
|
|
650
673
|
instances.
|
|
651
674
|
|
|
652
|
-
Results are cached process-wide: the cache key is the absolute
|
|
653
|
-
file path and the value is a
|
|
654
|
-
Once a schema for a
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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.
|
|
659
691
|
|
|
660
692
|
This function never raises exceptions. It always returns the best-effort
|
|
661
693
|
compiled schema together with a list of problems detected during processing
|
|
@@ -664,17 +696,18 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
|
664
696
|
Performance considerations:
|
|
665
697
|
First compilation of a schema involves file I/O, JSON parsing, and tree
|
|
666
698
|
traversal. For typical schemas (< 1000 nodes), this takes < 10ms. All
|
|
667
|
-
subsequent calls for the same schema path
|
|
668
|
-
(< 0.1ms). The cache is process-wide and persists
|
|
669
|
-
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.
|
|
670
702
|
|
|
671
703
|
Limitations:
|
|
672
704
|
- Schema changes on disk are not automatically reloaded; the application
|
|
673
705
|
must be restarted to pick up changes
|
|
674
706
|
- Very large schemas (> 10,000 nodes) may cause noticeable compilation
|
|
675
707
|
overhead on first load, but this is uncommon in practice
|
|
676
|
-
- The cache uses absolute file paths as keys, so
|
|
677
|
-
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
|
|
678
711
|
|
|
679
712
|
Note:
|
|
680
713
|
This function is used internally by :class:`SchemaLogger` and by the
|
|
@@ -685,12 +718,21 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
|
685
718
|
Tuple of (_CompiledSchema, list[_SchemaProblem]).
|
|
686
719
|
"""
|
|
687
720
|
schema_path = _get_schema_path()
|
|
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 ()))
|
|
688
730
|
|
|
689
731
|
# 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
|
|
691
|
-
# in the common case when the schema is already cached.
|
|
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.
|
|
692
734
|
with _cache_lock:
|
|
693
|
-
cached = _SCHEMA_CACHE.get(
|
|
735
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
694
736
|
if cached is not None:
|
|
695
737
|
return cached
|
|
696
738
|
|
|
@@ -712,16 +754,16 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
|
712
754
|
# thread might have compiled the schema (or handled the same error) while
|
|
713
755
|
# we were processing the exception. If so, use the cached result.
|
|
714
756
|
with _cache_lock:
|
|
715
|
-
cached = _SCHEMA_CACHE.get(
|
|
757
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
716
758
|
if cached is not None:
|
|
717
759
|
return cached
|
|
718
|
-
_SCHEMA_CACHE[
|
|
760
|
+
_SCHEMA_CACHE[cache_key] = result
|
|
719
761
|
return result
|
|
720
762
|
|
|
721
763
|
# Check root key conflicts before compiling the tree. This allows us to
|
|
722
764
|
# catch conflicts early and report them as schema problems. We do this
|
|
723
765
|
# before tree compilation to avoid unnecessary work if there are conflicts.
|
|
724
|
-
_check_root_conflicts(raw_schema, problems)
|
|
766
|
+
_check_root_conflicts(raw_schema, problems, forbidden_keys)
|
|
725
767
|
|
|
726
768
|
# Compile the schema tree into leaves. Each root key becomes a separate
|
|
727
769
|
# tree that we compile recursively. Problems are collected as we go.
|
|
@@ -748,9 +790,9 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
|
|
|
748
790
|
# instead of overwriting it with our own (which might be different if the file
|
|
749
791
|
# was modified between reads).
|
|
750
792
|
with _cache_lock:
|
|
751
|
-
cached = _SCHEMA_CACHE.get(
|
|
793
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
752
794
|
if cached is not None:
|
|
753
795
|
return cached
|
|
754
|
-
_SCHEMA_CACHE[
|
|
796
|
+
_SCHEMA_CACHE[cache_key] = result
|
|
755
797
|
|
|
756
798
|
return result
|
|
@@ -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,12 +85,20 @@ 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
103
|
# Convert system-level exceptions to _SchemaProblem so they can be
|
|
91
104
|
# handled the same way as schema validation problems.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|