From ba375e327e2a0a07b020821a304d6496c4290361 Mon Sep 17 00:00:00 2001 From: Yury Pliner Date: Sat, 30 Sep 2023 11:35:40 +0100 Subject: [PATCH 1/4] Add validation field errors --- marshmallow_recipe/__init__.py | 12 ++++++++++- marshmallow_recipe/validation.py | 36 +++++++++++++++++++++++++++++++ tests/test_validation.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/marshmallow_recipe/__init__.py b/marshmallow_recipe/__init__.py index 030d31b..f13e267 100644 --- a/marshmallow_recipe/__init__.py +++ b/marshmallow_recipe/__init__.py @@ -30,7 +30,14 @@ from .naming_case import CAMEL_CASE, CAPITAL_CAMEL_CASE, CamelCase, CapitalCamelCase, NamingCase from .options import NoneValueHandling, options from .serialization import EmptySchema, dump, dump_many, load, load_many, schema -from .validation import ValidationFunc, regexp_validate, validate +from .validation import ( + ValidationError, + ValidationFieldError, + ValidationFunc, + get_field_errors, + regexp_validate, + validate, +) __all__: tuple[str, ...] = ( # bake.py @@ -89,6 +96,9 @@ "ValidationFunc", "regexp_validate", "validate", + "ValidationError", + "ValidationFieldError", + "get_field_errors", ) __version__ = "0.0.32" diff --git a/marshmallow_recipe/validation.py b/marshmallow_recipe/validation.py index 19f3cbd..00aa879 100644 --- a/marshmallow_recipe/validation.py +++ b/marshmallow_recipe/validation.py @@ -1,4 +1,5 @@ import collections.abc +import dataclasses import re from typing import Any @@ -22,3 +23,38 @@ def _validator_with_custom_error(value: Any) -> Any: return result return _validator_with_custom_error + + +ValidationError = marshmallow.ValidationError + + +@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +class ValidationFieldError: + name: str + error: str | None = None + nested_errors: list["ValidationFieldError"] | None = None + + +def get_field_errors(exc: ValidationError) -> list[ValidationFieldError]: + return __get_field_errors_from_normalized_messages(exc.normalized_messages()) + + +def __get_field_errors_from_normalized_messages(normalized_messages: dict[Any, Any]) -> list[ValidationFieldError]: + errors: list["ValidationFieldError"] = [] + + for key, value in normalized_messages.items(): + if not isinstance(key, (str, int)): + continue + + if isinstance(value, dict): + errors.append( + ValidationFieldError(name=str(key), nested_errors=__get_field_errors_from_normalized_messages(value)) + ) + elif isinstance(value, str): + errors.append(ValidationFieldError(name=str(key), error=value)) + elif isinstance(value, list) and all([isinstance(item, str) for item in value]): + errors.append(ValidationFieldError(name=str(key), error=str.join("; ", value))) + + errors.sort(key=lambda x: x.name) + + return errors diff --git a/tests/test_validation.py b/tests/test_validation.py index 8885bb9..85e0012 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -278,3 +278,40 @@ class IntContainer: mr.dump(IntContainer(value=42)) assert exc_info.value.messages == {"value": ["Should be negative."]} + + +def test_get_normalized_messages() -> None: + @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) + class NestedContainer: + value: int + values: list[int] + + @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) + class Container: + value: int + values: list[int] + nested: NestedContainer + + with pytest.raises(mr.ValidationError) as exc_info: + mr.load( + Container, + {"value": "invalid", "values": ["invalid"], "nested": {"value": "invalid", "values": ["invalid"]}}, + ) + + assert mr.get_field_errors(exc_info.value) == [ + mr.ValidationFieldError( + name="nested", + nested_errors=[ + mr.ValidationFieldError(name="value", error="Not a valid integer."), + mr.ValidationFieldError( + name="values", + nested_errors=[mr.ValidationFieldError(name="0", error="Not a valid integer.")], + ), + ], + ), + mr.ValidationFieldError(name="value", error="Not a valid integer."), + mr.ValidationFieldError( + name="values", + nested_errors=[mr.ValidationFieldError(name="0", error="Not a valid integer.")], + ), + ] From 9c7ffa70c35b087c426d849d00143b200d7611f6 Mon Sep 17 00:00:00 2001 From: Yury Pliner Date: Sat, 30 Sep 2023 11:48:54 +0100 Subject: [PATCH 2/4] Rename test --- tests/test_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 85e0012..30f61e6 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -280,7 +280,7 @@ class IntContainer: assert exc_info.value.messages == {"value": ["Should be negative."]} -def test_get_normalized_messages() -> None: +def test_get_field_errors() -> None: @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class NestedContainer: value: int From ef274979e2dd220f497e068c955ceec905422f4f Mon Sep 17 00:00:00 2001 From: Yury Pliner Date: Sat, 30 Sep 2023 11:53:19 +0100 Subject: [PATCH 3/4] Fix join --- marshmallow_recipe/validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/marshmallow_recipe/validation.py b/marshmallow_recipe/validation.py index 00aa879..ae3b5eb 100644 --- a/marshmallow_recipe/validation.py +++ b/marshmallow_recipe/validation.py @@ -53,7 +53,7 @@ def __get_field_errors_from_normalized_messages(normalized_messages: dict[Any, A elif isinstance(value, str): errors.append(ValidationFieldError(name=str(key), error=value)) elif isinstance(value, list) and all([isinstance(item, str) for item in value]): - errors.append(ValidationFieldError(name=str(key), error=str.join("; ", value))) + errors.append(ValidationFieldError(name=str(key), error="; ".join(value))) errors.sort(key=lambda x: x.name) From e622ba164c4339e01611501870155d6edf076c28 Mon Sep 17 00:00:00 2001 From: Yury Pliner Date: Sat, 30 Sep 2023 14:18:26 +0100 Subject: [PATCH 4/4] Rearrange code --- marshmallow_recipe/validation.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/marshmallow_recipe/validation.py b/marshmallow_recipe/validation.py index ae3b5eb..d0432e1 100644 --- a/marshmallow_recipe/validation.py +++ b/marshmallow_recipe/validation.py @@ -28,7 +28,7 @@ def _validator_with_custom_error(value: Any) -> Any: ValidationError = marshmallow.ValidationError -@dataclasses.dataclass(kw_only=True, frozen=True, slots=True) +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class ValidationFieldError: name: str error: str | None = None @@ -40,21 +40,27 @@ def get_field_errors(exc: ValidationError) -> list[ValidationFieldError]: def __get_field_errors_from_normalized_messages(normalized_messages: dict[Any, Any]) -> list[ValidationFieldError]: - errors: list["ValidationFieldError"] = [] + field_errors: list[ValidationFieldError] = [] for key, value in normalized_messages.items(): if not isinstance(key, (str, int)): continue + name = str(key) + error: str | None = None + nested_errors: list[ValidationFieldError] | None = None + if isinstance(value, dict): - errors.append( - ValidationFieldError(name=str(key), nested_errors=__get_field_errors_from_normalized_messages(value)) - ) + nested_errors = __get_field_errors_from_normalized_messages(value) elif isinstance(value, str): - errors.append(ValidationFieldError(name=str(key), error=value)) + error = value elif isinstance(value, list) and all([isinstance(item, str) for item in value]): - errors.append(ValidationFieldError(name=str(key), error="; ".join(value))) + error = "; ".join(value) + else: + continue + + field_errors.append(ValidationFieldError(name=name, error=error, nested_errors=nested_errors)) - errors.sort(key=lambda x: x.name) + field_errors.sort(key=lambda x: x.name) - return errors + return field_errors