diff --git a/CHANGELOG.md b/CHANGELOG.md index 9863707..6b65a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 602fe3a..45679b0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 485997c..e894259 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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]] @@ -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" ] diff --git a/src/logging_objects_with_schema/py.typed b/src/logging_objects_with_schema/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 2f30e86..74ff80b 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -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: diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index d717042..168cb58 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -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 @@ -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) @@ -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( @@ -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( @@ -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. @@ -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( @@ -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 diff --git a/tests/private/test_schema_loader.py b/tests/private/test_schema_loader.py index 744f0ed..ad73f49 100644 --- a/tests/private/test_schema_loader.py +++ b/tests/private/test_schema_loader.py @@ -212,8 +212,12 @@ def test_schema_with_only_inner_nodes_produces_empty_compiled_schema( compiled, problems = compile_schema_internal() assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty - # Schema with only inner nodes is valid, just empty (no problems) - assert problems == [] + # Schema with empty inner node should produce problem + assert len(problems) > 0 + assert any( + "must be either a leaf" in p.message and "or have children" in p.message + for p in problems + ) def test_deeply_nested_schema_compiles_correctly( @@ -1497,6 +1501,188 @@ def test_is_leaf_node_with_both_none() -> None: assert is_leaf_node(value_dict) is False +def test_is_leaf_node_with_type_as_object() -> None: + """_is_leaf_node should return False when type is an object (child node).""" + value_dict = { + "type": {"type": "str", "source": "some.type"}, + "other": "value", + } + assert is_leaf_node(value_dict) is False + + +def test_is_leaf_node_with_source_as_object() -> None: + """_is_leaf_node should return False when source is an object (child node).""" + value_dict = { + "source": {"type": "str", "source": "some.source"}, + "other": "value", + } + assert is_leaf_node(value_dict) is False + + +def test_is_leaf_node_with_both_as_objects() -> None: + """_is_leaf_node should return False when both type and source are objects.""" + value_dict = { + "type": {"type": "str", "source": "some.type"}, + "source": {"type": "str", "source": "some.source"}, + } + assert is_leaf_node(value_dict) is False + + +def test_is_leaf_node_with_type_as_object_source_as_string() -> None: + """_is_leaf_node should return False when type is object (even if source is string).""" # noqa: E501 + value_dict = { + "type": {"type": "str", "source": "some.type"}, + "source": "request_id", + } + assert is_leaf_node(value_dict) is False + + +def test_is_leaf_node_with_source_as_object_type_as_string() -> None: + """_is_leaf_node should return False when source is object (even if type is string).""" # noqa: E501 + value_dict = { + "type": "str", + "source": {"type": "str", "source": "some.source"}, + } + assert is_leaf_node(value_dict) is False + + +def test_leaf_node_with_child_nodes_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaf node with child nodes (objects) should produce problem.""" + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": { + "type": "str", + "source": "request_id", + "child": {"type": "str", "source": "child"}, # Child node - error + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + assert any("cannot have both properties" in p.message for p in problems) + + +def test_inner_node_with_type_source_as_strings_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Inner node with type/source as strings should produce problem.""" + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": { + "type": { + "type": "str", + "source": "some.type", + }, # Object - makes it inner node + "source": "request_id", # String - error for inner node + "child": {"type": "str", "source": "child"}, # Child node + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + # Schema is not empty because it has valid child leaves, but should have problems + assert any("cannot have both properties" in p.message for p in problems) + + +def test_leaf_node_with_type_string_source_object_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaf node with type as string and source as object should produce problem. + + This tests the edge case where type is a string (leaf property) but source + is an object (child). This should be detected as a mixed node and produce + an error "cannot have both properties and children". + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": { + "type": "str", # String - leaf property + "source": { + "type": "str", + "source": "some.source", + }, # Object - child, error for leaf node + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + assert any("cannot have both properties" in p.message for p in problems) + + +def test_empty_node_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Empty node (no properties, no children) should produce problem.""" + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "EmptyNode": {}, # Empty node - error + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + assert any( + "must be either a leaf" in p.message and "or have children" in p.message + for p in problems + ) + + +def test_mixed_node_with_properties_and_children_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with both properties (type/source as strings) and children should produce problem.""" # noqa: E501 + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "MixedNode": { + "type": "str", # Property + "source": "request_id", # Property + "child": {"type": "str", "source": "child"}, # Child - error + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + assert any( + "cannot have both properties" in p.message and "and children" in p.message + for p in problems + ) + + def test_get_schema_path_uses_cached_missing_file_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1707,3 +1893,341 @@ def test_compile_schema_internal_caches_by_forbidden_keys( compiled3, problems3 = compile_schema_internal(forbidden_keys={"some_key"}) assert compiled3 is not compiled1 # Different cache entry assert not problems3 + + +def test_node_with_only_item_type_as_string_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with only item_type as string should produce problem. + + This tests the edge case where item_type is the only string field. The node + will be determined as "leaf" by _determine_node_type_and_validate, but + _validate_and_create_leaf should reject it because type and source are required. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "Tags": {"item_type": "str"}, # Only item_type, missing type and source + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problems because type and source are required + assert any("type cannot be None or empty" in p.message for p in problems) + + +def test_node_with_type_as_number_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with type as number (non-string, non-object) should produce problem. + + This tests the edge case where type field has a non-string, non-object value. + The node will be determined as "leaf" (because source is a string), but + _validate_and_create_leaf should reject it because type must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": 123, "source": "request_id"}, # type is number + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because type must be a string, not a number + assert any("type cannot be None or empty" in p.message for p in problems) + + +def test_node_with_type_as_boolean_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with type as boolean (non-string, non-object) should produce problem. + + This tests the edge case where type field has a non-string, non-object value. + The node will be determined as "leaf" (because source is a string), but + _validate_and_create_leaf should reject it because type must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": True, "source": "request_id"}, # type is boolean + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because type must be a string, not boolean + assert any("type cannot be None or empty" in p.message for p in problems) + + +def test_node_with_source_as_boolean_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with source as boolean (non-string, non-object) should produce problem. + + This tests the edge case where source field has a non-string, non-object value. + The node will be determined as "leaf" (because type is a string), but + _validate_and_create_leaf should reject it because source must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": "str", "source": True}, # source is boolean + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because source must be a string, not boolean + assert any("source cannot be None or empty" in p.message for p in problems) + + +def test_node_with_source_as_number_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with source as number (non-string, non-object) should produce problem. + + This tests the edge case where source field has a non-string, non-object value. + The node will be determined as "leaf" (because type is a string), but + _validate_and_create_leaf should reject it because source must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": "str", "source": 123}, # source is number + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because source must be a string, not number + assert any("source cannot be None or empty" in p.message for p in problems) + + +def test_node_with_type_and_source_as_numbers_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with both type and source as numbers should produce problem. + + This tests the edge case where both type and source fields have non-string, + non-object values. The node should be determined as "empty" (neither leaf nor inner) + because has_leaf_properties = False (no string fields) and has_children = False. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": 123, "source": 456}, # Both are numbers + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because node is neither leaf nor inner + assert any( + "must be either a leaf" in p.message and "or have children" in p.message + for p in problems + ) + + +def test_determine_node_type_with_item_type_only() -> None: + """_determine_node_type_and_validate identifies node with only item_type as leaf. + + This tests that a node with only item_type as string (without type and source) + is correctly identified as a leaf node by _determine_node_type_and_validate. + The validation of required fields happens later in _validate_and_create_leaf. + """ + problems: list[_SchemaProblem] = [] + value_dict = {"item_type": "str"} # Only item_type, no type or source + + node_type, is_valid = schema_loader._determine_node_type_and_validate( + value_dict, ("ServicePayload",), "Tags", problems + ) + + # Should be identified as leaf (has_leaf_properties = True) + assert node_type == "leaf" + assert is_valid is True + assert problems == [] # No problems at this stage, validation happens later + + +def test_node_with_item_type_as_number_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with item_type as number (non-string) should produce problem. + + This tests the edge case where item_type field has a non-string value. + The node will be determined as "leaf" (because type and source are strings), + but _validate_and_create_leaf should reject it because item_type must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "Tags": { + "type": "list", + "source": "tags", + "item_type": 123, # item_type is number + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because item_type must be a string, not a number + assert any("item_type is required for list type" in p.message for p in problems) + + +def test_node_with_item_type_as_boolean_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with item_type as boolean (non-string) should produce problem. + + This tests the edge case where item_type field has a non-string value. + The node will be determined as "leaf" (because type and source are strings), + but _validate_and_create_leaf should reject it because item_type must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "Tags": { + "type": "list", + "source": "tags", + "item_type": True, # item_type is boolean + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because item_type must be a string, not a boolean + assert any("item_type is required for list type" in p.message for p in problems) + + +def test_node_with_item_type_as_object_is_determined_as_inner( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with item_type as object (Mapping) should be determined as inner node. + + This tests that when item_type is an object (Mapping), it counts as a child, + not as a leaf property. The node should be determined as "inner" and the + nested structure should be processed correctly. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "Tags": { + "item_type": { + "type": "str", + "source": "some.item_type", + }, # item_type is object - should be treated as child + }, + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + # Node should be determined as inner, not leaf + # Should have valid child leaves from the nested structure + assert not compiled.is_empty + assert any(leaf.source == "some.item_type" for leaf in compiled.leaves) + assert problems == [] + + +def test_node_with_type_as_list_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with type as list (non-string, non-Mapping) should produce problem. + + This tests the edge case where type field has a list value (not a Mapping). + The node will be determined as "leaf" (because source is a string), but + _validate_and_create_leaf should reject it because type must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": ["str"], "source": "request_id"}, # type is list + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because type must be a string, not a list + assert any("type cannot be None or empty" in p.message for p in problems) + + +def test_node_with_source_as_list_produces_problem( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Node with source as list (non-string, non-Mapping) should produce problem. + + This tests the edge case where source field has a list value (not a Mapping). + The node will be determined as "leaf" (because type is a string), but + _validate_and_create_leaf should reject it because source must be a string. + """ + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": { + "type": "str", + "source": ["request_id"], + }, # source is list + }, + }, + ) + + compiled, problems = compile_schema_internal() + assert isinstance(compiled, _CompiledSchema) + assert compiled.is_empty + # Should have problem because source must be a string, not a list + assert any("source cannot be None or empty" in p.message for p in problems) diff --git a/tests/test_mypy_support.py b/tests/test_mypy_support.py new file mode 100644 index 0000000..d342088 --- /dev/null +++ b/tests/test_mypy_support.py @@ -0,0 +1,107 @@ +"""Tests for mypy type checking support.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from logging_objects_with_schema import SchemaLogger +from tests.helpers import _write_schema + + +def test_py_typed_file_exists() -> None: + """Verify that py.typed marker file exists in the package.""" + package_dir = Path(__file__).parent.parent / "src" / "logging_objects_with_schema" + py_typed = package_dir / "py.typed" + + assert py_typed.exists(), "py.typed marker file must exist for mypy support" + + +def test_mypy_type_checking() -> None: + """Verify that mypy can type-check the package without errors.""" + package_dir = Path(__file__).parent.parent / "src" / "logging_objects_with_schema" + + # Run mypy on the package directory + result = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + str(package_dir), + "--no-error-summary", + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + + assert ( + result.returncode == 0 + ), f"mypy found type errors:\n{result.stdout}\n{result.stderr}" + + +def test_mypy_strict_type_checking() -> None: + """Verify that mypy can type-check the package in strict mode without errors.""" + package_dir = Path(__file__).parent.parent / "src" / "logging_objects_with_schema" + + # Run mypy in strict mode on the package directory + result = subprocess.run( + [ + sys.executable, + "-m", + "mypy", + str(package_dir), + "--strict", + "--no-error-summary", + ], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent, + ) + + assert ( + result.returncode == 0 + ), f"mypy found type errors in strict mode:\n{result.stdout}\n{result.stderr}" + + +def test_schema_logger_type_annotations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify that SchemaLogger has correct type annotations for mypy.""" + # This test verifies that type annotations are correct by using them + # If mypy is run on this file, it should not find any errors + + monkeypatch.chdir(tmp_path) + _write_schema(tmp_path, {"ServicePayload": {}}) + + # Test that SchemaLogger can be typed correctly + logger: SchemaLogger = SchemaLogger("test-logger", forbidden_keys=None) + + # Test that __init__ signature accepts correct types + logger2: SchemaLogger = SchemaLogger( + name="test-logger-2", + level=10, + forbidden_keys={"key1", "key2"}, + ) + + # Test that SchemaLogger is compatible with logging.Logger + import logging + + def accept_logger(logger: logging.Logger) -> logging.Logger: + return logger + + # This should work without type errors + result: logging.Logger = accept_logger(logger) + assert isinstance(result, SchemaLogger) + + # Test that logger methods are accessible and typed + logger.info("test message") + logger.debug("debug message", extra={"key": "value"}) + logger.warning("warning message") + + # Verify logger2 was created successfully (test __init__ signature) + assert logger2.name == "test-logger-2" diff --git a/uv.lock b/uv.lock index 316b18b..ca411b0 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.2.0" +version = "0.3.0rc2" [package.source] editable = "."