Skip to content

Commit 2f141aa

Browse files
committed
Add test
1 parent 31b6412 commit 2f141aa

File tree

2 files changed

+131
-16
lines changed

2 files changed

+131
-16
lines changed

src/cattrs/strategies/_listfromdict.py

+84-8
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
from .. import BaseConverter, SimpleStructureHook
1111
from ..dispatch import UnstructureHook
12+
from ..errors import (
13+
AttributeValidationNote,
14+
ClassValidationError,
15+
IterableValidationError,
16+
IterableValidationNote,
17+
)
1218
from ..fns import identity
1319
from ..gen.typeddicts import is_typeddict
1420

@@ -48,14 +54,84 @@ def configure_list_from_dict(
4854
if isinstance(field, Attribute):
4955
field = field.name
5056

51-
def structure_hook(
52-
value: Mapping,
53-
_: Any = seq_type,
54-
_arg_type=arg_type,
55-
_arg_hook=arg_structure_hook,
56-
_field=field,
57-
) -> list[T]:
58-
return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()]
57+
if converter.detailed_validation:
58+
59+
def structure_hook(
60+
value: Mapping,
61+
_: Any = seq_type,
62+
_arg_type=arg_type,
63+
_arg_hook=arg_structure_hook,
64+
_field=field,
65+
) -> list[T]:
66+
res = []
67+
errors = []
68+
for k, v in value.items():
69+
try:
70+
res.append(_arg_hook(v | {_field: k}, _arg_type))
71+
except ClassValidationError as exc:
72+
# Rewrite the notes of any errors relating to `_field`
73+
non_key_exceptions = []
74+
key_exceptions = []
75+
for inner_exc in exc.exceptions:
76+
if not (existing := getattr(inner_exc, "__notes__", [])):
77+
non_key_exceptions.append(inner_exc)
78+
continue
79+
for note in existing:
80+
if not isinstance(note, AttributeValidationNote):
81+
continue
82+
if note.name == _field:
83+
inner_exc.__notes__.remove(note)
84+
inner_exc.__notes__.append(
85+
IterableValidationNote(
86+
f"Structuring mapping key @ key {k!r}",
87+
note.name,
88+
note.type,
89+
)
90+
)
91+
key_exceptions.append(inner_exc)
92+
break
93+
else:
94+
non_key_exceptions.append(inner_exc)
95+
96+
if non_key_exceptions != exc.exceptions:
97+
if non_key_exceptions:
98+
errors.append(
99+
new_exc := ClassValidationError(
100+
exc.message, non_key_exceptions, exc.cl
101+
)
102+
)
103+
new_exc.__notes__ = [
104+
*getattr(exc, "__notes__", []),
105+
IterableValidationNote(
106+
"Structuring mapping value @ key {k!r}",
107+
k,
108+
_arg_type,
109+
),
110+
]
111+
else:
112+
exc.__notes__ = [
113+
*getattr(exc, "__notes__", []),
114+
IterableValidationNote(
115+
"Structuring mapping value @ key {k!r}", k, _arg_type
116+
),
117+
]
118+
errors.append(exc)
119+
if key_exceptions:
120+
errors.extend(key_exceptions)
121+
if errors:
122+
raise IterableValidationError("While structuring", errors, dict)
123+
return res
124+
125+
else:
126+
127+
def structure_hook(
128+
value: Mapping,
129+
_: Any = seq_type,
130+
_arg_type=arg_type,
131+
_arg_hook=arg_structure_hook,
132+
_field=field,
133+
) -> list[T]:
134+
return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()]
59135

60136
arg_unstructure_hook = converter.get_unstructure_hook(arg_type, cache_result=False)
61137

tests/strategies/test_list_from_dict.py

+47-8
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,28 @@
66
import pytest
77
from attrs import define, fields
88

9-
from cattrs import BaseConverter
9+
from cattrs import BaseConverter, transform_error
10+
from cattrs.converters import Converter
11+
from cattrs.errors import IterableValidationError
12+
from cattrs.gen import make_dict_structure_fn, override
1013
from cattrs.strategies import configure_list_from_dict
1114

1215

1316
@define
1417
class AttrsA:
1518
a: int
16-
b: str
19+
b: int
1720

1821

1922
@dataclass
2023
class DataclassA:
2124
a: int
22-
b: str
25+
b: int
2326

2427

2528
class TypedDictA(TypedDict):
2629
a: int
27-
b: str
30+
b: int
2831

2932

3033
@pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA])
@@ -33,18 +36,54 @@ def test_simple_roundtrip(
3336
):
3437
hook, hook2 = configure_list_from_dict(list[cls], "a", converter)
3538

36-
structured = [cls(a=1, b="2"), cls(a=3, b="4")]
39+
structured = [cls(a=1, b=2), cls(a=3, b=4)]
3740
unstructured = hook2(structured)
38-
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
41+
assert unstructured == {1: {"b": 2}, 3: {"b": 4}}
3942

4043
assert hook(unstructured) == structured
4144

4245

4346
def test_simple_roundtrip_attrs(converter: BaseConverter):
4447
hook, hook2 = configure_list_from_dict(list[AttrsA], fields(AttrsA).a, converter)
4548

46-
structured = [AttrsA(a=1, b="2"), AttrsA(a=3, b="4")]
49+
structured = [AttrsA(a=1, b=2), AttrsA(a=3, b=4)]
4750
unstructured = hook2(structured)
48-
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
51+
assert unstructured == {1: {"b": 2}, 3: {"b": 4}}
4952

5053
assert hook(unstructured) == structured
54+
55+
56+
def test_validation_errors():
57+
"""
58+
With detailed validation, validation errors should be adjusted for the
59+
extracted keys.
60+
"""
61+
conv = Converter(detailed_validation=True)
62+
hook, _ = configure_list_from_dict(list[AttrsA], "a", conv)
63+
64+
# Key failure
65+
with pytest.raises(IterableValidationError) as exc:
66+
hook({"a": {"b": "1"}})
67+
68+
assert transform_error(exc.value) == [
69+
"invalid value for type, expected int @ $['a']"
70+
]
71+
72+
# Value failure
73+
with pytest.raises(IterableValidationError) as exc:
74+
hook({1: {"b": "a"}})
75+
76+
assert transform_error(exc.value) == [
77+
"invalid value for type, expected int @ $[1].b"
78+
]
79+
80+
conv.register_structure_hook(
81+
AttrsA, make_dict_structure_fn(AttrsA, conv, _cattrs_forbid_extra_keys=True)
82+
)
83+
hook, _ = configure_list_from_dict(list[AttrsA], "a", conv)
84+
85+
# Value failure, not attribute related
86+
with pytest.raises(IterableValidationError) as exc:
87+
hook({1: {"b": 1, "c": 2}})
88+
89+
assert transform_error(exc.value) == ["extra fields found (c) @ $[1]"]

0 commit comments

Comments
 (0)