logging-objects-with-schema 0.1.4__tar.gz → 0.3.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.3.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
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Programming Language :: Python :: 3 :: Only
17
17
  Classifier: Intended Audience :: Developers
18
18
  Classifier: Topic :: System :: Logging
19
+ Classifier: Typing :: Typed
19
20
  Requires-Dist: pytest>=7.0.0 ; extra == 'dev'
20
21
  Requires-Dist: pytest-cov>=4.0.0 ; extra == 'dev'
21
22
  Requires-Dist: black>=23.0.0 ; extra == 'dev'
@@ -213,11 +214,27 @@ An example of a valid empty schema (no leaves, no problems):
213
214
 
214
215
  **Schema structure:**
215
216
 
216
- - **Inner nodes**: Objects without `type` and `source` fields (used for nesting).
217
- - **Leaf nodes**: Objects with both `type` and `source` fields. A valid leaf
218
- must have both fields present and non-empty.
217
+ - **Inner nodes**: Objects that contain child nodes (any fields that are objects).
218
+ Inner nodes cannot have `type` or `source` as strings (they can only have
219
+ these fields as objects, which are child nodes). Inner nodes must have at
220
+ least one child node.
221
+ - **Leaf nodes**: Objects with `type` and `source` fields as strings (properties).
222
+ A valid leaf must have both `type` and `source` present and non-empty. Leaf
223
+ nodes cannot have child nodes (any fields that are objects).
224
+ - **Node validation rules**:
225
+ - A node cannot be both a leaf and an inner node (cannot have both properties
226
+ and children simultaneously).
227
+ - A node cannot be empty (must be either a leaf with properties or an inner
228
+ node with children).
229
+ - Fields `type`, `source`, and `item_type` can be used as child node names
230
+ in inner nodes (as objects), but then they are treated as children, not
231
+ as leaf properties.
219
232
  - **`type`**: One of `"str"`, `"int"`, `"float"`, `"bool"`, or `"list"`.
233
+ Must be a string for leaf nodes.
220
234
  - **`source`**: The name of the field in `extra` from which the value is taken.
235
+ Must be a string for leaf nodes.
236
+ - **`item_type`**: Optional field for list-typed leaves. Must be a string if
237
+ present. See "List-typed fields" section below.
221
238
  - **Root key restrictions**: Root keys cannot conflict with standard `logging`
222
239
  module fields (e.g., `name`, `levelno`, `pathname`). Such conflicts cause
223
240
  schema validation to fail.
@@ -283,3 +300,78 @@ Example:
283
300
 
284
301
  With `extra={"request_id": "abc-123"}`, the value appears in both
285
302
  `ServicePayload.RequestID` and `ServicePayload.Metadata.ID`.
303
+
304
+ ## Inheritance and custom forbidden root keys
305
+
306
+ `SchemaLogger` supports inheritance, allowing subclasses to add additional
307
+ forbidden root keys for schema validation. This is useful when you need to
308
+ prevent certain root keys from being used in your schema beyond the builtin
309
+ `logging.LogRecord` attributes.
310
+
311
+ ### Basic inheritance
312
+
313
+ Each subclass can pass the `forbidden_keys` parameter to the parent's
314
+ `__init__()` method. The builtin set of forbidden keys (standard `logging.LogRecord`
315
+ attributes) is always present and cannot be replaced - additional keys are
316
+ merged with the builtin set.
317
+
318
+ Example:
319
+
320
+ ```python
321
+ from logging_objects_with_schema import SchemaLogger
322
+ import logging
323
+
324
+ class MyLogger(SchemaLogger):
325
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
326
+ # Add custom forbidden keys
327
+ super().__init__(name, level, forbidden_keys={"custom_forbidden_key"})
328
+ ```
329
+
330
+ ### Multi-level inheritance
331
+
332
+ When creating a hierarchy of loggers, each subclass can pass `forbidden_keys`
333
+ from its own subclasses to the parent, merging them with its own set. The
334
+ library does not automatically propagate keys up the inheritance chain - each
335
+ subclass must implement this logic itself.
336
+
337
+ Example:
338
+
339
+ ```python
340
+ from logging_objects_with_schema import SchemaLogger
341
+ import logging
342
+
343
+ class ParentLogger(SchemaLogger):
344
+ def __init__(
345
+ self, name: str, level: int = logging.NOTSET, forbidden_keys: set[str] | None = None
346
+ ) -> None:
347
+ # Merge parent's keys with keys from child
348
+ parent_keys = {"parent_forbidden_key"}
349
+ if forbidden_keys:
350
+ parent_keys = parent_keys | forbidden_keys
351
+ super().__init__(name, level, forbidden_keys=parent_keys)
352
+
353
+ class ChildLogger(ParentLogger):
354
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
355
+ # Pass child's keys to parent, which will merge them
356
+ super().__init__(name, level, forbidden_keys={"child_forbidden_key"})
357
+ ```
358
+
359
+ In this example, the final set of forbidden keys will be:
360
+
361
+ - Builtin `logging.LogRecord` attributes (always present)
362
+ - `parent_forbidden_key` (from `ParentLogger`)
363
+ - `child_forbidden_key` (from `ChildLogger`)
364
+
365
+ All keys are merged together - they are not replaced, only supplemented.
366
+
367
+ ### Important notes
368
+
369
+ - The builtin set of forbidden keys (standard `logging.LogRecord` attributes)
370
+ is always present and cannot be replaced or removed
371
+ - Additional forbidden keys are merged with the builtin set, not replaced
372
+ - Each subclass must implement the logic to pass `forbidden_keys` to its parent
373
+ if it wants to propagate keys from its own subclasses
374
+ - The `forbidden_keys` parameter is optional - if not provided, only builtin
375
+ keys are used, maintaining 100% backward compatibility
376
+ - `None` and empty `set()` are semantically equivalent for `forbidden_keys` -
377
+ both mean "no additional forbidden keys" and produce the same result
@@ -180,11 +180,27 @@ An example of a valid empty schema (no leaves, no problems):
180
180
 
181
181
  **Schema structure:**
182
182
 
183
- - **Inner nodes**: Objects without `type` and `source` fields (used for nesting).
184
- - **Leaf nodes**: Objects with both `type` and `source` fields. A valid leaf
185
- must have both fields present and non-empty.
183
+ - **Inner nodes**: Objects that contain child nodes (any fields that are objects).
184
+ Inner nodes cannot have `type` or `source` as strings (they can only have
185
+ these fields as objects, which are child nodes). Inner nodes must have at
186
+ least one child node.
187
+ - **Leaf nodes**: Objects with `type` and `source` fields as strings (properties).
188
+ A valid leaf must have both `type` and `source` present and non-empty. Leaf
189
+ nodes cannot have child nodes (any fields that are objects).
190
+ - **Node validation rules**:
191
+ - A node cannot be both a leaf and an inner node (cannot have both properties
192
+ and children simultaneously).
193
+ - A node cannot be empty (must be either a leaf with properties or an inner
194
+ node with children).
195
+ - Fields `type`, `source`, and `item_type` can be used as child node names
196
+ in inner nodes (as objects), but then they are treated as children, not
197
+ as leaf properties.
186
198
  - **`type`**: One of `"str"`, `"int"`, `"float"`, `"bool"`, or `"list"`.
199
+ Must be a string for leaf nodes.
187
200
  - **`source`**: The name of the field in `extra` from which the value is taken.
201
+ Must be a string for leaf nodes.
202
+ - **`item_type`**: Optional field for list-typed leaves. Must be a string if
203
+ present. See "List-typed fields" section below.
188
204
  - **Root key restrictions**: Root keys cannot conflict with standard `logging`
189
205
  module fields (e.g., `name`, `levelno`, `pathname`). Such conflicts cause
190
206
  schema validation to fail.
@@ -250,3 +266,78 @@ Example:
250
266
 
251
267
  With `extra={"request_id": "abc-123"}`, the value appears in both
252
268
  `ServicePayload.RequestID` and `ServicePayload.Metadata.ID`.
269
+
270
+ ## Inheritance and custom forbidden root keys
271
+
272
+ `SchemaLogger` supports inheritance, allowing subclasses to add additional
273
+ forbidden root keys for schema validation. This is useful when you need to
274
+ prevent certain root keys from being used in your schema beyond the builtin
275
+ `logging.LogRecord` attributes.
276
+
277
+ ### Basic inheritance
278
+
279
+ Each subclass can pass the `forbidden_keys` parameter to the parent's
280
+ `__init__()` method. The builtin set of forbidden keys (standard `logging.LogRecord`
281
+ attributes) is always present and cannot be replaced - additional keys are
282
+ merged with the builtin set.
283
+
284
+ Example:
285
+
286
+ ```python
287
+ from logging_objects_with_schema import SchemaLogger
288
+ import logging
289
+
290
+ class MyLogger(SchemaLogger):
291
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
292
+ # Add custom forbidden keys
293
+ super().__init__(name, level, forbidden_keys={"custom_forbidden_key"})
294
+ ```
295
+
296
+ ### Multi-level inheritance
297
+
298
+ When creating a hierarchy of loggers, each subclass can pass `forbidden_keys`
299
+ from its own subclasses to the parent, merging them with its own set. The
300
+ library does not automatically propagate keys up the inheritance chain - each
301
+ subclass must implement this logic itself.
302
+
303
+ Example:
304
+
305
+ ```python
306
+ from logging_objects_with_schema import SchemaLogger
307
+ import logging
308
+
309
+ class ParentLogger(SchemaLogger):
310
+ def __init__(
311
+ self, name: str, level: int = logging.NOTSET, forbidden_keys: set[str] | None = None
312
+ ) -> None:
313
+ # Merge parent's keys with keys from child
314
+ parent_keys = {"parent_forbidden_key"}
315
+ if forbidden_keys:
316
+ parent_keys = parent_keys | forbidden_keys
317
+ super().__init__(name, level, forbidden_keys=parent_keys)
318
+
319
+ class ChildLogger(ParentLogger):
320
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
321
+ # Pass child's keys to parent, which will merge them
322
+ super().__init__(name, level, forbidden_keys={"child_forbidden_key"})
323
+ ```
324
+
325
+ In this example, the final set of forbidden keys will be:
326
+
327
+ - Builtin `logging.LogRecord` attributes (always present)
328
+ - `parent_forbidden_key` (from `ParentLogger`)
329
+ - `child_forbidden_key` (from `ChildLogger`)
330
+
331
+ All keys are merged together - they are not replaced, only supplemented.
332
+
333
+ ### Important notes
334
+
335
+ - The builtin set of forbidden keys (standard `logging.LogRecord` attributes)
336
+ is always present and cannot be replaced or removed
337
+ - Additional forbidden keys are merged with the builtin set, not replaced
338
+ - Each subclass must implement the logic to pass `forbidden_keys` to its parent
339
+ if it wants to propagate keys from its own subclasses
340
+ - The `forbidden_keys` parameter is optional - if not provided, only builtin
341
+ keys are used, maintaining 100% backward compatibility
342
+ - `None` and empty `set()` are semantically equivalent for `forbidden_keys` -
343
+ 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.3.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"
@@ -27,7 +27,8 @@ classifiers = [
27
27
  "Programming Language :: Python :: 3.14",
28
28
  "Programming Language :: Python :: 3 :: Only",
29
29
  "Intended Audience :: Developers",
30
- "Topic :: System :: Logging"
30
+ "Topic :: System :: Logging",
31
+ "Typing :: Typed"
31
32
  ]
32
33
 
33
34
  [[project.authors]]
@@ -93,8 +94,7 @@ warn_unreachable = true
93
94
  strict_equality = true
94
95
 
95
96
  [tool.bandit]
96
- skips = [ "B101", "B601" ]
97
- exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
97
+ exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov", "tests" ]
98
98
 
99
99
  [tool.coverage.run]
100
100
  source = [ "src" ]
@@ -42,7 +42,7 @@ def _create_validation_error_json(field: str, error: str, value: Any) -> str:
42
42
 
43
43
 
44
44
  def _validate_list_value(
45
- value: list,
45
+ value: list[Any],
46
46
  source: str,
47
47
  item_expected_type: type | None,
48
48
  ) -> _DataProblem | None:
@@ -16,7 +16,7 @@ import threading
16
16
  from collections.abc import Iterable, Mapping, MutableMapping
17
17
  from dataclasses import dataclass
18
18
  from pathlib import Path
19
- from typing import Any
19
+ from typing import Any, Literal
20
20
 
21
21
  from .errors import _SchemaProblem
22
22
 
@@ -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
 
@@ -381,11 +383,99 @@ def _is_empty_or_none(value: Any) -> bool:
381
383
  return value is None or (isinstance(value, str) and value.strip() == "")
382
384
 
383
385
 
386
+ def _determine_node_type_and_validate(
387
+ value_dict: dict[str, Any],
388
+ path: tuple[str, ...],
389
+ key: str,
390
+ problems: list[_SchemaProblem],
391
+ ) -> tuple[Literal["leaf", "inner"] | None, bool]:
392
+ """Determine node type (leaf/inner) and validate node structure.
393
+
394
+ A node can be either:
395
+ - A leaf node: has 'type' and 'source' as strings (properties), no children
396
+ - An inner node: has children (any fields that are objects), no leaf properties
397
+
398
+ A node cannot have both properties and children, and cannot be empty.
399
+
400
+ Args:
401
+ value_dict: Dictionary containing node data.
402
+ path: Current path in the schema tree.
403
+ key: Current key being processed.
404
+ problems: List to collect validation problems. Validation errors are
405
+ automatically appended to this list when an invalid node is detected.
406
+
407
+ Returns:
408
+ Tuple of (node_type, is_valid) where:
409
+ - node_type: "leaf" for valid leaf nodes, "inner" for valid inner nodes,
410
+ or None if the node is invalid
411
+ - is_valid: True if node is valid, False if there are validation errors
412
+
413
+ When is_valid is False:
414
+ - node_type is always None
415
+ - A validation problem has been added to the problems list
416
+ - The caller should skip processing this node (e.g., use continue)
417
+ """
418
+ type_value = value_dict.get("type")
419
+ source_value = value_dict.get("source")
420
+ item_type_value = value_dict.get("item_type")
421
+
422
+ # Check if node has leaf properties
423
+ # Leaf properties are: type (required, string), source (required, string),
424
+ # item_type (optional, string). If type/source/item_type are objects,
425
+ # they are children, not properties
426
+ has_leaf_properties = (
427
+ isinstance(type_value, str)
428
+ or isinstance(source_value, str)
429
+ or isinstance(item_type_value, str)
430
+ )
431
+
432
+ # Check if node has children (any field that is an object/Mapping)
433
+ # Children can have ANY names, including type, source, item_type - this is
434
+ # valid for inner nodes. If type, source, or item_type are objects,
435
+ # they count as children
436
+ has_children = any(
437
+ isinstance(field_value, Mapping) for field_value in value_dict.values()
438
+ )
439
+
440
+ # Validate node structure
441
+ if has_leaf_properties and has_children:
442
+ # Node cannot have both properties and children
443
+ problems.append(
444
+ _SchemaProblem(
445
+ f"Invalid node at {_format_path(path, key)}: "
446
+ f"node cannot have both properties (type/source as strings) "
447
+ f"and children (object fields)"
448
+ ),
449
+ )
450
+ return (None, False)
451
+
452
+ if not has_leaf_properties and not has_children:
453
+ # Node must be either a leaf or have children
454
+ problems.append(
455
+ _SchemaProblem(
456
+ f"Invalid node at {_format_path(path, key)}: "
457
+ f"node must be either a leaf (with type/source as strings) "
458
+ f"or have children (object fields)"
459
+ ),
460
+ )
461
+ return (None, False)
462
+
463
+ # Node is valid - determine type
464
+ if has_leaf_properties:
465
+ return ("leaf", True)
466
+ else: # has_children
467
+ return ("inner", True)
468
+
469
+
384
470
  def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
385
471
  """Check if a schema node is a leaf node.
386
472
 
387
- A leaf node is identified by having either 'type' or 'source' field.
388
- Inner nodes have neither of these fields.
473
+ A leaf node is identified by having at least one of 'type' or 'source' fields
474
+ that is a string. If 'type' or 'source' are themselves objects (not strings),
475
+ they are child nodes, not leaf properties.
476
+
477
+ Inner nodes have either no 'type'/'source' fields, or have these fields as
478
+ objects (child nodes) rather than strings.
389
479
 
390
480
  We use `.get()` with `is not None` check instead of `in` operator because:
391
481
  - A field might be present but have a None value (which indicates an error)
@@ -398,7 +488,19 @@ def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
398
488
  Returns:
399
489
  True if the node is a leaf, False if it's an inner node.
400
490
  """
401
- return value_dict.get("type") is not None or value_dict.get("source") is not None
491
+ type_value = value_dict.get("type")
492
+ source_value = value_dict.get("source")
493
+
494
+ # If either field is an object (Mapping), this is an inner node, not a leaf
495
+ if isinstance(type_value, Mapping) or isinstance(source_value, Mapping):
496
+ return False
497
+
498
+ # Check if at least one field is present and is a string
499
+ if isinstance(type_value, str) or isinstance(source_value, str):
500
+ return True
501
+
502
+ # Neither field is present as a string - this is an inner node
503
+ return False
402
504
 
403
505
 
404
506
  def _validate_and_create_leaf(
@@ -422,8 +524,10 @@ def _validate_and_create_leaf(
422
524
  leaf_source = value_dict.get("source")
423
525
 
424
526
  # This is supposed to be a leaf - validate required fields first.
425
- type_invalid = _is_empty_or_none(leaf_type)
426
- source_invalid = _is_empty_or_none(leaf_source)
527
+ # Type must be a string (not None, not empty, and not other types like bool/int)
528
+ type_invalid = _is_empty_or_none(leaf_type) or not isinstance(leaf_type, str)
529
+ # Source must be a string (not None, not empty, and not other types like bool/int)
530
+ source_invalid = _is_empty_or_none(leaf_source) or not isinstance(leaf_source, str)
427
531
 
428
532
  if type_invalid:
429
533
  problems.append(
@@ -444,6 +548,9 @@ def _validate_and_create_leaf(
444
548
  if type_invalid or source_invalid:
445
549
  return None
446
550
 
551
+ # Note: Check for children is done in _determine_node_type_and_validate()
552
+ # before this function is called, so we don't need to check here.
553
+
447
554
  # Convert to string before lookup to handle cases where the JSON parser
448
555
  # might return non-string types (though this shouldn't happen with valid JSON).
449
556
  # This ensures type safety and consistent behavior.
@@ -461,7 +568,11 @@ def _validate_and_create_leaf(
461
568
  # to ensure element homogeneity (e.g. list[str], list[int]).
462
569
  if expected_type is list:
463
570
  item_type_name = value_dict.get("item_type")
464
- item_type_invalid = _is_empty_or_none(item_type_name)
571
+ # Item type must be a string (not None, not empty, and not other types
572
+ # like bool/int)
573
+ item_type_invalid = _is_empty_or_none(item_type_name) or not isinstance(
574
+ item_type_name, str
575
+ )
465
576
  if item_type_invalid:
466
577
  problems.append(
467
578
  _SchemaProblem(
@@ -554,12 +665,25 @@ def _compile_schema_tree(
554
665
  # might be a read-only Mapping (e.g., from JSON parsing).
555
666
  value_dict = dict(value)
556
667
 
557
- if _is_leaf_node(value_dict):
668
+ # Determine node type and validate structure
669
+ # (checks for mixed nodes, empty nodes, etc.)
670
+ node_type, is_valid = _determine_node_type_and_validate(
671
+ value_dict, path, key, problems
672
+ )
673
+
674
+ if not is_valid:
675
+ # Node has validation errors, skip processing but continue with other nodes
676
+ continue
677
+
678
+ if node_type == "leaf":
679
+ # Process as leaf node
558
680
  leaf = _validate_and_create_leaf(value_dict, path, key, problems)
559
681
  if leaf is not None:
560
682
  yield leaf
561
- else:
562
- # This is an inner node; recurse into children.
683
+ else: # node_type == "inner"
684
+ # Process as inner node - recurse into children
685
+ # Note: node_type can only be "leaf" or "inner" when is_valid is True.
686
+ # If is_valid is False, we already continue above.
563
687
  for child_leaf in _compile_schema_tree(value_dict, path + (key,), problems):
564
688
  yield child_leaf
565
689
 
@@ -625,11 +749,30 @@ def _get_builtin_logrecord_attributes() -> set[str]:
625
749
 
626
750
 
627
751
  def _check_root_conflicts(
628
- schema_dict: Mapping[str, Any], problems: list[_SchemaProblem]
752
+ schema_dict: Mapping[str, Any],
753
+ problems: list[_SchemaProblem],
754
+ forbidden_keys: set[str] | None = None,
629
755
  ) -> None:
630
- """Check schema root keys for conflicts with reserved logging fields."""
756
+ """Check schema root keys for conflicts with reserved logging fields.
631
757
 
758
+ Args:
759
+ schema_dict: The schema dictionary to check.
760
+ problems: List to collect validation problems.
761
+ forbidden_keys: Additional forbidden root keys to check against.
762
+ These keys are merged with builtin LogRecord attributes.
763
+ Builtin keys cannot be replaced, only supplemented.
764
+ Note: None and empty set() are semantically equivalent - both
765
+ mean "no additional forbidden keys" and produce the same result.
766
+ """
767
+ # Builtin keys always present, cannot be replaced
632
768
  forbidden_root_keys = _get_builtin_logrecord_attributes()
769
+ # Merge with additional forbidden keys if provided.
770
+ # We use `if forbidden_keys:` instead of `if forbidden_keys is not None:` because
771
+ # both None and empty set() semantically mean "no additional forbidden keys".
772
+ # This check treats them equivalently: None is falsy, and empty set() is also
773
+ # falsy, so both cases skip the merge operation, which is the correct behavior.
774
+ if forbidden_keys:
775
+ forbidden_root_keys = forbidden_root_keys | forbidden_keys
633
776
 
634
777
  for key in schema_dict.keys():
635
778
  if key in forbidden_root_keys:
@@ -640,7 +783,9 @@ def _check_root_conflicts(
640
783
  )
641
784
 
642
785
 
643
- def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
786
+ def _compile_schema_internal(
787
+ forbidden_keys: set[str] | None = None,
788
+ ) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
644
789
  """Compile JSON schema into ``_CompiledSchema`` and collect all problems.
645
790
 
646
791
  The function loads the raw JSON schema, validates its structure, checks
@@ -649,13 +794,22 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
649
794
  discovered during this process are reported as :class:`_SchemaProblem`
650
795
  instances.
651
796
 
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.
797
+ Results are cached process-wide: the cache key is a tuple of the absolute
798
+ schema file path and the set of additional forbidden keys. The value is a
799
+ tuple ``(_CompiledSchema, list[_SchemaProblem])``. Once a schema for a
800
+ given path and forbidden keys set has been observed (including the cases
801
+ when it is missing or invalid), subsequent calls always return the cached
802
+ result without re-reading or re-compiling the schema. To pick up on-disk
803
+ changes to the schema, the application must restart the process. See the
804
+ README section \"Schema caching and thread safety\" for more details.
805
+
806
+ Args:
807
+ forbidden_keys: Additional forbidden root keys to check against.
808
+ These keys are merged with builtin LogRecord attributes.
809
+ Builtin keys cannot be replaced, only supplemented.
810
+ Note: None and empty set() are semantically equivalent - both
811
+ mean "no additional forbidden keys" and produce the same result.
812
+ They also produce the same cache key, so cached results are shared.
659
813
 
660
814
  This function never raises exceptions. It always returns the best-effort
661
815
  compiled schema together with a list of problems detected during processing
@@ -664,17 +818,18 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
664
818
  Performance considerations:
665
819
  First compilation of a schema involves file I/O, JSON parsing, and tree
666
820
  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.
821
+ subsequent calls for the same schema path and forbidden keys return
822
+ immediately from cache (< 0.1ms). The cache is process-wide and persists
823
+ for the application lifetime.
670
824
 
671
825
  Limitations:
672
826
  - Schema changes on disk are not automatically reloaded; the application
673
827
  must be restarted to pick up changes
674
828
  - Very large schemas (> 10,000 nodes) may cause noticeable compilation
675
829
  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
830
+ - The cache uses absolute file paths and forbidden keys sets as keys, so
831
+ schema files must be accessible via the same path throughout the
832
+ application lifetime
678
833
 
679
834
  Note:
680
835
  This function is used internally by :class:`SchemaLogger` and by the
@@ -685,12 +840,21 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
685
840
  Tuple of (_CompiledSchema, list[_SchemaProblem]).
686
841
  """
687
842
  schema_path = _get_schema_path()
843
+ # Create cache key that includes forbidden keys.
844
+ # We use `frozenset(forbidden_keys or ())` instead of checking for None explicitly
845
+ # because both None and empty set() should produce the same cache key (frozenset()).
846
+ # This is semantically correct: both None and set() mean "no additional forbidden
847
+ # keys", so they should share the same cached compilation result. The `or ()`
848
+ # operator converts None to empty tuple, which frozenset() then converts to empty
849
+ # frozenset, and empty set() also becomes empty frozenset, ensuring cache key
850
+ # consistency.
851
+ cache_key = (schema_path, frozenset(forbidden_keys or ()))
688
852
 
689
853
  # 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.
854
+ # to compile schema for this path and forbidden keys set. This provides thread-safe
855
+ # cache access in the common case when the schema is already cached.
692
856
  with _cache_lock:
693
- cached = _SCHEMA_CACHE.get(schema_path)
857
+ cached = _SCHEMA_CACHE.get(cache_key)
694
858
  if cached is not None:
695
859
  return cached
696
860
 
@@ -712,16 +876,16 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
712
876
  # thread might have compiled the schema (or handled the same error) while
713
877
  # we were processing the exception. If so, use the cached result.
714
878
  with _cache_lock:
715
- cached = _SCHEMA_CACHE.get(schema_path)
879
+ cached = _SCHEMA_CACHE.get(cache_key)
716
880
  if cached is not None:
717
881
  return cached
718
- _SCHEMA_CACHE[schema_path] = result
882
+ _SCHEMA_CACHE[cache_key] = result
719
883
  return result
720
884
 
721
885
  # Check root key conflicts before compiling the tree. This allows us to
722
886
  # catch conflicts early and report them as schema problems. We do this
723
887
  # before tree compilation to avoid unnecessary work if there are conflicts.
724
- _check_root_conflicts(raw_schema, problems)
888
+ _check_root_conflicts(raw_schema, problems, forbidden_keys)
725
889
 
726
890
  # Compile the schema tree into leaves. Each root key becomes a separate
727
891
  # tree that we compile recursively. Problems are collected as we go.
@@ -748,9 +912,9 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]:
748
912
  # instead of overwriting it with our own (which might be different if the file
749
913
  # was modified between reads).
750
914
  with _cache_lock:
751
- cached = _SCHEMA_CACHE.get(schema_path)
915
+ cached = _SCHEMA_CACHE.get(cache_key)
752
916
  if cached is not None:
753
917
  return cached
754
- _SCHEMA_CACHE[schema_path] = result
918
+ _SCHEMA_CACHE[cache_key] = result
755
919
 
756
920
  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.