logging-objects-with-schema 0.1.0__tar.gz → 0.1.1__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.0
3
+ Version: 0.1.1
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
@@ -177,6 +177,13 @@ except SchemaValidationError as e:
177
177
  print(f" - {problem.message}")
178
178
  # Decide whether to abort or continue with default logger
179
179
  raise
180
+ except (OSError, ValueError, RuntimeError) as e:
181
+ # System-level errors (e.g., inaccessible working directory when os.getcwd()
182
+ # fails, lock failures, path resolution issues) are raised directly, not
183
+ # wrapped in SchemaValidationError. Note: OSError when reading the schema
184
+ # file is converted to SchemaValidationError and handled above.
185
+ print(f"System error during logger initialization: {e}")
186
+ raise
180
187
 
181
188
  # When logging, handle DataValidationError
182
189
  try:
@@ -356,6 +363,21 @@ consistent type expectations when reusing a `source` field.
356
363
  The application decides whether `SchemaValidationError` is a fatal error that
357
364
  should abort startup, or whether it should be logged and ignored.
358
365
 
366
+ **Note**: In rare cases, system-level errors may be raised directly instead of
367
+ being wrapped in `SchemaValidationError`. Specifically:
368
+
369
+ - `OSError` when the current working directory is inaccessible or deleted (e.g.,
370
+ when `os.getcwd()` fails) is propagated as-is to indicate environmental issues.
371
+ However, `OSError` that occurs when reading the schema file (e.g., permission
372
+ denied, I/O errors) is converted to `SchemaValidationError` and reported as a
373
+ schema problem.
374
+ - `ValueError` when path resolution fails due to invalid characters or malformed
375
+ paths during schema file discovery is propagated as-is.
376
+ - `RuntimeError` when thread lock acquisition fails is propagated as-is.
377
+
378
+ Applications should handle these exceptions separately if they need to
379
+ distinguish between schema validation failures and system-level errors.
380
+
359
381
  ## Schema caching and thread safety
360
382
 
361
383
  - The library caches compiled schemas to avoid recompiling the same schema file
@@ -144,6 +144,13 @@ except SchemaValidationError as e:
144
144
  print(f" - {problem.message}")
145
145
  # Decide whether to abort or continue with default logger
146
146
  raise
147
+ except (OSError, ValueError, RuntimeError) as e:
148
+ # System-level errors (e.g., inaccessible working directory when os.getcwd()
149
+ # fails, lock failures, path resolution issues) are raised directly, not
150
+ # wrapped in SchemaValidationError. Note: OSError when reading the schema
151
+ # file is converted to SchemaValidationError and handled above.
152
+ print(f"System error during logger initialization: {e}")
153
+ raise
147
154
 
148
155
  # When logging, handle DataValidationError
149
156
  try:
@@ -323,6 +330,21 @@ consistent type expectations when reusing a `source` field.
323
330
  The application decides whether `SchemaValidationError` is a fatal error that
324
331
  should abort startup, or whether it should be logged and ignored.
325
332
 
333
+ **Note**: In rare cases, system-level errors may be raised directly instead of
334
+ being wrapped in `SchemaValidationError`. Specifically:
335
+
336
+ - `OSError` when the current working directory is inaccessible or deleted (e.g.,
337
+ when `os.getcwd()` fails) is propagated as-is to indicate environmental issues.
338
+ However, `OSError` that occurs when reading the schema file (e.g., permission
339
+ denied, I/O errors) is converted to `SchemaValidationError` and reported as a
340
+ schema problem.
341
+ - `ValueError` when path resolution fails due to invalid characters or malformed
342
+ paths during schema file discovery is propagated as-is.
343
+ - `RuntimeError` when thread lock acquisition fails is propagated as-is.
344
+
345
+ Applications should handle these exceptions separately if they need to
346
+ distinguish between schema validation failures and system-level errors.
347
+
326
348
  ## Schema caching and thread safety
327
349
 
328
350
  - The library caches compiled schemas to avoid recompiling the same schema file
@@ -0,0 +1,97 @@
1
+ [build-system]
2
+ requires = [ "uv_build >= 0.9.11, <0.10.0" ]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "logging-objects-with-schema"
7
+ version = "0.1.1"
8
+ description = "Proxy logging wrapper that validates extra fields against a JSON schema."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [ ]
12
+ license = "Apache-2.0"
13
+ license-files = [ "LICENSE" ]
14
+ keywords = [
15
+ "logging",
16
+ "schema",
17
+ "validation",
18
+ "structured-logging",
19
+ "structured-logs"
20
+ ]
21
+ classifiers = [
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Intended Audience :: Developers",
30
+ "Topic :: System :: Logging"
31
+ ]
32
+
33
+ [[project.authors]]
34
+ name = "Dmitrii Safronov"
35
+ email = "dhameoelin+logging-objects-with-schema@gmail.com"
36
+
37
+ [project.optional-dependencies]
38
+ dev = [
39
+ "pytest>=7.0.0",
40
+ "pytest-cov>=4.0.0",
41
+ "black>=23.0.0",
42
+ "isort>=5.12.0",
43
+ "flake8>=6.0.0",
44
+ "flake8-pyproject>=1.2.3",
45
+ "mypy>=1.18.1",
46
+ "bandit>=1.8.6",
47
+ "pre-commit>=3.5.0"
48
+ ]
49
+
50
+ [project.urls]
51
+ Source = "https://github.com/disafronov/python-logging-objects-with-schema"
52
+ Issues = "https://github.com/disafronov/python-logging-objects-with-schema/issues"
53
+
54
+ [tool.isort]
55
+ profile = "black"
56
+ line_length = 88
57
+ multi_line_output = 3
58
+
59
+ [tool.black]
60
+ line-length = 88
61
+ include = "\\.pyi?$"
62
+ extend-exclude = """
63
+ /(
64
+ \\.eggs
65
+ | \\.git
66
+ | \\.hg
67
+ | \\.mypy_cache
68
+ | \\.tox
69
+ | \\.venv
70
+ | build
71
+ | dist
72
+ )/
73
+ """
74
+
75
+ [tool.flake8]
76
+ max-line-length = 88
77
+ extend-ignore = [ "E203", "W503" ]
78
+ exclude = [ ".venv", "__pycache__", ".git", "htmlcov" ]
79
+
80
+ [tool.mypy]
81
+ exclude = "tests/"
82
+ warn_return_any = true
83
+ warn_unused_configs = true
84
+ disallow_untyped_defs = true
85
+ disallow_incomplete_defs = true
86
+ check_untyped_defs = true
87
+ disallow_untyped_decorators = true
88
+ no_implicit_optional = true
89
+ warn_redundant_casts = true
90
+ warn_unused_ignores = true
91
+ warn_no_return = true
92
+ warn_unreachable = true
93
+ strict_equality = true
94
+
95
+ [tool.bandit]
96
+ skips = [ "B101", "B601" ]
97
+ exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
- from typing import List
7
6
 
8
7
 
9
8
  @dataclass
@@ -40,10 +39,10 @@ class SchemaValidationError(Exception):
40
39
  """
41
40
 
42
41
  def __init__(
43
- self, message: str, problems: List[SchemaProblem] | None = None
42
+ self, message: str, problems: list[SchemaProblem] | None = None
44
43
  ) -> None:
45
44
  super().__init__(message)
46
- self.problems: List[SchemaProblem] = problems or []
45
+ self.problems: list[SchemaProblem] = problems or []
47
46
 
48
47
 
49
48
  @dataclass
@@ -80,6 +79,6 @@ class DataValidationError(Exception):
80
79
  ... # Note: valid fields were already logged before this exception
81
80
  """
82
81
 
83
- def __init__(self, message: str, problems: List[DataProblem] | None = None) -> None:
82
+ def __init__(self, message: str, problems: list[DataProblem] | None = None) -> None:
84
83
  super().__init__(message)
85
- self.problems: List[DataProblem] = problems or []
84
+ self.problems: list[DataProblem] = problems or []
@@ -0,0 +1,254 @@
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 collections import defaultdict
10
+ from typing import Any, Mapping, MutableMapping
11
+
12
+ from .errors import DataProblem
13
+ from .schema_loader import CompiledSchema, SchemaLeaf
14
+
15
+
16
+ def _validate_list_value(
17
+ value: list,
18
+ source: str,
19
+ item_expected_type: type | None,
20
+ ) -> DataProblem | None:
21
+ """Validate that a list value matches the expected item type.
22
+
23
+ Validates that all elements in the list have the exact type declared by
24
+ ``item_expected_type``. Empty lists are always considered valid.
25
+
26
+ Args:
27
+ value: The list value to validate.
28
+ source: The source field name (for error messages).
29
+ item_expected_type: Expected type for list elements. Must not be None
30
+ for list-typed leaves.
31
+
32
+ Returns:
33
+ DataProblem if validation fails, None if validation succeeds.
34
+ """
35
+ if item_expected_type is None:
36
+ return DataProblem(
37
+ f"Field '{source}' is declared as list in schema but "
38
+ f"has no item type configured",
39
+ )
40
+
41
+ if len(value) == 0:
42
+ # Empty lists are always valid
43
+ return None
44
+
45
+ invalid_item_types = {
46
+ type(item).__name__ for item in value if type(item) is not item_expected_type
47
+ }
48
+
49
+ if invalid_item_types:
50
+ return DataProblem(
51
+ f"Field '{source}' is a list but contains elements "
52
+ f"with types {sorted(invalid_item_types)}; "
53
+ f"expected all elements to be of type "
54
+ f"{item_expected_type.__name__}",
55
+ )
56
+
57
+ return None
58
+
59
+
60
+ def _set_nested_value(
61
+ target: MutableMapping[str, Any],
62
+ path: tuple[str, ...],
63
+ value: Any,
64
+ ) -> None:
65
+ """Set a value in a nested dictionary structure following the given path.
66
+
67
+ Creates intermediate dictionaries as needed. The last element of the path
68
+ is used as the final key for the value.
69
+
70
+ Args:
71
+ target: The root dictionary to modify.
72
+ path: Tuple of keys representing the path to the target location.
73
+ value: The value to set at the target location.
74
+ """
75
+ current = target
76
+ for key in path[:-1]:
77
+ child = current.get(key)
78
+ if not isinstance(child, dict):
79
+ child = {}
80
+ current[key] = child
81
+ current = child
82
+
83
+ current[path[-1]] = value
84
+
85
+
86
+ def _validate_and_apply_leaf(
87
+ leaf: SchemaLeaf,
88
+ value: Any,
89
+ source: str,
90
+ extra: MutableMapping[str, Any],
91
+ problems: list[DataProblem],
92
+ ) -> None:
93
+ """Validate a value against a schema leaf and apply it if valid.
94
+
95
+ Performs strict type checking and list validation. If validation passes,
96
+ the value is written to the target location in the extra dictionary.
97
+
98
+ Args:
99
+ leaf: The schema leaf to validate against.
100
+ value: The value to validate and apply.
101
+ source: The source field name (for error messages).
102
+ extra: The target dictionary to write the value to if validation passes.
103
+ problems: List to append validation problems to.
104
+ """
105
+ # Use strict type checking (type() is) instead of isinstance() to
106
+ # prevent bool values from passing validation for int types (since
107
+ # bool is a subclass of int). This ensures that the actual
108
+ # runtime type matches the schema type exactly.
109
+ if type(value) is not leaf.expected_type:
110
+ problems.append(
111
+ DataProblem(
112
+ f"Field '{source}' has type {type(value).__name__}, "
113
+ f"expected {leaf.expected_type.__name__}",
114
+ ),
115
+ )
116
+ return
117
+
118
+ # For lists, validate that all elements strictly match the declared
119
+ # item_expected_type (homogeneous primitive list).
120
+ # Note: isinstance() check is needed for type narrowing (mypy), even though
121
+ # type(value) is list is already guaranteed by the check above.
122
+ if leaf.expected_type is list and isinstance(value, list):
123
+ list_problem = _validate_list_value(value, source, leaf.item_expected_type)
124
+ if list_problem is not None:
125
+ problems.append(list_problem)
126
+ return
127
+
128
+ _set_nested_value(extra, leaf.path, value)
129
+
130
+
131
+ def _strip_empty(node: Any) -> Any:
132
+ """Remove empty dictionaries and None values from a nested structure.
133
+
134
+ Recursively walks through a dictionary structure and removes:
135
+ - Empty dictionaries ({})
136
+ - None values
137
+
138
+ This helper is used by ``_apply_schema_internal`` on the final payload
139
+ to avoid leaving empty containers created during schema application.
140
+
141
+ Note:
142
+ This function is part of the internal implementation details and is
143
+ not considered a public API. Its signature and behaviour may change
144
+ between releases without preserving backward compatibility.
145
+
146
+ Args:
147
+ node: The node to process (can be dict, list, or any other type).
148
+
149
+ Returns:
150
+ The cleaned structure with empty dicts and None values removed.
151
+ """
152
+ if isinstance(node, dict):
153
+ cleaned = {k: _strip_empty(v) for k, v in node.items()}
154
+ return {k: v for k, v in cleaned.items() if v != {} and v is not None}
155
+ return node
156
+
157
+
158
+ def _apply_schema_internal(
159
+ compiled: CompiledSchema,
160
+ extra_values: Mapping[str, Any],
161
+ ) -> tuple[dict[str, Any], list[DataProblem]]:
162
+ """Internal function to build structured ``extra`` from compiled schema.
163
+
164
+ The function applies a :class:`CompiledSchema` to user-provided ``extra``
165
+ values and returns a tuple ``(structured_extra, problems)`` where:
166
+
167
+ - ``structured_extra`` is a nested dictionary that follows the schema
168
+ structure and contains only fields that passed validation;
169
+ - ``problems`` is a list of :class:`DataProblem` describing all data
170
+ issues observed during processing.
171
+
172
+ Behaviour summary:
173
+
174
+ - If the compiled schema is effectively empty (no valid leaves),
175
+ all fields from ``extra_values`` are treated as redundant: the returned
176
+ payload is empty, and a :class:`DataProblem` is created for each field.
177
+ - For each ``source`` mentioned in the schema when there are valid leaves:
178
+ - if the source is missing from ``extra_values``, it is silently skipped;
179
+ - if the corresponding value is ``None``, a ``DataProblem`` is recorded
180
+ and the value is not written to the payload.
181
+ - Type checks are strict: the runtime type must exactly match the declared
182
+ Python type (``type(value) is leaf.expected_type``). This prevents
183
+ ``bool`` from being accepted where ``int`` is expected.
184
+ - For list-typed leaves:
185
+ - empty lists are accepted;
186
+ - all elements must have the exact type declared by the leaf
187
+ ``item_expected_type`` (for example, list[str], list[int]);
188
+ - non-primitive elements and elements of a different primitive type are
189
+ rejected with a ``DataProblem`` and the list value is not written.
190
+ - Redundant fields from ``extra_values`` (not referenced by any leaf
191
+ ``source``) are always reported as problems: each such field generates
192
+ a :class:`DataProblem` indicating that it is not defined in the schema.
193
+ - A single ``source`` may be used by multiple leaves. The value is
194
+ validated independently for each leaf and written only to locations
195
+ where the type matches; mismatched locations produce ``DataProblem``
196
+ entries, but do not affect successful locations.
197
+
198
+ The function itself does not raise ``DataValidationError``; it only
199
+ accumulates :class:`DataProblem` instances for the caller to handle.
200
+
201
+ Note:
202
+ This function is used internally by :class:`SchemaLogger` and is not
203
+ considered part of the public API. Its signature and behaviour may
204
+ change between releases without preserving backward compatibility.
205
+
206
+ Returns:
207
+ Tuple of (structured_extra, list[DataProblem]).
208
+ """
209
+ extra: dict[str, Any] = {}
210
+ problems: list[DataProblem] = []
211
+
212
+ # Group leaves by source for efficient processing
213
+ source_to_leaves: dict[str, list[SchemaLeaf]] = defaultdict(list)
214
+ for leaf in compiled.leaves:
215
+ source_to_leaves[leaf.source].append(leaf)
216
+
217
+ used_sources = set(source_to_leaves.keys())
218
+
219
+ for source, leaves in source_to_leaves.items():
220
+ if source not in extra_values:
221
+ continue
222
+
223
+ value = extra_values[source]
224
+
225
+ # Check for None values explicitly (None values are not allowed)
226
+ # This check must be done once per source, not once per leaf
227
+ if value is None:
228
+ problems.append(
229
+ DataProblem(
230
+ f"Field '{source}' is None, but None values are not allowed",
231
+ ),
232
+ )
233
+ continue
234
+
235
+ for leaf in leaves:
236
+ _validate_and_apply_leaf(leaf, value, source, extra, problems)
237
+
238
+ # Report redundant fields for any keys not referenced by schema leaves.
239
+ # Optimization: if schema is empty (no used_sources), all fields are redundant,
240
+ # so we can skip the membership check for each key.
241
+ redundant_keys = (
242
+ extra_values.keys()
243
+ if not used_sources
244
+ else (key for key in extra_values.keys() if key not in used_sources)
245
+ )
246
+ for key in redundant_keys:
247
+ problems.append(
248
+ DataProblem(
249
+ f"Field '{key}' is not defined in schema and will be ignored",
250
+ ),
251
+ )
252
+
253
+ cleaned_extra = _strip_empty(extra)
254
+ return cleaned_extra, problems