From ca0f1955bc3705b03c8a8d4eb8c03eddc78eb235 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 13:31:58 +0400 Subject: [PATCH 01/26] ci(release): update release configuration - Added changelog generation support to the release process. - Updated the assets for the git release to include the CHANGELOG.md file. - Enhanced the commit message template to dynamically include the author's name and email. Signed-off-by: Dmitrii Safronov --- .releaserc.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.releaserc.json b/.releaserc.json index dbfea86..4e5aec6 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -27,9 +27,10 @@ ["@semantic-release/exec", { "prepareCmd": "node -e \"const fs=require('fs'),toml=require('@iarna/toml');let version='${nextRelease.version}';version=version.replace(/-rc\\\\./g,'rc');const pyprojectFile='pyproject.toml';const pyprojectData=toml.parse(fs.readFileSync(pyprojectFile,'utf8'));const packageName=pyprojectData.project.name;pyprojectData.project.version=version;fs.writeFileSync(pyprojectFile,toml.stringify(pyprojectData));const uvLockFile='uv.lock';const uvLockData=toml.parse(fs.readFileSync(uvLockFile,'utf8'));const packageIndex=uvLockData.package.findIndex(p=>p.name===packageName);if(packageIndex!==-1){uvLockData.package[packageIndex].version=version;fs.writeFileSync(uvLockFile,toml.stringify(uvLockData));}\"" }], + ["@semantic-release/changelog", {}], ["@semantic-release/git", { - "assets": ["pyproject.toml", "uv.lock"], - "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}\n\nSigned-off-by: Release Bot " + "assets": ["CHANGELOG.md", "pyproject.toml", "uv.lock"], + "message": `chore(release): \${nextRelease.version}\n\n\${nextRelease.notes}\n\nSigned-off-by: ${process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || "Release Bot"} <${process.env.GIT_AUTHOR_EMAIL || process.env.GIT_COMMITTER_EMAIL || "noreply@github.com"}>` }], ["@semantic-release/github", {}] ] From 38e7522071236d1e73361860aeba8015876de434 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 13:43:15 +0400 Subject: [PATCH 02/26] chore(release): add semantic release configuration - Introduced a new release configuration file to manage versioning and changelog generation using semantic-release. - Configured plugins for commit analysis, release notes generation, and automated version updates in project files. - Set up a commit message template to include author information for better traceability. Signed-off-by: Dmitrii Safronov --- .releaserc.json => .releaserc.cjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) rename .releaserc.json => .releaserc.cjs (84%) diff --git a/.releaserc.json b/.releaserc.cjs similarity index 84% rename from .releaserc.json rename to .releaserc.cjs index 4e5aec6..9b7a5e9 100644 --- a/.releaserc.json +++ b/.releaserc.cjs @@ -30,7 +30,11 @@ ["@semantic-release/changelog", {}], ["@semantic-release/git", { "assets": ["CHANGELOG.md", "pyproject.toml", "uv.lock"], - "message": `chore(release): \${nextRelease.version}\n\n\${nextRelease.notes}\n\nSigned-off-by: ${process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || "Release Bot"} <${process.env.GIT_AUTHOR_EMAIL || process.env.GIT_COMMITTER_EMAIL || "noreply@github.com"}>` + "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}\n\nSigned-off-by: " + + (process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || "Release Bot") + + " <" + + (process.env.GIT_AUTHOR_EMAIL || process.env.GIT_COMMITTER_EMAIL || "noreply@github.com") + + ">" }], ["@semantic-release/github", {}] ] From 4fe0e1be7ca8512de5dd7fbc7efe5ccf734ecf41 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 13:47:17 +0400 Subject: [PATCH 03/26] chore(release): update release configuration format - Changed the release configuration file from JSON format to CommonJS module format for better compatibility with Node.js environments. - This update allows for more flexible configuration options in the release process. Signed-off-by: Dmitrii Safronov --- .releaserc.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.releaserc.cjs b/.releaserc.cjs index 9b7a5e9..6a19308 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -1,4 +1,4 @@ -{ +module.exports = { "branches": [ "release", { From 3eb842a0b215c393ae4d1275d89e1afe9d20afca Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 13:52:38 +0400 Subject: [PATCH 04/26] chore(release): format adjustments in release configuration - Reformatted the release configuration file for improved readability by aligning the array elements. - Ensured consistency in the structure of the configuration for better maintainability. Signed-off-by: Dmitrii Safronov --- .releaserc.cjs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.releaserc.cjs b/.releaserc.cjs index 6a19308..d1f698b 100644 --- a/.releaserc.cjs +++ b/.releaserc.cjs @@ -10,17 +10,17 @@ module.exports = { ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits", "releaseRules": [ - { "type": "feat", "release": "minor" }, - { "type": "fix", "release": "patch" }, - { "type": "perf", "release": "patch" }, - { "type": "revert", "release": "patch" }, - { "type": "refactor", "release": "patch" }, - { "type": "docs", "release": false }, - { "type": "style", "release": false }, - { "type": "test", "release": false }, - { "type": "build", "release": false }, - { "type": "ci", "release": false }, - { "type": "chore", "release": false } + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "perf", "release": "patch" }, + { "type": "revert", "release": "patch" }, + { "type": "refactor", "release": "patch" }, + { "type": "docs", "release": false }, + { "type": "style", "release": false }, + { "type": "test", "release": false }, + { "type": "build", "release": false }, + { "type": "ci", "release": false }, + { "type": "chore", "release": false } ] }], ["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }], @@ -31,10 +31,10 @@ module.exports = { ["@semantic-release/git", { "assets": ["CHANGELOG.md", "pyproject.toml", "uv.lock"], "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}\n\nSigned-off-by: " + - (process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || "Release Bot") + - " <" + - (process.env.GIT_AUTHOR_EMAIL || process.env.GIT_COMMITTER_EMAIL || "noreply@github.com") + - ">" + (process.env.GIT_AUTHOR_NAME || process.env.GIT_COMMITTER_NAME || "Release Bot") + + " <" + + (process.env.GIT_AUTHOR_EMAIL || process.env.GIT_COMMITTER_EMAIL || "noreply@github.com") + + ">" }], ["@semantic-release/github", {}] ] From 6ae8f8668c7eb1615ef819853eda436a682886a7 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:20:47 +0400 Subject: [PATCH 05/26] refactor(schema): rename SchemaLeaf to _SchemaLeaf for internal use - Updated the schema loader and applier to use the new internal class name _SchemaLeaf, reflecting its non-public API status. - Adjusted type hints and references throughout the codebase to ensure consistency with the new naming convention. - Modified related test cases to accommodate the changes in class naming. Signed-off-by: Dmitrii Safronov --- .../schema_applier.py | 6 +- .../schema_loader.py | 28 ++++--- tests/test_formatter.py | 78 +++++++++---------- tests/test_schema_loader.py | 14 ++-- 4 files changed, 66 insertions(+), 60 deletions(-) diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 29885ec..68ac564 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -12,7 +12,7 @@ from typing import Any from .errors import DataProblem -from .schema_loader import CompiledSchema, SchemaLeaf +from .schema_loader import CompiledSchema, _SchemaLeaf def _create_validation_error_json(field: str, error: str, value: Any) -> str: @@ -124,7 +124,7 @@ def _set_nested_value( def _validate_and_apply_leaf( - leaf: SchemaLeaf, + leaf: _SchemaLeaf, value: Any, source: str, extra: MutableMapping[str, Any], @@ -280,7 +280,7 @@ def _apply_schema_internal( # all leaves for a given source together, which is more efficient and allows # us to validate the value once per source (e.g., checking for None) rather # than once per leaf. - source_to_leaves: dict[str, list[SchemaLeaf]] = defaultdict(list) + source_to_leaves: dict[str, list[_SchemaLeaf]] = defaultdict(list) for leaf in compiled.leaves: source_to_leaves[leaf.source].append(leaf) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 64c02cc..73ce0d0 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -27,9 +27,13 @@ @dataclass -class SchemaLeaf: +class _SchemaLeaf: """Represents a single leaf in the schema tree. + This class is part of the internal implementation and is not considered + a public API. Its signature and behaviour may change between releases + without preserving backward compatibility. + Attributes: path: Full path of keys from the schema root to this leaf. source: Name of the field in the ``extra`` mapping. @@ -48,7 +52,7 @@ class SchemaLeaf: class CompiledSchema: """Internal representation of a compiled schema.""" - leaves: list[SchemaLeaf] + leaves: list[_SchemaLeaf] @property def is_empty(self) -> bool: @@ -393,8 +397,8 @@ def _validate_and_create_leaf( path: tuple[str, ...], key: str, problems: list[SchemaProblem], -) -> SchemaLeaf | None: - """Validate a leaf node and create SchemaLeaf if valid. +) -> _SchemaLeaf | None: + """Validate a leaf node and create _SchemaLeaf if valid. Args: value_dict: Dictionary containing leaf node data. @@ -403,7 +407,7 @@ def _validate_and_create_leaf( problems: List to collect validation problems. Returns: - SchemaLeaf if validation passes, None otherwise. + _SchemaLeaf if validation passes, None otherwise. """ leaf_type = value_dict.get("type") leaf_source = value_dict.get("source") @@ -471,11 +475,11 @@ def _validate_and_create_leaf( ) return None - return SchemaLeaf( + return _SchemaLeaf( path=path + (key,), # Convert to string to ensure type consistency. Even though source should # be a string from JSON, this guards against unexpected types and ensures - # the SchemaLeaf always has a string source. + # the _SchemaLeaf always has a string source. source=str(leaf_source), expected_type=expected_type, item_expected_type=item_expected_type, @@ -486,13 +490,13 @@ def _compile_schema_tree( node: MutableMapping[str, Any], path: tuple[str, ...], problems: list[SchemaProblem], -) -> Iterable[SchemaLeaf]: - """Recursively compile a schema node into SchemaLeaf objects. +) -> Iterable[_SchemaLeaf]: + """Recursively compile a schema node into _SchemaLeaf objects. This function recursively walks the schema tree structure, identifying leaf nodes (those with ``type`` and ``source`` fields) and inner nodes (those without these fields). Leaf nodes are validated and converted to - :class:`SchemaLeaf` objects, while inner nodes are recursively processed. + :class:`_SchemaLeaf` objects, while inner nodes are recursively processed. Performance considerations: Time complexity is O(n) where n is the total number of nodes in the @@ -512,7 +516,7 @@ def _compile_schema_tree( problems: List to collect validation problems. Yields: - SchemaLeaf objects found in the tree. + _SchemaLeaf objects found in the tree. """ # Check for excessive nesting depth (DoS protection: prevent deeply nested # schemas that could cause stack overflow or excessive memory usage). @@ -708,7 +712,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: # Compile the schema tree into leaves. Each root key becomes a separate # tree that we compile recursively. Problems are collected as we go. - leaves: list[SchemaLeaf] = [] + leaves: list[_SchemaLeaf] = [] for key, value in raw_schema.items(): if not isinstance(value, Mapping): problems.append( diff --git a/tests/test_formatter.py b/tests/test_formatter.py index da0bf1f..413b953 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -20,7 +20,7 @@ from logging_objects_with_schema.schema_applier import ( _validate_list_value as validate_list_value, ) -from logging_objects_with_schema.schema_loader import CompiledSchema, SchemaLeaf +from logging_objects_with_schema.schema_loader import CompiledSchema, _SchemaLeaf def test_strip_empty_removes_empty_dicts() -> None: @@ -92,17 +92,17 @@ def test_apply_schema_nested_structure() -> None: """apply_schema_internal should build nested structures correctly.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Metrics", "CPU"), source="cpu_usage", expected_type=float, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Metrics", "Memory"), source="memory_usage", expected_type=float, @@ -131,12 +131,12 @@ def test_apply_schema_multiple_leaves_same_source_same_type() -> None: """Multiple leaves with same source and type should write to all locations.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Metadata", "ID"), source="request_id", expected_type=str, @@ -160,10 +160,10 @@ def test_apply_schema_multiple_leaves_same_source_different_types() -> None: """Multiple leaves with same source but different types validate independently.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="id", expected_type=str ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "IDNumber"), source="id", expected_type=int ), ], @@ -184,7 +184,7 @@ def test_apply_schema_empty_list_valid() -> None: """Empty lists should be considered valid.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Tags"), source="tags", expected_type=list, @@ -206,7 +206,7 @@ def test_apply_schema_list_with_primitives() -> None: """Lists with primitive values should be valid.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Tags"), source="tags", expected_type=list, @@ -228,7 +228,7 @@ def test_apply_schema_list_with_mixed_primitives() -> None: """Lists with mixed primitive types should be invalid.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Values"), source="values", expected_type=list, @@ -248,7 +248,7 @@ def test_apply_schema_list_with_non_primitives_invalid() -> None: """Lists with non-primitive elements should produce problems.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Items"), source="items", expected_type=list, @@ -267,7 +267,7 @@ def test_apply_schema_list_with_nested_list_invalid() -> None: """Nested lists should produce problems.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Items"), source="items", expected_type=list, @@ -286,7 +286,7 @@ def test_apply_schema_type_mismatch_produces_problem() -> None: """Type mismatches should produce problems.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int ), ], @@ -302,7 +302,7 @@ def test_apply_schema_none_value_produces_problem() -> None: """None values should produce problems.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int ), ], @@ -318,12 +318,12 @@ def test_apply_schema_none_value_with_multiple_leaves_produces_single_problem() """None values with multiple leaves referencing same source produce one problem.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Metadata", "ID"), source="request_id", expected_type=str, @@ -342,12 +342,12 @@ def test_apply_schema_partial_validation() -> None: """Some fields valid, others invalid should log valid ones and report problems.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int ), ], @@ -372,7 +372,7 @@ def test_apply_schema_redundant_fields_with_non_empty_schema() -> None: """Redundant fields should produce problems when schema is not empty.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, @@ -420,12 +420,12 @@ def test_apply_schema_strips_empty_dicts() -> None: """apply_schema_internal should strip empty dictionaries from result.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "Metadata", "ID"), source="request_id", expected_type=str, @@ -447,7 +447,7 @@ def test_apply_schema_deeply_nested_structure() -> None: """apply_schema_internal should handle deeply nested structures.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("Level1", "Level2", "Level3", "Level4", "Value"), source="value", expected_type=str, @@ -474,12 +474,12 @@ def test_apply_schema_missing_fields_no_problems() -> None: """Missing fields in extra should not produce problems, just be omitted.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int ), ], @@ -494,8 +494,8 @@ def test_apply_schema_multiple_sources_different_branches() -> None: """Multiple sources in different branches should work independently.""" schema = CompiledSchema( leaves=[ - SchemaLeaf(path=("Branch1", "Value"), source="value1", expected_type=str), - SchemaLeaf(path=("Branch2", "Value"), source="value2", expected_type=int), + _SchemaLeaf(path=("Branch1", "Value"), source="value1", expected_type=str), + _SchemaLeaf(path=("Branch2", "Value"), source="value2", expected_type=int), ], ) extra = { @@ -514,7 +514,7 @@ def test_apply_schema_bool_not_accepted_for_int() -> None: """Bool values should not pass validation for int types.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int ), ], @@ -540,7 +540,7 @@ def test_apply_schema_int_not_accepted_for_bool() -> None: """Int values should not pass validation for bool types.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "IsActive"), source="is_active", expected_type=bool, @@ -568,22 +568,22 @@ def test_apply_schema_strict_type_checking_for_all_primitives() -> None: """Strict type checking should work for all primitive types.""" schema = CompiledSchema( leaves=[ - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "StringField"), source="string_field", expected_type=str, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "IntField"), source="int_field", expected_type=int, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "FloatField"), source="float_field", expected_type=float, ), - SchemaLeaf( + _SchemaLeaf( path=("ServicePayload", "BoolField"), source="bool_field", expected_type=bool, @@ -723,7 +723,7 @@ def test_set_nested_value_overwrites_existing_value() -> None: def test_validate_and_apply_leaf_valid_value() -> None: """validate_and_apply_leaf should apply valid value to target.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "RequestID"), source="request_id", expected_type=str, @@ -739,7 +739,7 @@ def test_validate_and_apply_leaf_valid_value() -> None: def test_validate_and_apply_leaf_type_mismatch() -> None: """validate_and_apply_leaf should add problem for type mismatch.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int, @@ -756,7 +756,7 @@ def test_validate_and_apply_leaf_type_mismatch() -> None: def test_validate_and_apply_leaf_valid_list() -> None: """validate_and_apply_leaf should apply valid list value.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "Tags"), source="tags", expected_type=list, @@ -773,7 +773,7 @@ def test_validate_and_apply_leaf_valid_list() -> None: def test_validate_and_apply_leaf_invalid_list() -> None: """validate_and_apply_leaf should add problem for invalid list.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "Values"), source="values", expected_type=list, @@ -791,7 +791,7 @@ def test_validate_and_apply_leaf_invalid_list() -> None: def test_validate_and_apply_leaf_empty_list() -> None: """validate_and_apply_leaf should accept empty lists.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "Tags"), source="tags", expected_type=list, @@ -808,7 +808,7 @@ def test_validate_and_apply_leaf_empty_list() -> None: def test_validate_and_apply_leaf_list_without_item_type() -> None: """validate_and_apply_leaf should add problem when item_expected_type is None.""" - leaf = SchemaLeaf( + leaf = _SchemaLeaf( path=("ServicePayload", "Items"), source="items", expected_type=list, diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index d6b49ef..f38b613 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -18,7 +18,6 @@ MAX_SCHEMA_DEPTH, SCHEMA_FILE_NAME, CompiledSchema, - SchemaLeaf, ) from logging_objects_with_schema.schema_loader import ( _cache_and_return_found_path as cache_and_return_found_path, @@ -58,6 +57,9 @@ from logging_objects_with_schema.schema_loader import ( _load_raw_schema as load_raw_schema, ) +from logging_objects_with_schema.schema_loader import ( + _SchemaLeaf, +) from logging_objects_with_schema.schema_loader import ( _validate_and_create_leaf as validate_and_create_leaf, ) @@ -617,7 +619,7 @@ def test_is_empty_or_none_with_non_string_types() -> None: def test_validate_and_create_leaf_valid_primitive() -> None: - """_validate_and_create_leaf should create SchemaLeaf for valid primitive type.""" + """_validate_and_create_leaf should create _SchemaLeaf for valid primitive type.""" problems: list[SchemaProblem] = [] value_dict = {"type": "str", "source": "request_id"} @@ -626,7 +628,7 @@ def test_validate_and_create_leaf_valid_primitive() -> None: ) assert leaf is not None - assert isinstance(leaf, SchemaLeaf) + assert isinstance(leaf, _SchemaLeaf) assert leaf.path == ("ServicePayload", "RequestID") assert leaf.source == "request_id" assert leaf.expected_type is str @@ -635,14 +637,14 @@ def test_validate_and_create_leaf_valid_primitive() -> None: def test_validate_and_create_leaf_valid_list_type() -> None: - """_validate_and_create_leaf should create SchemaLeaf for valid list type.""" + """_validate_and_create_leaf should create _SchemaLeaf for valid list type.""" problems: list[SchemaProblem] = [] value_dict = {"type": "list", "source": "tags", "item_type": "str"} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) assert leaf is not None - assert isinstance(leaf, SchemaLeaf) + assert isinstance(leaf, _SchemaLeaf) assert leaf.path == ("ServicePayload", "Tags") assert leaf.source == "tags" assert leaf.expected_type is list @@ -1269,7 +1271,7 @@ def test_compile_schema_tree_compiles_simple_tree() -> None: leaves = list(compile_schema_tree(node, (), problems)) assert len(leaves) == 2 - assert all(isinstance(leaf, SchemaLeaf) for leaf in leaves) + assert all(isinstance(leaf, _SchemaLeaf) for leaf in leaves) sources = {leaf.source for leaf in leaves} assert sources == {"request_id", "user_id"} assert problems == [] From b9e9594fde373caa9d66c726cb3d462f35a1ac42 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:25:36 +0400 Subject: [PATCH 06/26] refactor(schema): rename CompiledSchema to _CompiledSchema for internal use - Updated the schema loader, applier, and logger to use the new internal class name _CompiledSchema, reflecting its non-public API status. - Adjusted type hints and references throughout the codebase to ensure consistency with the new naming convention. - Modified related test cases to accommodate the changes in class naming. Signed-off-by: Dmitrii Safronov --- .../schema_applier.py | 6 +-- .../schema_loader.py | 35 +++++++------ .../schema_logger.py | 6 +-- tests/test_formatter.py | 46 ++++++++--------- tests/test_schema_loader.py | 50 ++++++++++--------- 5 files changed, 75 insertions(+), 68 deletions(-) diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 68ac564..875b3cc 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -12,7 +12,7 @@ from typing import Any from .errors import DataProblem -from .schema_loader import CompiledSchema, _SchemaLeaf +from .schema_loader import _CompiledSchema, _SchemaLeaf def _create_validation_error_json(field: str, error: str, value: Any) -> str: @@ -203,12 +203,12 @@ def _strip_empty(node: Any) -> Any: def _apply_schema_internal( - compiled: CompiledSchema, + compiled: _CompiledSchema, extra_values: Mapping[str, Any], ) -> tuple[dict[str, Any], list[DataProblem]]: """Internal function to build structured ``extra`` from compiled schema. - The function applies a :class:`CompiledSchema` to user-provided ``extra`` + The function applies a :class:`_CompiledSchema` to user-provided ``extra`` values and returns a tuple ``(structured_extra, problems)`` where: - ``structured_extra`` is a nested dictionary that follows the schema diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 73ce0d0..56333dd 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -49,8 +49,13 @@ class _SchemaLeaf: @dataclass -class CompiledSchema: - """Internal representation of a compiled schema.""" +class _CompiledSchema: + """Internal representation of a compiled schema. + + This class is part of the internal implementation and is not considered + a public API. Its signature and behaviour may change between releases + without preserving backward compatibility. + """ leaves: list[_SchemaLeaf] @@ -63,16 +68,16 @@ def is_empty(self) -> bool: def _create_empty_compiled_schema_with_problems( problems: list[SchemaProblem], -) -> tuple[CompiledSchema, list[SchemaProblem]]: - """Create an empty CompiledSchema with problems. +) -> tuple[_CompiledSchema, list[SchemaProblem]]: + """Create an empty _CompiledSchema with problems. Args: problems: List of schema problems. Returns: - Tuple of (empty CompiledSchema, problems list). + Tuple of (empty _CompiledSchema, problems list). """ - return (CompiledSchema(leaves=[]), problems) + return (_CompiledSchema(leaves=[]), problems) _TYPE_MAP: Mapping[str, type] = { @@ -105,9 +110,9 @@ def _create_empty_compiled_schema_with_problems( # use double-checked locking to avoid race conditions. # Compiled schema cache: Key is absolute schema_path, Value is tuple of -# (CompiledSchema, list[SchemaProblem]). This cache stores both successful +# (_CompiledSchema, list[SchemaProblem]). This cache stores both successful # compilations and failures (with problems list). -_SCHEMA_CACHE: dict[Path, tuple[CompiledSchema, list[SchemaProblem]]] = {} +_SCHEMA_CACHE: dict[Path, tuple[_CompiledSchema, list[SchemaProblem]]] = {} _cache_lock = threading.RLock() @@ -627,17 +632,17 @@ def _check_root_conflicts( ) -def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: - """Compile JSON schema into ``CompiledSchema`` and collect all problems. +def _compile_schema_internal() -> tuple[_CompiledSchema, list[SchemaProblem]]: + """Compile JSON schema into ``_CompiledSchema`` and collect all problems. The function loads the raw JSON schema, validates its structure, checks root keys for conflicts with reserved ``logging.LogRecord`` attributes - and compiles all valid leaves into a :class:`CompiledSchema`. All issues + and compiles all valid leaves into a :class:`_CompiledSchema`. All issues discovered during this process are reported as :class:`SchemaProblem` instances. Results are cached process-wide: the cache key is the absolute schema - file path and the value is a tuple ``(CompiledSchema, list[SchemaProblem])``. + file path and the value is a tuple ``(_CompiledSchema, list[SchemaProblem])``. Once a schema for a given path has been observed (including the cases when it is missing or invalid), subsequent calls always return the cached result without re-reading or re-compiling the schema. To pick up on-disk changes @@ -646,7 +651,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: This function never raises exceptions. It always returns the best-effort compiled schema together with a list of problems detected during processing - (an empty ``CompiledSchema`` when the schema is missing or invalid). + (an empty ``_CompiledSchema`` when the schema is missing or invalid). Performance considerations: First compilation of a schema involves file I/O, JSON parsing, and tree @@ -669,7 +674,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: between releases without preserving backward compatibility. Returns: - Tuple of (CompiledSchema, list[SchemaProblem]). + Tuple of (_CompiledSchema, list[SchemaProblem]). """ schema_path = _get_schema_path() @@ -726,7 +731,7 @@ def _compile_schema_internal() -> tuple[CompiledSchema, list[SchemaProblem]]: for leaf in _compile_schema_tree(dict(value), (key,), problems): leaves.append(leaf) - compiled = CompiledSchema(leaves=leaves) + compiled = _CompiledSchema(leaves=leaves) result = (compiled, problems) # Double-checked locking: Check cache again while holding lock. Another thread diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 70bfabd..f9de8dc 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -19,7 +19,7 @@ from .errors import SchemaProblem from .schema_applier import _apply_schema_internal -from .schema_loader import CompiledSchema, _compile_schema_internal +from .schema_loader import _compile_schema_internal, _CompiledSchema # Python 3.11+ has improved findCaller() implementation with proper stacklevel support. # For Python < 3.11, we use inspect.stack() as a fallback due to known issues with @@ -102,7 +102,7 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: # Note: System exceptions (KeyboardInterrupt, SystemExit) are not # caught, which is the correct behavior. problems = [SchemaProblem(f"Schema compilation failed: {exc}")] - compiled = CompiledSchema(leaves=[]) + compiled = _CompiledSchema(leaves=[]) if problems: # Schema is invalid; log problems and terminate without creating @@ -111,7 +111,7 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: # Schema is valid; create the logger instance. super().__init__(name, level) - self._schema: CompiledSchema = compiled + self._schema: _CompiledSchema = compiled def _log( self, diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 413b953..d859f1d 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -20,7 +20,7 @@ from logging_objects_with_schema.schema_applier import ( _validate_list_value as validate_list_value, ) -from logging_objects_with_schema.schema_loader import CompiledSchema, _SchemaLeaf +from logging_objects_with_schema.schema_loader import _CompiledSchema, _SchemaLeaf def test_strip_empty_removes_empty_dicts() -> None: @@ -75,7 +75,7 @@ def test_apply_schema_empty_schema_returns_empty() -> None: """apply_schema_internal with empty schema should return empty dict and problems.""" import json - schema = CompiledSchema(leaves=[]) + schema = _CompiledSchema(leaves=[]) extra = {"field1": "value1", "field2": 42} result, problems = apply_schema_internal(schema, extra) assert result == {} @@ -90,7 +90,7 @@ def test_apply_schema_empty_schema_returns_empty() -> None: def test_apply_schema_nested_structure() -> None: """apply_schema_internal should build nested structures correctly.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -129,7 +129,7 @@ def test_apply_schema_nested_structure() -> None: def test_apply_schema_multiple_leaves_same_source_same_type() -> None: """Multiple leaves with same source and type should write to all locations.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -158,7 +158,7 @@ def test_apply_schema_multiple_leaves_same_source_same_type() -> None: def test_apply_schema_multiple_leaves_same_source_different_types() -> None: """Multiple leaves with same source but different types validate independently.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), source="id", expected_type=str @@ -182,7 +182,7 @@ def test_apply_schema_multiple_leaves_same_source_different_types() -> None: def test_apply_schema_empty_list_valid() -> None: """Empty lists should be considered valid.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "Tags"), @@ -204,7 +204,7 @@ def test_apply_schema_empty_list_valid() -> None: def test_apply_schema_list_with_primitives() -> None: """Lists with primitive values should be valid.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "Tags"), @@ -226,7 +226,7 @@ def test_apply_schema_list_with_primitives() -> None: def test_apply_schema_list_with_mixed_primitives() -> None: """Lists with mixed primitive types should be invalid.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "Values"), @@ -246,7 +246,7 @@ def test_apply_schema_list_with_mixed_primitives() -> None: def test_apply_schema_list_with_non_primitives_invalid() -> None: """Lists with non-primitive elements should produce problems.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "Items"), @@ -265,7 +265,7 @@ def test_apply_schema_list_with_non_primitives_invalid() -> None: def test_apply_schema_list_with_nested_list_invalid() -> None: """Nested lists should produce problems.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "Items"), @@ -284,7 +284,7 @@ def test_apply_schema_list_with_nested_list_invalid() -> None: def test_apply_schema_type_mismatch_produces_problem() -> None: """Type mismatches should produce problems.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int @@ -300,7 +300,7 @@ def test_apply_schema_type_mismatch_produces_problem() -> None: def test_apply_schema_none_value_produces_problem() -> None: """None values should produce problems.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int @@ -316,7 +316,7 @@ def test_apply_schema_none_value_produces_problem() -> None: def test_apply_schema_none_value_with_multiple_leaves_produces_single_problem() -> None: """None values with multiple leaves referencing same source produce one problem.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -340,7 +340,7 @@ def test_apply_schema_none_value_with_multiple_leaves_produces_single_problem() def test_apply_schema_partial_validation() -> None: """Some fields valid, others invalid should log valid ones and report problems.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -370,7 +370,7 @@ def test_apply_schema_partial_validation() -> None: def test_apply_schema_redundant_fields_with_non_empty_schema() -> None: """Redundant fields should produce problems when schema is not empty.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -400,7 +400,7 @@ def test_apply_schema_redundant_fields_with_empty_schema() -> None: """Redundant fields should produce problems when schema is empty.""" import json - schema = CompiledSchema(leaves=[]) + schema = _CompiledSchema(leaves=[]) extra = { "unknown_field": "value", "another_unknown": 42, @@ -418,7 +418,7 @@ def test_apply_schema_redundant_fields_with_empty_schema() -> None: def test_apply_schema_strips_empty_dicts() -> None: """apply_schema_internal should strip empty dictionaries from result.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -445,7 +445,7 @@ def test_apply_schema_strips_empty_dicts() -> None: def test_apply_schema_deeply_nested_structure() -> None: """apply_schema_internal should handle deeply nested structures.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("Level1", "Level2", "Level3", "Level4", "Value"), @@ -472,7 +472,7 @@ def test_apply_schema_deeply_nested_structure() -> None: def test_apply_schema_missing_fields_no_problems() -> None: """Missing fields in extra should not produce problems, just be omitted.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "RequestID"), @@ -492,7 +492,7 @@ def test_apply_schema_missing_fields_no_problems() -> None: def test_apply_schema_multiple_sources_different_branches() -> None: """Multiple sources in different branches should work independently.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf(path=("Branch1", "Value"), source="value1", expected_type=str), _SchemaLeaf(path=("Branch2", "Value"), source="value2", expected_type=int), @@ -512,7 +512,7 @@ def test_apply_schema_multiple_sources_different_branches() -> None: def test_apply_schema_bool_not_accepted_for_int() -> None: """Bool values should not pass validation for int types.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "UserID"), source="user_id", expected_type=int @@ -538,7 +538,7 @@ def test_apply_schema_bool_not_accepted_for_int() -> None: def test_apply_schema_int_not_accepted_for_bool() -> None: """Int values should not pass validation for bool types.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "IsActive"), @@ -566,7 +566,7 @@ def test_apply_schema_int_not_accepted_for_bool() -> None: def test_apply_schema_strict_type_checking_for_all_primitives() -> None: """Strict type checking should work for all primitive types.""" - schema = CompiledSchema( + schema = _CompiledSchema( leaves=[ _SchemaLeaf( path=("ServicePayload", "StringField"), diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index f38b613..1e0ecbc 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -17,7 +17,6 @@ from logging_objects_with_schema.schema_loader import ( MAX_SCHEMA_DEPTH, SCHEMA_FILE_NAME, - CompiledSchema, ) from logging_objects_with_schema.schema_loader import ( _cache_and_return_found_path as cache_and_return_found_path, @@ -40,6 +39,9 @@ from logging_objects_with_schema.schema_loader import ( _compile_schema_tree as compile_schema_tree, ) +from logging_objects_with_schema.schema_loader import ( + _CompiledSchema, +) from logging_objects_with_schema.schema_loader import ( _create_empty_compiled_schema_with_problems as create_empty_schema, ) @@ -78,7 +80,7 @@ def test_missing_schema_file_produces_empty_schema_and_problem( compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert problems @@ -94,7 +96,7 @@ def test_completely_invalid_schema_is_empty_and_reports_problems( compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert problems @@ -117,7 +119,7 @@ def test_partially_valid_schema_preserves_valid_leaves( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty assert any(leaf.source == "request_id" for leaf in compiled.leaves) assert problems @@ -141,7 +143,7 @@ def test_root_key_conflicting_with_logging_field_produces_problem( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert any( "conflicts with reserved logging fields" in problem.message for problem in problems @@ -165,7 +167,7 @@ def test_root_key_not_conflicting_passes_validation( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty # Should not have problems related to root key conflicts root_conflict_problems = [ @@ -184,7 +186,7 @@ def test_empty_schema_produces_empty_compiled_schema( _write_schema(tmp_path, {}) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert problems == [] @@ -208,7 +210,7 @@ def test_schema_with_only_inner_nodes_produces_empty_compiled_schema( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty # Schema with only inner nodes is valid, just empty (no problems) assert problems == [] @@ -239,7 +241,7 @@ def test_deeply_nested_schema_compiles_correctly( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty assert len(compiled.leaves) == 1 assert compiled.leaves[0].source == "value" @@ -276,7 +278,7 @@ def test_duplicate_source_in_different_branches( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty # Should have two leaves with same source but different paths leaves_with_source = [ @@ -305,7 +307,7 @@ def test_incomplete_leaf_missing_type( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert any("type cannot be None or empty" in p.message for p in problems) @@ -327,7 +329,7 @@ def test_incomplete_leaf_missing_source( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert any("source cannot be None or empty" in p.message for p in problems) @@ -349,7 +351,7 @@ def test_incomplete_leaf_empty_source( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert any("source cannot be None or empty" in p.message for p in problems) @@ -371,7 +373,7 @@ def test_incomplete_leaf_empty_type( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert any("type cannot be None or empty" in p.message for p in problems) @@ -399,7 +401,7 @@ def test_multiple_root_keys_with_valid_leaves( ) compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty assert len(compiled.leaves) == 3 sources = {leaf.source for leaf in compiled.leaves} @@ -489,7 +491,7 @@ def fake_open(self, *args, **kwargs): # type: ignore[override] compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert problems assert any("Failed to read schema file" in p.message for p in problems) @@ -512,7 +514,7 @@ def test_schema_changes_on_disk_are_not_reloaded_in_same_process( ) compiled1, problems1 = compile_schema_internal() - assert isinstance(compiled1, CompiledSchema) + assert isinstance(compiled1, _CompiledSchema) assert not compiled1.is_empty assert any(leaf.source == "v1" for leaf in compiled1.leaves) assert problems1 == [] @@ -530,7 +532,7 @@ def test_schema_changes_on_disk_are_not_reloaded_in_same_process( compiled2, problems2 = compile_schema_internal() # Compiled schema and problems should be served from cache, ignoring # on-disk changes. - assert isinstance(compiled2, CompiledSchema) + assert isinstance(compiled2, _CompiledSchema) assert not compiled2.is_empty assert any(leaf.source == "v1" for leaf in compiled2.leaves) assert not any(leaf.source == "v2" for leaf in compiled2.leaves) @@ -549,7 +551,7 @@ def test_invalid_schema_result_is_cached_within_same_process( _write_schema(tmp_path, {"foo": "not-an-object"}) compiled1, problems1 = compile_schema_internal() - assert isinstance(compiled1, CompiledSchema) + assert isinstance(compiled1, _CompiledSchema) assert compiled1.is_empty assert problems1 @@ -566,7 +568,7 @@ def test_invalid_schema_result_is_cached_within_same_process( compiled2, problems2 = compile_schema_internal() # Still see the cached "invalid" result: no leaves and the original problems. - assert isinstance(compiled2, CompiledSchema) + assert isinstance(compiled2, _CompiledSchema) assert compiled2.is_empty assert problems2 @@ -842,7 +844,7 @@ def test_schema_exceeds_max_depth_produces_problem( compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert any( "exceeds maximum allowed depth" in p.message @@ -873,7 +875,7 @@ def test_schema_at_max_depth_compiles_correctly( compiled, problems = compile_schema_internal() - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert not compiled.is_empty assert len(compiled.leaves) == 1 assert compiled.leaves[0].source == "value" @@ -1170,7 +1172,7 @@ def test_create_empty_compiled_schema_with_problems() -> None: compiled, result_problems = create_empty_schema(problems) - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert compiled.leaves == [] assert result_problems == problems @@ -1183,7 +1185,7 @@ def test_create_empty_compiled_schema_with_empty_problems() -> None: compiled, result_problems = create_empty_schema(problems) - assert isinstance(compiled, CompiledSchema) + assert isinstance(compiled, _CompiledSchema) assert compiled.is_empty assert result_problems == [] From cbc430a05c4d2abf5c512e3ab03ac773735af9e1 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:37:37 +0400 Subject: [PATCH 07/26] refactor(schema): rename SchemaProblem to _SchemaProblem for internal use - Updated the schema loader and related components to use the new internal class name _SchemaProblem, reflecting its non-public API status. - Adjusted type hints and references throughout the codebase to ensure consistency with the new naming convention. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/errors.py | 8 ++- .../schema_loader.py | 52 +++++++++---------- .../schema_logger.py | 12 ++--- tests/test_schema_loader.py | 52 +++++++++---------- tests/test_schema_logger_mimic.py | 6 +-- 5 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index fef5f16..0a16415 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -6,9 +6,13 @@ @dataclass -class SchemaProblem: +class _SchemaProblem: """Describes a single problem encountered while loading the schema. + This class is part of the internal implementation and is not considered + a public API. Its signature and behaviour may change between releases + without preserving backward compatibility. + This class is used to report schema validation errors during schema compilation. Schema problems are fatal: if any are detected during logger initialization, the application is terminated after logging @@ -41,7 +45,7 @@ class DataProblem: This class is used to report validation errors when applying the compiled schema to user-provided ``extra`` fields during logging. Unlike - :class:`SchemaProblem`, data problems are not fatal: they are collected + :class:`_SchemaProblem`, data problems are not fatal: they are collected and logged as ERROR messages *after* the main log record has been emitted, ensuring 100% compatibility with standard logger behavior. diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 56333dd..88951d1 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Any -from .errors import SchemaProblem +from .errors import _SchemaProblem SCHEMA_FILE_NAME = "logging_objects_with_schema.json" @@ -67,8 +67,8 @@ def is_empty(self) -> bool: def _create_empty_compiled_schema_with_problems( - problems: list[SchemaProblem], -) -> tuple[_CompiledSchema, list[SchemaProblem]]: + problems: list[_SchemaProblem], +) -> tuple[_CompiledSchema, list[_SchemaProblem]]: """Create an empty _CompiledSchema with problems. Args: @@ -110,9 +110,9 @@ def _create_empty_compiled_schema_with_problems( # use double-checked locking to avoid race conditions. # Compiled schema cache: Key is absolute schema_path, Value is tuple of -# (_CompiledSchema, list[SchemaProblem]). This cache stores both successful +# (_CompiledSchema, list[_SchemaProblem]). This cache stores both successful # compilations and failures (with problems list). -_SCHEMA_CACHE: dict[Path, tuple[_CompiledSchema, list[SchemaProblem]]] = {} +_SCHEMA_CACHE: dict[Path, tuple[_CompiledSchema, list[_SchemaProblem]]] = {} _cache_lock = threading.RLock() @@ -300,7 +300,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]: This function attempts to read and parse the schema file. If any problems occur (file not found, I/O errors, invalid JSON, wrong top-level type), it raises exceptions (FileNotFoundError or ValueError). These exceptions - are then converted to :class:`SchemaProblem` instances by the caller + are then converted to :class:`_SchemaProblem` instances by the caller (_compile_schema_internal). Args: @@ -328,7 +328,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]: except OSError as exc: # Normalise I/O errors when reading the schema file (e.g., permission # denied, file not found) to ValueError so that _compile_schema_internal() - # can report them as SchemaProblem instances instead of leaking raw + # can report them as _SchemaProblem instances instead of leaking raw # OSError to callers. Note: System-level OSError (e.g., from os.getcwd()) # is not caught here and propagates directly. raise ValueError( @@ -339,7 +339,7 @@ def _load_raw_schema(schema_path: Path) -> tuple[dict[str, Any], Path]: if not isinstance(data, dict): # Normalise non-object top-level schemas into a ValueError so that the - # caller can report it as a SchemaProblem while keeping type safety. + # caller can report it as a _SchemaProblem while keeping type safety. raise ValueError("Top-level schema must be a JSON object") return data, schema_path @@ -401,7 +401,7 @@ def _validate_and_create_leaf( value_dict: dict[str, Any], path: tuple[str, ...], key: str, - problems: list[SchemaProblem], + problems: list[_SchemaProblem], ) -> _SchemaLeaf | None: """Validate a leaf node and create _SchemaLeaf if valid. @@ -423,7 +423,7 @@ def _validate_and_create_leaf( if type_invalid: problems.append( - SchemaProblem( + _SchemaProblem( f"Incomplete leaf at {_format_path(path, key)}: " f"type cannot be None or empty", ), @@ -431,7 +431,7 @@ def _validate_and_create_leaf( if source_invalid: problems.append( - SchemaProblem( + _SchemaProblem( f"Incomplete leaf at {_format_path(path, key)}: " f"source cannot be None or empty", ), @@ -446,7 +446,7 @@ def _validate_and_create_leaf( expected_type = _TYPE_MAP.get(str(leaf_type)) if expected_type is None: problems.append( - SchemaProblem( + _SchemaProblem( f"Unknown type '{leaf_type}' at {_format_path(path, key)}", ), ) @@ -460,7 +460,7 @@ def _validate_and_create_leaf( item_type_invalid = _is_empty_or_none(item_type_name) if item_type_invalid: problems.append( - SchemaProblem( + _SchemaProblem( f"Incomplete leaf at {_format_path(path, key)}: " f"item_type is required for list type and " f"cannot be None or empty", @@ -472,7 +472,7 @@ def _validate_and_create_leaf( # Item type must be a primitive (str, int, float, bool), not list if item_expected_type is None or item_expected_type is list: problems.append( - SchemaProblem( + _SchemaProblem( f"Invalid item_type '{item_type_name}' at " f"{_format_path(path, key)}: only primitive item types " f"('str', 'int', 'float', 'bool') are allowed for lists", @@ -494,7 +494,7 @@ def _validate_and_create_leaf( def _compile_schema_tree( node: MutableMapping[str, Any], path: tuple[str, ...], - problems: list[SchemaProblem], + problems: list[_SchemaProblem], ) -> Iterable[_SchemaLeaf]: """Recursively compile a schema node into _SchemaLeaf objects. @@ -529,7 +529,7 @@ def _compile_schema_tree( # and to provide clear error messages about the problematic path. if len(path) > MAX_SCHEMA_DEPTH: problems.append( - SchemaProblem( + _SchemaProblem( f"Schema nesting depth exceeds maximum allowed depth of " f"{MAX_SCHEMA_DEPTH} at path {_format_path(path)}" ), @@ -539,7 +539,7 @@ def _compile_schema_tree( for key, value in node.items(): if not isinstance(value, Mapping): problems.append( - SchemaProblem( + _SchemaProblem( f"Invalid schema at {_format_path(path, key)}: expected object" ), ) @@ -617,7 +617,7 @@ def get_builtin_logrecord_attributes() -> set[str]: def _check_root_conflicts( - schema_dict: Mapping[str, Any], problems: list[SchemaProblem] + schema_dict: Mapping[str, Any], problems: list[_SchemaProblem] ) -> None: """Check schema root keys for conflicts with reserved logging fields.""" @@ -626,23 +626,23 @@ def _check_root_conflicts( for key in schema_dict.keys(): if key in forbidden_root_keys: problems.append( - SchemaProblem( + _SchemaProblem( f"Root key '{key}' conflicts with reserved logging fields", ), ) -def _compile_schema_internal() -> tuple[_CompiledSchema, list[SchemaProblem]]: +def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]: """Compile JSON schema into ``_CompiledSchema`` and collect all problems. The function loads the raw JSON schema, validates its structure, checks root keys for conflicts with reserved ``logging.LogRecord`` attributes and compiles all valid leaves into a :class:`_CompiledSchema`. All issues - discovered during this process are reported as :class:`SchemaProblem` + discovered during this process are reported as :class:`_SchemaProblem` instances. Results are cached process-wide: the cache key is the absolute schema - file path and the value is a tuple ``(_CompiledSchema, list[SchemaProblem])``. + file path and the value is a tuple ``(_CompiledSchema, list[_SchemaProblem])``. Once a schema for a given path has been observed (including the cases when it is missing or invalid), subsequent calls always return the cached result without re-reading or re-compiling the schema. To pick up on-disk changes @@ -674,7 +674,7 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[SchemaProblem]]: between releases without preserving backward compatibility. Returns: - Tuple of (_CompiledSchema, list[SchemaProblem]). + Tuple of (_CompiledSchema, list[_SchemaProblem]). """ schema_path = _get_schema_path() @@ -693,12 +693,12 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[SchemaProblem]]: # while holding the lock. If another thread already compiled it, we use that # result instead of storing our own (which might be different if the file changed). - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] try: raw_schema, _ = _load_raw_schema(schema_path) except (FileNotFoundError, ValueError) as exc: - problems.append(SchemaProblem(str(exc))) + problems.append(_SchemaProblem(str(exc))) result = _create_empty_compiled_schema_with_problems(problems) # Double-checked locking: Check cache again while holding lock. Another # thread might have compiled the schema (or handled the same error) while @@ -721,7 +721,7 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[SchemaProblem]]: for key, value in raw_schema.items(): if not isinstance(value, Mapping): problems.append( - SchemaProblem(f"Invalid schema at {key}: expected object"), + _SchemaProblem(f"Invalid schema at {key}: expected object"), ) continue diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index f9de8dc..2d7dd2e 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -17,7 +17,7 @@ from collections.abc import Mapping from typing import Any -from .errors import SchemaProblem +from .errors import _SchemaProblem from .schema_applier import _apply_schema_internal from .schema_loader import _compile_schema_internal, _CompiledSchema @@ -27,7 +27,7 @@ _USE_FINDCALLER = sys.version_info >= (3, 11) -def _log_schema_problems_and_exit(problems: list[SchemaProblem]) -> None: +def _log_schema_problems_and_exit(problems: list[_SchemaProblem]) -> None: """Log schema problems to stderr and terminate the application. Uses os._exit(1) instead of sys.exit(1) to ensure immediate termination @@ -87,21 +87,21 @@ def __init__(self, name: str, level: int = logging.NOTSET) -> None: try: compiled, problems = _compile_schema_internal() except (OSError, ValueError, RuntimeError) as exc: - # Convert system-level exceptions to SchemaProblem so they can be + # Convert system-level exceptions to _SchemaProblem so they can be # handled the same way as schema validation problems. # - OSError: system-level file system issues (e.g., os.getcwd() failures # when the current working directory is inaccessible or deleted). # Note: OSError that occurs when reading the schema file (e.g., permission - # denied, I/O errors) is converted to SchemaProblem in _load_raw_schema() + # denied, I/O errors) is converted to _SchemaProblem in _load_raw_schema() # and does not reach this exception handler. # - ValueError: path resolution issues (e.g., invalid path characters, # malformed paths during schema file discovery) # - RuntimeError: threading issues (e.g., lock acquisition problems) # Note: JSON parsing and schema structure validation errors are - # converted to SchemaProblem instances and do not raise ValueError here. + # converted to _SchemaProblem instances and do not raise ValueError here. # Note: System exceptions (KeyboardInterrupt, SystemExit) are not # caught, which is the correct behavior. - problems = [SchemaProblem(f"Schema compilation failed: {exc}")] + problems = [_SchemaProblem(f"Schema compilation failed: {exc}")] compiled = _CompiledSchema(leaves=[]) if problems: diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index 1e0ecbc..031ba75 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -13,7 +13,7 @@ from conftest import _write_schema import logging_objects_with_schema.schema_loader as schema_loader -from logging_objects_with_schema.errors import SchemaProblem +from logging_objects_with_schema.errors import _SchemaProblem from logging_objects_with_schema.schema_loader import ( MAX_SCHEMA_DEPTH, SCHEMA_FILE_NAME, @@ -129,7 +129,7 @@ def test_root_key_conflicting_with_logging_field_produces_problem( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Root key conflicting with logging.LogRecord field produces SchemaProblem.""" + """Root key conflicting with logging.LogRecord field produces _SchemaProblem.""" monkeypatch.chdir(tmp_path) # Use a known LogRecord attribute like "name" or "levelno" @@ -622,7 +622,7 @@ def test_is_empty_or_none_with_non_string_types() -> None: def test_validate_and_create_leaf_valid_primitive() -> None: """_validate_and_create_leaf should create _SchemaLeaf for valid primitive type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "str", "source": "request_id"} leaf = validate_and_create_leaf( @@ -640,7 +640,7 @@ def test_validate_and_create_leaf_valid_primitive() -> None: def test_validate_and_create_leaf_valid_list_type() -> None: """_validate_and_create_leaf should create _SchemaLeaf for valid list type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "list", "source": "tags", "item_type": "str"} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) @@ -656,7 +656,7 @@ def test_validate_and_create_leaf_valid_list_type() -> None: def test_validate_and_create_leaf_missing_type() -> None: """_validate_and_create_leaf should return None and add problem for missing type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"source": "request_id"} leaf = validate_and_create_leaf( @@ -670,7 +670,7 @@ def test_validate_and_create_leaf_missing_type() -> None: def test_validate_and_create_leaf_missing_source() -> None: """_validate_and_create_leaf should return None for missing source.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "str"} leaf = validate_and_create_leaf( @@ -684,7 +684,7 @@ def test_validate_and_create_leaf_missing_source() -> None: def test_validate_and_create_leaf_empty_type() -> None: """_validate_and_create_leaf should return None for empty type string.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "", "source": "request_id"} leaf = validate_and_create_leaf( @@ -698,7 +698,7 @@ def test_validate_and_create_leaf_empty_type() -> None: def test_validate_and_create_leaf_whitespace_type() -> None: """_validate_and_create_leaf should return None for whitespace-only type string.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": " ", "source": "request_id"} leaf = validate_and_create_leaf( @@ -712,7 +712,7 @@ def test_validate_and_create_leaf_whitespace_type() -> None: def test_validate_and_create_leaf_empty_source() -> None: """_validate_and_create_leaf should return None for empty source string.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "str", "source": ""} leaf = validate_and_create_leaf( @@ -726,7 +726,7 @@ def test_validate_and_create_leaf_empty_source() -> None: def test_validate_and_create_leaf_whitespace_source() -> None: """_validate_and_create_leaf should return None for whitespace source.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "str", "source": "\t\n"} leaf = validate_and_create_leaf( @@ -740,7 +740,7 @@ def test_validate_and_create_leaf_whitespace_source() -> None: def test_validate_and_create_leaf_unknown_type() -> None: """_validate_and_create_leaf should return None for unknown type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "unknown_type", "source": "request_id"} leaf = validate_and_create_leaf( @@ -755,7 +755,7 @@ def test_validate_and_create_leaf_unknown_type() -> None: def test_validate_and_create_leaf_list_missing_item_type() -> None: """_validate_and_create_leaf should return None for list type without item_type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "list", "source": "tags"} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) @@ -767,7 +767,7 @@ def test_validate_and_create_leaf_list_missing_item_type() -> None: def test_validate_and_create_leaf_list_empty_item_type() -> None: """_validate_and_create_leaf should return None for empty item_type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "list", "source": "tags", "item_type": ""} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) @@ -779,7 +779,7 @@ def test_validate_and_create_leaf_list_empty_item_type() -> None: def test_validate_and_create_leaf_list_invalid_item_type() -> None: """_validate_and_create_leaf should return None for invalid item_type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "list", "source": "tags", "item_type": "list"} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) @@ -792,7 +792,7 @@ def test_validate_and_create_leaf_list_invalid_item_type() -> None: def test_validate_and_create_leaf_list_unknown_item_type() -> None: """_validate_and_create_leaf should return None for unknown item_type.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] value_dict = {"type": "list", "source": "tags", "item_type": "unknown"} leaf = validate_and_create_leaf(value_dict, ("ServicePayload",), "Tags", problems) @@ -804,7 +804,7 @@ def test_validate_and_create_leaf_list_unknown_item_type() -> None: def test_validate_and_create_leaf_all_primitive_types() -> None: """_validate_and_create_leaf should work with all primitive types.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] for type_name, expected_type in [ ("str", str), @@ -1166,8 +1166,8 @@ def test_get_current_working_directory_changes_with_cwd( def test_create_empty_compiled_schema_with_problems() -> None: """_create_empty_compiled_schema_with_problems creates empty schema with problems.""" # noqa: E501 problems = [ - SchemaProblem("Problem 1"), - SchemaProblem("Problem 2"), + _SchemaProblem("Problem 1"), + _SchemaProblem("Problem 2"), ] compiled, result_problems = create_empty_schema(problems) @@ -1181,7 +1181,7 @@ def test_create_empty_compiled_schema_with_problems() -> None: def test_create_empty_compiled_schema_with_empty_problems() -> None: """_create_empty_compiled_schema_with_problems works with empty problems list.""" # noqa: E501 - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] compiled, result_problems = create_empty_schema(problems) @@ -1262,7 +1262,7 @@ def test_load_raw_schema_raises_value_error_for_non_object( def test_compile_schema_tree_compiles_simple_tree() -> None: """_compile_schema_tree should compile a simple schema tree into leaves.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] node = { "ServicePayload": { "RequestID": {"type": "str", "source": "request_id"}, @@ -1281,7 +1281,7 @@ def test_compile_schema_tree_compiles_simple_tree() -> None: def test_compile_schema_tree_handles_nested_structure() -> None: """_compile_schema_tree should handle deeply nested structures.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] node = { "Level1": { "Level2": { @@ -1302,7 +1302,7 @@ def test_compile_schema_tree_handles_nested_structure() -> None: def test_compile_schema_tree_reports_invalid_nodes() -> None: """_compile_schema_tree should report problems for invalid nodes.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] node = { "Valid": { "Leaf": {"type": "str", "source": "valid"}, @@ -1320,7 +1320,7 @@ def test_compile_schema_tree_reports_invalid_nodes() -> None: def test_compile_schema_tree_respects_max_depth() -> None: """_compile_schema_tree should stop processing when max depth is exceeded.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] # Create a path that exceeds MAX_SCHEMA_DEPTH node = {} current = node @@ -1402,7 +1402,7 @@ def test_get_builtin_logrecord_attributes_is_cached() -> None: def test_check_root_conflicts_reports_conflicts() -> None: """_check_root_conflicts should report conflicts with reserved logging fields.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] schema_dict = { "name": { "Value": {"type": "str", "source": "value"}, @@ -1420,7 +1420,7 @@ def test_check_root_conflicts_reports_conflicts() -> None: def test_check_root_conflicts_no_conflicts() -> None: """_check_root_conflicts should not report problems when no conflicts exist.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] schema_dict = { "ServicePayload": { "RequestID": {"type": "str", "source": "request_id"}, @@ -1437,7 +1437,7 @@ def test_check_root_conflicts_no_conflicts() -> None: def test_check_root_conflicts_empty_schema() -> None: """_check_root_conflicts should handle empty schema.""" - problems: list[SchemaProblem] = [] + problems: list[_SchemaProblem] = [] schema_dict: dict[str, Any] = {} check_root_conflicts(schema_dict, problems) diff --git a/tests/test_schema_logger_mimic.py b/tests/test_schema_logger_mimic.py index df7b65d..b6b1133 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test_schema_logger_mimic.py @@ -257,7 +257,7 @@ def test_schema_logger_handles_oserror_from_getcwd_and_terminates( """SchemaLogger should catch OSError from os.getcwd() and terminate. If os.getcwd() raises OSError (e.g., when CWD is deleted), the exception - should be converted to SchemaProblem, logger should be cleaned up from cache, + should be converted to _SchemaProblem, logger should be cleaned up from cache, and application should be terminated. """ import os @@ -322,7 +322,7 @@ def test_schema_logger_handles_runtimeerror_from_lock_and_terminates( """SchemaLogger should catch RuntimeError from threading locks and terminate. If a threading lock raises RuntimeError (e.g., deadlock detection), - the exception should be converted to SchemaProblem, logger should be cleaned + the exception should be converted to _SchemaProblem, logger should be cleaned up from cache, and application should be terminated. """ import os @@ -393,7 +393,7 @@ def test_schema_logger_handles_valueerror_and_terminates( If ValueError is raised during schema compilation (outside of the try-except block in _compile_schema_internal), the exception should be - converted to SchemaProblem, logger should be cleaned up from cache, + converted to _SchemaProblem, logger should be cleaned up from cache, and application should be terminated. """ import os From c5a1a9b320166ec18cc24bf80565279443cc3b88 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:53:18 +0400 Subject: [PATCH 08/26] refactor(schema): rename DataProblem to _DataProblem for internal use - Updated the logging schema components to use the new internal class name _DataProblem, reflecting its non-public API status. - Adjusted type hints and references throughout the codebase to ensure consistency with the new naming convention. - Modified related test cases to accommodate the changes in class naming. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/errors.py | 6 ++- .../schema_applier.py | 38 +++++++++---------- .../schema_logger.py | 4 +- tests/test_formatter.py | 18 ++++----- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/src/logging_objects_with_schema/errors.py b/src/logging_objects_with_schema/errors.py index 0a16415..b91624e 100644 --- a/src/logging_objects_with_schema/errors.py +++ b/src/logging_objects_with_schema/errors.py @@ -40,9 +40,13 @@ class _SchemaProblem: @dataclass -class DataProblem: +class _DataProblem: """Describes a single problem encountered while validating log data. + This class is part of the internal implementation and is not considered + a public API. Its signature and behaviour may change between releases + without preserving backward compatibility. + This class is used to report validation errors when applying the compiled schema to user-provided ``extra`` fields during logging. Unlike :class:`_SchemaProblem`, data problems are not fatal: they are collected diff --git a/src/logging_objects_with_schema/schema_applier.py b/src/logging_objects_with_schema/schema_applier.py index 875b3cc..2f30e86 100644 --- a/src/logging_objects_with_schema/schema_applier.py +++ b/src/logging_objects_with_schema/schema_applier.py @@ -11,7 +11,7 @@ from collections.abc import Mapping, MutableMapping from typing import Any -from .errors import DataProblem +from .errors import _DataProblem from .schema_loader import _CompiledSchema, _SchemaLeaf @@ -45,7 +45,7 @@ def _validate_list_value( value: list, source: str, item_expected_type: type | None, -) -> DataProblem | None: +) -> _DataProblem | None: """Validate that a list value matches the expected item type. Validates that all elements in the list have the exact type declared by @@ -58,11 +58,11 @@ def _validate_list_value( for list-typed leaves. Returns: - DataProblem if validation fails, None if validation succeeds. + _DataProblem if validation fails, None if validation succeeds. """ if item_expected_type is None: error_msg = "is a list but has no item type configured" - return DataProblem(_create_validation_error_json(source, error_msg, value)) + return _DataProblem(_create_validation_error_json(source, error_msg, value)) if len(value) == 0: # Empty lists are always valid @@ -83,7 +83,7 @@ def _validate_list_value( f"expected all elements to be of type " f"{item_expected_type.__name__}" ) - return DataProblem(_create_validation_error_json(source, error_msg, value)) + return _DataProblem(_create_validation_error_json(source, error_msg, value)) return None @@ -128,7 +128,7 @@ def _validate_and_apply_leaf( value: Any, source: str, extra: MutableMapping[str, Any], - problems: list[DataProblem], + problems: list[_DataProblem], ) -> None: """Validate a value against a schema leaf and apply it if valid. @@ -152,7 +152,7 @@ def _validate_and_apply_leaf( f"expected {leaf.expected_type.__name__}" ) problems.append( - DataProblem(_create_validation_error_json(source, error_msg, value)) + _DataProblem(_create_validation_error_json(source, error_msg, value)) ) return @@ -205,7 +205,7 @@ def _strip_empty(node: Any) -> Any: def _apply_schema_internal( compiled: _CompiledSchema, extra_values: Mapping[str, Any], -) -> tuple[dict[str, Any], list[DataProblem]]: +) -> tuple[dict[str, Any], list[_DataProblem]]: """Internal function to build structured ``extra`` from compiled schema. The function applies a :class:`_CompiledSchema` to user-provided ``extra`` @@ -213,17 +213,17 @@ def _apply_schema_internal( - ``structured_extra`` is a nested dictionary that follows the schema structure and contains only fields that passed validation; - - ``problems`` is a list of :class:`DataProblem` describing all data + - ``problems`` is a list of :class:`_DataProblem` describing all data issues observed during processing. Behaviour summary: - If the compiled schema is effectively empty (no valid leaves), all fields from ``extra_values`` are treated as redundant: the returned - payload is empty, and a :class:`DataProblem` is created for each field. + payload is empty, and a :class:`_DataProblem` is created for each field. - For each ``source`` mentioned in the schema when there are valid leaves: - if the source is missing from ``extra_values``, it is silently skipped; - - if the corresponding value is ``None``, a ``DataProblem`` is recorded + - if the corresponding value is ``None``, a ``_DataProblem`` is recorded and the value is not written to the payload. - Type checks are strict: the runtime type must exactly match the declared Python type (``type(value) is leaf.expected_type``). This prevents @@ -233,17 +233,17 @@ def _apply_schema_internal( - all elements must have the exact type declared by the leaf ``item_expected_type`` (for example, list[str], list[int]); - non-primitive elements and elements of a different primitive type are - rejected with a ``DataProblem`` and the list value is not written. + rejected with a ``_DataProblem`` and the list value is not written. - Redundant fields from ``extra_values`` (not referenced by any leaf ``source``) are always reported as problems: each such field generates - a :class:`DataProblem` indicating that it is not defined in the schema. + a :class:`_DataProblem` indicating that it is not defined in the schema. - A single ``source`` may be used by multiple leaves. The value is validated independently for each leaf and written only to locations - where the type matches; mismatched locations produce ``DataProblem`` + where the type matches; mismatched locations produce ``_DataProblem`` entries, but do not affect successful locations. The function itself does not raise exceptions; it only accumulates - :class:`DataProblem` instances for the caller to handle. + :class:`_DataProblem` instances for the caller to handle. Performance considerations: Time complexity is O(n + m) where n is the number of leaves in the @@ -269,10 +269,10 @@ def _apply_schema_internal( change between releases without preserving backward compatibility. Returns: - Tuple of (structured_extra, list[DataProblem]). + Tuple of (structured_extra, list[_DataProblem]). """ extra: dict[str, Any] = {} - problems: list[DataProblem] = [] + problems: list[_DataProblem] = [] # Group leaves by source field name. This is necessary because a single source # can be referenced by multiple leaves (allowing the same value to appear in @@ -304,7 +304,7 @@ def _apply_schema_internal( if value is None: error_msg = "is None" problems.append( - DataProblem(_create_validation_error_json(source, error_msg, None)) + _DataProblem(_create_validation_error_json(source, error_msg, None)) ) continue @@ -329,7 +329,7 @@ def _apply_schema_internal( for key in redundant_keys: error_msg = "is not defined in schema" problems.append( - DataProblem( + _DataProblem( _create_validation_error_json(key, error_msg, extra_values[key]) ) ) diff --git a/src/logging_objects_with_schema/schema_logger.py b/src/logging_objects_with_schema/schema_logger.py index 2d7dd2e..234561d 100644 --- a/src/logging_objects_with_schema/schema_logger.py +++ b/src/logging_objects_with_schema/schema_logger.py @@ -203,7 +203,7 @@ def _log( stack_info=False, stacklevel=stacklevel + 1 ) # Format error message as JSON for machine processing. - # Each DataProblem.message is already a JSON string (created by + # Each _DataProblem.message is already a JSON string (created by # _create_validation_error_json) with structure: # {"field": "...", "error": "...", "value": "..."} # We parse them back to dicts and combine into a single JSON object @@ -226,7 +226,7 @@ def _log( # create a fallback error object. This should never happen in # normal operation since problem.message is always created via # _create_validation_error_json, but protects against unexpected - # data corruption or manual DataProblem creation. The fallback + # data corruption or manual _DataProblem creation. The fallback # preserves the same structure (field, error, value) for # consistency. validation_errors.append( diff --git a/tests/test_formatter.py b/tests/test_formatter.py index d859f1d..5c66e84 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -6,7 +6,7 @@ from __future__ import annotations -from logging_objects_with_schema.errors import DataProblem +from logging_objects_with_schema.errors import _DataProblem from logging_objects_with_schema.schema_applier import ( _apply_schema_internal as apply_schema_internal, ) @@ -656,33 +656,33 @@ def test_validate_list_value_valid_items_returns_none() -> None: def test_validate_list_value_mixed_types_returns_problem() -> None: - """validate_list_value should return DataProblem for mixed types.""" + """validate_list_value should return _DataProblem for mixed types.""" result = validate_list_value([1, "two", 3], "test_field", int) - assert isinstance(result, DataProblem) + assert isinstance(result, _DataProblem) assert "test_field" in result.message assert "str" in result.message assert "expected all elements to be of type int" in result.message def test_validate_list_value_none_item_type_returns_problem() -> None: - """validate_list_value should return DataProblem when item_expected_type is None.""" + """validate_list_value should return _DataProblem when item_expected_type is None.""" # noqa: E501 result = validate_list_value([1, 2, 3], "test_field", None) - assert isinstance(result, DataProblem) + assert isinstance(result, _DataProblem) assert "test_field" in result.message assert "no item type configured" in result.message def test_validate_list_value_nested_list_returns_problem() -> None: - """validate_list_value should return DataProblem for nested lists.""" + """validate_list_value should return _DataProblem for nested lists.""" result = validate_list_value([1, [2, 3], 4], "test_field", int) - assert isinstance(result, DataProblem) + assert isinstance(result, _DataProblem) assert "list" in result.message.lower() def test_validate_list_value_dict_in_list_returns_problem() -> None: - """validate_list_value should return DataProblem for dicts in list.""" + """validate_list_value should return _DataProblem for dicts in list.""" result = validate_list_value([1, {"key": "value"}, 3], "test_field", int) - assert isinstance(result, DataProblem) + assert isinstance(result, _DataProblem) assert "dict" in result.message.lower() From 4887cd7cedc6f1ec26873f67d4983700bd0dfbb0 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:56:11 +0400 Subject: [PATCH 09/26] refactor(schema): rename get_builtin_logrecord_attributes to _get_builtin_logrecord_attributes for internal use - Updated the function name to reflect its non-public API status, ensuring clarity in its intended usage. - Adjusted all references and test cases to use the new function name, maintaining consistency across the codebase. Signed-off-by: Dmitrii Safronov --- .../schema_loader.py | 10 +++-- tests/test_schema_loader.py | 38 +++++++++---------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 88951d1..7a690ec 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -561,9 +561,13 @@ def _compile_schema_tree( @functools.lru_cache(maxsize=1) -def get_builtin_logrecord_attributes() -> set[str]: +def _get_builtin_logrecord_attributes() -> set[str]: """Get set of standard LogRecord attribute names. + This function is part of the internal implementation and is not considered + a public API. Its signature and behaviour may change between releases + without preserving backward compatibility. + This function extracts attribute names from LogRecord that represent system fields and should not be used as root keys in the schema or treated as user-provided extra fields. @@ -582,7 +586,7 @@ def get_builtin_logrecord_attributes() -> set[str]: Examples include: 'name', 'levelno', 'pathname', 'lineno', 'msg', etc. Example: - >>> forbidden = get_builtin_logrecord_attributes() + >>> forbidden = _get_builtin_logrecord_attributes() >>> "name" in forbidden True >>> "ServicePayload" in forbidden @@ -621,7 +625,7 @@ def _check_root_conflicts( ) -> None: """Check schema root keys for conflicts with reserved logging fields.""" - forbidden_root_keys = get_builtin_logrecord_attributes() + forbidden_root_keys = _get_builtin_logrecord_attributes() for key in schema_dict.keys(): if key in forbidden_root_keys: diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index 031ba75..6ea28e7 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -46,6 +46,9 @@ _create_empty_compiled_schema_with_problems as create_empty_schema, ) from logging_objects_with_schema.schema_loader import _format_path as format_path +from logging_objects_with_schema.schema_loader import ( + _get_builtin_logrecord_attributes, +) from logging_objects_with_schema.schema_loader import ( _get_current_working_directory as get_current_working_directory, ) @@ -65,9 +68,6 @@ from logging_objects_with_schema.schema_loader import ( _validate_and_create_leaf as validate_and_create_leaf, ) -from logging_objects_with_schema.schema_loader import ( - get_builtin_logrecord_attributes, -) def test_missing_schema_file_produces_empty_schema_and_problem( @@ -1338,18 +1338,18 @@ def test_compile_schema_tree_respects_max_depth() -> None: assert any("exceeds maximum allowed depth" in p.message for p in problems) -def test_get_builtin_logrecord_attributes_returns_set() -> None: - """get_builtin_logrecord_attributes should return a set of attribute names.""" - attributes = get_builtin_logrecord_attributes() +def test__get_builtin_logrecord_attributes_returns_set() -> None: + """_get_builtin_logrecord_attributes should return a set of attribute names.""" + attributes = _get_builtin_logrecord_attributes() assert isinstance(attributes, set) assert len(attributes) > 0 assert all(isinstance(attr, str) for attr in attributes) -def test_get_builtin_logrecord_attributes_includes_common_fields() -> None: - """get_builtin_logrecord_attributes should include common LogRecord fields.""" - attributes = get_builtin_logrecord_attributes() +def test__get_builtin_logrecord_attributes_includes_common_fields() -> None: + """_get_builtin_logrecord_attributes should include common LogRecord fields.""" + attributes = _get_builtin_logrecord_attributes() # These are standard LogRecord attributes assert "name" in attributes @@ -1359,18 +1359,18 @@ def test_get_builtin_logrecord_attributes_includes_common_fields() -> None: assert "msg" in attributes -def test_get_builtin_logrecord_attributes_excludes_private_attributes() -> None: - """get_builtin_logrecord_attributes should not include private attributes.""" - attributes = get_builtin_logrecord_attributes() +def test__get_builtin_logrecord_attributes_excludes_private_attributes() -> None: + """_get_builtin_logrecord_attributes should not include private attributes.""" + attributes = _get_builtin_logrecord_attributes() # Should not include private attributes (starting with _) private_attrs = {attr for attr in attributes if attr.startswith("_")} assert not private_attrs -def test_get_builtin_logrecord_attributes_excludes_methods() -> None: - """get_builtin_logrecord_attributes should not include callable methods.""" - attributes = get_builtin_logrecord_attributes() +def test__get_builtin_logrecord_attributes_excludes_methods() -> None: + """_get_builtin_logrecord_attributes should not include callable methods.""" + attributes = _get_builtin_logrecord_attributes() # Should not include methods (callable attributes) import logging @@ -1391,10 +1391,10 @@ def test_get_builtin_logrecord_attributes_excludes_methods() -> None: assert not callable(value), f"Attribute {attr} should not be callable" -def test_get_builtin_logrecord_attributes_is_cached() -> None: - """get_builtin_logrecord_attributes is cached (same result on multiple calls).""" # noqa: E501 - attrs1 = get_builtin_logrecord_attributes() - attrs2 = get_builtin_logrecord_attributes() +def test__get_builtin_logrecord_attributes_is_cached() -> None: + """_get_builtin_logrecord_attributes is cached (same result on multiple calls).""" # noqa: E501 + attrs1 = _get_builtin_logrecord_attributes() + attrs2 = _get_builtin_logrecord_attributes() # Should return the same set (cached) assert attrs1 is attrs2 From 6c3efea117585def24a0958c636983743cacbb71 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 17:59:25 +0400 Subject: [PATCH 10/26] refactor(schema): rename SCHEMA_FILE_NAME to _SCHEMA_FILE_NAME for internal use - Updated the schema loader and related components to use the new internal variable name _SCHEMA_FILE_NAME, reflecting its non-public API status. - Adjusted all references in the codebase, including tests, to ensure consistency with the new naming convention. Signed-off-by: Dmitrii Safronov --- .../schema_loader.py | 8 ++--- tests/conftest.py | 4 +-- tests/test_formatter_basic.py | 8 ++--- tests/test_integration.py | 4 +-- tests/test_schema_loader.py | 34 +++++++++---------- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 7a690ec..732b716 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -20,7 +20,7 @@ from .errors import _SchemaProblem -SCHEMA_FILE_NAME = "logging_objects_with_schema.json" +_SCHEMA_FILE_NAME = "logging_objects_with_schema.json" # Maximum allowed depth for schema nesting (protection against DoS) MAX_SCHEMA_DEPTH = 100 @@ -143,7 +143,7 @@ def _find_schema_file() -> Path | None: current = start_path while True: - schema_path = current / SCHEMA_FILE_NAME + schema_path = current / _SCHEMA_FILE_NAME if schema_path.exists(): # Use resolve() to get an absolute path and resolve any symbolic links. # This ensures we have a canonical path that can be used as a cache key @@ -247,7 +247,7 @@ def _cache_and_return_missing_path() -> Path: global _resolved_schema_path, _cached_cwd current_cwd = _get_current_working_directory() - schema_path = (current_cwd / SCHEMA_FILE_NAME).resolve() + schema_path = (current_cwd / _SCHEMA_FILE_NAME).resolve() _resolved_schema_path = schema_path _cached_cwd = current_cwd # Track CWD since path depends on it return schema_path @@ -256,7 +256,7 @@ def _cache_and_return_missing_path() -> Path: def _get_schema_path() -> Path: """Resolve the absolute path to the JSON schema file with caching semantics. - The function searches for ``SCHEMA_FILE_NAME`` by walking upward from the + The function searches for ``_SCHEMA_FILE_NAME`` by walking upward from the current working directory and caches the result: - When the schema file is found, its absolute path is cached as diff --git a/tests/conftest.py b/tests/conftest.py index b0b46b3..5c1c830 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ import pytest from logging_objects_with_schema import schema_loader -from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME +from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME @pytest.fixture(autouse=True) @@ -30,5 +30,5 @@ def _write_schema(tmp_path: Path, data: dict) -> None: tmp_path: Temporary directory path. data: Schema data to write as JSON. """ - schema_path = tmp_path / SCHEMA_FILE_NAME + schema_path = tmp_path / _SCHEMA_FILE_NAME schema_path.write_text(json.dumps(data), encoding="utf-8") diff --git a/tests/test_formatter_basic.py b/tests/test_formatter_basic.py index 5f6ae10..5555e9a 100644 --- a/tests/test_formatter_basic.py +++ b/tests/test_formatter_basic.py @@ -10,7 +10,7 @@ import pytest from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME +from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME def _configure_schema_logger(stream: StringIO) -> SchemaLogger: @@ -32,7 +32,7 @@ def test_schema_logger_type_mismatch_logs_error_after_logging( monkeypatch.chdir(tmp_path) - schema_path = tmp_path / SCHEMA_FILE_NAME + schema_path = tmp_path / _SCHEMA_FILE_NAME schema_path.write_text( json.dumps( { @@ -75,7 +75,7 @@ def test_schema_logger_valid_data_appears_in_log( monkeypatch.chdir(tmp_path) - schema_path = tmp_path / SCHEMA_FILE_NAME + schema_path = tmp_path / _SCHEMA_FILE_NAME schema_path.write_text( json.dumps( { @@ -115,7 +115,7 @@ def test_validation_error_record_has_function_name( monkeypatch.chdir(tmp_path) - schema_path = tmp_path / SCHEMA_FILE_NAME + schema_path = tmp_path / _SCHEMA_FILE_NAME schema_path.write_text( json.dumps( { diff --git a/tests/test_integration.py b/tests/test_integration.py index f5fe90f..ba2c8f7 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -14,7 +14,7 @@ from conftest import _write_schema from logging_objects_with_schema import SchemaLogger -from logging_objects_with_schema.schema_loader import SCHEMA_FILE_NAME +from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME def test_multiple_logger_instances_share_schema( @@ -371,7 +371,7 @@ def test_schema_file_permission_error_terminates_application( }, ) - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME original_open = schema_loader.Path.open # type: ignore[attr-defined] def fake_open(self, *args, **kwargs): # type: ignore[override] diff --git a/tests/test_schema_loader.py b/tests/test_schema_loader.py index 6ea28e7..bde7415 100644 --- a/tests/test_schema_loader.py +++ b/tests/test_schema_loader.py @@ -15,8 +15,8 @@ import logging_objects_with_schema.schema_loader as schema_loader from logging_objects_with_schema.errors import _SchemaProblem from logging_objects_with_schema.schema_loader import ( + _SCHEMA_FILE_NAME, MAX_SCHEMA_DEPTH, - SCHEMA_FILE_NAME, ) from logging_objects_with_schema.schema_loader import ( _cache_and_return_found_path as cache_and_return_found_path, @@ -434,7 +434,7 @@ def test_find_schema_file_searches_upward( # Should find schema file in root found_path = _find_schema_file() assert found_path is not None - assert found_path == (root_dir / SCHEMA_FILE_NAME).resolve() + assert found_path == (root_dir / _SCHEMA_FILE_NAME).resolve() assert found_path.exists() @@ -477,7 +477,7 @@ def test_schema_file_os_error_produces_problem_and_empty_schema( }, ) - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME original_open = schema_loader.Path.open # type: ignore[attr-defined] def fake_open(self, *args, **kwargs): # type: ignore[override] @@ -906,7 +906,7 @@ def test_get_schema_path_cached_file_deleted_re_searches( # Second call - should re-search and return path in current directory path2 = get_schema_path() - assert path2 == (tmp_path / SCHEMA_FILE_NAME).resolve() + assert path2 == (tmp_path / _SCHEMA_FILE_NAME).resolve() assert not path2.exists() @@ -925,13 +925,13 @@ def test_get_schema_path_cwd_change_invalidates_cache_when_file_not_found( # Start in dir1 (no schema file) monkeypatch.chdir(dir1) path1 = get_schema_path() - assert path1 == (dir1 / SCHEMA_FILE_NAME).resolve() + assert path1 == (dir1 / _SCHEMA_FILE_NAME).resolve() assert not path1.exists() # Change to dir2 (still no schema file, but different path expected) monkeypatch.chdir(dir2) path2 = get_schema_path() - assert path2 == (dir2 / SCHEMA_FILE_NAME).resolve() + assert path2 == (dir2 / _SCHEMA_FILE_NAME).resolve() assert path2 != path1 @@ -955,7 +955,7 @@ def test_get_schema_path_cwd_change_preserves_cache_when_file_found( monkeypatch.chdir(sub_dir) path1 = get_schema_path() assert path1.exists() - assert path1 == (tmp_path / SCHEMA_FILE_NAME).resolve() + assert path1 == (tmp_path / _SCHEMA_FILE_NAME).resolve() # Change to another subdirectory sub_dir2 = tmp_path / "subdir2" @@ -977,7 +977,7 @@ def test_check_cached_found_file_path_returns_path_when_exists( monkeypatch.chdir(tmp_path) # Create schema file - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME _write_schema( tmp_path, {"ServicePayload": {"RequestID": {"type": "str", "source": "request_id"}}}, @@ -1003,7 +1003,7 @@ def test_check_cached_found_file_path_returns_none_when_file_deleted( monkeypatch.chdir(tmp_path) # Create and cache schema file - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME _write_schema( tmp_path, {"ServicePayload": {"RequestID": {"type": "str", "source": "request_id"}}}, @@ -1040,7 +1040,7 @@ def test_check_cached_missing_file_path_returns_path_when_cwd_unchanged( monkeypatch.chdir(tmp_path) - expected_path = (tmp_path / SCHEMA_FILE_NAME).resolve() + expected_path = (tmp_path / _SCHEMA_FILE_NAME).resolve() with schema_loader._path_cache_lock: schema_loader._resolved_schema_path = expected_path @@ -1064,7 +1064,7 @@ def test_check_cached_missing_file_path_returns_none_when_cwd_changed( monkeypatch.chdir(dir1) with schema_loader._path_cache_lock: - schema_loader._resolved_schema_path = (dir1 / SCHEMA_FILE_NAME).resolve() + schema_loader._resolved_schema_path = (dir1 / _SCHEMA_FILE_NAME).resolve() schema_loader._cached_cwd = dir1.resolve() # Change CWD @@ -1096,7 +1096,7 @@ def test_cache_and_return_found_path_caches_path( monkeypatch.chdir(tmp_path) - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME _write_schema( tmp_path, {"ServicePayload": {"RequestID": {"type": "str", "source": "request_id"}}}, @@ -1118,7 +1118,7 @@ def test_cache_and_return_missing_path_caches_path( monkeypatch.chdir(tmp_path) - expected_path = (tmp_path / SCHEMA_FILE_NAME).resolve() + expected_path = (tmp_path / _SCHEMA_FILE_NAME).resolve() with schema_loader._path_cache_lock: result = cache_and_return_missing_path() @@ -1208,7 +1208,7 @@ def test_load_raw_schema_loads_valid_schema( assert isinstance(data, dict) assert data == schema_data - assert returned_schema_path == (tmp_path / SCHEMA_FILE_NAME).resolve() + assert returned_schema_path == (tmp_path / _SCHEMA_FILE_NAME).resolve() assert returned_schema_path.exists() @@ -1224,7 +1224,7 @@ def test_load_raw_schema_raises_file_not_found_when_missing( load_raw_schema(schema_path) assert "Schema file not found" in str(exc_info.value) - assert SCHEMA_FILE_NAME in str(exc_info.value) + assert _SCHEMA_FILE_NAME in str(exc_info.value) def test_load_raw_schema_raises_value_error_for_invalid_json( @@ -1233,7 +1233,7 @@ def test_load_raw_schema_raises_value_error_for_invalid_json( ) -> None: """_load_raw_schema should raise ValueError for invalid JSON.""" monkeypatch.chdir(tmp_path) - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME schema_file.write_text("{ invalid json }", encoding="utf-8") schema_path = get_schema_path() @@ -1249,7 +1249,7 @@ def test_load_raw_schema_raises_value_error_for_non_object( ) -> None: """_load_raw_schema should raise ValueError when top-level is not an object.""" monkeypatch.chdir(tmp_path) - schema_file = tmp_path / SCHEMA_FILE_NAME + schema_file = tmp_path / _SCHEMA_FILE_NAME # Write a JSON array instead of an object schema_file.write_text('["not", "an", "object"]', encoding="utf-8") From 64c568bb9279602c90575af94af3c6097be00ff6 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:07:42 +0400 Subject: [PATCH 11/26] test(schema_applier): add comprehensive tests for schema_applier functions - Introduced a new test suite for the schema_applier module, covering the functionality of apply_schema_internal and strip_empty functions. - Added tests for various scenarios including handling of empty dictionaries, nested structures, type mismatches, and validation of lists. - Ensured that all edge cases are addressed to improve the reliability of the schema application process. Signed-off-by: Dmitrii Safronov --- tests/{test_formatter.py => test_schema_applier.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_formatter.py => test_schema_applier.py} (100%) diff --git a/tests/test_formatter.py b/tests/test_schema_applier.py similarity index 100% rename from tests/test_formatter.py rename to tests/test_schema_applier.py From 2b103af001f1d29fb9df3b31d8f5f7f6eb6af919 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:17:49 +0400 Subject: [PATCH 12/26] test(errors): add comprehensive tests for _DataProblem and _SchemaProblem classes - Introduced a new test suite for the errors module, covering the functionality of _DataProblem and _SchemaProblem classes. - Added tests for message creation, equality, inequality, and validation of message formats including JSON structure and handling of None values. - Ensured thorough coverage of edge cases to enhance reliability and correctness of error handling. Signed-off-by: Dmitrii Safronov --- tests/test_errors.py | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/test_errors.py diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..c000f8f --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,139 @@ +"""Tests for errors module private classes.""" + +from __future__ import annotations + +import json + +from logging_objects_with_schema.errors import _DataProblem, _SchemaProblem + + +def test_schema_problem_creation() -> None: + """_SchemaProblem should be created with message attribute.""" + problem = _SchemaProblem("Test message") + assert problem.message == "Test message" + + +def test_schema_problem_with_empty_message() -> None: + """_SchemaProblem should accept empty message string.""" + problem = _SchemaProblem("") + assert problem.message == "" + + +def test_schema_problem_with_multiline_message() -> None: + """_SchemaProblem should accept multiline message.""" + message = "Line 1\nLine 2\nLine 3" + problem = _SchemaProblem(message) + assert problem.message == message + + +def test_schema_problem_equality() -> None: + """_SchemaProblem instances with same message should be equal.""" + problem1 = _SchemaProblem("Same message") + problem2 = _SchemaProblem("Same message") + assert problem1 == problem2 + + +def test_schema_problem_inequality() -> None: + """_SchemaProblem instances with different messages should not be equal.""" + problem1 = _SchemaProblem("Message 1") + problem2 = _SchemaProblem("Message 2") + assert problem1 != problem2 + + +def test_data_problem_creation() -> None: + """_DataProblem should be created with message attribute.""" + message = '{"field": "test", "error": "test error", "value": "test value"}' + problem = _DataProblem(message) + assert problem.message == message + + +def test_data_problem_message_is_valid_json() -> None: + """_DataProblem.message should be a valid JSON string.""" + message = '{"field": "test_field", "error": "test error", "value": "test value"}' + problem = _DataProblem(message) + + # Should not raise exception + parsed = json.loads(problem.message) + assert isinstance(parsed, dict) + assert "field" in parsed + assert "error" in parsed + assert "value" in parsed + + +def test_data_problem_message_structure() -> None: + """_DataProblem.message should have correct JSON structure.""" + message = '{"field": "user_id", "error": "expected int", "value": "abc"}' + problem = _DataProblem(message) + + parsed = json.loads(problem.message) + assert parsed["field"] == "user_id" + assert parsed["error"] == "expected int" + assert parsed["value"] == "abc" + + +def test_data_problem_equality() -> None: + """_DataProblem instances with same message should be equal.""" + message = '{"field": "test", "error": "error", "value": "value"}' + problem1 = _DataProblem(message) + problem2 = _DataProblem(message) + assert problem1 == problem2 + + +def test_data_problem_inequality() -> None: + """_DataProblem instances with different messages should not be equal.""" + message1 = '{"field": "field1", "error": "error1", "value": "value1"}' + message2 = '{"field": "field2", "error": "error2", "value": "value2"}' + problem1 = _DataProblem(message1) + problem2 = _DataProblem(message2) + assert problem1 != problem2 + + +def test_data_problem_with_repr_values() -> None: + """_DataProblem.message can contain repr() formatted values.""" + # This is how _create_validation_error_json creates messages + message = json.dumps( + { + "field": repr("user_id"), + "error": repr("expected int"), + "value": repr("abc-123"), + } + ) + problem = _DataProblem(message) + + parsed = json.loads(problem.message) + assert parsed["field"] == "'user_id'" + assert parsed["error"] == "'expected int'" + assert parsed["value"] == "'abc-123'" + + +def test_data_problem_with_none_value() -> None: + """_DataProblem.message can contain None value.""" + message = json.dumps( + { + "field": repr("user_id"), + "error": repr("is None"), + "value": repr(None), + } + ) + problem = _DataProblem(message) + + parsed = json.loads(problem.message) + assert parsed["value"] == "None" + + +def test_data_problem_with_complex_value() -> None: + """_DataProblem.message can contain complex repr() formatted values.""" + complex_value = {"nested": {"key": "value"}} + message = json.dumps( + { + "field": repr("tags"), + "error": repr("invalid type"), + "value": repr(complex_value), + } + ) + problem = _DataProblem(message) + + parsed = json.loads(problem.message) + # Value should be the repr() string representation + assert isinstance(parsed["value"], str) + assert "nested" in parsed["value"] From 81ba6ed41960dafcf824cc5b35345d1ec8e77746 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:18:04 +0400 Subject: [PATCH 13/26] test(schema_applier): add tests for _create_validation_error_json function - Introduced a new suite of tests for the _create_validation_error_json function, covering various data types including strings, integers, floats, booleans, None, dictionaries, and lists. - Ensured that the function correctly formats error messages into valid JSON, handles special characters and unicode, and wraps values in repr() as expected. - Enhanced test coverage to improve reliability and correctness of error handling in schema validation. Signed-off-by: Dmitrii Safronov --- tests/test_schema_applier.py | 147 +++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/test_schema_applier.py b/tests/test_schema_applier.py index 5c66e84..6d46a76 100644 --- a/tests/test_schema_applier.py +++ b/tests/test_schema_applier.py @@ -6,10 +6,15 @@ from __future__ import annotations +import json + from logging_objects_with_schema.errors import _DataProblem from logging_objects_with_schema.schema_applier import ( _apply_schema_internal as apply_schema_internal, ) +from logging_objects_with_schema.schema_applier import ( + _create_validation_error_json as create_validation_error_json, +) from logging_objects_with_schema.schema_applier import ( _set_nested_value as set_nested_value, ) @@ -822,3 +827,145 @@ def test_validate_and_apply_leaf_list_without_item_type() -> None: assert len(problems) == 1 assert "no item type configured" in problems[0].message assert extra == {} + + +# Tests for _create_validation_error_json helper function + + +def test_create_validation_error_json_with_string() -> None: + """_create_validation_error_json should create valid JSON with string value.""" + result = create_validation_error_json("user_id", "expected int", "abc-123") + + parsed = json.loads(result) + assert parsed["field"] == "'user_id'" + assert parsed["error"] == "'expected int'" + assert parsed["value"] == "'abc-123'" + + +def test_create_validation_error_json_with_int() -> None: + """_create_validation_error_json should use repr() for int values.""" + result = create_validation_error_json("count", "expected str", 42) + + parsed = json.loads(result) + assert parsed["field"] == "'count'" + assert parsed["error"] == "'expected str'" + assert parsed["value"] == "42" + + +def test_create_validation_error_json_with_float() -> None: + """_create_validation_error_json should use repr() for float values.""" + result = create_validation_error_json("price", "expected int", 3.14) + + parsed = json.loads(result) + assert parsed["field"] == "'price'" + assert parsed["error"] == "'expected int'" + assert parsed["value"] == "3.14" + + +def test_create_validation_error_json_with_bool() -> None: + """_create_validation_error_json should use repr() for bool values.""" + result = create_validation_error_json("is_active", "expected int", True) + + parsed = json.loads(result) + assert parsed["field"] == "'is_active'" + assert parsed["error"] == "'expected int'" + assert parsed["value"] == "True" + + +def test_create_validation_error_json_with_none() -> None: + """_create_validation_error_json should use repr() for None value.""" + result = create_validation_error_json("user_id", "is None", None) + + parsed = json.loads(result) + assert parsed["field"] == "'user_id'" + assert parsed["error"] == "'is None'" + assert parsed["value"] == "None" + + +def test_create_validation_error_json_with_dict() -> None: + """_create_validation_error_json should use repr() for dict values.""" + dict_value = {"key": "value", "nested": {"inner": 42}} + result = create_validation_error_json("tags", "invalid type", dict_value) + + parsed = json.loads(result) + assert parsed["field"] == "'tags'" + assert parsed["error"] == "'invalid type'" + # Value should be the repr() string representation + assert isinstance(parsed["value"], str) + assert "key" in parsed["value"] + assert "value" in parsed["value"] + + +def test_create_validation_error_json_with_list() -> None: + """_create_validation_error_json should use repr() for list values.""" + list_value = [1, 2, "three", {"key": "value"}] + result = create_validation_error_json("items", "invalid type", list_value) + + parsed = json.loads(result) + assert parsed["field"] == "'items'" + assert parsed["error"] == "'invalid type'" + # Value should be the repr() string representation + assert isinstance(parsed["value"], str) + assert "1" in parsed["value"] + assert "2" in parsed["value"] + + +def test_create_validation_error_json_returns_valid_json() -> None: + """_create_validation_error_json should return valid JSON string.""" + result = create_validation_error_json("field", "error", "value") + + # Should not raise exception + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert "field" in parsed + assert "error" in parsed + assert "value" in parsed + + +def test_create_validation_error_json_all_values_wrapped_in_repr() -> None: + """_create_validation_error_json should wrap all values in repr().""" + result = create_validation_error_json("test_field", "test error", "test value") + + parsed = json.loads(result) + # All values should be strings (wrapped in repr()) + assert isinstance(parsed["field"], str) + assert isinstance(parsed["error"], str) + assert isinstance(parsed["value"], str) + # Field and error should have quotes (repr() of strings) + assert parsed["field"].startswith("'") + assert parsed["field"].endswith("'") + assert parsed["error"].startswith("'") + assert parsed["error"].endswith("'") + + +def test_create_validation_error_json_with_special_characters() -> None: + """_create_validation_error_json should handle special characters via repr().""" + special_value = "value with\nnewline\tand\ttab" + result = create_validation_error_json("field", "error", special_value) + + parsed = json.loads(result) + # repr() should escape special characters + assert "\\n" in parsed["value"] or "\n" in parsed["value"] + assert "\\t" in parsed["value"] or "\t" in parsed["value"] + + +def test_create_validation_error_json_with_unicode() -> None: + """_create_validation_error_json should handle unicode characters via repr().""" + unicode_value = "тест 测试 🚀" + result = create_validation_error_json("field", "error", unicode_value) + + parsed = json.loads(result) + # Should be valid JSON with unicode preserved + assert ( + "тест" in parsed["value"] or "\\u0442\\u0435\\u0441\\u0442" in parsed["value"] + ) + + +def test_create_validation_error_json_with_empty_string() -> None: + """_create_validation_error_json should handle empty string value.""" + result = create_validation_error_json("field", "error", "") + + parsed = json.loads(result) + assert parsed["field"] == "'field'" + assert parsed["error"] == "'error'" + assert parsed["value"] == "''" From 899f8eb096c635dab586a18c480fa31229f0157e Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:18:23 +0400 Subject: [PATCH 14/26] test(schema_logger): add tests for _log_schema_problems_and_exit function - Introduced a new test suite for the _log_schema_problems_and_exit function, covering various scenarios including handling multiple problems, single problems, and empty problem lists. - Verified that the function correctly writes formatted messages to stderr, calls os._exit(1) as expected, and flushes stderr after writing. - Enhanced test coverage to ensure reliability and correctness in logging schema problems. Signed-off-by: Dmitrii Safronov --- tests/test_schema_logger.py | 104 ++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 tests/test_schema_logger.py diff --git a/tests/test_schema_logger.py b/tests/test_schema_logger.py new file mode 100644 index 0000000..69b4e2a --- /dev/null +++ b/tests/test_schema_logger.py @@ -0,0 +1,104 @@ +"""Tests for schema_logger module private functions.""" + +from __future__ import annotations + +from io import StringIO +from unittest.mock import MagicMock, patch + +from logging_objects_with_schema.errors import _SchemaProblem +from logging_objects_with_schema.schema_logger import _log_schema_problems_and_exit + + +def test_log_schema_problems_and_exit_writes_to_stderr() -> None: + """_log_schema_problems_and_exit should write formatted message to stderr.""" + problems = [ + _SchemaProblem("Problem 1"), + _SchemaProblem("Problem 2"), + ] + + stderr_capture = StringIO() + + with patch("sys.stderr", stderr_capture), patch("os._exit") as mock_exit: + _log_schema_problems_and_exit(problems) + + output = stderr_capture.getvalue() + assert "Schema has problems:" in output + assert "Problem 1" in output + assert "Problem 2" in output + assert output.endswith("\n") + mock_exit.assert_called_once_with(1) + + +def test_log_schema_problems_and_exit_calls_os_exit() -> None: + """_log_schema_problems_and_exit should call os._exit(1).""" + problems = [_SchemaProblem("Test problem")] + + with patch("os._exit") as mock_exit, patch("sys.stderr"): + _log_schema_problems_and_exit(problems) + + mock_exit.assert_called_once_with(1) + + +def test_log_schema_problems_and_exit_with_single_problem() -> None: + """_log_schema_problems_and_exit should format single problem correctly.""" + problems = [_SchemaProblem("Single problem message")] + + stderr_capture = StringIO() + + with patch("sys.stderr", stderr_capture), patch("os._exit"): + _log_schema_problems_and_exit(problems) + + output = stderr_capture.getvalue() + assert "Schema has problems:" in output + assert "Single problem message" in output + assert output.endswith("\n") + + +def test_log_schema_problems_and_exit_with_multiple_problems() -> None: + """_log_schema_problems_and_exit should format multiple problems correctly.""" + problems = [ + _SchemaProblem("First problem"), + _SchemaProblem("Second problem"), + _SchemaProblem("Third problem"), + ] + + stderr_capture = StringIO() + + with patch("sys.stderr", stderr_capture), patch("os._exit"): + _log_schema_problems_and_exit(problems) + + output = stderr_capture.getvalue() + assert "Schema has problems:" in output + assert "First problem" in output + assert "Second problem" in output + assert "Third problem" in output + assert ";" in output # Problems should be separated by semicolon + + +def test_log_schema_problems_and_exit_with_empty_list() -> None: + """_log_schema_problems_and_exit should handle empty problems list.""" + problems: list[_SchemaProblem] = [] + + stderr_capture = StringIO() + + with patch("sys.stderr", stderr_capture), patch("os._exit") as mock_exit: + _log_schema_problems_and_exit(problems) + + output = stderr_capture.getvalue() + assert "Schema has problems:" in output + mock_exit.assert_called_once_with(1) + + +def test_log_schema_problems_and_exit_flushes_stderr() -> None: + """_log_schema_problems_and_exit should flush stderr after writing.""" + problems = [_SchemaProblem("Test problem")] + + mock_stderr = MagicMock() + + with patch("sys.stderr", mock_stderr), patch("os._exit"): + _log_schema_problems_and_exit(problems) + + # Verify flush was called + mock_stderr.flush.assert_called_once() + # Verify write was called + mock_stderr.write.assert_called_once() From cf783df8518b87f25a8c1359cd0e63c990409358 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:28:50 +0400 Subject: [PATCH 15/26] test: rename tests for public API Signed-off-by: Dmitrii Safronov --- tests/{test_formatter_basic.py => test__formatter_basic.py} | 0 tests/{test_integration.py => test__integration.py} | 0 ...test_schema_logger_mimic.py => test__schema_logger_mimic.py} | 2 +- tests/{test_thread_safety.py => test__thread_safety.py} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename tests/{test_formatter_basic.py => test__formatter_basic.py} (100%) rename tests/{test_integration.py => test__integration.py} (100%) rename tests/{test_schema_logger_mimic.py => test__schema_logger_mimic.py} (99%) rename tests/{test_thread_safety.py => test__thread_safety.py} (100%) diff --git a/tests/test_formatter_basic.py b/tests/test__formatter_basic.py similarity index 100% rename from tests/test_formatter_basic.py rename to tests/test__formatter_basic.py diff --git a/tests/test_integration.py b/tests/test__integration.py similarity index 100% rename from tests/test_integration.py rename to tests/test__integration.py diff --git a/tests/test_schema_logger_mimic.py b/tests/test__schema_logger_mimic.py similarity index 99% rename from tests/test_schema_logger_mimic.py rename to tests/test__schema_logger_mimic.py index b6b1133..3533f24 100644 --- a/tests/test_schema_logger_mimic.py +++ b/tests/test__schema_logger_mimic.py @@ -74,7 +74,7 @@ def test_schema_logger_reports_correct_caller_location( output = stream.getvalue().strip() assert "hello from test" in output # The reported pathname should point to this test module, not schema_logger.py. - assert output.split(":", 1)[0].endswith("test_schema_logger_mimic.py") + assert output.split(":", 1)[0].endswith("test__schema_logger_mimic.py") def test_schema_logger_works_with_setloggerclass( diff --git a/tests/test_thread_safety.py b/tests/test__thread_safety.py similarity index 100% rename from tests/test_thread_safety.py rename to tests/test__thread_safety.py From 168fc488a367afd31ad53a74cafe67a6ae1e43d4 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:36:17 +0400 Subject: [PATCH 16/26] test: move tests for private API to subdirectory Signed-off-by: Dmitrii Safronov --- tests/private/__init__.py | 1 + tests/{ => private}/test_errors.py | 0 tests/{ => private}/test_schema_applier.py | 0 tests/{ => private}/test_schema_loader.py | 0 tests/{ => private}/test_schema_logger.py | 0 5 files changed, 1 insertion(+) create mode 100644 tests/private/__init__.py rename tests/{ => private}/test_errors.py (100%) rename tests/{ => private}/test_schema_applier.py (100%) rename tests/{ => private}/test_schema_loader.py (100%) rename tests/{ => private}/test_schema_logger.py (100%) diff --git a/tests/private/__init__.py b/tests/private/__init__.py new file mode 100644 index 0000000..cac0aeb --- /dev/null +++ b/tests/private/__init__.py @@ -0,0 +1 @@ +"""Tests for private/internal components of logging_objects_with_schema.""" diff --git a/tests/test_errors.py b/tests/private/test_errors.py similarity index 100% rename from tests/test_errors.py rename to tests/private/test_errors.py diff --git a/tests/test_schema_applier.py b/tests/private/test_schema_applier.py similarity index 100% rename from tests/test_schema_applier.py rename to tests/private/test_schema_applier.py diff --git a/tests/test_schema_loader.py b/tests/private/test_schema_loader.py similarity index 100% rename from tests/test_schema_loader.py rename to tests/private/test_schema_loader.py diff --git a/tests/test_schema_logger.py b/tests/private/test_schema_logger.py similarity index 100% rename from tests/test_schema_logger.py rename to tests/private/test_schema_logger.py From 36eb4783a0ad8a8a9ad40686971dd774249f5e62 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 18:44:28 +0400 Subject: [PATCH 17/26] test: rename tests for public API Signed-off-by: Dmitrii Safronov --- tests/{test__formatter_basic.py => test_data_validation.py} | 0 tests/{test__integration.py => test_integration.py} | 0 ...st__schema_logger_mimic.py => test_logging_compatibility.py} | 2 +- tests/{test__thread_safety.py => test_thread_safety.py} | 0 4 files changed, 1 insertion(+), 1 deletion(-) rename tests/{test__formatter_basic.py => test_data_validation.py} (100%) rename tests/{test__integration.py => test_integration.py} (100%) rename tests/{test__schema_logger_mimic.py => test_logging_compatibility.py} (99%) rename tests/{test__thread_safety.py => test_thread_safety.py} (100%) diff --git a/tests/test__formatter_basic.py b/tests/test_data_validation.py similarity index 100% rename from tests/test__formatter_basic.py rename to tests/test_data_validation.py diff --git a/tests/test__integration.py b/tests/test_integration.py similarity index 100% rename from tests/test__integration.py rename to tests/test_integration.py diff --git a/tests/test__schema_logger_mimic.py b/tests/test_logging_compatibility.py similarity index 99% rename from tests/test__schema_logger_mimic.py rename to tests/test_logging_compatibility.py index 3533f24..57aedd2 100644 --- a/tests/test__schema_logger_mimic.py +++ b/tests/test_logging_compatibility.py @@ -74,7 +74,7 @@ def test_schema_logger_reports_correct_caller_location( output = stream.getvalue().strip() assert "hello from test" in output # The reported pathname should point to this test module, not schema_logger.py. - assert output.split(":", 1)[0].endswith("test__schema_logger_mimic.py") + assert output.split(":", 1)[0].endswith("test_logging_compatibility.py") def test_schema_logger_works_with_setloggerclass( diff --git a/tests/test__thread_safety.py b/tests/test_thread_safety.py similarity index 100% rename from tests/test__thread_safety.py rename to tests/test_thread_safety.py From 386923a97551980a4723c0ef52c1229314c6b06c Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 19:00:34 +0400 Subject: [PATCH 18/26] test: reorganize tests and add edge case coverage - Reorganize test files to separate compatibility, validation, and integration concerns - Add defensive code path tests for schema_logger.py - Improve schema_logger.py coverage from 75% to 100% Signed-off-by: Dmitrii Safronov --- tests/test_data_validation.py | 348 ++++++++++++++++++++++++ tests/test_integration.py | 393 +++++++++++++++++++--------- tests/test_logging_compatibility.py | 333 +---------------------- 3 files changed, 623 insertions(+), 451 deletions(-) diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py index 5555e9a..afb7172 100644 --- a/tests/test_data_validation.py +++ b/tests/test_data_validation.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +from conftest import _write_schema from logging_objects_with_schema import SchemaLogger from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME @@ -166,3 +167,350 @@ def test_function() -> None: # Verify that exc_info is not set (we don't need traceback for validation errors) assert error_record.exc_info is None + + +def test_logger_handles_missing_extra_fields_gracefully( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should handle missing extra fields without errors.""" + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "RequestID": {"type": "str", "source": "request_id"}, + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Log without extra fields + logger.info("message without extra") + output = stream.getvalue() + assert "message without extra" in output + + # Log with partial extra fields + logger.info("message with partial", extra={"request_id": "abc-123"}) + output = stream.getvalue() + assert "message with partial" in output + + +def test_logger_with_empty_schema( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should treat any extra fields as invalid when schema is empty.""" + + monkeypatch.chdir(tmp_path) + _write_schema(tmp_path, {}) + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Log with extra fields: they should still be ignored in payload, but + # treated as data errors because schema defines no valid leaves. + logger.info("message", extra={"unknown_field": "value", "another": 42}) + + # Message should be logged + output = stream.getvalue() + assert "message" in output + # Fields should not appear in the main log message (they failed validation) + # but they should appear in the validation error message + main_log = output.split("ERROR:")[0] + assert "unknown_field" not in main_log + assert "another" not in main_log + + # Validation error should be logged as ERROR + assert "ERROR" in output + # Error message should be JSON with validation_errors + assert "validation_errors" in output + # Details of problems should be included in the error message + assert "unknown_field" in output + assert "another" in output + + +def test_logger_handles_invalid_json_in_data_problem_message( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should handle DataProblem with invalid JSON in message gracefully. + + This tests the defensive code path when _DataProblem.message is not valid JSON + (should never happen in normal operation, but protects against data corruption). + """ + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + from logging_objects_with_schema.errors import _DataProblem + from logging_objects_with_schema.schema_applier import _apply_schema_internal + + # Create a logger and patch _apply_schema_internal to return a DataProblem + # with invalid JSON in the message + logger = SchemaLogger("test") + original_apply = _apply_schema_internal + + def mock_apply_schema(schema, extra): + # Call original to get normal result, but replace one problem with invalid JSON + structured_extra, problems = original_apply(schema, extra) + if problems: + # Replace first problem with one that has invalid JSON + invalid_problem = _DataProblem("not valid json {") + problems = [invalid_problem] + problems[1:] + return structured_extra, problems + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + with monkeypatch.context() as m: + m.setattr( + "logging_objects_with_schema.schema_logger._apply_schema_internal", + mock_apply_schema, + ) + # Log with invalid type to trigger validation error + logger.info("msg", extra={"user_id": "not-an-int"}) + + output = stream.getvalue() + # Main message should be logged + assert "msg" in output + # Error should be logged with fallback message + assert "ERROR" in output + assert "validation_errors" in output + # Should contain fallback error message + assert "Failed to parse validation error" in output or "unknown" in output + + +def test_logger_handles_json_serialization_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should handle JSON serialization errors gracefully. + + This tests the defensive code path when json.dumps fails during error message + serialization (should never happen in normal operation, but protects against + edge cases). + """ + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + import json + from unittest.mock import patch + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Mock json.dumps to fail on the second call (when serializing validation errors) + original_dumps = json.dumps + call_count = 0 + + def mock_dumps(*args, **kwargs): + nonlocal call_count + call_count += 1 + # First call is from _create_validation_error_json (should succeed) + # Second call is from _log when combining errors (should fail) + if call_count == 2: + raise TypeError("Serialization failed") + return original_dumps(*args, **kwargs) + + with patch("json.dumps", side_effect=mock_dumps): + logger.info("msg", extra={"user_id": "not-an-int"}) + + output = stream.getvalue() + # Main message should be logged + assert "msg" in output + # Error should be logged with fallback message + assert "ERROR" in output + assert "validation_errors" in output + # Should contain fallback error message + assert "Failed to serialize validation errors" in output + + +def test_logger_handles_handler_exception( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should handle exceptions in handlers gracefully. + + This tests the defensive code path when a handler raises an exception + while processing a validation error log record. + """ + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + import sys + from io import StringIO + + class FailingHandler(logging.StreamHandler): + def emit(self, record: logging.LogRecord) -> None: + # Only fail on ERROR level (validation errors) + if record.levelno == logging.ERROR: + raise RuntimeError("Handler failed") + super().emit(record) + + stderr_capture = StringIO() + stream = StringIO() + handler = FailingHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + with monkeypatch.context() as m: + m.setattr(sys, "stderr", stderr_capture) + # Log with invalid type to trigger validation error + logger.info("msg", extra={"user_id": "not-an-int"}) + + # Main message should be logged (handler doesn't fail for INFO) + output = stream.getvalue() + assert "msg" in output + + # Error should be written to stderr because handler failed + stderr_output = stderr_capture.getvalue() + assert "Error in logging handler" in stderr_output + + +def test_logger_uses_inspect_stack_fallback_for_old_python( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should use inspect.stack() fallback for Python < 3.11. + + This tests the fallback code path when _USE_FINDCALLER is False + (simulating Python < 3.11 behavior). + """ + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + from unittest.mock import patch + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Mock _USE_FINDCALLER to be False to trigger inspect.stack() path + with patch( + "logging_objects_with_schema.schema_logger._USE_FINDCALLER", + False, + ): + # Log with invalid type to trigger validation error + def test_function() -> None: + logger.info("msg", extra={"user_id": "not-an-int"}) + + test_function() + + output = stream.getvalue() + # Main message should be logged + assert "msg" in output + # Error should be logged + assert "ERROR" in output + assert "validation_errors" in output + + +def test_logger_uses_findcaller_fallback_when_stack_too_short( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Logger should use findCaller() fallback when inspect.stack() is too short. + + This tests the fallback within fallback: when _USE_FINDCALLER is False + but inspect.stack() returns a stack that's shorter than expected. + """ + + monkeypatch.chdir(tmp_path) + _write_schema( + tmp_path, + { + "ServicePayload": { + "UserID": {"type": "int", "source": "user_id"}, + }, + }, + ) + + from unittest.mock import patch + + stream = StringIO() + handler = logging.StreamHandler(stream) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger = SchemaLogger("test") + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + # Mock _USE_FINDCALLER to be False and inspect.stack() to return short stack + def mock_stack(): + # Return a very short stack (shorter than expected) + return [ + type( + "Frame", (), {"filename": "test.py", "lineno": 1, "function": "test"} + )() + ] + + with ( + patch( + "logging_objects_with_schema.schema_logger._USE_FINDCALLER", + False, + ), + patch("inspect.stack", side_effect=mock_stack), + ): + # Log with invalid type to trigger validation error + logger.info("msg", extra={"user_id": "not-an-int"}) + + output = stream.getvalue() + # Main message should be logged + assert "msg" in output + # Error should be logged (fallback to findCaller should work) + assert "ERROR" in output + assert "validation_errors" in output diff --git a/tests/test_integration.py b/tests/test_integration.py index ba2c8f7..8004481 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,7 +1,8 @@ """Integration tests for SchemaLogger. These tests verify the behavior of SchemaLogger in realistic scenarios, -including multiple logger instances and working directory changes. +including multiple logger instances, working directory changes, and error +handling during initialization (schema problems, file system errors, etc.). """ from __future__ import annotations @@ -131,6 +132,30 @@ def test_schema_file_in_current_working_directory( assert "test message" in output +def test_schema_logger_creates_successfully_with_valid_empty_schema( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SchemaLogger should create successfully with a valid empty schema. + + A valid empty schema (e.g., {}) should not cause errors or terminate + the application. The logger should be created successfully. + """ + + monkeypatch.chdir(tmp_path) + _write_schema(tmp_path, {}) + + # Creating logger with empty schema should not raise exceptions or terminate + logger = SchemaLogger("test-logger") + + assert isinstance(logger, logging.Logger) + assert logger.name == "test-logger" + # Verify that the logger has the _schema attribute set + assert hasattr(logger, "_schema") + # Verify that the schema is empty (no leaves) + assert logger._schema.is_empty + + def test_schema_file_not_found_terminates_application( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -167,26 +192,39 @@ def fake_exit(code: int) -> None: assert "not found" in stderr_content.lower() -def test_schema_validation_error_on_invalid_schema_terminates_application( +def test_schema_file_permission_error_terminates_application( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Invalid schema should terminate application.""" + """Unreadable schema file should terminate application.""" import os import sys from io import StringIO + import logging_objects_with_schema.schema_loader as schema_loader + monkeypatch.chdir(tmp_path) + # Create a valid schema file before patching Path.open _write_schema( tmp_path, { - "name": { # Conflicts with LogRecord attribute - "Value": {"type": "str", "source": "value"}, + "ServicePayload": { + "RequestID": {"type": "str", "source": "request_id"}, }, }, ) + schema_file = tmp_path / _SCHEMA_FILE_NAME + original_open = schema_loader.Path.open # type: ignore[attr-defined] + + def fake_open(self, *args, **kwargs): # type: ignore[override] + if self == schema_file: + raise PermissionError("permission denied") + return original_open(self, *args, **kwargs) + + monkeypatch.setattr(schema_loader.Path, "open", fake_open) + exit_called = False exit_code = None stderr_output = StringIO() @@ -201,159 +239,261 @@ def fake_exit(code: int) -> None: monkeypatch.setattr(sys, "stderr", stderr_output) with pytest.raises(SystemExit): - SchemaLogger("test") + SchemaLogger("test-permission-error") assert exit_called assert exit_code == 1 stderr_content = stderr_output.getvalue() assert "Schema has problems" in stderr_content - assert "conflicts with reserved logging fields" in stderr_content + assert "Failed to read schema file" in stderr_content -def test_logger_with_setloggerclass_creates_schema_logger( +def test_schema_logger_terminates_on_bad_schema( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Using setLoggerClass should create SchemaLogger instances.""" + """SchemaLogger should terminate application when schema has problems.""" + + import os + import sys + from io import StringIO monkeypatch.chdir(tmp_path) + # Use a root key that conflicts with logging.LogRecord field _write_schema( tmp_path, { - "ServicePayload": { - "RequestID": {"type": "str", "source": "request_id"}, + "name": { + "Value": {"type": "str", "source": "value"}, }, }, ) - logging.setLoggerClass(SchemaLogger) - try: - logger1 = logging.getLogger("test1") - logger2 = logging.getLogger("test2") + exit_called = False + exit_code = None + stderr_output = StringIO() - assert isinstance(logger1, SchemaLogger) - assert isinstance(logger2, SchemaLogger) - assert logger1.name == "test1" - assert logger2.name == "test2" - finally: - logging.setLoggerClass(logging.Logger) + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) -def test_logger_handles_missing_extra_fields_gracefully( + with pytest.raises(SystemExit): + SchemaLogger("test-logger") + + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + + +def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Logger should handle missing extra fields without errors.""" + """SchemaLogger should not leave broken instances in the logging cache. + + If schema validation fails during initialisation, the partially constructed + logger instance must not remain registered in logging's internal cache. + Otherwise, a subsequent logging.getLogger(name) call could return this + broken instance and lead to AttributeError when accessing _schema. + """ monkeypatch.chdir(tmp_path) - _write_schema( - tmp_path, - { - "ServicePayload": { - "RequestID": {"type": "str", "source": "request_id"}, - "UserID": {"type": "int", "source": "user_id"}, + + import os + import sys + from io import StringIO + + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + logging.setLoggerClass(SchemaLogger) + try: + # 1) Write an invalid schema that triggers termination. + _write_schema( + tmp_path, + { + "name": { + "Value": {"type": "str", "source": "value"}, + }, }, - }, - ) + ) - stream = StringIO() - handler = logging.StreamHandler(stream) - handler.setFormatter(logging.Formatter("%(message)s")) - logger = SchemaLogger("test") - logger.addHandler(handler) - logger.setLevel(logging.INFO) + # Attempting to create/get the logger should terminate the application, + # and the partially initialised instance must be removed from cache. + with pytest.raises(SystemExit): + logging.getLogger("bad-schema-logger") - # Log without extra fields - logger.info("message without extra") - output = stream.getvalue() - assert "message without extra" in output + assert exit_called + assert exit_code == 1 - # Log with partial extra fields - logger.info("message with partial", extra={"request_id": "abc-123"}) - output = stream.getvalue() - assert "message with partial" in output + # Ensure that the logger with this name is not left registered in the + # logging manager cache after failed initialisation. + assert "bad-schema-logger" not in logging.Logger.manager.loggerDict + finally: + logging.setLoggerClass(logging.Logger) -def test_logger_validates_data_after_logging( +def test_schema_logger_handles_oserror_from_getcwd_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Logger should log validation errors as ERROR messages after logging.""" + """SchemaLogger should catch OSError from os.getcwd() and terminate. + + If os.getcwd() raises OSError (e.g., when CWD is deleted), the exception + should be converted to _SchemaProblem, logger should be cleaned up from cache, + and application should be terminated. + """ + import os + import sys + from io import StringIO + + import logging_objects_with_schema.schema_loader as schema_loader monkeypatch.chdir(tmp_path) - _write_schema( - tmp_path, - { - "ServicePayload": { - "UserID": {"type": "int", "source": "user_id"}, - }, - }, - ) + _write_schema(tmp_path, {"ServicePayload": {}}) - stream = StringIO() - handler = logging.StreamHandler(stream) - handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) - logger = SchemaLogger("test") - logger.addHandler(handler) - logger.setLevel(logging.INFO) + original_getcwd = os.getcwd - # Log with invalid type - should not raise exception - logger.info("message", extra={"user_id": "not-an-int"}) + def fake_getcwd() -> str: + raise OSError("Current working directory no longer exists") - # Message should be logged - output = stream.getvalue() - assert "message" in output + monkeypatch.setattr(os, "getcwd", fake_getcwd) - # Validation error should be logged as ERROR - assert "ERROR" in output - # Error message should be JSON with validation_errors - assert "validation_errors" in output + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + logging.setLoggerClass(SchemaLogger) + try: + # Clear schema cache to force recompilation + with schema_loader._cache_lock: + schema_loader._SCHEMA_CACHE.clear() + with schema_loader._path_cache_lock: + schema_loader._resolved_schema_path = None + schema_loader._cached_cwd = None + + # Attempting to create/get the logger should terminate the application, + # and the partially initialised instance must be removed from cache. + with pytest.raises(SystemExit): + logging.getLogger("oserror-logger") + + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + + # Ensure that the logger with this name is not left registered in the + # logging manager cache after failed initialisation. + assert "oserror-logger" not in logging.Logger.manager.loggerDict + finally: + monkeypatch.setattr(os, "getcwd", original_getcwd) + logging.setLoggerClass(logging.Logger) -def test_logger_with_empty_schema( +def test_schema_logger_handles_runtimeerror_from_lock_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Logger should treat any extra fields as invalid when schema is empty.""" + """SchemaLogger should catch RuntimeError from threading locks and terminate. + + If a threading lock raises RuntimeError (e.g., deadlock detection), + the exception should be converted to _SchemaProblem, logger should be cleaned + up from cache, and application should be terminated. + """ + import os + import sys + from io import StringIO + + import logging_objects_with_schema.schema_loader as schema_loader monkeypatch.chdir(tmp_path) - _write_schema(tmp_path, {}) + _write_schema(tmp_path, {"ServicePayload": {}}) - stream = StringIO() - handler = logging.StreamHandler(stream) - handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) - logger = SchemaLogger("test") - logger.addHandler(handler) - logger.setLevel(logging.INFO) + original_lock = schema_loader._cache_lock - # Log with extra fields: they should still be ignored in payload, but - # treated as data errors because schema defines no valid leaves. - logger.info("message", extra={"unknown_field": "value", "another": 42}) + class FakeLock: + def __enter__(self) -> None: + raise RuntimeError("Lock acquisition failed") - # Message should be logged - output = stream.getvalue() - assert "message" in output - # Fields should not appear in the main log message (they failed validation) - # but they should appear in the validation error message - main_log = output.split("ERROR:")[0] - assert "unknown_field" not in main_log - assert "another" not in main_log - - # Validation error should be logged as ERROR - assert "ERROR" in output - # Error message should be JSON with validation_errors - assert "validation_errors" in output - # Details of problems should be included in the error message - assert "unknown_field" in output - assert "another" in output + def __exit__(self, *args: object) -> None: + pass + fake_lock = FakeLock() + monkeypatch.setattr(schema_loader, "_cache_lock", fake_lock) -def test_schema_file_permission_error_terminates_application( + exit_called = False + exit_code = None + stderr_output = StringIO() + + def fake_exit(code: int) -> None: + nonlocal exit_called, exit_code + exit_called = True + exit_code = code + raise SystemExit(code) + + monkeypatch.setattr(os, "_exit", fake_exit) + monkeypatch.setattr(sys, "stderr", stderr_output) + + logging.setLoggerClass(SchemaLogger) + try: + # Clear schema cache to force recompilation + with original_lock: + schema_loader._SCHEMA_CACHE.clear() + with schema_loader._path_cache_lock: + schema_loader._resolved_schema_path = None + schema_loader._cached_cwd = None + + # Attempting to create/get the logger should terminate the application, + # and the partially initialised instance must be removed from cache. + with pytest.raises(SystemExit): + logging.getLogger("runtimeerror-logger") + + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + + # Ensure that the logger with this name is not left registered in the + # logging manager cache after failed initialisation. + assert "runtimeerror-logger" not in logging.Logger.manager.loggerDict + finally: + monkeypatch.setattr(schema_loader, "_cache_lock", original_lock) + logging.setLoggerClass(logging.Logger) + + +def test_schema_logger_handles_valueerror_and_terminates( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Unreadable schema file should terminate application.""" + """SchemaLogger should catch ValueError and terminate. + If ValueError is raised during schema compilation (outside of the + try-except block in _compile_schema_internal), the exception should be + converted to _SchemaProblem, logger should be cleaned up from cache, + and application should be terminated. + """ import os import sys from io import StringIO @@ -361,25 +501,14 @@ def test_schema_file_permission_error_terminates_application( import logging_objects_with_schema.schema_loader as schema_loader monkeypatch.chdir(tmp_path) - # Create a valid schema file before patching Path.open - _write_schema( - tmp_path, - { - "ServicePayload": { - "RequestID": {"type": "str", "source": "request_id"}, - }, - }, - ) + _write_schema(tmp_path, {"ServicePayload": {}}) - schema_file = tmp_path / _SCHEMA_FILE_NAME - original_open = schema_loader.Path.open # type: ignore[attr-defined] + original_get_schema_path = schema_loader._get_schema_path - def fake_open(self, *args, **kwargs): # type: ignore[override] - if self == schema_file: - raise PermissionError("permission denied") - return original_open(self, *args, **kwargs) + def fake_get_schema_path() -> Path: + raise ValueError("Unexpected value error during path resolution") - monkeypatch.setattr(schema_loader.Path, "open", fake_open) + monkeypatch.setattr(schema_loader, "_get_schema_path", fake_get_schema_path) exit_called = False exit_code = None @@ -394,11 +523,27 @@ def fake_exit(code: int) -> None: monkeypatch.setattr(os, "_exit", fake_exit) monkeypatch.setattr(sys, "stderr", stderr_output) - with pytest.raises(SystemExit): - SchemaLogger("test-permission-error") - - assert exit_called - assert exit_code == 1 - stderr_content = stderr_output.getvalue() - assert "Schema has problems" in stderr_content - assert "Failed to read schema file" in stderr_content + logging.setLoggerClass(SchemaLogger) + try: + # Clear schema cache to force recompilation + with schema_loader._cache_lock: + schema_loader._SCHEMA_CACHE.clear() + with schema_loader._path_cache_lock: + schema_loader._resolved_schema_path = None + schema_loader._cached_cwd = None + + # Attempting to create/get the logger should terminate the application, + # and the partially initialised instance must be removed from cache. + with pytest.raises(SystemExit): + logging.getLogger("valueerror-logger") + + assert exit_called + assert exit_code == 1 + assert "Schema has problems" in stderr_output.getvalue() + + # Ensure that the logger with this name is not left registered in the + # logging manager cache after failed initialisation. + assert "valueerror-logger" not in logging.Logger.manager.loggerDict + finally: + monkeypatch.setattr(schema_loader, "_get_schema_path", original_get_schema_path) + logging.setLoggerClass(logging.Logger) diff --git a/tests/test_logging_compatibility.py b/tests/test_logging_compatibility.py index 57aedd2..fd83e05 100644 --- a/tests/test_logging_compatibility.py +++ b/tests/test_logging_compatibility.py @@ -96,11 +96,15 @@ def test_schema_logger_works_with_setloggerclass( logging.setLoggerClass(logging.Logger) -def test_schema_logger_validates_extra_fields( +def test_schema_logger_accepts_extra_fields_without_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """SchemaLogger should validate extra fields according to schema.""" + """SchemaLogger should accept extra fields without raising exceptions. + + This test verifies compatibility: SchemaLogger behaves like logging.Logger + and doesn't raise exceptions when extra fields are provided. + """ monkeypatch.chdir(tmp_path) _write_schema( @@ -124,328 +128,3 @@ def test_schema_logger_validates_extra_fields( output = stream.getvalue() assert "msg" in output - - -def test_schema_logger_terminates_on_bad_schema( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should terminate application when schema has problems.""" - - import os - import sys - from io import StringIO - - monkeypatch.chdir(tmp_path) - # Use a root key that conflicts with logging.LogRecord field - _write_schema( - tmp_path, - { - "name": { - "Value": {"type": "str", "source": "value"}, - }, - }, - ) - - exit_called = False - exit_code = None - stderr_output = StringIO() - - def fake_exit(code: int) -> None: - nonlocal exit_called, exit_code - exit_called = True - exit_code = code - raise SystemExit(code) - - monkeypatch.setattr(os, "_exit", fake_exit) - monkeypatch.setattr(sys, "stderr", stderr_output) - - with pytest.raises(SystemExit): - SchemaLogger("test-logger") - - assert exit_called - assert exit_code == 1 - assert "Schema has problems" in stderr_output.getvalue() - - -def test_schema_logger_creates_successfully_with_valid_empty_schema( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should create successfully with a valid empty schema. - - A valid empty schema (e.g., {}) should not cause errors or terminate - the application. The logger should be created successfully. - """ - - monkeypatch.chdir(tmp_path) - _write_schema(tmp_path, {}) - - # Creating logger with empty schema should not raise exceptions or terminate - logger = SchemaLogger("test-logger") - - assert isinstance(logger, logging.Logger) - assert logger.name == "test-logger" - # Verify that the logger has the _schema attribute set - assert hasattr(logger, "_schema") - # Verify that the schema is empty (no leaves) - assert logger._schema.is_empty - - -def test_schema_logger_does_not_leave_partially_initialised_logger_in_cache( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should not leave broken instances in the logging cache. - - If schema validation fails during initialisation, the partially constructed - logger instance must not remain registered in logging's internal cache. - Otherwise, a subsequent logging.getLogger(name) call could return this - broken instance and lead to AttributeError when accessing _schema. - """ - - monkeypatch.chdir(tmp_path) - - import os - import sys - from io import StringIO - - exit_called = False - exit_code = None - stderr_output = StringIO() - - def fake_exit(code: int) -> None: - nonlocal exit_called, exit_code - exit_called = True - exit_code = code - raise SystemExit(code) - - monkeypatch.setattr(os, "_exit", fake_exit) - monkeypatch.setattr(sys, "stderr", stderr_output) - - logging.setLoggerClass(SchemaLogger) - try: - # 1) Write an invalid schema that triggers termination. - _write_schema( - tmp_path, - { - "name": { - "Value": {"type": "str", "source": "value"}, - }, - }, - ) - - # Attempting to create/get the logger should terminate the application, - # and the partially initialised instance must be removed from cache. - with pytest.raises(SystemExit): - logging.getLogger("bad-schema-logger") - - assert exit_called - assert exit_code == 1 - - # Ensure that the logger with this name is not left registered in the - # logging manager cache after failed initialisation. - assert "bad-schema-logger" not in logging.Logger.manager.loggerDict - finally: - logging.setLoggerClass(logging.Logger) - - -def test_schema_logger_handles_oserror_from_getcwd_and_terminates( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should catch OSError from os.getcwd() and terminate. - - If os.getcwd() raises OSError (e.g., when CWD is deleted), the exception - should be converted to _SchemaProblem, logger should be cleaned up from cache, - and application should be terminated. - """ - import os - import sys - from io import StringIO - - import logging_objects_with_schema.schema_loader as schema_loader - - monkeypatch.chdir(tmp_path) - _write_schema(tmp_path, {"ServicePayload": {}}) - - original_getcwd = os.getcwd - - def fake_getcwd() -> str: - raise OSError("Current working directory no longer exists") - - monkeypatch.setattr(os, "getcwd", fake_getcwd) - - exit_called = False - exit_code = None - stderr_output = StringIO() - - def fake_exit(code: int) -> None: - nonlocal exit_called, exit_code - exit_called = True - exit_code = code - raise SystemExit(code) - - monkeypatch.setattr(os, "_exit", fake_exit) - monkeypatch.setattr(sys, "stderr", stderr_output) - - logging.setLoggerClass(SchemaLogger) - try: - # Clear schema cache to force recompilation - with schema_loader._cache_lock: - schema_loader._SCHEMA_CACHE.clear() - with schema_loader._path_cache_lock: - schema_loader._resolved_schema_path = None - schema_loader._cached_cwd = None - - # Attempting to create/get the logger should terminate the application, - # and the partially initialised instance must be removed from cache. - with pytest.raises(SystemExit): - logging.getLogger("oserror-logger") - - assert exit_called - assert exit_code == 1 - assert "Schema has problems" in stderr_output.getvalue() - - # Ensure that the logger with this name is not left registered in the - # logging manager cache after failed initialisation. - assert "oserror-logger" not in logging.Logger.manager.loggerDict - finally: - monkeypatch.setattr(os, "getcwd", original_getcwd) - logging.setLoggerClass(logging.Logger) - - -def test_schema_logger_handles_runtimeerror_from_lock_and_terminates( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should catch RuntimeError from threading locks and terminate. - - If a threading lock raises RuntimeError (e.g., deadlock detection), - the exception should be converted to _SchemaProblem, logger should be cleaned - up from cache, and application should be terminated. - """ - import os - import sys - from io import StringIO - - import logging_objects_with_schema.schema_loader as schema_loader - - monkeypatch.chdir(tmp_path) - _write_schema(tmp_path, {"ServicePayload": {}}) - - original_lock = schema_loader._cache_lock - - class FakeLock: - def __enter__(self) -> None: - raise RuntimeError("Lock acquisition failed") - - def __exit__(self, *args: object) -> None: - pass - - fake_lock = FakeLock() - monkeypatch.setattr(schema_loader, "_cache_lock", fake_lock) - - exit_called = False - exit_code = None - stderr_output = StringIO() - - def fake_exit(code: int) -> None: - nonlocal exit_called, exit_code - exit_called = True - exit_code = code - raise SystemExit(code) - - monkeypatch.setattr(os, "_exit", fake_exit) - monkeypatch.setattr(sys, "stderr", stderr_output) - - logging.setLoggerClass(SchemaLogger) - try: - # Clear schema cache to force recompilation - with original_lock: - schema_loader._SCHEMA_CACHE.clear() - with schema_loader._path_cache_lock: - schema_loader._resolved_schema_path = None - schema_loader._cached_cwd = None - - # Attempting to create/get the logger should terminate the application, - # and the partially initialised instance must be removed from cache. - with pytest.raises(SystemExit): - logging.getLogger("runtimeerror-logger") - - assert exit_called - assert exit_code == 1 - assert "Schema has problems" in stderr_output.getvalue() - - # Ensure that the logger with this name is not left registered in the - # logging manager cache after failed initialisation. - assert "runtimeerror-logger" not in logging.Logger.manager.loggerDict - finally: - monkeypatch.setattr(schema_loader, "_cache_lock", original_lock) - logging.setLoggerClass(logging.Logger) - - -def test_schema_logger_handles_valueerror_and_terminates( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """SchemaLogger should catch ValueError and terminate. - - If ValueError is raised during schema compilation (outside of the - try-except block in _compile_schema_internal), the exception should be - converted to _SchemaProblem, logger should be cleaned up from cache, - and application should be terminated. - """ - import os - import sys - from io import StringIO - - import logging_objects_with_schema.schema_loader as schema_loader - - monkeypatch.chdir(tmp_path) - _write_schema(tmp_path, {"ServicePayload": {}}) - - original_get_schema_path = schema_loader._get_schema_path - - def fake_get_schema_path() -> Path: - raise ValueError("Unexpected value error during path resolution") - - monkeypatch.setattr(schema_loader, "_get_schema_path", fake_get_schema_path) - - exit_called = False - exit_code = None - stderr_output = StringIO() - - def fake_exit(code: int) -> None: - nonlocal exit_called, exit_code - exit_called = True - exit_code = code - raise SystemExit(code) - - monkeypatch.setattr(os, "_exit", fake_exit) - monkeypatch.setattr(sys, "stderr", stderr_output) - - logging.setLoggerClass(SchemaLogger) - try: - # Clear schema cache to force recompilation - with schema_loader._cache_lock: - schema_loader._SCHEMA_CACHE.clear() - with schema_loader._path_cache_lock: - schema_loader._resolved_schema_path = None - schema_loader._cached_cwd = None - - # Attempting to create/get the logger should terminate the application, - # and the partially initialised instance must be removed from cache. - with pytest.raises(SystemExit): - logging.getLogger("valueerror-logger") - - assert exit_called - assert exit_code == 1 - assert "Schema has problems" in stderr_output.getvalue() - - # Ensure that the logger with this name is not left registered in the - # logging manager cache after failed initialisation. - assert "valueerror-logger" not in logging.Logger.manager.loggerDict - finally: - monkeypatch.setattr(schema_loader, "_get_schema_path", original_get_schema_path) - logging.setLoggerClass(logging.Logger) From 65a560c0d4d25a192466ac50f15891996a1c2a00 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 19:12:00 +0400 Subject: [PATCH 19/26] refactor(tests): move _write_schema function to helpers module - Consolidated shared test helper functions by relocating _write_schema from conftest.py to a new helpers.py file. - Updated all test files to import _write_schema from the new location, ensuring consistent usage across the test suite. - Simplified conftest.py by removing unnecessary code, enhancing clarity and maintainability. Signed-off-by: Dmitrii Safronov --- tests/conftest.py | 17 +---------------- tests/helpers.py | 19 +++++++++++++++++++ tests/private/test_schema_loader.py | 2 +- tests/test_data_validation.py | 2 +- tests/test_integration.py | 2 +- tests/test_logging_compatibility.py | 2 +- tests/test_thread_safety.py | 2 +- 7 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 tests/helpers.py diff --git a/tests/conftest.py b/tests/conftest.py index 5c1c830..f11696f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,10 @@ -"""Shared fixtures and helper functions for tests.""" +"""Shared fixtures for tests.""" from __future__ import annotations -import json -from pathlib import Path - import pytest from logging_objects_with_schema import schema_loader -from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME @pytest.fixture(autouse=True) @@ -21,14 +17,3 @@ def clear_schema_cache() -> None: with schema_loader._path_cache_lock: schema_loader._resolved_schema_path = None schema_loader._cached_cwd = None - - -def _write_schema(tmp_path: Path, data: dict) -> None: - """Write schema file to temporary directory. - - Args: - tmp_path: Temporary directory path. - data: Schema data to write as JSON. - """ - schema_path = tmp_path / _SCHEMA_FILE_NAME - schema_path.write_text(json.dumps(data), encoding="utf-8") diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..d09090b --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,19 @@ +"""Helper functions for tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME + + +def _write_schema(tmp_path: Path, data: dict) -> None: + """Write schema file to temporary directory. + + Args: + tmp_path: Temporary directory path. + data: Schema data to write as JSON. + """ + schema_path = tmp_path / _SCHEMA_FILE_NAME + schema_path.write_text(json.dumps(data), encoding="utf-8") diff --git a/tests/private/test_schema_loader.py b/tests/private/test_schema_loader.py index bde7415..614f21c 100644 --- a/tests/private/test_schema_loader.py +++ b/tests/private/test_schema_loader.py @@ -10,7 +10,6 @@ from typing import Any import pytest -from conftest import _write_schema import logging_objects_with_schema.schema_loader as schema_loader from logging_objects_with_schema.errors import _SchemaProblem @@ -68,6 +67,7 @@ from logging_objects_with_schema.schema_loader import ( _validate_and_create_leaf as validate_and_create_leaf, ) +from tests.helpers import _write_schema def test_missing_schema_file_produces_empty_schema_and_problem( diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py index afb7172..c6e2e46 100644 --- a/tests/test_data_validation.py +++ b/tests/test_data_validation.py @@ -8,10 +8,10 @@ from pathlib import Path import pytest -from conftest import _write_schema from logging_objects_with_schema import SchemaLogger from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME +from tests.helpers import _write_schema def _configure_schema_logger(stream: StringIO) -> SchemaLogger: diff --git a/tests/test_integration.py b/tests/test_integration.py index 8004481..d372aa2 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,10 +12,10 @@ from pathlib import Path import pytest -from conftest import _write_schema from logging_objects_with_schema import SchemaLogger from logging_objects_with_schema.schema_loader import _SCHEMA_FILE_NAME +from tests.helpers import _write_schema def test_multiple_logger_instances_share_schema( diff --git a/tests/test_logging_compatibility.py b/tests/test_logging_compatibility.py index fd83e05..02793d0 100644 --- a/tests/test_logging_compatibility.py +++ b/tests/test_logging_compatibility.py @@ -7,9 +7,9 @@ from pathlib import Path import pytest -from conftest import _write_schema from logging_objects_with_schema import SchemaLogger +from tests.helpers import _write_schema def test_schema_logger_is_logging_logger_instance( diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py index e38a085..2bca573 100644 --- a/tests/test_thread_safety.py +++ b/tests/test_thread_safety.py @@ -7,9 +7,9 @@ from pathlib import Path import pytest -from conftest import _write_schema from logging_objects_with_schema import SchemaLogger +from tests.helpers import _write_schema def test_concurrent_logger_creation( From fed6756929dc88b2d9facdbce7b6d316885f8024 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 19:20:39 +0400 Subject: [PATCH 20/26] test(schema_loader): add test for cached result during exception handling - Introduced a new test to verify that _compile_schema_internal correctly uses a cached result when an exception occurs during schema loading. - The test ensures that if another thread has already compiled the schema, the cached result is returned instead of creating a new one, enhancing the reliability of the schema compilation process. Signed-off-by: Dmitrii Safronov --- tests/private/test_schema_loader.py | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/private/test_schema_loader.py b/tests/private/test_schema_loader.py index 614f21c..4771538 100644 --- a/tests/private/test_schema_loader.py +++ b/tests/private/test_schema_loader.py @@ -1495,3 +1495,79 @@ def test_is_leaf_node_with_both_none() -> None: """_is_leaf_node should return False when both type and source are None.""" value_dict = {"type": None, "source": None} assert is_leaf_node(value_dict) is False + + +def test_compile_schema_internal_uses_cached_result_during_exception_handling( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_compile_schema_internal should use cached result during exception handling. + + This test covers line 718: return cached result in exception handler when + another thread already compiled the schema while we were processing exception. + """ + monkeypatch.chdir(tmp_path) + + # Clear cache first to ensure we don't hit the fast-path check + with schema_loader._cache_lock: + schema_loader._SCHEMA_CACHE.clear() + with schema_loader._path_cache_lock: + schema_loader._resolved_schema_path = None + schema_loader._cached_cwd = None + + # Create a pre-compiled result that simulates what another thread would cache + pre_cached_result = ( + _CompiledSchema(leaves=[]), + [_SchemaProblem("Pre-cached error")], + ) + + # Patch _SCHEMA_CACHE.get to return None on first check (fast-path), + # but return cached result on second check (in exception handler) + original_cache = schema_loader._SCHEMA_CACHE + call_count = 0 + + def mock_cache_get( + key: Path, + ) -> tuple[_CompiledSchema, list[_SchemaProblem]] | None: + nonlocal call_count + call_count += 1 + # First call is from fast-path check (line 694) - return None + # Second call is from exception handler (line 716) - return cached result + if call_count == 1: + return None + return pre_cached_result + + # Create a mock cache that uses our custom get method + class MockCache(dict): + def get(self, key: Path) -> tuple[_CompiledSchema, list[_SchemaProblem]] | None: + return mock_cache_get(key) + + mock_cache = MockCache() + monkeypatch.setattr(schema_loader, "_SCHEMA_CACHE", mock_cache) + + # Now patch _load_raw_schema to raise exception, which will trigger + # exception handler that checks cache (line 718) + original_load = schema_loader._load_raw_schema + + def mock_load_raises( + *args: object, **kwargs: object + ) -> tuple[dict[str, Any], Path]: + raise FileNotFoundError("Schema file not found") + + monkeypatch.setattr(schema_loader, "_load_raw_schema", mock_load_raises) + + try: + # This will trigger exception handling path + # Fast-path check will return None (call_count == 1) + # Exception handler will find pre_cached_result in cache + # (call_count == 2, line 718) + result, problems = compile_schema_internal() + + # Should return the cached result, not create a new one + assert result is pre_cached_result[0] + assert problems is pre_cached_result[1] + assert call_count == 2 # Should have checked cache twice + finally: + # Restore original + monkeypatch.setattr(schema_loader, "_load_raw_schema", original_load) + monkeypatch.setattr(schema_loader, "_SCHEMA_CACHE", original_cache) From 150e29b6a7e58a1e249eecb6677a82c77ad57eec Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 19:35:26 +0400 Subject: [PATCH 21/26] fix(schema_loader): improve caching logic for missing schema file paths - Updated the _get_schema_path function to check for cached missing file paths before checking for found file paths, ensuring that the cache is not invalidated incorrectly. - Added a new test to verify that the cached missing file path is used correctly on subsequent calls, enhancing the reliability of schema file retrieval. Signed-off-by: Dmitrii Safronov --- .../schema_loader.py | 14 +++++++---- tests/private/test_schema_loader.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 732b716..7b42e67 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -274,16 +274,20 @@ def _get_schema_path() -> Path: Absolute path where the schema file is located or expected to be. """ with _path_cache_lock: + # Check cached path for missing file first (CWD-dependent) + # This must be checked before found file cache, because + # _check_cached_found_file_path would invalidate the cache if file + # doesn't exist, even for missing file cache. + if _cached_cwd is not None: + cached_path = _check_cached_missing_file_path() + if cached_path is not None: + return cached_path + # Check cached path for found file (CWD-independent) cached_path = _check_cached_found_file_path() if cached_path is not None: return cached_path - # Check cached path for missing file (CWD-dependent) - cached_path = _check_cached_missing_file_path() - if cached_path is not None: - return cached_path - # Search for schema file found_path = _find_schema_file() if found_path is not None: diff --git a/tests/private/test_schema_loader.py b/tests/private/test_schema_loader.py index 4771538..55d9435 100644 --- a/tests/private/test_schema_loader.py +++ b/tests/private/test_schema_loader.py @@ -1497,6 +1497,31 @@ def test_is_leaf_node_with_both_none() -> None: assert is_leaf_node(value_dict) is False +def test_get_schema_path_uses_cached_missing_file_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_get_schema_path should use cached missing file path on second call. + + This test covers line 285: return cached_path when missing file is cached. + After fixing _check_cached_found_file_path to not invalidate cache for + missing files, this path is now reachable. + """ + monkeypatch.chdir(tmp_path) + + # First call - file not found, should cache the missing path + path1 = get_schema_path() + assert path1 == (tmp_path / _SCHEMA_FILE_NAME).resolve() + assert not path1.exists() + + # Second call - should return cached path (line 285) + # _check_cached_found_file_path() will return None (because _cached_cwd is not None) + # _check_cached_missing_file_path() should return the cached path + path2 = get_schema_path() + assert path2 == path1 + assert path2 == (tmp_path / _SCHEMA_FILE_NAME).resolve() + + def test_compile_schema_internal_uses_cached_result_during_exception_handling( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 7a866b20078cf364a182c7db860d6e6fe674d82e Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 19:35:54 +0400 Subject: [PATCH 22/26] chore(coverage): modify coverage options and enhance configuration - Updated the coverage options in the Makefile to simplify the command. - Added detailed coverage configuration in pyproject.toml, including source specification, branch coverage, and exclusion rules for specific lines. - Configured HTML report generation to output to the "htmlcov" directory for better organization. Signed-off-by: Dmitrii Safronov --- Makefile | 2 +- pyproject.toml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b8a53b0..8e3ef20 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Variables PYTEST_CMD = uv run python -m pytest -v -COVERAGE_OPTS = --cov=. --cov-report=term-missing --cov-report=html +COVERAGE_OPTS = --cov --cov-report=term-missing --cov-report=html # Phony targets .PHONY: all clean format help install lint test test-coverage diff --git a/pyproject.toml b/pyproject.toml index a0a00ee..65c3ea0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,3 +95,27 @@ strict_equality = true [tool.bandit] skips = [ "B101", "B601" ] exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ] + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + # Don't complain about missing debug-only code: + "def __repr__", + "if self\\.debug", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", +] +show_missing = true +skip_covered = false + +[tool.coverage.html] +directory = "htmlcov" From 333f2b1cc05ef5157a88c55f6a53aeb377b8e456 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 8 Dec 2025 15:50:01 +0000 Subject: [PATCH 23/26] chore(release): 0.1.4-rc.1 ## [0.1.4-rc.1](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.3...v0.1.4-rc.1) (2025-12-08) ### Bug Fixes * **schema_loader:** improve caching logic for missing schema file paths ([150e29b](https://github.com/disafronov/python-logging-objects-with-schema/commit/150e29b6a7e58a1e249eecb6677a82c77ad57eec)) Signed-off-by: Release Bot --- CHANGELOG.md | 5 +++++ pyproject.toml | 22 +++++++++------------- uv.lock | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e6ba80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## [0.1.4-rc.1](https://github.com/disafronov/python-logging-objects-with-schema/compare/v0.1.3...v0.1.4-rc.1) (2025-12-08) + +### Bug Fixes + +* **schema_loader:** improve caching logic for missing schema file paths ([150e29b](https://github.com/disafronov/python-logging-objects-with-schema/commit/150e29b6a7e58a1e249eecb6677a82c77ad57eec)) diff --git a/pyproject.toml b/pyproject.toml index 65c3ea0..72af2b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "logging-objects-with-schema" -version = "0.1.3" +version = "0.1.4rc1" description = "Proxy logging wrapper that validates extra fields against a JSON schema." readme = "README.md" requires-python = ">=3.10" @@ -97,22 +97,18 @@ skips = [ "B101", "B601" ] exclude_dirs = [ ".venv", "__pycache__", ".git", "htmlcov" ] [tool.coverage.run] -source = ["src"] +source = [ "src" ] branch = true [tool.coverage.report] exclude_lines = [ - # Don't complain about missing debug-only code: - "def __repr__", - "if self\\.debug", - # Don't complain if tests don't hit defensive assertion code: - "raise AssertionError", - "raise NotImplementedError", - # Don't complain if non-runnable code isn't run: - "if 0:", - "if __name__ == .__main__.:", - # Don't complain about abstract methods, they aren't run: - "@(abc\\.)?abstractmethod", + "def __repr__", + "if self\\.debug", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "@(abc\\.)?abstractmethod" ] show_missing = true skip_covered = false diff --git a/uv.lock b/uv.lock index 494b59e..a970c2b 100644 --- a/uv.lock +++ b/uv.lock @@ -1394,7 +1394,7 @@ version = "0.6.3" [[package]] name = "logging-objects-with-schema" -version = "0.1.3" +version = "0.1.4rc1" [package.source] editable = "." From f75e707e91369ae7d2801d19c59fda1a54988399 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:54:32 +0000 Subject: [PATCH 24/26] chore(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/auto-pr-description.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-pr-description.yml b/.github/workflows/auto-pr-description.yml index 2cd8f26..7e52a2d 100644 --- a/.github/workflows/auto-pr-description.yml +++ b/.github/workflows/auto-pr-description.yml @@ -18,7 +18,7 @@ jobs: if: github.event.pull_request.head.ref == 'main' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Generate PR description id: gen From ffa604161f214e174f4c80e7f0b145f553c5752a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:02:41 +0000 Subject: [PATCH 25/26] chore(deps): bump actions/github-script from 7 to 8 Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/auto-pr-description.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-pr-description.yml b/.github/workflows/auto-pr-description.yml index 7e52a2d..be3b94f 100644 --- a/.github/workflows/auto-pr-description.yml +++ b/.github/workflows/auto-pr-description.yml @@ -28,7 +28,7 @@ jobs: api_token: ${{ secrets.GITHUB_TOKEN }} - name: Force overwrite PR title and body - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: PR_BODY: ${{ steps.gen.outputs.pull_request_description }} with: From 3761491daa61892ada31a79bdcf4f44f595032e7 Mon Sep 17 00:00:00 2001 From: Dmitrii Safronov Date: Mon, 8 Dec 2025 20:09:03 +0400 Subject: [PATCH 26/26] docs(schema_loader): enhance thread-safety in schema compilation check Updated the comment in the _compile_schema_internal function to clarify that the fast-path check for cached schema compilation now includes a lock for thread-safety, ensuring safe access to the cache in multi-threaded scenarios. Signed-off-by: Dmitrii Safronov --- src/logging_objects_with_schema/schema_loader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/logging_objects_with_schema/schema_loader.py b/src/logging_objects_with_schema/schema_loader.py index 7b42e67..a9587d4 100644 --- a/src/logging_objects_with_schema/schema_loader.py +++ b/src/logging_objects_with_schema/schema_loader.py @@ -686,9 +686,9 @@ def _compile_schema_internal() -> tuple[_CompiledSchema, list[_SchemaProblem]]: """ schema_path = _get_schema_path() - # Fast-path: First check (without holding lock) if we have already attempted - # to compile schema for this path. This avoids lock contention in the common - # case when the schema is already cached. + # Fast-path: First check (with lock for thread-safety) if we have already attempted + # to compile schema for this path. This provides thread-safe cache access + # in the common case when the schema is already cached. with _cache_lock: cached = _SCHEMA_CACHE.get(schema_path) if cached is not None: