logging-objects-with-schema 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,474 @@
1
+ """Schema loading and validation for logging_objects_with_schema.
2
+
3
+ The schema is expected to live in a JSON file named
4
+ ``logging_objects_with_schema.json`` in the application root
5
+ directory. The schema defines which extra fields are allowed to be
6
+ emitted and which Python types they must have.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import threading
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Tuple
18
+
19
+ from .errors import SchemaProblem
20
+
21
+ SCHEMA_FILE_NAME = "logging_objects_with_schema.json"
22
+
23
+ # Maximum allowed depth for schema nesting (protection against DoS)
24
+ MAX_SCHEMA_DEPTH = 100
25
+
26
+
27
+ @dataclass
28
+ class SchemaLeaf:
29
+ """Represents a single leaf in the schema tree.
30
+
31
+ Attributes:
32
+ path: Full path of keys from the schema root to this leaf.
33
+ source: Name of the field in the ``extra`` mapping.
34
+ expected_type: Expected Python type of the value (str, int, float, bool, list).
35
+ item_expected_type: Expected Python type of list elements when
36
+ ``expected_type`` is ``list``. For non-list leaves this is ``None``.
37
+ """
38
+
39
+ path: Tuple[str, ...]
40
+ source: str
41
+ expected_type: type
42
+ item_expected_type: type | None = None
43
+
44
+
45
+ @dataclass
46
+ class CompiledSchema:
47
+ """Internal representation of a compiled schema."""
48
+
49
+ leaves: List[SchemaLeaf]
50
+
51
+ @property
52
+ def is_empty(self) -> bool:
53
+ """Return True if there are no valid leaves."""
54
+
55
+ return not self.leaves
56
+
57
+
58
+ _TYPE_MAP: Mapping[str, type] = {
59
+ "str": str,
60
+ "int": int,
61
+ "float": float,
62
+ "bool": bool,
63
+ "list": list,
64
+ }
65
+
66
+ # Module-level cache for compiled schemas.
67
+ # Key: absolute schema_path, Value: (CompiledSchema, List[SchemaProblem])
68
+ # This cache is thread-safe: all read and write operations are protected by
69
+ # _cache_lock.
70
+ _SCHEMA_CACHE: Dict[Path, Tuple[CompiledSchema, List[SchemaProblem]]] = {}
71
+
72
+ _cache_lock = threading.RLock()
73
+
74
+ # Cache for resolved schema path (to avoid re-searching when CWD changes)
75
+ _resolved_schema_path: Path | None = None
76
+ # CWD that was used when caching a path for a file that was not found
77
+ # None means the cached path is an absolute path to a found file (CWD-independent)
78
+ # Non-None means the cached path is an absolute path based on CWD
79
+ # when file was not found
80
+ _cached_cwd: Path | None = None
81
+ _path_cache_lock = threading.Lock()
82
+
83
+
84
+ def _find_schema_file() -> Path | None:
85
+ """Search for schema file by walking up the directory tree.
86
+
87
+ Starts from current working directory and searches upward for the schema file.
88
+ If found, returns the absolute path to the file. If not found, returns None.
89
+
90
+ Returns:
91
+ Absolute path to schema file if found, None otherwise.
92
+ """
93
+ start_path = Path(os.getcwd()).resolve()
94
+ current = start_path
95
+
96
+ while True:
97
+ schema_path = current / SCHEMA_FILE_NAME
98
+ if schema_path.exists():
99
+ return schema_path.resolve()
100
+
101
+ # Move to parent directory
102
+ parent = current.parent
103
+ if parent == current:
104
+ # Reached filesystem root, stop searching
105
+ break
106
+ current = parent
107
+
108
+ return None
109
+
110
+
111
+ def _get_schema_path() -> Path:
112
+ """Resolve the absolute path to the JSON schema file with caching semantics.
113
+
114
+ The function searches for ``SCHEMA_FILE_NAME`` by walking upward from the
115
+ current working directory and caches the result:
116
+
117
+ - When the schema file is found, its absolute path is cached as
118
+ CWD-independent. Subsequent calls reuse this path as long as the file
119
+ still exists on disk.
120
+ - When the schema file is not found, an absolute path based on the current
121
+ CWD is cached (the directory where the file was expected but missing).
122
+ This cache entry is tied to the CWD and is invalidated automatically
123
+ if the process changes CWD, so that a new search can be performed.
124
+
125
+ In both cases the returned path may or may not exist and is suitable for
126
+ reporting clear error messages to the caller.
127
+
128
+ Returns:
129
+ Absolute path where the schema file is located or expected to be.
130
+ """
131
+ global _resolved_schema_path, _cached_cwd
132
+
133
+ with _path_cache_lock:
134
+ if _resolved_schema_path is not None:
135
+ # If cached path is an absolute path to a found file (CWD-independent)
136
+ if _cached_cwd is None:
137
+ # Schema file was found, absolute path doesn't depend on CWD
138
+ # Return it if it still exists
139
+ if _resolved_schema_path.exists():
140
+ return _resolved_schema_path
141
+ # If cached path doesn't exist, re-search (schema might have been moved)
142
+ _resolved_schema_path = None
143
+ else:
144
+ # Cached path is based on CWD when file was not found,
145
+ # check if CWD changed
146
+ current_cwd = Path(os.getcwd()).resolve()
147
+ if current_cwd != _cached_cwd:
148
+ # CWD changed, invalidate cache and re-search from new CWD
149
+ _resolved_schema_path = None
150
+ _cached_cwd = None
151
+ else:
152
+ # CWD unchanged, return cached path
153
+ return _resolved_schema_path
154
+
155
+ # Search for schema file
156
+ found_path = _find_schema_file()
157
+ if found_path is not None:
158
+ _resolved_schema_path = found_path
159
+ _cached_cwd = None # Absolute path doesn't depend on CWD
160
+ return found_path
161
+
162
+ # Schema file not found, return absolute path in current working directory
163
+ # (this path may not exist, but allows caller to report proper error)
164
+ current_cwd = Path(os.getcwd()).resolve()
165
+ schema_path = (current_cwd / SCHEMA_FILE_NAME).resolve()
166
+ _resolved_schema_path = schema_path
167
+ _cached_cwd = current_cwd # Track CWD since path depends on it
168
+ return schema_path
169
+
170
+
171
+ def _load_raw_schema() -> Tuple[Dict[str, Any], Path]:
172
+ """Load raw JSON schema from the application root.
173
+
174
+ This function always attempts to read the schema file and records
175
+ any problems as :class:`SchemaProblem` instances.
176
+
177
+ Note:
178
+ This helper is part of the internal implementation and is not
179
+ considered a public API.
180
+
181
+ Returns:
182
+ Tuple of (schema data, schema file path).
183
+ """
184
+ schema_path = _get_schema_path()
185
+
186
+ if not schema_path.exists():
187
+ # Let the caller decide how to report this.
188
+ raise FileNotFoundError(f"Schema file not found: {schema_path}")
189
+
190
+ try:
191
+ with schema_path.open("r", encoding="utf-8") as f:
192
+ data = json.load(f)
193
+ except OSError as exc:
194
+ # Normalise I/O errors to ValueError so that _compile_schema_internal()
195
+ # can report them as SchemaProblem instances instead of leaking raw
196
+ # OSError to callers.
197
+ raise ValueError(
198
+ f"Failed to read schema file {schema_path}: {exc}",
199
+ ) from exc
200
+ except json.JSONDecodeError as exc:
201
+ raise ValueError(f"Failed to parse JSON schema: {exc}") from exc
202
+
203
+ if not isinstance(data, dict):
204
+ # Normalise non-object top-level schemas into a ValueError so that the
205
+ # caller can report it as a SchemaProblem while keeping type safety.
206
+ raise ValueError("Top-level schema must be a JSON object")
207
+
208
+ return data, schema_path
209
+
210
+
211
+ def _compile_schema_tree(
212
+ node: MutableMapping[str, Any],
213
+ path: Tuple[str, ...],
214
+ problems: List[SchemaProblem],
215
+ ) -> Iterable[SchemaLeaf]:
216
+ """Recursively compile a schema node into SchemaLeaf objects.
217
+
218
+ Args:
219
+ node: The schema node to compile.
220
+ path: Current path in the schema tree.
221
+ problems: List to collect validation problems.
222
+
223
+ Yields:
224
+ SchemaLeaf objects found in the tree.
225
+ """
226
+ # Check for excessive nesting depth
227
+ if len(path) > MAX_SCHEMA_DEPTH:
228
+ problems.append(
229
+ SchemaProblem(
230
+ f"Schema nesting depth exceeds maximum allowed depth of "
231
+ f"{MAX_SCHEMA_DEPTH} at path {'.'.join(path)}"
232
+ ),
233
+ )
234
+ return
235
+
236
+ for key, value in node.items():
237
+ if not isinstance(value, Mapping):
238
+ problems.append(
239
+ SchemaProblem(
240
+ f"Invalid schema at {'.'.join(path + (key,))}: expected object"
241
+ ),
242
+ )
243
+ continue
244
+
245
+ value_dict = dict(value)
246
+ leaf_type = value_dict.get("type")
247
+ leaf_source = value_dict.get("source")
248
+
249
+ if leaf_type is not None or leaf_source is not None:
250
+ # This is supposed to be a leaf - validate required fields first.
251
+ type_invalid = leaf_type is None or (
252
+ isinstance(leaf_type, str) and leaf_type.strip() == ""
253
+ )
254
+ source_invalid = leaf_source is None or (
255
+ isinstance(leaf_source, str) and leaf_source.strip() == ""
256
+ )
257
+
258
+ if type_invalid:
259
+ problems.append(
260
+ SchemaProblem(
261
+ f"Incomplete leaf at {'.'.join(path + (key,))}: "
262
+ f"type cannot be None or empty",
263
+ ),
264
+ )
265
+
266
+ if source_invalid:
267
+ problems.append(
268
+ SchemaProblem(
269
+ f"Incomplete leaf at {'.'.join(path + (key,))}: "
270
+ f"source cannot be None or empty",
271
+ ),
272
+ )
273
+
274
+ if type_invalid or source_invalid:
275
+ continue
276
+
277
+ expected_type = _TYPE_MAP.get(str(leaf_type))
278
+ if expected_type is None:
279
+ problems.append(
280
+ SchemaProblem(
281
+ f"Unknown type '{leaf_type}' at {'.'.join(path + (key,))}",
282
+ ),
283
+ )
284
+ continue
285
+
286
+ item_expected_type: type | None = None
287
+ # For list-typed leaves we require an explicit, primitive item_type
288
+ # to ensure element homogeneity (e.g. list[str], list[int]).
289
+ if expected_type is list:
290
+ item_type_name = value_dict.get("item_type")
291
+ item_type_invalid = item_type_name is None or (
292
+ isinstance(item_type_name, str) and item_type_name.strip() == ""
293
+ )
294
+ if item_type_invalid:
295
+ problems.append(
296
+ SchemaProblem(
297
+ f"Incomplete leaf at {'.'.join(path + (key,))}: "
298
+ f"item_type is required for list type and "
299
+ f"cannot be None or empty",
300
+ ),
301
+ )
302
+ continue
303
+
304
+ item_expected_type = _TYPE_MAP.get(str(item_type_name))
305
+ # Item type must be a primitive (str, int, float, bool), not list
306
+ if item_expected_type is None or item_expected_type is list:
307
+ problems.append(
308
+ SchemaProblem(
309
+ f"Invalid item_type '{item_type_name}' at "
310
+ f"{'.'.join(path + (key,))}: only primitive item types "
311
+ f"('str', 'int', 'float', 'bool') are allowed for lists",
312
+ ),
313
+ )
314
+ continue
315
+
316
+ yield SchemaLeaf(
317
+ path=path + (key,),
318
+ source=str(leaf_source),
319
+ expected_type=expected_type,
320
+ item_expected_type=item_expected_type,
321
+ )
322
+ else:
323
+ # This is an inner node; recurse into children.
324
+ for child_leaf in _compile_schema_tree(value_dict, path + (key,), problems):
325
+ yield child_leaf
326
+
327
+
328
+ def get_builtin_logrecord_attributes() -> set[str]:
329
+ """Get set of standard LogRecord attribute names.
330
+
331
+ This function extracts attribute names from LogRecord that represent
332
+ system fields and should not be used as root keys in the schema or
333
+ treated as user-provided extra fields.
334
+
335
+ The function creates a minimal LogRecord instance and uses introspection
336
+ to discover all non-callable, non-private attributes. This is the standard
337
+ way to get attribute names from a class instance in Python.
338
+
339
+ Returns:
340
+ Set of attribute names that are reserved by the logging system.
341
+ Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc.
342
+
343
+ Example:
344
+ >>> forbidden = get_builtin_logrecord_attributes()
345
+ >>> "name" in forbidden
346
+ True
347
+ >>> "ServicePayload" in forbidden
348
+ False
349
+ """
350
+ # Create a minimal LogRecord instance to inspect its attributes
351
+ # This is necessary because LogRecord attributes are not defined as
352
+ # class attributes but are set in __init__
353
+ record = logging.LogRecord(
354
+ name="",
355
+ level=0,
356
+ pathname="",
357
+ lineno=0,
358
+ msg="",
359
+ args=(),
360
+ exc_info=None,
361
+ )
362
+
363
+ # Use dir() to get all attributes, then filter out:
364
+ # - Private attributes (starting with _)
365
+ # - Callable attributes (methods)
366
+ # This leaves only data fields that represent actual LogRecord attributes
367
+ forbidden = set()
368
+ for attr_name in dir(record):
369
+ if attr_name.startswith("_"):
370
+ continue
371
+ attr_value = getattr(record, attr_name, None)
372
+ if not callable(attr_value):
373
+ forbidden.add(attr_name)
374
+
375
+ return forbidden
376
+
377
+
378
+ def _check_root_conflicts(
379
+ schema_dict: Mapping[str, Any], problems: List[SchemaProblem]
380
+ ) -> None:
381
+ """Check schema root keys for conflicts with reserved logging fields."""
382
+
383
+ forbidden_root_keys = get_builtin_logrecord_attributes()
384
+
385
+ for key in schema_dict.keys():
386
+ if key in forbidden_root_keys:
387
+ problems.append(
388
+ SchemaProblem(
389
+ f"Root key '{key}' conflicts with reserved logging fields",
390
+ ),
391
+ )
392
+
393
+
394
+ def _compile_schema_internal() -> Tuple[CompiledSchema, List[SchemaProblem]]:
395
+ """Compile JSON schema into ``CompiledSchema`` and collect all problems.
396
+
397
+ The function loads the raw JSON schema, validates its structure, checks
398
+ root keys for conflicts with reserved ``logging.LogRecord`` attributes
399
+ and compiles all valid leaves into a :class:`CompiledSchema`. All issues
400
+ discovered during this process are reported as :class:`SchemaProblem`
401
+ instances.
402
+
403
+ Results are cached process-wide: the cache key is the absolute schema
404
+ file path and the value is a tuple ``(CompiledSchema, List[SchemaProblem])``.
405
+ Once a schema for a given path has been observed (including the cases when
406
+ it is missing or invalid), subsequent calls always return the cached result
407
+ without re-reading or re-compiling the schema. To pick up on-disk changes
408
+ to the schema, the application must restart the process. See the README
409
+ section \"Schema caching and thread safety\" for more details.
410
+
411
+ This function never raises ``SchemaValidationError``. It always returns
412
+ the best-effort compiled schema together with a list of problems detected
413
+ during processing (an empty ``CompiledSchema`` when the schema is missing
414
+ or invalid).
415
+
416
+ Note:
417
+ This function is used internally by :class:`SchemaLogger` and by the
418
+ test suite. It is not part of the public package API and may change
419
+ between releases without preserving backward compatibility.
420
+
421
+ Returns:
422
+ Tuple of (CompiledSchema, List[SchemaProblem]).
423
+ """
424
+ schema_path = _get_schema_path().resolve()
425
+
426
+ # Fast-path: if we have already attempted to compile schema for this path,
427
+ # return cached result (successful, missing-file, or invalid-schema).
428
+ with _cache_lock:
429
+ cached = _SCHEMA_CACHE.get(schema_path)
430
+ if cached is not None:
431
+ return cached
432
+
433
+ problems: List[SchemaProblem] = []
434
+
435
+ try:
436
+ raw_schema, loaded_path = _load_raw_schema()
437
+ except FileNotFoundError as exc:
438
+ problems.append(SchemaProblem(str(exc)))
439
+ result = (CompiledSchema(leaves=[]), problems)
440
+ with _cache_lock:
441
+ _SCHEMA_CACHE[schema_path] = result
442
+ return result
443
+ except ValueError as exc:
444
+ problems.append(SchemaProblem(str(exc)))
445
+ result = (CompiledSchema(leaves=[]), problems)
446
+ with _cache_lock:
447
+ _SCHEMA_CACHE[schema_path] = result
448
+ return result
449
+
450
+ _check_root_conflicts(raw_schema, problems)
451
+
452
+ leaves: List[SchemaLeaf] = []
453
+ for key, value in raw_schema.items():
454
+ if not isinstance(value, Mapping):
455
+ problems.append(
456
+ SchemaProblem(f"Invalid schema at {key}: expected object"),
457
+ )
458
+ continue
459
+
460
+ for leaf in _compile_schema_tree(dict(value), (key,), problems):
461
+ leaves.append(leaf)
462
+
463
+ compiled = CompiledSchema(leaves=leaves)
464
+ result = (compiled, problems)
465
+
466
+ # Cache the result for this schema path (thread-safe write).
467
+ # NOTE: ``loaded_path`` is expected to be identical to ``schema_path`` here
468
+ # because both are derived from ``_get_schema_path()``. We still use
469
+ # ``schema_path`` as the cache key consistently to avoid any confusion if
470
+ # ``_load_raw_schema()`` implementation changes in the future.
471
+ with _cache_lock:
472
+ _SCHEMA_CACHE[schema_path] = result
473
+
474
+ return result
@@ -0,0 +1,118 @@
1
+ """Logger subclass that applies a JSON schema to extra fields.
2
+
3
+ This class extends the standard ``logging.Logger`` to validate and filter
4
+ user-provided ``extra`` fields according to a compiled JSON schema. It raises
5
+ a DataValidationError *after* the log record has been emitted when problems
6
+ are detected.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+
15
+ from .errors import DataValidationError, SchemaValidationError
16
+ from .schema_applier import _apply_schema_internal
17
+ from .schema_loader import CompiledSchema, _compile_schema_internal
18
+
19
+
20
+ class SchemaLogger(logging.Logger):
21
+ """Logger subclass that enforces schema on extra fields.
22
+
23
+ This class extends :class:`logging.Logger` to add validation for
24
+ ``extra`` fields according to a compiled JSON schema. It is designed
25
+ to be used as a drop-in replacement via :func:`logging.setLoggerClass`.
26
+
27
+ The schema is loaded from ``logging_objects_with_schema.json`` in the
28
+ application root directory during initialization. If the schema cannot
29
+ be loaded or validated, a :class:`SchemaValidationError` is raised.
30
+
31
+ Example:
32
+ >>> import logging
33
+ >>> from logging_objects_with_schema import SchemaLogger
34
+ >>> logging.setLoggerClass(SchemaLogger)
35
+ >>> logger = logging.getLogger("my_service")
36
+ >>> logger.info("request processed", extra={"request_id": "abc-123"})
37
+ """
38
+
39
+ def __init__(self, name: str, level: int = logging.NOTSET) -> None:
40
+ """Initialise the schema-aware logger.
41
+
42
+ The schema is compiled once during construction. If any
43
+ problems are detected in the schema, a SchemaValidationError
44
+ will be raised and this logger instance is not usable.
45
+
46
+ Args:
47
+ name: Logger name (same as :class:`logging.Logger`).
48
+ level: Logger level (same as :class:`logging.Logger`).
49
+ """
50
+ super().__init__(name, level)
51
+
52
+ try:
53
+ compiled, problems = _compile_schema_internal()
54
+ except Exception:
55
+ # Ensure that a partially initialised logger instance is not left
56
+ # registered in the logging manager if schema compilation fails.
57
+ # Otherwise, subsequent logging.getLogger(name) calls could return
58
+ # this broken instance and lead to AttributeError at runtime.
59
+ self.manager.loggerDict.pop(self.name, None)
60
+ raise
61
+
62
+ if problems:
63
+ # Schema is invalid; remove this instance from the logging manager
64
+ # cache before raising, for the same reason as above.
65
+ self.manager.loggerDict.pop(self.name, None)
66
+ raise SchemaValidationError("Schema has problems", problems=problems)
67
+
68
+ self._schema: CompiledSchema = compiled
69
+
70
+ def _log(
71
+ self,
72
+ level: int,
73
+ msg: object,
74
+ args: tuple[object, ...] | Mapping[str, object],
75
+ exc_info: Any = None,
76
+ extra: Mapping[str, object] | None = None,
77
+ stack_info: bool = False,
78
+ stacklevel: int = 1,
79
+ ) -> None:
80
+ """Log a message with the specified level and schema-validated extra.
81
+
82
+ This method validates and filters the ``extra`` parameter according
83
+ to the compiled schema before delegating to the parent class.
84
+
85
+ Args:
86
+ level: Logging level.
87
+ msg: Message format string.
88
+ args: Arguments for message formatting.
89
+ exc_info: Exception information.
90
+ extra: Extra fields to include in the log record.
91
+ stack_info: Whether to include stack information.
92
+ stacklevel: Stack level for caller information.
93
+
94
+ Raises:
95
+ DataValidationError: If the ``extra`` data does not match the schema.
96
+ """
97
+ structured_extra, data_problems = _apply_schema_internal(
98
+ self._schema,
99
+ extra or {},
100
+ )
101
+
102
+ super()._log(
103
+ level,
104
+ msg,
105
+ args,
106
+ exc_info=exc_info,
107
+ extra=structured_extra,
108
+ stack_info=stack_info,
109
+ # Increment stacklevel to account for this override frame so that
110
+ # caller information points to user code instead of SchemaLogger._log.
111
+ stacklevel=stacklevel + 1,
112
+ )
113
+
114
+ if data_problems:
115
+ raise DataValidationError(
116
+ "Log data does not match schema",
117
+ problems=data_problems,
118
+ )