logging-objects-with-schema 0.1.3__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.3
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.3"
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"
@@ -95,3 +95,23 @@ strict_equality = true
95
95
  [tool.bandit]
96
96
  skips = [ "B101", "B601" ]
97
97
  exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
98
+
99
+ [tool.coverage.run]
100
+ source = [ "src" ]
101
+ branch = true
102
+
103
+ [tool.coverage.report]
104
+ exclude_lines = [
105
+ "def __repr__",
106
+ "if self\\.debug",
107
+ "raise AssertionError",
108
+ "raise NotImplementedError",
109
+ "if 0:",
110
+ "if __name__ == .__main__.:",
111
+ "@(abc\\.)?abstractmethod"
112
+ ]
113
+ show_missing = true
114
+ skip_covered = false
115
+
116
+ [tool.coverage.html]
117
+ directory = "htmlcov"
@@ -6,9 +6,13 @@ from dataclasses import dataclass
6
6
 
7
7
 
8
8
  @dataclass
9
- class SchemaProblem:
9
+ class _SchemaProblem:
10
10
  """Describes a single problem encountered while loading the schema.
11
11
 
12
+ This class is part of the internal implementation and is not considered
13
+ a public API. Its signature and behaviour may change between releases
14
+ without preserving backward compatibility.
15
+
12
16
  This class is used to report schema validation errors during schema
13
17
  compilation. Schema problems are fatal: if any are detected during
14
18
  logger initialization, the application is terminated after logging
@@ -36,12 +40,16 @@ class SchemaProblem:
36
40
 
37
41
 
38
42
  @dataclass
39
- class DataProblem:
43
+ class _DataProblem:
40
44
  """Describes a single problem encountered while validating log data.
41
45
 
46
+ This class is part of the internal implementation and is not considered
47
+ a public API. Its signature and behaviour may change between releases
48
+ without preserving backward compatibility.
49
+
42
50
  This class is used to report validation errors when applying the compiled
43
51
  schema to user-provided ``extra`` fields during logging. Unlike
44
- :class:`SchemaProblem`, data problems are not fatal: they are collected
52
+ :class:`_SchemaProblem`, data problems are not fatal: they are collected
45
53
  and logged as ERROR messages *after* the main log record has been emitted,
46
54
  ensuring 100% compatibility with standard logger behavior.
47
55
 
@@ -11,8 +11,8 @@ from collections import defaultdict
11
11
  from collections.abc import Mapping, MutableMapping
12
12
  from typing import Any
13
13
 
14
- from .errors import DataProblem
15
- from .schema_loader import CompiledSchema, SchemaLeaf
14
+ from .errors import _DataProblem
15
+ from .schema_loader import _CompiledSchema, _SchemaLeaf
16
16
 
17
17
 
18
18
  def _create_validation_error_json(field: str, error: str, value: Any) -> str:
@@ -45,7 +45,7 @@ def _validate_list_value(
45
45
  value: list,
46
46
  source: str,
47
47
  item_expected_type: type | None,
48
- ) -> DataProblem | None:
48
+ ) -> _DataProblem | None:
49
49
  """Validate that a list value matches the expected item type.
50
50
 
51
51
  Validates that all elements in the list have the exact type declared by
@@ -58,11 +58,11 @@ def _validate_list_value(
58
58
  for list-typed leaves.
59
59
 
60
60
  Returns:
61
- DataProblem if validation fails, None if validation succeeds.
61
+ _DataProblem if validation fails, None if validation succeeds.
62
62
  """
63
63
  if item_expected_type is None:
64
64
  error_msg = "is a list but has no item type configured"
65
- return DataProblem(_create_validation_error_json(source, error_msg, value))
65
+ return _DataProblem(_create_validation_error_json(source, error_msg, value))
66
66
 
67
67
  if len(value) == 0:
68
68
  # Empty lists are always valid
@@ -83,7 +83,7 @@ def _validate_list_value(
83
83
  f"expected all elements to be of type "
84
84
  f"{item_expected_type.__name__}"
85
85
  )
86
- return DataProblem(_create_validation_error_json(source, error_msg, value))
86
+ return _DataProblem(_create_validation_error_json(source, error_msg, value))
87
87
 
88
88
  return None
89
89
 
@@ -124,11 +124,11 @@ def _set_nested_value(
124
124
 
125
125
 
126
126
  def _validate_and_apply_leaf(
127
- leaf: SchemaLeaf,
127
+ leaf: _SchemaLeaf,
128
128
  value: Any,
129
129
  source: str,
130
130
  extra: MutableMapping[str, Any],
131
- problems: list[DataProblem],
131
+ problems: list[_DataProblem],
132
132
  ) -> None:
133
133
  """Validate a value against a schema leaf and apply it if valid.
134
134
 
@@ -152,7 +152,7 @@ def _validate_and_apply_leaf(
152
152
  f"expected {leaf.expected_type.__name__}"
153
153
  )
154
154
  problems.append(
155
- DataProblem(_create_validation_error_json(source, error_msg, value))
155
+ _DataProblem(_create_validation_error_json(source, error_msg, value))
156
156
  )
157
157
  return
158
158
 
@@ -203,27 +203,27 @@ def _strip_empty(node: Any) -> Any:
203
203
 
204
204
 
205
205
  def _apply_schema_internal(
206
- compiled: CompiledSchema,
206
+ compiled: _CompiledSchema,
207
207
  extra_values: Mapping[str, Any],
208
- ) -> tuple[dict[str, Any], list[DataProblem]]:
208
+ ) -> tuple[dict[str, Any], list[_DataProblem]]:
209
209
  """Internal function to build structured ``extra`` from compiled schema.
210
210
 
211
- The function applies a :class:`CompiledSchema` to user-provided ``extra``
211
+ The function applies a :class:`_CompiledSchema` to user-provided ``extra``
212
212
  values and returns a tuple ``(structured_extra, problems)`` where:
213
213
 
214
214
  - ``structured_extra`` is a nested dictionary that follows the schema
215
215
  structure and contains only fields that passed validation;
216
- - ``problems`` is a list of :class:`DataProblem` describing all data
216
+ - ``problems`` is a list of :class:`_DataProblem` describing all data
217
217
  issues observed during processing.
218
218
 
219
219
  Behaviour summary:
220
220
 
221
221
  - If the compiled schema is effectively empty (no valid leaves),
222
222
  all fields from ``extra_values`` are treated as redundant: the returned
223
- payload is empty, and a :class:`DataProblem` is created for each field.
223
+ payload is empty, and a :class:`_DataProblem` is created for each field.
224
224
  - For each ``source`` mentioned in the schema when there are valid leaves:
225
225
  - if the source is missing from ``extra_values``, it is silently skipped;
226
- - if the corresponding value is ``None``, a ``DataProblem`` is recorded
226
+ - if the corresponding value is ``None``, a ``_DataProblem`` is recorded
227
227
  and the value is not written to the payload.
228
228
  - Type checks are strict: the runtime type must exactly match the declared
229
229
  Python type (``type(value) is leaf.expected_type``). This prevents
@@ -233,17 +233,17 @@ def _apply_schema_internal(
233
233
  - all elements must have the exact type declared by the leaf
234
234
  ``item_expected_type`` (for example, list[str], list[int]);
235
235
  - non-primitive elements and elements of a different primitive type are
236
- rejected with a ``DataProblem`` and the list value is not written.
236
+ rejected with a ``_DataProblem`` and the list value is not written.
237
237
  - Redundant fields from ``extra_values`` (not referenced by any leaf
238
238
  ``source``) are always reported as problems: each such field generates
239
- a :class:`DataProblem` indicating that it is not defined in the schema.
239
+ a :class:`_DataProblem` indicating that it is not defined in the schema.
240
240
  - A single ``source`` may be used by multiple leaves. The value is
241
241
  validated independently for each leaf and written only to locations
242
- where the type matches; mismatched locations produce ``DataProblem``
242
+ where the type matches; mismatched locations produce ``_DataProblem``
243
243
  entries, but do not affect successful locations.
244
244
 
245
245
  The function itself does not raise exceptions; it only accumulates
246
- :class:`DataProblem` instances for the caller to handle.
246
+ :class:`_DataProblem` instances for the caller to handle.
247
247
 
248
248
  Performance considerations:
249
249
  Time complexity is O(n + m) where n is the number of leaves in the
@@ -269,10 +269,10 @@ def _apply_schema_internal(
269
269
  change between releases without preserving backward compatibility.
270
270
 
271
271
  Returns:
272
- Tuple of (structured_extra, list[DataProblem]).
272
+ Tuple of (structured_extra, list[_DataProblem]).
273
273
  """
274
274
  extra: dict[str, Any] = {}
275
- problems: list[DataProblem] = []
275
+ problems: list[_DataProblem] = []
276
276
 
277
277
  # Group leaves by source field name. This is necessary because a single source
278
278
  # can be referenced by multiple leaves (allowing the same value to appear in
@@ -280,7 +280,7 @@ def _apply_schema_internal(
280
280
  # all leaves for a given source together, which is more efficient and allows
281
281
  # us to validate the value once per source (e.g., checking for None) rather
282
282
  # than once per leaf.
283
- source_to_leaves: dict[str, list[SchemaLeaf]] = defaultdict(list)
283
+ source_to_leaves: dict[str, list[_SchemaLeaf]] = defaultdict(list)
284
284
  for leaf in compiled.leaves:
285
285
  source_to_leaves[leaf.source].append(leaf)
286
286
 
@@ -304,7 +304,7 @@ def _apply_schema_internal(
304
304
  if value is None:
305
305
  error_msg = "is None"
306
306
  problems.append(
307
- DataProblem(_create_validation_error_json(source, error_msg, None))
307
+ _DataProblem(_create_validation_error_json(source, error_msg, None))
308
308
  )
309
309
  continue
310
310
 
@@ -329,7 +329,7 @@ def _apply_schema_internal(
329
329
  for key in redundant_keys:
330
330
  error_msg = "is not defined in schema"
331
331
  problems.append(
332
- DataProblem(
332
+ _DataProblem(
333
333
  _create_validation_error_json(key, error_msg, extra_values[key])
334
334
  )
335
335
  )
@@ -18,18 +18,22 @@ from dataclasses import dataclass
18
18
  from pathlib import Path
19
19
  from typing import Any
20
20
 
21
- from .errors import SchemaProblem
21
+ from .errors import _SchemaProblem
22
22
 
23
- SCHEMA_FILE_NAME = "logging_objects_with_schema.json"
23
+ _SCHEMA_FILE_NAME = "logging_objects_with_schema.json"
24
24
 
25
25
  # Maximum allowed depth for schema nesting (protection against DoS)
26
26
  MAX_SCHEMA_DEPTH = 100
27
27
 
28
28
 
29
29
  @dataclass
30
- class SchemaLeaf:
30
+ class _SchemaLeaf:
31
31
  """Represents a single leaf in the schema tree.
32
32
 
33
+ This class is part of the internal implementation and is not considered
34
+ a public API. Its signature and behaviour may change between releases
35
+ without preserving backward compatibility.
36
+
33
37
  Attributes:
34
38
  path: Full path of keys from the schema root to this leaf.
35
39
  source: Name of the field in the ``extra`` mapping.
@@ -45,10 +49,15 @@ class SchemaLeaf:
45
49
 
46
50
 
47
51
  @dataclass
48
- class CompiledSchema:
49
- """Internal representation of a compiled schema."""
52
+ class _CompiledSchema:
53
+ """Internal representation of a compiled schema.
54
+
55
+ This class is part of the internal implementation and is not considered
56
+ a public API. Its signature and behaviour may change between releases
57
+ without preserving backward compatibility.
58
+ """
50
59
 
51
- leaves: list[SchemaLeaf]
60
+ leaves: list[_SchemaLeaf]
52
61
 
53
62
  @property
54
63
  def is_empty(self) -> bool:
@@ -58,17 +67,17 @@ class CompiledSchema:
58
67
 
59
68
 
60
69
  def _create_empty_compiled_schema_with_problems(
61
- problems: list[SchemaProblem],
62
- ) -> tuple[CompiledSchema, list[SchemaProblem]]:
63
- """Create an empty CompiledSchema with problems.
70
+ problems: list[_SchemaProblem],
71
+ ) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
72
+ """Create an empty _CompiledSchema with problems.
64
73
 
65
74
  Args:
66
75
  problems: List of schema problems.
67
76
 
68
77
  Returns:
69
- Tuple of (empty CompiledSchema, problems list).
78
+ Tuple of (empty _CompiledSchema, problems list).
70
79
  """
71
- return (CompiledSchema(leaves=[]), problems)
80
+ return (_CompiledSchema(leaves=[]), problems)
72
81
 
73
82
 
74
83
  _TYPE_MAP: Mapping[str, type] = {
@@ -100,10 +109,12 @@ _TYPE_MAP: Mapping[str, type] = {
100
109
  # cache stores the result of compiling that file. Both are thread-safe and
101
110
  # use double-checked locking to avoid race conditions.
102
111
 
103
- # Compiled schema cache: Key is absolute schema_path, Value is tuple of
104
- # (CompiledSchema, list[SchemaProblem]). This cache stores both successful
105
- # compilations and failures (with problems list).
106
- _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
+ ] = {}
107
118
 
108
119
  _cache_lock = threading.RLock()
109
120
 
@@ -134,7 +145,7 @@ def _find_schema_file() -> Path | None:
134
145
  current = start_path
135
146
 
136
147
  while True:
137
- schema_path = current / SCHEMA_FILE_NAME
148
+ schema_path = current / _SCHEMA_FILE_NAME
138
149
  if schema_path.exists():
139
150
  # Use resolve() to get an absolute path and resolve any symbolic links.
140
151
  # This ensures we have a canonical path that can be used as a cache key
@@ -238,7 +249,7 @@ def _cache_and_return_missing_path() -> Path:
238
249
  global _resolved_schema_path, _cached_cwd
239
250
 
240
251
  current_cwd = _get_current_working_directory()
241
- schema_path = (current_cwd / SCHEMA_FILE_NAME).resolve()
252
+ schema_path = (current_cwd / _SCHEMA_FILE_NAME).resolve()
242
253
  _resolved_schema_path = schema_path
243
254
  _cached_cwd = current_cwd # Track CWD since path depends on it
244
255
  return schema_path
@@ -247,7 +258,7 @@ def _cache_and_return_missing_path() -> Path:
247
258
  def _get_schema_path() -> Path:
248
259
  """Resolve the absolute path to the JSON schema file with caching semantics.
249
260
 
250
- The function searches for ``SCHEMA_FILE_NAME`` by walking upward from the
261
+ The function searches for ``_SCHEMA_FILE_NAME`` by walking upward from the
251
262
  current working directory and caches the result:
252
263
 
253
264
  - When the schema file is found, its absolute path is cached as
@@ -265,16 +276,20 @@ def _get_schema_path() -> Path:
265
276
  Absolute path where the schema file is located or expected to be.
266
277
  """
267
278
  with _path_cache_lock:
279
+ # Check cached path for missing file first (CWD-dependent)
280
+ # This must be checked before found file cache, because
281
+ # _check_cached_found_file_path would invalidate the cache if file
282
+ # doesn't exist, even for missing file cache.
283
+ if _cached_cwd is not None:
284
+ cached_path = _check_cached_missing_file_path()
285
+ if cached_path is not None:
286
+ return cached_path
287
+
268
288
  # Check cached path for found file (CWD-independent)
269
289
  cached_path = _check_cached_found_file_path()
270
290
  if cached_path is not None:
271
291
  return cached_path
272
292
 
273
- # Check cached path for missing file (CWD-dependent)
274
- cached_path = _check_cached_missing_file_path()
275
- if cached_path is not None:
276
- return cached_path
277
-
278
293
  # Search for schema file
279
294
  found_path = _find_schema_file()
280
295
  if found_path is not None:
@@ -291,7 +306,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
291
306
  This function attempts to read and parse the schema file. If any problems
292
307
  occur (file not found, I/O errors, invalid JSON, wrong top-level type),
293
308
  it raises exceptions (FileNotFoundError or ValueError). These exceptions
294
- are then converted to :class:`SchemaProblem` instances by the caller
309
+ are then converted to :class:`_SchemaProblem` instances by the caller
295
310
  (_compile_schema_internal).
296
311
 
297
312
  Args:
@@ -319,7 +334,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
319
334
  except OSError as exc:
320
335
  # Normalise I/O errors when reading the schema file (e.g., permission
321
336
  # denied, file not found) to ValueError so that _compile_schema_internal()
322
- # can report them as SchemaProblem instances instead of leaking raw
337
+ # can report them as _SchemaProblem instances instead of leaking raw
323
338
  # OSError to callers. Note: System-level OSError (e.g., from os.getcwd())
324
339
  # is not caught here and propagates directly.
325
340
  raise ValueError(
@@ -330,7 +345,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]:
330
345
 
331
346
  if not isinstance(data, dict):
332
347
  # Normalise non-object top-level schemas into a ValueError so that the
333
- # caller can report it as a SchemaProblem while keeping type safety.
348
+ # caller can report it as a _SchemaProblem while keeping type safety.
334
349
  raise ValueError("Top-level schema must be a JSON object")
335
350
 
336
351
  return data, schema_path
@@ -392,9 +407,9 @@ def _validate_and_create_leaf(
392
407
  value_dict: dict[str, Any],
393
408
  path: tuple[str, ...],
394
409
  key: str,
395
- problems: list[SchemaProblem],
396
- ) -> SchemaLeaf | None:
397
- """Validate a leaf node and create SchemaLeaf if valid.
410
+ problems: list[_SchemaProblem],
411
+ ) -> _SchemaLeaf | None:
412
+ """Validate a leaf node and create _SchemaLeaf if valid.
398
413
 
399
414
  Args:
400
415
  value_dict: Dictionary containing leaf node data.
@@ -403,7 +418,7 @@ def _validate_and_create_leaf(
403
418
  problems: List to collect validation problems.
404
419
 
405
420
  Returns:
406
- SchemaLeaf if validation passes, None otherwise.
421
+ _SchemaLeaf if validation passes, None otherwise.
407
422
  """
408
423
  leaf_type = value_dict.get("type")
409
424
  leaf_source = value_dict.get("source")
@@ -414,7 +429,7 @@ def _validate_and_create_leaf(
414
429
 
415
430
  if type_invalid:
416
431
  problems.append(
417
- SchemaProblem(
432
+ _SchemaProblem(
418
433
  f"Incomplete leaf at {_format_path(path, key)}: "
419
434
  f"type cannot be None or empty",
420
435
  ),
@@ -422,7 +437,7 @@ def _validate_and_create_leaf(
422
437
 
423
438
  if source_invalid:
424
439
  problems.append(
425
- SchemaProblem(
440
+ _SchemaProblem(
426
441
  f"Incomplete leaf at {_format_path(path, key)}: "
427
442
  f"source cannot be None or empty",
428
443
  ),
@@ -437,7 +452,7 @@ def _validate_and_create_leaf(
437
452
  expected_type = _TYPE_MAP.get(str(leaf_type))
438
453
  if expected_type is None:
439
454
  problems.append(
440
- SchemaProblem(
455
+ _SchemaProblem(
441
456
  f"Unknown type '{leaf_type}' at {_format_path(path, key)}",
442
457
  ),
443
458
  )
@@ -451,7 +466,7 @@ def _validate_and_create_leaf(
451
466
  item_type_invalid = _is_empty_or_none(item_type_name)
452
467
  if item_type_invalid:
453
468
  problems.append(
454
- SchemaProblem(
469
+ _SchemaProblem(
455
470
  f"Incomplete leaf at {_format_path(path, key)}: "
456
471
  f"item_type is required for list type and "
457
472
  f"cannot be None or empty",
@@ -463,7 +478,7 @@ def _validate_and_create_leaf(
463
478
  # Item type must be a primitive (str, int, float, bool), not list
464
479
  if item_expected_type is None or item_expected_type is list:
465
480
  problems.append(
466
- SchemaProblem(
481
+ _SchemaProblem(
467
482
  f"Invalid item_type '{item_type_name}' at "
468
483
  f"{_format_path(path, key)}: only primitive item types "
469
484
  f"('str', 'int', 'float', 'bool') are allowed for lists",
@@ -471,11 +486,11 @@ def _validate_and_create_leaf(
471
486
  )
472
487
  return None
473
488
 
474
- return SchemaLeaf(
489
+ return _SchemaLeaf(
475
490
  path=path + (key,),
476
491
  # Convert to string to ensure type consistency. Even though source should
477
492
  # be a string from JSON, this guards against unexpected types and ensures
478
- # the SchemaLeaf always has a string source.
493
+ # the _SchemaLeaf always has a string source.
479
494
  source=str(leaf_source),
480
495
  expected_type=expected_type,
481
496
  item_expected_type=item_expected_type,
@@ -485,14 +500,14 @@ def _validate_and_create_leaf(
485
500
  def _compile_schema_tree(
486
501
  node: MutableMapping[str, Any],
487
502
  path: tuple[str, ...],
488
- problems: list[SchemaProblem],
489
- ) -> Iterable[SchemaLeaf]:
490
- """Recursively compile a schema node into SchemaLeaf objects.
503
+ problems: list[_SchemaProblem],
504
+ ) -> Iterable[_SchemaLeaf]:
505
+ """Recursively compile a schema node into _SchemaLeaf objects.
491
506
 
492
507
  This function recursively walks the schema tree structure, identifying leaf
493
508
  nodes (those with ``type`` and ``source`` fields) and inner nodes (those
494
509
  without these fields). Leaf nodes are validated and converted to
495
- :class:`SchemaLeaf` objects, while inner nodes are recursively processed.
510
+ :class:`_SchemaLeaf` objects, while inner nodes are recursively processed.
496
511
 
497
512
  Performance considerations:
498
513
  Time complexity is O(n) where n is the total number of nodes in the
@@ -512,7 +527,7 @@ def _compile_schema_tree(
512
527
  problems: List to collect validation problems.
513
528
 
514
529
  Yields:
515
- SchemaLeaf objects found in the tree.
530
+ _SchemaLeaf objects found in the tree.
516
531
  """
517
532
  # Check for excessive nesting depth (DoS protection: prevent deeply nested
518
533
  # schemas that could cause stack overflow or excessive memory usage).
@@ -520,7 +535,7 @@ def _compile_schema_tree(
520
535
  # and to provide clear error messages about the problematic path.
521
536
  if len(path) > MAX_SCHEMA_DEPTH:
522
537
  problems.append(
523
- SchemaProblem(
538
+ _SchemaProblem(
524
539
  f"Schema nesting depth exceeds maximum allowed depth of "
525
540
  f"{MAX_SCHEMA_DEPTH} at path {_format_path(path)}"
526
541
  ),
@@ -530,7 +545,7 @@ def _compile_schema_tree(
530
545
  for key, value in node.items():
531
546
  if not isinstance(value, Mapping):
532
547
  problems.append(
533
- SchemaProblem(
548
+ _SchemaProblem(
534
549
  f"Invalid schema at {_format_path(path, key)}: expected object"
535
550
  ),
536
551
  )
@@ -552,9 +567,13 @@ def _compile_schema_tree(
552
567
 
553
568
 
554
569
  @functools.lru_cache(maxsize=1)
555
- def get_builtin_logrecord_attributes() -> set[str]:
570
+ def _get_builtin_logrecord_attributes() -> set[str]:
556
571
  """Get set of standard LogRecord attribute names.
557
572
 
573
+ This function is part of the internal implementation and is not considered
574
+ a public API. Its signature and behaviour may change between releases
575
+ without preserving backward compatibility.
576
+
558
577
  This function extracts attribute names from LogRecord that represent
559
578
  system fields and should not be used as root keys in the schema or
560
579
  treated as user-provided extra fields.
@@ -573,7 +592,7 @@ def get_builtin_logrecord_attributes() -> set[str]:
573
592
  Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc.
574
593
 
575
594
  Example:
576
- >>> forbidden = get_builtin_logrecord_attributes()
595
+ >>> forbidden = _get_builtin_logrecord_attributes()
577
596
  >>> "name" in forbidden
578
597
  True
579
598
  >>> "ServicePayload" in forbidden
@@ -608,56 +627,87 @@ def get_builtin_logrecord_attributes() -> set[str]:
608
627
 
609
628
 
610
629
  def _check_root_conflicts(
611
- 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,
612
633
  ) -> None:
613
- """Check schema root keys for conflicts with reserved logging fields."""
634
+ """Check schema root keys for conflicts with reserved logging fields.
614
635
 
615
- forbidden_root_keys = get_builtin_logrecord_attributes()
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
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
616
654
 
617
655
  for key in schema_dict.keys():
618
656
  if key in forbidden_root_keys:
619
657
  problems.append(
620
- SchemaProblem(
658
+ _SchemaProblem(
621
659
  f"Root key '{key}' conflicts with reserved logging fields",
622
660
  ),
623
661
  )
624
662
 
625
663
 
626
- def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
627
- """Compile JSON schema into ``CompiledSchema`` and collect all problems.
664
+ def _compile_schema_internal(
665
+ forbidden_keys: set[str] | None = None,
666
+ ) -> tuple[_CompiledSchema, list[_SchemaProblem]]:
667
+ """Compile JSON schema into ``_CompiledSchema`` and collect all problems.
628
668
 
629
669
  The function loads the raw JSON schema, validates its structure, checks
630
670
  root keys for conflicts with reserved ``logging.LogRecord`` attributes
631
- and compiles all valid leaves into a :class:`CompiledSchema`. All issues
632
- discovered during this process are reported as :class:`SchemaProblem`
671
+ and compiles all valid leaves into a :class:`_CompiledSchema`. All issues
672
+ discovered during this process are reported as :class:`_SchemaProblem`
633
673
  instances.
634
674
 
635
- Results are cached process-wide: the cache key is the absolute schema
636
- file path and the value is a tuple ``(CompiledSchema, list[SchemaProblem])``.
637
- Once a schema for a given path has been observed (including the cases when
638
- it is missing or invalid), subsequent calls always return the cached result
639
- without re-reading or re-compiling the schema. To pick up on-disk changes
640
- to the schema, the application must restart the process. See the README
641
- 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.
642
691
 
643
692
  This function never raises exceptions. It always returns the best-effort
644
693
  compiled schema together with a list of problems detected during processing
645
- (an empty ``CompiledSchema`` when the schema is missing or invalid).
694
+ (an empty ``_CompiledSchema`` when the schema is missing or invalid).
646
695
 
647
696
  Performance considerations:
648
697
  First compilation of a schema involves file I/O, JSON parsing, and tree
649
698
  traversal. For typical schemas (< 1000 nodes), this takes < 10ms. All
650
- subsequent calls for the same schema path return immediately from cache
651
- (< 0.1ms). The cache is process-wide and persists for the application
652
- 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.
653
702
 
654
703
  Limitations:
655
704
  - Schema changes on disk are not automatically reloaded; the application
656
705
  must be restarted to pick up changes
657
706
  - Very large schemas (> 10,000 nodes) may cause noticeable compilation
658
707
  overhead on first load, but this is uncommon in practice
659
- - The cache uses absolute file paths as keys, so schema files must be
660
- 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
661
711
 
662
712
  Note:
663
713
  This function is used internally by :class:`SchemaLogger` and by the
@@ -665,15 +715,24 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
665
715
  between releases without preserving backward compatibility.
666
716
 
667
717
  Returns:
668
- Tuple of (CompiledSchema, list[SchemaProblem]).
718
+ Tuple of (_CompiledSchema, list[_SchemaProblem]).
669
719
  """
670
720
  schema_path = _get_schema_path()
671
-
672
- # Fast-path: First check (without holding lock) if we have already attempted
673
- # to compile schema for this path. This avoids lock contention in the common
674
- # case when the schema is already cached.
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 ()))
730
+
731
+ # Fast-path: First check (with lock for thread-safety) if we have already attempted
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.
675
734
  with _cache_lock:
676
- cached = _SCHEMA_CACHE.get(schema_path)
735
+ cached = _SCHEMA_CACHE.get(cache_key)
677
736
  if cached is not None:
678
737
  return cached
679
738
 
@@ -684,35 +743,35 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
684
743
  # while holding the lock. If another thread already compiled it, we use that
685
744
  # result instead of storing our own (which might be different if the file changed).
686
745
 
687
- problems: list[SchemaProblem] = []
746
+ problems: list[_SchemaProblem] = []
688
747
 
689
748
  try:
690
749
  raw_schema, _ = _load_raw_schema(schema_path)
691
750
  except (FileNotFoundError, ValueError) as exc:
692
- problems.append(SchemaProblem(str(exc)))
751
+ problems.append(_SchemaProblem(str(exc)))
693
752
  result = _create_empty_compiled_schema_with_problems(problems)
694
753
  # Double-checked locking: Check cache again while holding lock. Another
695
754
  # thread might have compiled the schema (or handled the same error) while
696
755
  # we were processing the exception. If so, use the cached result.
697
756
  with _cache_lock:
698
- cached = _SCHEMA_CACHE.get(schema_path)
757
+ cached = _SCHEMA_CACHE.get(cache_key)
699
758
  if cached is not None:
700
759
  return cached
701
- _SCHEMA_CACHE[schema_path] = result
760
+ _SCHEMA_CACHE[cache_key] = result
702
761
  return result
703
762
 
704
763
  # Check root key conflicts before compiling the tree. This allows us to
705
764
  # catch conflicts early and report them as schema problems. We do this
706
765
  # before tree compilation to avoid unnecessary work if there are conflicts.
707
- _check_root_conflicts(raw_schema, problems)
766
+ _check_root_conflicts(raw_schema, problems, forbidden_keys)
708
767
 
709
768
  # Compile the schema tree into leaves. Each root key becomes a separate
710
769
  # tree that we compile recursively. Problems are collected as we go.
711
- leaves: list[SchemaLeaf] = []
770
+ leaves: list[_SchemaLeaf] = []
712
771
  for key, value in raw_schema.items():
713
772
  if not isinstance(value, Mapping):
714
773
  problems.append(
715
- SchemaProblem(f"Invalid schema at {key}: expected object"),
774
+ _SchemaProblem(f"Invalid schema at {key}: expected object"),
716
775
  )
717
776
  continue
718
777
 
@@ -722,7 +781,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
722
781
  for leaf in _compile_schema_tree(dict(value), (key,), problems):
723
782
  leaves.append(leaf)
724
783
 
725
- compiled = CompiledSchema(leaves=leaves)
784
+ compiled = _CompiledSchema(leaves=leaves)
726
785
  result = (compiled, problems)
727
786
 
728
787
  # Double-checked locking: Check cache again while holding lock. Another thread
@@ -731,9 +790,9 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]:
731
790
  # instead of overwriting it with our own (which might be different if the file
732
791
  # was modified between reads).
733
792
  with _cache_lock:
734
- cached = _SCHEMA_CACHE.get(schema_path)
793
+ cached = _SCHEMA_CACHE.get(cache_key)
735
794
  if cached is not None:
736
795
  return cached
737
- _SCHEMA_CACHE[schema_path] = result
796
+ _SCHEMA_CACHE[cache_key] = result
738
797
 
739
798
  return result
@@ -17,9 +17,9 @@ import sys
17
17
  from collections.abc import Mapping
18
18
  from typing import Any
19
19
 
20
- from .errors import SchemaProblem
20
+ from .errors import _SchemaProblem
21
21
  from .schema_applier import _apply_schema_internal
22
- from .schema_loader import CompiledSchema, _compile_schema_internal
22
+ from .schema_loader import _compile_schema_internal, _CompiledSchema
23
23
 
24
24
  # Python 3.11+ has improved findCaller() implementation with proper stacklevel support.
25
25
  # For Python < 3.11, we use inspect.stack() as a fallback due to known issues with
@@ -27,7 +27,7 @@ from .schema_loader import CompiledSchema, _compile_schema_internal
27
27
  _USE_FINDCALLER = sys.version_info >= (3, 11)
28
28
 
29
29
 
30
- def _log_schema_problems_and_exit(problems: list[SchemaProblem]) -> None:
30
+ def _log_schema_problems_and_exit(problems: list[_SchemaProblem]) -> None:
31
31
  """Log schema problems to stderr and terminate the application.
32
32
 
33
33
  Uses os._exit(1) instead of sys.exit(1) to ensure immediate termination
@@ -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,29 +85,37 @@ 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
- # Convert system-level exceptions to SchemaProblem so they can be
103
+ # Convert system-level exceptions to _SchemaProblem so they can be
91
104
  # handled the same way as schema validation problems.
92
105
  # - OSError: system-level file system issues (e.g., os.getcwd() failures
93
106
  # when the current working directory is inaccessible or deleted).
94
107
  # Note: OSError that occurs when reading the schema file (e.g., permission
95
- # denied, I/O errors) is converted to SchemaProblem in _load_raw_schema()
108
+ # denied, I/O errors) is converted to _SchemaProblem in _load_raw_schema()
96
109
  # and does not reach this exception handler.
97
110
  # - ValueError: path resolution issues (e.g., invalid path characters,
98
111
  # malformed paths during schema file discovery)
99
112
  # - RuntimeError: threading issues (e.g., lock acquisition problems)
100
113
  # Note: JSON parsing and schema structure validation errors are
101
- # converted to SchemaProblem instances and do not raise ValueError here.
114
+ # converted to _SchemaProblem instances and do not raise ValueError here.
102
115
  # Note: System exceptions (KeyboardInterrupt, SystemExit) are not
103
116
  # caught, which is the correct behavior.
104
- problems = [SchemaProblem(f"Schema compilation failed: {exc}")]
105
- compiled = CompiledSchema(leaves=[])
117
+ problems = [_SchemaProblem(f"Schema compilation failed: {exc}")]
118
+ compiled = _CompiledSchema(leaves=[])
106
119
 
107
120
  if problems:
108
121
  # Schema is invalid; log problems and terminate without creating
@@ -111,7 +124,7 @@ class SchemaLogger(logging.Logger):
111
124
 
112
125
  # Schema is valid; create the logger instance.
113
126
  super().__init__(name, level)
114
- self._schema: CompiledSchema = compiled
127
+ self._schema: _CompiledSchema = compiled
115
128
 
116
129
  def _log(
117
130
  self,
@@ -203,7 +216,7 @@ class SchemaLogger(logging.Logger):
203
216
  stack_info=False, stacklevel=stacklevel + 1
204
217
  )
205
218
  # Format error message as JSON for machine processing.
206
- # Each DataProblem.message is already a JSON string (created by
219
+ # Each _DataProblem.message is already a JSON string (created by
207
220
  # _create_validation_error_json) with structure:
208
221
  # {"field": "...", "error": "...", "value": "..."}
209
222
  # We parse them back to dicts and combine into a single JSON object
@@ -226,7 +239,7 @@ class SchemaLogger(logging.Logger):
226
239
  # create a fallback error object. This should never happen in
227
240
  # normal operation since problem.message is always created via
228
241
  # _create_validation_error_json, but protects against unexpected
229
- # data corruption or manual DataProblem creation. The fallback
242
+ # data corruption or manual _DataProblem creation. The fallback
230
243
  # preserves the same structure (field, error, value) for
231
244
  # consistency.
232
245
  validation_errors.append(