From a97349c00d168280c9c94ce62eee55c28218a164 Mon Sep 17 00:00:00 2001 From: Nuno Pereira Date: Thu, 25 Dec 2025 12:36:39 +0000 Subject: [PATCH] Implemented path population for validatione errors --- CHANGELOG.md | 3 + requirements.txt | 5 +- tests/enum_test.py | 6 - tests/list_test.py | 36 +++++- tests/path_test.py | 253 +++++++++++++++++++++++++++++++++++++++++++ tests/record_test.py | 55 +++++++++- tests/tuple_test.py | 53 +++++++++ tests/zon_test.py | 28 +++++ zon/__init__.py | 60 ++++++++-- 9 files changed, 481 insertions(+), 18 deletions(-) create mode 100644 tests/path_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab3cef..9113136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Path population on ZonIssue instances for better tracking what caused an error. + ## [3.0.0] - 2025-05-16 ### Changed diff --git a/requirements.txt b/requirements.txt index 3bca0ec..9c5cae8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,8 @@ black==24.3.0 build==1.0.3 pylint==3.0.2 pytest==7.4.3 -twine==4.0.2 +twine validators==0.28.3 typing_extensions==4.12.2 -pytest-cov==5.0.0 \ No newline at end of file +pytest-cov==5.0.0 +pylint-pytest==1.1.8 \ No newline at end of file diff --git a/tests/enum_test.py b/tests/enum_test.py index 5d94543..a433b16 100644 --- a/tests/enum_test.py +++ b/tests/enum_test.py @@ -40,9 +40,6 @@ def test_enum_safe_validate(validator): def test_enum_exclude(validator): - - # TODO: test through validation - assert validator.exclude(["1"]).enum == {"2", "3", "4", "5"} assert validator.exclude(["2"]).enum == {"1", "3", "4", "5"} assert validator.exclude(["3"]).enum == {"1", "2", "4", "5"} @@ -51,9 +48,6 @@ def test_enum_exclude(validator): def test_enum_extract(validator): - - # TODO: test through validation - assert validator.extract(["1"]).enum == { "1", } diff --git a/tests/list_test.py b/tests/list_test.py index 6171c62..e3932e3 100644 --- a/tests/list_test.py +++ b/tests/list_test.py @@ -13,7 +13,6 @@ def test_list_get_element(element_validator): class TestElementListOuterFunction: - @pytest.fixture def validator(self, element_validator): return zon.element_list(element_validator) @@ -75,7 +74,6 @@ def test_list_max(self, validator): class TestElementListMethod: - @pytest.fixture def validator(self, element_validator): return element_validator.list() @@ -134,3 +132,37 @@ def test_list_max(self, validator): with pytest.raises(zon.error.ZonError): _validator.validate(["1", "2", "3"]) + + +def test_list_error_paths(element_validator): + """Test that validation errors for list elements have correct paths""" + validator = zon.element_list( + element_validator.min(2) + ) # Require strings with min length 2 + + # Test single element validation error + result = validator.safe_validate(["valid", "x"]) # Second string too short + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["1"] + + # Test multiple element validation errors + result = validator.safe_validate(["x", "y", "valid"]) # First two strings too short + assert result[0] is False + issues = result[1].issues + assert len(issues) == 2 + paths = [issue.path for issue in issues] + assert ["0"] in paths + assert ["1"] in paths + + # Test with wrong type element + result = validator.safe_validate(["valid", 123]) # Second element not a string + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["1"] + + # Test empty list - should be valid (no elements to validate) + result = validator.safe_validate([]) + assert result[0] is True diff --git a/tests/path_test.py b/tests/path_test.py new file mode 100644 index 0000000..778f0b6 --- /dev/null +++ b/tests/path_test.py @@ -0,0 +1,253 @@ +import pytest + +import zon + + +def test_nested_record_in_record_paths(): + """Test path population for nested records within records""" + # Create nested record structure: user -> address -> street + address_validator = zon.record( + { + "street": zon.string().min(5), + "city": zon.string(), + } + ) + + user_validator = zon.record( + { + "name": zon.string(), + "address": address_validator, + } + ) + + # Test nested error path + result = user_validator.safe_validate( + { + "name": "John", + "address": { + "street": "x", # Too short (violates min(5)) + "city": "NYC", + }, + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["address", "street"] + + +def test_nested_list_in_record_paths(): + """Test path population for lists within records""" + # Record containing a list of strings + user_validator = zon.record( + { + "name": zon.string(), + "hobbies": zon.string().list().min(1), # Non-empty list of strings + } + ) + + # Test list element error within record + result = user_validator.safe_validate( + { + "name": "John", + "hobbies": ["reading", 123, "coding"], # Second element wrong type + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["hobbies", "1"] + + # Test multiple list element errors within record + result = user_validator.safe_validate( + { + "name": "John", + "hobbies": [123, 456, "coding"], # First two elements wrong type + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 2 + paths = [issue.path for issue in issues] + assert ["hobbies", "0"] in paths + assert ["hobbies", "1"] in paths + + +def test_nested_record_in_list_paths(): + """Test path population for records within lists""" + # List of user records + user_record = zon.record( + { + "name": zon.string().min(1), # Require at least 1 character + "age": zon.number().int().positive(), + } + ) + users_validator = user_record.list() + + # Test record field error within list + result = users_validator.safe_validate( + [ + {"name": "John", "age": 25}, + {"name": "Jane", "age": -5}, # Negative age + ] + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["1", "age"] + + # Test multiple record errors within list + result = users_validator.safe_validate( + [ + {"name": "", "age": 25}, # Empty name + {"name": "Jane", "age": -5}, # Negative age + ] + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 2 + paths = [issue.path for issue in issues] + assert ["0", "name"] in paths + assert ["1", "age"] in paths + + +def test_deeply_nested_structure_paths(): + """Test path population for deeply nested structures""" + # Create a deeply nested structure: company -> departments -> employees -> address + address_validator = zon.record( + { + "street": zon.string(), + "zip": zon.string().regex(r"^\d{5}$"), + } + ) + + employee_validator = zon.record( + { + "name": zon.string(), + "address": address_validator, + } + ) + + department_validator = zon.record( + { + "name": zon.string(), + "employees": employee_validator.list(), + } + ) + + company_validator = zon.record( + { + "name": zon.string(), + "departments": department_validator.list(), + } + ) + + # Test very deeply nested error + result = company_validator.safe_validate( + { + "name": "TechCorp", + "departments": [ + { + "name": "Engineering", + "employees": [ + { + "name": "Alice", + "address": { + "street": "123 Main St", + "zip": "123", # Invalid zip code (not 5 digits) + }, + } + ], + } + ], + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["departments", "0", "employees", "0", "address", "zip"] + + +def test_tuple_in_record_paths(): + """Test path population for tuples within records""" + # Record containing a tuple + person_validator = zon.record( + { + "name": zon.string(), + "coordinates": zon.element_tuple( + [zon.number(), zon.number()] + ), # (x, y) coordinates + } + ) + + # Test tuple element error within record + result = person_validator.safe_validate( + { + "name": "John", + "coordinates": (10.5, "not_a_number"), # Second element wrong type + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["coordinates", "1"] + + +def test_list_in_tuple_paths(): + """Test path population for lists within tuples""" + # Tuple containing a list + order_validator = zon.element_tuple( + [ + zon.string(), # order_id + zon.string().list(), # items + ] + ) + + # Test list element error within tuple + result = order_validator.safe_validate(("order123", ["item1", 123, "item3"])) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["1", "1"] # Tuple index 1, list index 1 + + +def test_optional_nested_structure_paths(): + """Test path population for optional nested structures""" + # Optional nested record + optional_address_validator = zon.record( + { + "street": zon.string(), + "city": zon.string(), + } + ).optional() + + user_validator = zon.record( + { + "name": zon.string(), + "address": optional_address_validator, + } + ) + + # Test error in optional nested structure + result = user_validator.safe_validate( + { + "name": "John", + "address": { + "street": "123 Main St", + "city": 123, # Wrong type + }, + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["address", "city"] + + # Test with None (should be valid for optional) + result = user_validator.safe_validate( + { + "name": "John", + "address": None, + } + ) + assert result[0] is True diff --git a/tests/record_test.py b/tests/record_test.py index a78d570..1e3e198 100644 --- a/tests/record_test.py +++ b/tests/record_test.py @@ -49,7 +49,7 @@ def test_record_validate(validator): ) -def test_record_sage_validate(validator): +def test_record_safe_validate(validator): assert validator.safe_validate( { "name": "John", @@ -335,3 +335,56 @@ def test_record_unknown_key_policy_passthrough(validator): "age": 1, "unknown": 1, } + + +def test_record_error_paths(validator): + """Test that validation errors for record fields have correct paths""" + # Test field validation error path + result = validator.safe_validate( + { + "name": "", # Too short (violates min(1)) + "age": 1, + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["name"] + + # Test another field validation error path + result = validator.safe_validate( + { + "name": "John", + "age": -5, # Not positive + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["age"] + + # Test multiple field validation errors + result = validator.safe_validate( + { + "name": "", # Too short + "age": -5, # Not positive + } + ) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 2 + paths = [issue.path for issue in issues] + assert ["name"] in paths + assert ["age"] in paths + + # Test with missing required field + result = validator.safe_validate( + { + "name": "John", + # age missing + } + ) + assert result[0] is False + issues = result[1].issues + # Missing field should also have the field name in the path + assert any(issue.path == ["age"] for issue in issues) diff --git a/tests/tuple_test.py b/tests/tuple_test.py index ddc5f30..2ed9f12 100644 --- a/tests/tuple_test.py +++ b/tests/tuple_test.py @@ -51,3 +51,56 @@ def test_tuple_rest(validator): with pytest.raises(zon.error.ZonError): _validator.validate(("1", 1.5, True, "1")) + + +def test_tuple_error_paths(items): + """Test that validation errors for tuple elements have correct paths""" + validator = zon.element_tuple(items) + + # Test tuple element validation error + result = validator.safe_validate( + ("valid", "not_a_number", True) + ) # Second element wrong type + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["1"] + + # Test another tuple element validation error + result = validator.safe_validate( + ("valid", 1.5, "not_a_boolean") + ) # Third element wrong type + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == ["2"] + + # Test multiple tuple element validation errors + result = validator.safe_validate( + ("valid", "not_a_number", "not_a_boolean") + ) # Second and third wrong + assert result[0] is False + issues = result[1].issues + assert len(issues) == 2 + paths = [issue.path for issue in issues] + assert ["1"] in paths + assert ["2"] in paths + + # Test tuple with rest elements + validator_with_rest = validator.rest(zon.string()) + result = validator_with_rest.safe_validate( + ("valid", 1.5, True, "good", 123, "also_good") + ) # 5th element wrong type + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == [ + "1" + ] # Rest elements have their own indexing starting from 0 + + # Test wrong number of elements (no element-specific paths) + result = validator.safe_validate(("valid", 1.5)) # Missing third element + assert result[0] is False + issues = result[1].issues + # Wrong number of elements should be at the top level, not element paths + assert all(len(issue.path) == 0 for issue in issues) diff --git a/tests/zon_test.py b/tests/zon_test.py index 63f7059..88c4cc6 100644 --- a/tests/zon_test.py +++ b/tests/zon_test.py @@ -16,3 +16,31 @@ def test_refinement(validator): with pytest.raises(zon.error.ZonError): _validator.validate("21") + + +def test_top_level_error_path(validator): + """Test that top-level validation errors have an empty path""" + _validator = validator.min(3) + + result = _validator.safe_validate("ab") + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == [] # Empty path for top-level errors + + # Test with number validator + number_validator = zon.number().int().positive() + result = number_validator.safe_validate(-5) + assert result[0] is False + issues = result[1].issues + assert len(issues) == 1 + assert issues[0].path == [] # Empty path for top-level errors + + # Test with boolean validator + bool_validator = zon.boolean() + result = bool_validator.safe_validate("not_a_boolean") + assert result[0] is False + issues = result[1].issues + assert len(issues) >= 1 + for issue in issues: + assert issue.path == [] # All top-level errors should have empty paths diff --git a/zon/__init__.py b/zon/__init__.py index 4b8e596..240d09e 100644 --- a/zon/__init__.py +++ b/zon/__init__.py @@ -1263,7 +1263,16 @@ def _default_validate(self, data, ctx: ValidationContext): (validated, data_or_error) = zon.safe_validate(validation_value) if not validated: - ctx.add_issues(data_or_error.issues) + updated_issues = [ + ZonIssue( + value=issue.value, + message=issue.message, + path=[key] + issue.path, + ) + for issue in data_or_error.issues + ] + + ctx.add_issues(updated_issues) elif data_or_error is not None: # in case of optional data data_to_return[key] = data_or_error @@ -1291,7 +1300,16 @@ def _default_validate(self, data, ctx: ValidationContext): (valid, data_or_error) = self._catchall.safe_validate(value) if not valid: - ctx.add_issues(data_or_error.issues) + updated_issues = [ + ZonIssue( + value=issue.value, + message=issue.message, + path=[key] + issue.path, + ) + for issue in data_or_error.issues + ] + + ctx.add_issues(updated_issues) elif data_or_error is not None: # in case of optional data data_to_return[key] = data_or_error @@ -1528,11 +1546,21 @@ def _default_validate(self, data: T, ctx: ValidationContext): ctx.add_issue(ZonIssue(value=data, message="Not a valid list", path=[])) return data - for element in data: + for i, element in enumerate(data): (valid, data_or_error) = self._element.safe_validate(element) if not valid: - ctx.add_issues(data_or_error.issues) + + updated_issues = [ + ZonIssue( + value=issue.value, + message=issue.message, + path=[str(i)] + issue.path, + ) + for issue in data_or_error.issues + ] + + ctx.add_issues(updated_issues) return data @@ -1645,14 +1673,32 @@ def _default_validate(self, data: T, ctx: ValidationContext): (valid, data_or_error) = _validator.safe_validate(data[i]) if not valid: - ctx.add_issues(data_or_error.issues) + updated_issues = [ + ZonIssue( + value=issue.value, + message=issue.message, + path=[str(i)] + issue.path, + ) + for issue in data_or_error.issues + ] + + ctx.add_issues(updated_issues) if self._rest is not None: - for extra_value in data[len(self.items) :]: + for i, extra_value in enumerate(data[len(self.items) :]): (valid, data_or_error) = self._rest.safe_validate(extra_value) if not valid: - ctx.add_issues(data_or_error.issues) + updated_issues = [ + ZonIssue( + value=issue.value, + message=issue.message, + path=[str(i)] + issue.path, + ) + for issue in data_or_error.issues + ] + + ctx.add_issues(updated_issues) return data