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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logging-objects-with-schema
3
- Version: 0.1.4
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.1.4"
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"
@@ -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, Value is tuple of
113
- # (_CompiledSchema, list[_SchemaProblem]). This cache stores both successful
114
- # compilations and failures (with problems list).
115
- _SCHEMA_CACHE: dict[Path, tuple[_CompiledSchema, list[_SchemaProblem]]] = {}
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], problems: list[_SchemaProblem]
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() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
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 schema
653
- file path and the value is a tuple ``(_CompiledSchema, list[_SchemaProblem])``.
654
- Once a schema for a given path has been observed (including the cases when
655
- it is missing or invalid), subsequent calls always return the cached result
656
- without re-reading or re-compiling the schema. To pick up on-disk changes
657
- to the schema, the application must restart the process. See the README
658
- section \"Schema caching and thread safety\" for more details.
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 return immediately from cache
668
- (< 0.1ms). The cache is process-wide and persists for the application
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 schema files must be
677
- accessible via the same path throughout the application lifetime
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 cache access
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(schema_path)
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(schema_path)
757
+ cached = _SCHEMA_CACHE.get(cache_key)
716
758
  if cached is not None:
717
759
  return cached
718
- _SCHEMA_CACHE[schema_path] = result
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(schema_path)
793
+ cached = _SCHEMA_CACHE.get(cache_key)
752
794
  if cached is not None:
753
795
  return cached
754
- _SCHEMA_CACHE[schema_path] = result
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__(self, name: str, level: int = logging.NOTSET) -> None:
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.