logging-objects-with-schema 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.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
@@ -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'
@@ -25,6 +26,7 @@ Requires-Dist: flake8-pyproject>=1.2.3 ; extra == 'dev'
25
26
  Requires-Dist: mypy>=1.18.1 ; extra == 'dev'
26
27
  Requires-Dist: bandit>=1.8.6 ; extra == 'dev'
27
28
  Requires-Dist: pre-commit>=3.5.0 ; extra == 'dev'
29
+ Requires-Dist: vulture>=2.0.0 ; extra == 'dev'
28
30
  Requires-Python: >=3.10
29
31
  Project-URL: Issues, https://github.com/disafronov/python-logging-objects-with-schema/issues
30
32
  Project-URL: Source, https://github.com/disafronov/python-logging-objects-with-schema
@@ -213,11 +215,27 @@ An example of a valid empty schema (no leaves, no problems):
213
215
 
214
216
  **Schema structure:**
215
217
 
216
- - **Inner nodes**: Objects without `type` and `source` fields (used for nesting).
217
- - **Leaf nodes**: Objects with both `type` and `source` fields. A valid leaf
218
- must have both fields present and non-empty.
218
+ - **Inner nodes**: Objects that contain child nodes (any fields that are objects).
219
+ Inner nodes cannot have `type` or `source` as strings (they can only have
220
+ these fields as objects, which are child nodes). Inner nodes must have at
221
+ least one child node.
222
+ - **Leaf nodes**: Objects with `type` and `source` fields as strings (properties).
223
+ A valid leaf must have both `type` and `source` present and non-empty. Leaf
224
+ nodes cannot have child nodes (any fields that are objects).
225
+ - **Node validation rules**:
226
+ - A node cannot be both a leaf and an inner node (cannot have both properties
227
+ and children simultaneously).
228
+ - A node cannot be empty (must be either a leaf with properties or an inner
229
+ node with children).
230
+ - Fields `type`, `source`, and `item_type` can be used as child node names
231
+ in inner nodes (as objects), but then they are treated as children, not
232
+ as leaf properties.
219
233
  - **`type`**: One of `"str"`, `"int"`, `"float"`, `"bool"`, or `"list"`.
234
+ Must be a string for leaf nodes.
220
235
  - **`source`**: The name of the field in `extra` from which the value is taken.
236
+ Must be a string for leaf nodes.
237
+ - **`item_type`**: Optional field for list-typed leaves. Must be a string if
238
+ present. See "List-typed fields" section below.
221
239
  - **Root key restrictions**: Root keys cannot conflict with standard `logging`
222
240
  module fields (e.g., `name`, `levelno`, `pathname`). Such conflicts cause
223
241
  schema validation to fail.
@@ -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 without `type` and `source` fields (used for nesting).
184
- - **Leaf nodes**: Objects with both `type` and `source` fields. A valid leaf
185
- must have both fields present and non-empty.
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.
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "logging-objects-with-schema"
7
- version = "0.2.0"
7
+ version = "0.3.1"
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]]
@@ -44,7 +45,8 @@ classifiers = [
44
45
  "flake8-pyproject>=1.2.3",
45
46
  "mypy>=1.18.1",
46
47
  "bandit>=1.8.6",
47
- "pre-commit>=3.5.0"
48
+ "pre-commit>=3.5.0",
49
+ "vulture>=2.0.0"
48
50
  ]
49
51
 
50
52
  [project.urls]
@@ -93,8 +95,7 @@ warn_unreachable = true
93
95
  strict_equality = true
94
96
 
95
97
  [tool.bandit]
96
- skips = [ "B101", "B601" ]
97
- exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
98
+ exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov", "tests" ]
98
99
 
99
100
  [tool.coverage.run]
100
101
  source = [ "src" ]
@@ -115,3 +116,9 @@ skip_covered = false
115
116
 
116
117
  [tool.coverage.html]
117
118
  directory = "htmlcov"
119
+
120
+ [tool.vulture]
121
+ min_confidence = 80
122
+ exclude = [ "__pycache__" ]
123
+ ignore_names = [ "cls" ]
124
+ paths = [ "src" ]
@@ -42,7 +42,7 @@ def _create_validation_error_json(field: str, error: str, value: Any) -> str:
42
42
 
43
43
 
44
44
  def _validate_list_value(
45
- value: list,
45
+ value: list[Any],
46
46
  source: str,
47
47
  item_expected_type: type | None,
48
48
  ) -> _DataProblem | None:
@@ -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
 
@@ -177,24 +177,31 @@ def _check_cached_found_file_path() -> Path | None:
177
177
  When a schema file was found, its absolute path is cached as CWD-independent.
178
178
  This function checks if the cached path still exists on disk.
179
179
 
180
+ Note: This function must be called while holding _path_cache_lock.
181
+
180
182
  Returns:
181
183
  Cached path if file still exists, None if file was deleted (cache invalidated).
182
184
  """
183
185
  global _resolved_schema_path
184
186
 
185
- with _path_cache_lock:
186
- if _resolved_schema_path is None:
187
- return None
188
-
189
- # Schema file was found, absolute path doesn't depend on CWD
190
- # Return it if it still exists
191
- if _resolved_schema_path.exists():
192
- return _resolved_schema_path
187
+ if _resolved_schema_path is None:
188
+ return None
193
189
 
194
- # Cached file no longer exists; invalidate cache so caller will re-search
195
- _resolved_schema_path = None
190
+ # Check if this is actually a found file cache (not missing file cache)
191
+ # by checking _cached_cwd. We don't need 'global' here since we only read it.
192
+ if _cached_cwd is not None:
193
+ # This path was cached as missing file, not found file
196
194
  return None
197
195
 
196
+ # Schema file was found, absolute path doesn't depend on CWD
197
+ # Return it if it still exists
198
+ if _resolved_schema_path.exists():
199
+ return _resolved_schema_path
200
+
201
+ # Cached file no longer exists; invalidate cache so caller will re-search
202
+ _resolved_schema_path = None
203
+ return None
204
+
198
205
 
199
206
  def _check_cached_missing_file_path() -> Path | None:
200
207
  """Check if cached path for a missing file is still valid.
@@ -202,26 +209,27 @@ def _check_cached_missing_file_path() -> Path | None:
202
209
  When a schema file was not found, a path based on CWD is cached.
203
210
  This function checks if CWD has changed since caching.
204
211
 
212
+ Note: This function must be called while holding _path_cache_lock.
213
+
205
214
  Returns:
206
215
  Cached path if CWD unchanged, None if CWD changed (cache invalidated).
207
216
  """
208
217
  global _resolved_schema_path, _cached_cwd
209
218
 
210
- with _path_cache_lock:
211
- if _resolved_schema_path is None or _cached_cwd is None:
212
- return None
219
+ if _resolved_schema_path is None or _cached_cwd is None:
220
+ return None
213
221
 
214
- # Cached path is based on CWD when file was not found,
215
- # check if CWD changed
216
- current_cwd = _get_current_working_directory()
217
- if current_cwd != _cached_cwd:
218
- # CWD changed, invalidate cache and re-search from new CWD
219
- _resolved_schema_path = None
220
- _cached_cwd = None
221
- return None
222
+ # Cached path is based on CWD when file was not found,
223
+ # check if CWD changed
224
+ current_cwd = _get_current_working_directory()
225
+ if current_cwd != _cached_cwd:
226
+ # CWD changed, invalidate cache and re-search from new CWD
227
+ _resolved_schema_path = None
228
+ _cached_cwd = None
229
+ return None
222
230
 
223
- # CWD unchanged, return cached path
224
- return _resolved_schema_path
231
+ # CWD unchanged, return cached path
232
+ return _resolved_schema_path
225
233
 
226
234
 
227
235
  def _cache_and_return_found_path(found_path: Path) -> Path:
@@ -383,11 +391,99 @@ def _is_empty_or_none(value: Any) -> bool:
383
391
  return value is None or (isinstance(value, str) and value.strip() == "")
384
392
 
385
393
 
394
+ def _determine_node_type_and_validate(
395
+ value_dict: dict[str, Any],
396
+ path: tuple[str, ...],
397
+ key: str,
398
+ problems: list[_SchemaProblem],
399
+ ) -> tuple[Literal["leaf", "inner"] | None, bool]:
400
+ """Determine node type (leaf/inner) and validate node structure.
401
+
402
+ A node can be either:
403
+ - A leaf node: has 'type' and 'source' as strings (properties), no children
404
+ - An inner node: has children (any fields that are objects), no leaf properties
405
+
406
+ A node cannot have both properties and children, and cannot be empty.
407
+
408
+ Args:
409
+ value_dict: Dictionary containing node data.
410
+ path: Current path in the schema tree.
411
+ key: Current key being processed.
412
+ problems: List to collect validation problems. Validation errors are
413
+ automatically appended to this list when an invalid node is detected.
414
+
415
+ Returns:
416
+ Tuple of (node_type, is_valid) where:
417
+ - node_type: "leaf" for valid leaf nodes, "inner" for valid inner nodes,
418
+ or None if the node is invalid
419
+ - is_valid: True if node is valid, False if there are validation errors
420
+
421
+ When is_valid is False:
422
+ - node_type is always None
423
+ - A validation problem has been added to the problems list
424
+ - The caller should skip processing this node (e.g., use continue)
425
+ """
426
+ type_value = value_dict.get("type")
427
+ source_value = value_dict.get("source")
428
+ item_type_value = value_dict.get("item_type")
429
+
430
+ # Check if node has leaf properties
431
+ # Leaf properties are: type (required, string), source (required, string),
432
+ # item_type (optional, string). If type/source/item_type are objects,
433
+ # they are children, not properties
434
+ has_leaf_properties = (
435
+ isinstance(type_value, str)
436
+ or isinstance(source_value, str)
437
+ or isinstance(item_type_value, str)
438
+ )
439
+
440
+ # Check if node has children (any field that is an object/Mapping)
441
+ # Children can have ANY names, including type, source, item_type - this is
442
+ # valid for inner nodes. If type, source, or item_type are objects,
443
+ # they count as children
444
+ has_children = any(
445
+ isinstance(field_value, Mapping) for field_value in value_dict.values()
446
+ )
447
+
448
+ # Validate node structure
449
+ if has_leaf_properties and has_children:
450
+ # Node cannot have both properties and children
451
+ problems.append(
452
+ _SchemaProblem(
453
+ f"Invalid node at {_format_path(path, key)}: "
454
+ f"node cannot have both properties (type/source as strings) "
455
+ f"and children (object fields)"
456
+ ),
457
+ )
458
+ return (None, False)
459
+
460
+ if not has_leaf_properties and not has_children:
461
+ # Node must be either a leaf or have children
462
+ problems.append(
463
+ _SchemaProblem(
464
+ f"Invalid node at {_format_path(path, key)}: "
465
+ f"node must be either a leaf (with type/source as strings) "
466
+ f"or have children (object fields)"
467
+ ),
468
+ )
469
+ return (None, False)
470
+
471
+ # Node is valid - determine type
472
+ if has_leaf_properties:
473
+ return ("leaf", True)
474
+ else: # has_children
475
+ return ("inner", True)
476
+
477
+
386
478
  def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
387
479
  """Check if a schema node is a leaf node.
388
480
 
389
- A leaf node is identified by having either 'type' or 'source' field.
390
- Inner nodes have neither of these fields.
481
+ A leaf node is identified by having at least one of 'type' or 'source' fields
482
+ that is a string. If 'type' or 'source' are themselves objects (not strings),
483
+ they are child nodes, not leaf properties.
484
+
485
+ Inner nodes have either no 'type'/'source' fields, or have these fields as
486
+ objects (child nodes) rather than strings.
391
487
 
392
488
  We use `.get()` with `is not None` check instead of `in` operator because:
393
489
  - A field might be present but have a None value (which indicates an error)
@@ -400,7 +496,19 @@ def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
400
496
  Returns:
401
497
  True if the node is a leaf, False if it's an inner node.
402
498
  """
403
- return value_dict.get("type") is not None or value_dict.get("source") is not None
499
+ type_value = value_dict.get("type")
500
+ source_value = value_dict.get("source")
501
+
502
+ # If either field is an object (Mapping), this is an inner node, not a leaf
503
+ if isinstance(type_value, Mapping) or isinstance(source_value, Mapping):
504
+ return False
505
+
506
+ # Check if at least one field is present and is a string
507
+ if isinstance(type_value, str) or isinstance(source_value, str):
508
+ return True
509
+
510
+ # Neither field is present as a string - this is an inner node
511
+ return False
404
512
 
405
513
 
406
514
  def _validate_and_create_leaf(
@@ -424,8 +532,10 @@ def _validate_and_create_leaf(
424
532
  leaf_source = value_dict.get("source")
425
533
 
426
534
  # This is supposed to be a leaf - validate required fields first.
427
- type_invalid = _is_empty_or_none(leaf_type)
428
- source_invalid = _is_empty_or_none(leaf_source)
535
+ # Type must be a string (not None, not empty, and not other types like bool/int)
536
+ type_invalid = _is_empty_or_none(leaf_type) or not isinstance(leaf_type, str)
537
+ # Source must be a string (not None, not empty, and not other types like bool/int)
538
+ source_invalid = _is_empty_or_none(leaf_source) or not isinstance(leaf_source, str)
429
539
 
430
540
  if type_invalid:
431
541
  problems.append(
@@ -446,6 +556,9 @@ def _validate_and_create_leaf(
446
556
  if type_invalid or source_invalid:
447
557
  return None
448
558
 
559
+ # Note: Check for children is done in _determine_node_type_and_validate()
560
+ # before this function is called, so we don't need to check here.
561
+
449
562
  # Convert to string before lookup to handle cases where the JSON parser
450
563
  # might return non-string types (though this shouldn't happen with valid JSON).
451
564
  # This ensures type safety and consistent behavior.
@@ -463,7 +576,11 @@ def _validate_and_create_leaf(
463
576
  # to ensure element homogeneity (e.g. list[str], list[int]).
464
577
  if expected_type is list:
465
578
  item_type_name = value_dict.get("item_type")
466
- item_type_invalid = _is_empty_or_none(item_type_name)
579
+ # Item type must be a string (not None, not empty, and not other types
580
+ # like bool/int)
581
+ item_type_invalid = _is_empty_or_none(item_type_name) or not isinstance(
582
+ item_type_name, str
583
+ )
467
584
  if item_type_invalid:
468
585
  problems.append(
469
586
  _SchemaProblem(
@@ -556,12 +673,25 @@ def _compile_schema_tree(
556
673
  # might be a read-only Mapping (e.g., from JSON parsing).
557
674
  value_dict = dict(value)
558
675
 
559
- if _is_leaf_node(value_dict):
676
+ # Determine node type and validate structure
677
+ # (checks for mixed nodes, empty nodes, etc.)
678
+ node_type, is_valid = _determine_node_type_and_validate(
679
+ value_dict, path, key, problems
680
+ )
681
+
682
+ if not is_valid:
683
+ # Node has validation errors, skip processing but continue with other nodes
684
+ continue
685
+
686
+ if node_type == "leaf":
687
+ # Process as leaf node
560
688
  leaf = _validate_and_create_leaf(value_dict, path, key, problems)
561
689
  if leaf is not None:
562
690
  yield leaf
563
- else:
564
- # This is an inner node; recurse into children.
691
+ else: # node_type == "inner"
692
+ # Process as inner node - recurse into children
693
+ # Note: node_type can only be "leaf" or "inner" when is_valid is True.
694
+ # If is_valid is False, we already continue above.
565
695
  for child_leaf in _compile_schema_tree(value_dict, path + (key,), problems):
566
696
  yield child_leaf
567
697
 
@@ -678,8 +808,8 @@ def _compile_schema_internal(
678
808
  given path and forbidden keys set has been observed (including the cases
679
809
  when it is missing or invalid), subsequent calls always return the cached
680
810
  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.
811
+ changes to the schema, the application must restart the process. The schema
812
+ is cached process-wide and is thread-safe.
683
813
 
684
814
  Args:
685
815
  forbidden_keys: Additional forbidden root keys to check against.