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,17 @@
1
+ """Top-level package for logging_objects_with_schema.
2
+
3
+ This package provides a logger subclass built on top of the standard
4
+ logging package that validates and filters user-provided extra fields
5
+ according to an application-defined JSON schema.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .errors import DataValidationError, SchemaValidationError
11
+ from .schema_logger import SchemaLogger
12
+
13
+ __all__ = [
14
+ "SchemaLogger",
15
+ "SchemaValidationError",
16
+ "DataValidationError",
17
+ ]
@@ -0,0 +1,85 @@
1
+ """Custom exception types used by logging_objects_with_schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List
7
+
8
+
9
+ @dataclass
10
+ class SchemaProblem:
11
+ """Describes a single problem encountered while loading the schema.
12
+
13
+ Attributes:
14
+ message: Human-readable description of the problem.
15
+ """
16
+
17
+ message: str
18
+
19
+
20
+ class SchemaValidationError(Exception):
21
+ """Raised when there are problems with the JSON schema definition.
22
+
23
+ This exception is raised during SchemaLogger initialization when the schema
24
+ file cannot be loaded, parsed, or validated. The logger instance will not
25
+ be created if this exception is raised.
26
+
27
+ The human-readable summary is stored in the exception message, while
28
+ detailed information about each violation is available in the ``problems``
29
+ attribute.
30
+
31
+ Attributes:
32
+ problems: List of SchemaProblem instances describing each validation issue.
33
+
34
+ Example:
35
+ >>> try:
36
+ ... logger = SchemaLogger("my_logger")
37
+ ... except SchemaValidationError as e:
38
+ ... for problem in e.problems:
39
+ ... print(f"Schema error: {problem.message}")
40
+ """
41
+
42
+ def __init__(
43
+ self, message: str, problems: List[SchemaProblem] | None = None
44
+ ) -> None:
45
+ super().__init__(message)
46
+ self.problems: List[SchemaProblem] = problems or []
47
+
48
+
49
+ @dataclass
50
+ class DataProblem:
51
+ """Describes a single problem encountered while validating log data.
52
+
53
+ Attributes:
54
+ message: Human-readable description of the validation problem.
55
+ """
56
+
57
+ message: str
58
+
59
+
60
+ class DataValidationError(Exception):
61
+ """Raised when log record data does not satisfy the configured schema.
62
+
63
+ This exception is raised *after* the valid part of the log record
64
+ has already been formatted and sent to the underlying handler. This means
65
+ that even if validation fails, the valid fields will still appear in the log.
66
+
67
+ The exception message provides a summary description of the validation
68
+ failure, while detailed information about individual problems is exposed
69
+ via the ``problems`` attribute.
70
+
71
+ Attributes:
72
+ problems: List of DataProblem instances describing each validation issue.
73
+
74
+ Example:
75
+ >>> try:
76
+ ... logger.info("processing", extra={"user_id": "not-an-int"})
77
+ ... except DataValidationError as e:
78
+ ... for problem in e.problems:
79
+ ... print(f"Validation error: {problem.message}")
80
+ ... # Note: valid fields were already logged before this exception
81
+ """
82
+
83
+ def __init__(self, message: str, problems: List[DataProblem] | None = None) -> None:
84
+ super().__init__(message)
85
+ self.problems: List[DataProblem] = problems or []
@@ -0,0 +1,184 @@
1
+ """Internal schema application logic for logging_objects_with_schema.
2
+
3
+ This module provides the core function for applying a compiled schema
4
+ to user-provided extra fields, used by SchemaLogger.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Mapping, MutableMapping, Tuple
10
+
11
+ from .errors import DataProblem
12
+ from .schema_loader import CompiledSchema, SchemaLeaf
13
+
14
+
15
+ def _strip_empty(node: Any) -> Any:
16
+ """Remove empty dictionaries and None values from a nested structure.
17
+
18
+ Recursively walks through a dictionary structure and removes:
19
+ - Empty dictionaries ({})
20
+ - None values
21
+
22
+ This helper is used by ``_apply_schema_internal`` on the final payload
23
+ to avoid leaving empty containers created during schema application.
24
+
25
+ Note:
26
+ This function is part of the internal implementation details and is
27
+ not considered a public API. Its signature and behaviour may change
28
+ between releases without preserving backward compatibility.
29
+
30
+ Args:
31
+ node: The node to process (can be dict, list, or any other type).
32
+
33
+ Returns:
34
+ The cleaned structure with empty dicts and None values removed.
35
+ """
36
+ if isinstance(node, dict):
37
+ cleaned = {k: _strip_empty(v) for k, v in node.items()}
38
+ return {k: v for k, v in cleaned.items() if v != {} and v is not None}
39
+ return node
40
+
41
+
42
+ def _apply_schema_internal(
43
+ compiled: CompiledSchema,
44
+ extra_values: Mapping[str, Any],
45
+ ) -> Tuple[Dict[str, Any], List[DataProblem]]:
46
+ """Internal function to build structured ``extra`` from compiled schema.
47
+
48
+ The function applies a :class:`CompiledSchema` to user-provided ``extra``
49
+ values and returns a tuple ``(structured_extra, problems)`` where:
50
+
51
+ - ``structured_extra`` is a nested dictionary that follows the schema
52
+ structure and contains only fields that passed validation;
53
+ - ``problems`` is a list of :class:`DataProblem` describing all data
54
+ issues observed during processing.
55
+
56
+ Behaviour summary:
57
+
58
+ - If the compiled schema is effectively empty (no valid leaves),
59
+ all fields from ``extra_values`` are treated as redundant: the returned
60
+ payload is empty, and a :class:`DataProblem` is created for each field.
61
+ - For each ``source`` mentioned in the schema when there are valid leaves:
62
+ - if the source is missing from ``extra_values``, it is silently skipped;
63
+ - if the corresponding value is ``None``, a ``DataProblem`` is recorded
64
+ and the value is not written to the payload.
65
+ - Type checks are strict: the runtime type must exactly match the declared
66
+ Python type (``type(value) is leaf.expected_type``). This prevents
67
+ ``bool`` from being accepted where ``int`` is expected.
68
+ - For list-typed leaves:
69
+ - empty lists are accepted;
70
+ - all elements must have the exact type declared by the leaf
71
+ ``item_expected_type`` (for example, list[str], list[int]);
72
+ - non-primitive elements and elements of a different primitive type are
73
+ rejected with a ``DataProblem`` and the list value is not written.
74
+ - Redundant fields from ``extra_values`` (not referenced by any leaf
75
+ ``source``) are always reported as problems: each such field generates
76
+ a :class:`DataProblem` indicating that it is not defined in the schema.
77
+ - A single ``source`` may be used by multiple leaves. The value is
78
+ validated independently for each leaf and written only to locations
79
+ where the type matches; mismatched locations produce ``DataProblem``
80
+ entries, but do not affect successful locations.
81
+
82
+ The function itself does not raise ``DataValidationError``; it only
83
+ accumulates :class:`DataProblem` instances for the caller to handle.
84
+
85
+ Note:
86
+ This function is used internally by :class:`SchemaLogger` and is not
87
+ considered part of the public API. Its signature and behaviour may
88
+ change between releases without preserving backward compatibility.
89
+
90
+ Returns:
91
+ Tuple of (structured_extra, List[DataProblem]).
92
+ """
93
+ extra: Dict[str, Any] = {}
94
+ problems: List[DataProblem] = []
95
+
96
+ used_sources = {leaf.source for leaf in compiled.leaves}
97
+
98
+ source_to_leaves: Dict[str, List[SchemaLeaf]] = {}
99
+ for leaf in compiled.leaves:
100
+ source_to_leaves.setdefault(leaf.source, []).append(leaf)
101
+
102
+ for source, leaves in source_to_leaves.items():
103
+ if source not in extra_values:
104
+ continue
105
+
106
+ value = extra_values.get(source)
107
+
108
+ # Check for None values explicitly (None values are not allowed)
109
+ # This check must be done once per source, not once per leaf
110
+ if value is None:
111
+ problems.append(
112
+ DataProblem(
113
+ f"Field '{source}' is None, but None values are not allowed",
114
+ ),
115
+ )
116
+ continue
117
+
118
+ for leaf in leaves:
119
+ # Use strict type checking (type() is) instead of isinstance() to
120
+ # prevent bool values from passing validation for int types (since
121
+ # bool is a subclass of int). This ensures that the actual
122
+ # runtime type matches the schema type exactly.
123
+ if type(value) is not leaf.expected_type:
124
+ problems.append(
125
+ DataProblem(
126
+ f"Field '{source}' has type {type(value).__name__}, "
127
+ f"expected {leaf.expected_type.__name__}",
128
+ ),
129
+ )
130
+ continue
131
+
132
+ # For lists, validate that all elements strictly match the declared
133
+ # item_expected_type (homogeneous primitive list).
134
+ if leaf.expected_type is list and isinstance(value, list):
135
+ item_expected_type = leaf.item_expected_type
136
+ # item_expected_type should always be set for list leaves by the
137
+ # schema compiler, but we guard defensively.
138
+ if item_expected_type is None:
139
+ problems.append(
140
+ DataProblem(
141
+ f"Field '{source}' is declared as list in schema but "
142
+ f"has no item type configured",
143
+ ),
144
+ )
145
+ continue
146
+
147
+ if len(value) > 0:
148
+ invalid_item_types = {
149
+ type(item).__name__
150
+ for item in value
151
+ if type(item) is not item_expected_type
152
+ }
153
+ if invalid_item_types:
154
+ problems.append(
155
+ DataProblem(
156
+ f"Field '{source}' is a list but contains elements "
157
+ f"with types {sorted(invalid_item_types)}; "
158
+ f"expected all elements to be of type "
159
+ f"{item_expected_type.__name__}",
160
+ ),
161
+ )
162
+ continue
163
+
164
+ target: MutableMapping[str, Any] = extra
165
+ for key in leaf.path[:-1]:
166
+ child = target.get(key)
167
+ if not isinstance(child, dict):
168
+ child = {}
169
+ target[key] = child
170
+ target = child
171
+
172
+ target[leaf.path[-1]] = value
173
+
174
+ # Report redundant fields for any keys not referenced by schema leaves.
175
+ for key in extra_values.keys():
176
+ if key not in used_sources:
177
+ problems.append(
178
+ DataProblem(
179
+ f"Field '{key}' is not defined in schema and will be ignored",
180
+ ),
181
+ )
182
+
183
+ cleaned_extra = _strip_empty(extra)
184
+ return cleaned_extra, problems