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.
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/PKG-INFO +96 -4
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/README.md +94 -3
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/pyproject.toml +4 -4
- logging_objects_with_schema-0.3.0/src/logging_objects_with_schema/py.typed +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/src/logging_objects_with_schema/schema_applier.py +1 -1
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/src/logging_objects_with_schema/schema_loader.py +201 -37
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/src/logging_objects_with_schema/schema_logger.py +15 -2
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/LICENSE +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/src/logging_objects_with_schema/__init__.py +0 -0
- {logging_objects_with_schema-0.1.4 → logging_objects_with_schema-0.3.0}/src/logging_objects_with_schema/errors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: logging-objects-with-schema
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
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
|
-
|
|
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" ]
|
|
File without changes
|
|
@@ -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,
|
|
113
|
-
# (_CompiledSchema, list[_SchemaProblem]).
|
|
114
|
-
# compilations and failures (with problems list).
|
|
115
|
-
_SCHEMA_CACHE: dict[
|
|
112
|
+
# Compiled schema cache: Key is tuple of (absolute schema_path, frozenset of
|
|
113
|
+
# forbidden_keys), Value is tuple of (_CompiledSchema, list[_SchemaProblem]).
|
|
114
|
+
# This cache stores both successful compilations and failures (with problems list).
|
|
115
|
+
_SCHEMA_CACHE: dict[
|
|
116
|
+
tuple[Path, frozenset[str]], tuple[_CompiledSchema, list[_SchemaProblem]]
|
|
117
|
+
] = {}
|
|
116
118
|
|
|
117
119
|
_cache_lock = threading.RLock()
|
|
118
120
|
|
|
@@ -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
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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],
|
|
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(
|
|
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
|
|
653
|
-
file path and the value is a
|
|
654
|
-
Once a schema for a
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
|
668
|
-
(< 0.1ms). The cache is process-wide and persists
|
|
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
|
|
677
|
-
accessible via the same path throughout the
|
|
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
|
|
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(
|
|
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(
|
|
879
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
716
880
|
if cached is not None:
|
|
717
881
|
return cached
|
|
718
|
-
_SCHEMA_CACHE[
|
|
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(
|
|
915
|
+
cached = _SCHEMA_CACHE.get(cache_key)
|
|
752
916
|
if cached is not None:
|
|
753
917
|
return cached
|
|
754
|
-
_SCHEMA_CACHE[
|
|
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__(
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
name: str,
|
|
75
|
+
level: int = logging.NOTSET,
|
|
76
|
+
forbidden_keys: set[str] | None = None,
|
|
77
|
+
) -> None:
|
|
73
78
|
"""Initialise the schema-aware logger.
|
|
74
79
|
|
|
75
80
|
The schema is compiled once during construction. If any
|
|
@@ -80,12 +85,20 @@ class SchemaLogger(logging.Logger):
|
|
|
80
85
|
Args:
|
|
81
86
|
name: Logger name (same as :class:`logging.Logger`).
|
|
82
87
|
level: Logger level (same as :class:`logging.Logger`).
|
|
88
|
+
forbidden_keys: Additional forbidden root keys to check against.
|
|
89
|
+
These keys are merged with builtin LogRecord attributes.
|
|
90
|
+
Builtin keys cannot be replaced, only supplemented.
|
|
91
|
+
Subclasses can override this method and pass their own
|
|
92
|
+
forbidden keys to the parent, merging them with keys from
|
|
93
|
+
their own subclasses if needed.
|
|
94
|
+
Note: None and empty set() are semantically equivalent - both
|
|
95
|
+
mean "no additional forbidden keys" and produce the same result.
|
|
83
96
|
"""
|
|
84
97
|
# Validate schema before creating the logger instance to avoid
|
|
85
98
|
# registering a broken logger in the logging manager cache.
|
|
86
99
|
# Schema is compiled and cached first, then problems are checked.
|
|
87
100
|
try:
|
|
88
|
-
compiled, problems = _compile_schema_internal()
|
|
101
|
+
compiled, problems = _compile_schema_internal(forbidden_keys)
|
|
89
102
|
except (OSError, ValueError, RuntimeError) as exc:
|
|
90
103
|
# Convert system-level exceptions to _SchemaProblem so they can be
|
|
91
104
|
# handled the same way as schema validation problems.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|