From 2f141aa5aa4144c253d9c6c2233b97fef65673e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 28 Dec 2024 23:01:08 +0100 Subject: [PATCH] Add test --- src/cattrs/strategies/_listfromdict.py | 92 ++++++++++++++++++++++--- tests/strategies/test_list_from_dict.py | 55 ++++++++++++--- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/cattrs/strategies/_listfromdict.py b/src/cattrs/strategies/_listfromdict.py index d98d5dee..6b82be4d 100644 --- a/src/cattrs/strategies/_listfromdict.py +++ b/src/cattrs/strategies/_listfromdict.py @@ -9,6 +9,12 @@ from .. import BaseConverter, SimpleStructureHook from ..dispatch import UnstructureHook +from ..errors import ( + AttributeValidationNote, + ClassValidationError, + IterableValidationError, + IterableValidationNote, +) from ..fns import identity from ..gen.typeddicts import is_typeddict @@ -48,14 +54,84 @@ def configure_list_from_dict( if isinstance(field, Attribute): field = field.name - def structure_hook( - value: Mapping, - _: Any = seq_type, - _arg_type=arg_type, - _arg_hook=arg_structure_hook, - _field=field, - ) -> list[T]: - return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()] + if converter.detailed_validation: + + def structure_hook( + value: Mapping, + _: Any = seq_type, + _arg_type=arg_type, + _arg_hook=arg_structure_hook, + _field=field, + ) -> list[T]: + res = [] + errors = [] + for k, v in value.items(): + try: + res.append(_arg_hook(v | {_field: k}, _arg_type)) + except ClassValidationError as exc: + # Rewrite the notes of any errors relating to `_field` + non_key_exceptions = [] + key_exceptions = [] + for inner_exc in exc.exceptions: + if not (existing := getattr(inner_exc, "__notes__", [])): + non_key_exceptions.append(inner_exc) + continue + for note in existing: + if not isinstance(note, AttributeValidationNote): + continue + if note.name == _field: + inner_exc.__notes__.remove(note) + inner_exc.__notes__.append( + IterableValidationNote( + f"Structuring mapping key @ key {k!r}", + note.name, + note.type, + ) + ) + key_exceptions.append(inner_exc) + break + else: + non_key_exceptions.append(inner_exc) + + if non_key_exceptions != exc.exceptions: + if non_key_exceptions: + errors.append( + new_exc := ClassValidationError( + exc.message, non_key_exceptions, exc.cl + ) + ) + new_exc.__notes__ = [ + *getattr(exc, "__notes__", []), + IterableValidationNote( + "Structuring mapping value @ key {k!r}", + k, + _arg_type, + ), + ] + else: + exc.__notes__ = [ + *getattr(exc, "__notes__", []), + IterableValidationNote( + "Structuring mapping value @ key {k!r}", k, _arg_type + ), + ] + errors.append(exc) + if key_exceptions: + errors.extend(key_exceptions) + if errors: + raise IterableValidationError("While structuring", errors, dict) + return res + + else: + + def structure_hook( + value: Mapping, + _: Any = seq_type, + _arg_type=arg_type, + _arg_hook=arg_structure_hook, + _field=field, + ) -> list[T]: + return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()] arg_unstructure_hook = converter.get_unstructure_hook(arg_type, cache_result=False) diff --git a/tests/strategies/test_list_from_dict.py b/tests/strategies/test_list_from_dict.py index b67d7339..de3ada93 100644 --- a/tests/strategies/test_list_from_dict.py +++ b/tests/strategies/test_list_from_dict.py @@ -6,25 +6,28 @@ import pytest from attrs import define, fields -from cattrs import BaseConverter +from cattrs import BaseConverter, transform_error +from cattrs.converters import Converter +from cattrs.errors import IterableValidationError +from cattrs.gen import make_dict_structure_fn, override from cattrs.strategies import configure_list_from_dict @define class AttrsA: a: int - b: str + b: int @dataclass class DataclassA: a: int - b: str + b: int class TypedDictA(TypedDict): a: int - b: str + b: int @pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA]) @@ -33,9 +36,9 @@ def test_simple_roundtrip( ): hook, hook2 = configure_list_from_dict(list[cls], "a", converter) - structured = [cls(a=1, b="2"), cls(a=3, b="4")] + structured = [cls(a=1, b=2), cls(a=3, b=4)] unstructured = hook2(structured) - assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}} + assert unstructured == {1: {"b": 2}, 3: {"b": 4}} assert hook(unstructured) == structured @@ -43,8 +46,44 @@ def test_simple_roundtrip( def test_simple_roundtrip_attrs(converter: BaseConverter): hook, hook2 = configure_list_from_dict(list[AttrsA], fields(AttrsA).a, converter) - structured = [AttrsA(a=1, b="2"), AttrsA(a=3, b="4")] + structured = [AttrsA(a=1, b=2), AttrsA(a=3, b=4)] unstructured = hook2(structured) - assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}} + assert unstructured == {1: {"b": 2}, 3: {"b": 4}} assert hook(unstructured) == structured + + +def test_validation_errors(): + """ + With detailed validation, validation errors should be adjusted for the + extracted keys. + """ + conv = Converter(detailed_validation=True) + hook, _ = configure_list_from_dict(list[AttrsA], "a", conv) + + # Key failure + with pytest.raises(IterableValidationError) as exc: + hook({"a": {"b": "1"}}) + + assert transform_error(exc.value) == [ + "invalid value for type, expected int @ $['a']" + ] + + # Value failure + with pytest.raises(IterableValidationError) as exc: + hook({1: {"b": "a"}}) + + assert transform_error(exc.value) == [ + "invalid value for type, expected int @ $[1].b" + ] + + conv.register_structure_hook( + AttrsA, make_dict_structure_fn(AttrsA, conv, _cattrs_forbid_extra_keys=True) + ) + hook, _ = configure_list_from_dict(list[AttrsA], "a", conv) + + # Value failure, not attribute related + with pytest.raises(IterableValidationError) as exc: + hook({1: {"b": 1, "c": 2}}) + + assert transform_error(exc.value) == ["extra fields found (c) @ $[1]"]