Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
## [0.3.0-rc.2](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.3.0-rc.1...v0.3.0-rc.2) (2025-12-09)

### Bug Fixes

* **schema_loader:** enhance validation for leaf node source type ([784c105](https://github.com/disafronov/python-logging-objects-with-schema/commit/784c105105ca96e78c69c0f844693011981f6410))
* **schema_loader:** improve validation for leaf node types ([92a5ba0](https://github.com/disafronov/python-logging-objects-with-schema/commit/92a5ba0a2887bf48917da2576f2ef139f4050a5a))

## [0.3.0-rc.1](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.2.0...v0.3.0-rc.1) (2025-12-09)

### Features

* add type annotations and mypy support ([d907b1e](https://github.com/disafronov/python-logging-objects-with-schema/commit/d907b1e71c42efc0e54843877d4233d7e88d5f56))

## [0.2.0](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.4...v0.2.0) (2025-12-09)

### Features
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,27 @@ An example of a valid empty schema (no leaves, no problems):

**Schema structure:**

- **Inner nodes**: Objects without `type` and `source` fields (used for nesting).
- **Leaf nodes**: Objects with both `type` and `source` fields. A valid leaf
must have both fields present and non-empty.
- **Inner nodes**: Objects that contain child nodes (any fields that are objects).
Inner nodes cannot have `type` or `source` as strings (they can only have
these fields as objects, which are child nodes). Inner nodes must have at
least one child node.
- **Leaf nodes**: Objects with `type` and `source` fields as strings (properties).
A valid leaf must have both `type` and `source` present and non-empty. Leaf
nodes cannot have child nodes (any fields that are objects).
- **Node validation rules**:
- A node cannot be both a leaf and an inner node (cannot have both properties
and children simultaneously).
- A node cannot be empty (must be either a leaf with properties or an inner
node with children).
- Fields `type`, `source`, and `item_type` can be used as child node names
in inner nodes (as objects), but then they are treated as children, not
as leaf properties.
- **`type`**: One of `"str"`, `"int"`, `"float"`, `"bool"`, or `"list"`.
Must be a string for leaf nodes.
- **`source`**: The name of the field in `extra` from which the value is taken.
Must be a string for leaf nodes.
- **`item_type`**: Optional field for list-typed leaves. Must be a string if
present. See "List-typed fields" section below.
- **Root key restrictions**: Root keys cannot conflict with standard `logging`
module fields (e.g., `name`, `levelno`, `pathname`). Such conflicts cause
schema validation to fail.
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "uv_build"

[project]
name = "logging-objects-with-schema"
version = "0.2.0"
version = "0.3.0rc2"
description = "Proxy logging wrapper that validates extra fields against a JSON schema."
readme = "README.md"
requires-python = ">=3.10"
Expand All @@ -27,7 +27,8 @@ classifiers = [
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3 :: Only",
"Intended Audience :: Developers",
"Topic :: System :: Logging"
"Topic :: System :: Logging",
"Typing :: Typed"
]

[[project.authors]]
Expand Down Expand Up @@ -93,8 +94,7 @@ warn_unreachable = true
strict_equality = true

[tool.bandit]
skips = [ "B101", "B601" ]
exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ]
exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov", "tests" ]

[tool.coverage.run]
source = [ "src" ]
Expand Down
Empty file.
2 changes: 1 addition & 1 deletion src/logging_objects_with_schema/schema_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _create_validation_error_json(field: str, error: str, value: Any) -> str:


def _validate_list_value(
value: list,
value: list[Any],
source: str,
item_expected_type: type | None,
) -> _DataProblem | None:
Expand Down
142 changes: 132 additions & 10 deletions src/logging_objects_with_schema/schema_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from collections.abc import Iterable, Mapping, MutableMapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, Literal

from .errors import _SchemaProblem

Expand Down Expand Up @@ -383,11 +383,99 @@ def _is_empty_or_none(value: Any) -> bool:
return value is None or (isinstance(value, str) and value.strip() == "")


def _determine_node_type_and_validate(
value_dict: dict[str, Any],
path: tuple[str, ...],
key: str,
problems: list[_SchemaProblem],
) -> tuple[Literal["leaf", "inner"] | None, bool]:
"""Determine node type (leaf/inner) and validate node structure.

A node can be either:
- A leaf node: has 'type' and 'source' as strings (properties), no children
- An inner node: has children (any fields that are objects), no leaf properties

A node cannot have both properties and children, and cannot be empty.

Args:
value_dict: Dictionary containing node data.
path: Current path in the schema tree.
key: Current key being processed.
problems: List to collect validation problems. Validation errors are
automatically appended to this list when an invalid node is detected.

Returns:
Tuple of (node_type, is_valid) where:
- node_type: "leaf" for valid leaf nodes, "inner" for valid inner nodes,
or None if the node is invalid
- is_valid: True if node is valid, False if there are validation errors

When is_valid is False:
- node_type is always None
- A validation problem has been added to the problems list
- The caller should skip processing this node (e.g., use continue)
"""
type_value = value_dict.get("type")
source_value = value_dict.get("source")
item_type_value = value_dict.get("item_type")

# Check if node has leaf properties
# Leaf properties are: type (required, string), source (required, string),
# item_type (optional, string). If type/source/item_type are objects,
# they are children, not properties
has_leaf_properties = (
isinstance(type_value, str)
or isinstance(source_value, str)
or isinstance(item_type_value, str)
)

# Check if node has children (any field that is an object/Mapping)
# Children can have ANY names, including type, source, item_type - this is
# valid for inner nodes. If type, source, or item_type are objects,
# they count as children
has_children = any(
isinstance(field_value, Mapping) for field_value in value_dict.values()
)

# Validate node structure
if has_leaf_properties and has_children:
# Node cannot have both properties and children
problems.append(
_SchemaProblem(
f"Invalid node at {_format_path(path, key)}: "
f"node cannot have both properties (type/source as strings) "
f"and children (object fields)"
),
)
return (None, False)

if not has_leaf_properties and not has_children:
# Node must be either a leaf or have children
problems.append(
_SchemaProblem(
f"Invalid node at {_format_path(path, key)}: "
f"node must be either a leaf (with type/source as strings) "
f"or have children (object fields)"
),
)
return (None, False)

# Node is valid - determine type
if has_leaf_properties:
return ("leaf", True)
else: # has_children
return ("inner", True)


def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
"""Check if a schema node is a leaf node.

A leaf node is identified by having either 'type' or 'source' field.
Inner nodes have neither of these fields.
A leaf node is identified by having at least one of 'type' or 'source' fields
that is a string. If 'type' or 'source' are themselves objects (not strings),
they are child nodes, not leaf properties.

Inner nodes have either no 'type'/'source' fields, or have these fields as
objects (child nodes) rather than strings.

We use `.get()` with `is not None` check instead of `in` operator because:
- A field might be present but have a None value (which indicates an error)
Expand All @@ -400,7 +488,19 @@ def _is_leaf_node(value_dict: dict[str, Any]) -> bool:
Returns:
True if the node is a leaf, False if it's an inner node.
"""
return value_dict.get("type") is not None or value_dict.get("source") is not None
type_value = value_dict.get("type")
source_value = value_dict.get("source")

# If either field is an object (Mapping), this is an inner node, not a leaf
if isinstance(type_value, Mapping) or isinstance(source_value, Mapping):
return False

# Check if at least one field is present and is a string
if isinstance(type_value, str) or isinstance(source_value, str):
return True

# Neither field is present as a string - this is an inner node
return False


def _validate_and_create_leaf(
Expand All @@ -424,8 +524,10 @@ def _validate_and_create_leaf(
leaf_source = value_dict.get("source")

# This is supposed to be a leaf - validate required fields first.
type_invalid = _is_empty_or_none(leaf_type)
source_invalid = _is_empty_or_none(leaf_source)
# Type must be a string (not None, not empty, and not other types like bool/int)
type_invalid = _is_empty_or_none(leaf_type) or not isinstance(leaf_type, str)
# Source must be a string (not None, not empty, and not other types like bool/int)
source_invalid = _is_empty_or_none(leaf_source) or not isinstance(leaf_source, str)

if type_invalid:
problems.append(
Expand All @@ -446,6 +548,9 @@ def _validate_and_create_leaf(
if type_invalid or source_invalid:
return None

# Note: Check for children is done in _determine_node_type_and_validate()
# before this function is called, so we don't need to check here.

# Convert to string before lookup to handle cases where the JSON parser
# might return non-string types (though this shouldn't happen with valid JSON).
# This ensures type safety and consistent behavior.
Expand All @@ -463,7 +568,11 @@ def _validate_and_create_leaf(
# to ensure element homogeneity (e.g. list[str], list[int]).
if expected_type is list:
item_type_name = value_dict.get("item_type")
item_type_invalid = _is_empty_or_none(item_type_name)
# Item type must be a string (not None, not empty, and not other types
# like bool/int)
item_type_invalid = _is_empty_or_none(item_type_name) or not isinstance(
item_type_name, str
)
if item_type_invalid:
problems.append(
_SchemaProblem(
Expand Down Expand Up @@ -556,12 +665,25 @@ def _compile_schema_tree(
# might be a read-only Mapping (e.g., from JSON parsing).
value_dict = dict(value)

if _is_leaf_node(value_dict):
# Determine node type and validate structure
# (checks for mixed nodes, empty nodes, etc.)
node_type, is_valid = _determine_node_type_and_validate(
value_dict, path, key, problems
)

if not is_valid:
# Node has validation errors, skip processing but continue with other nodes
continue

if node_type == "leaf":
# Process as leaf node
leaf = _validate_and_create_leaf(value_dict, path, key, problems)
if leaf is not None:
yield leaf
else:
# This is an inner node; recurse into children.
else: # node_type == "inner"
# Process as inner node - recurse into children
# Note: node_type can only be "leaf" or "inner" when is_valid is True.
# If is_valid is False, we already continue above.
for child_leaf in _compile_schema_tree(value_dict, path + (key,), problems):
yield child_leaf

Expand Down
Loading
Loading