Skip to content

Commit 4155e97

Browse files
committed
Add test
1 parent 31b6412 commit 4155e97

File tree

2 files changed

+119
-16
lines changed

2 files changed

+119
-16
lines changed

src/cattrs/strategies/_listfromdict.py

Lines changed: 84 additions & 8 deletions
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

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,27 @@
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
1012
from cattrs.strategies import configure_list_from_dict
1113

1214

1315
@define
1416
class AttrsA:
1517
a: int
16-
b: str
18+
b: int
1719

1820

1921
@dataclass
2022
class DataclassA:
2123
a: int
22-
b: str
24+
b: int
2325

2426

2527
class TypedDictA(TypedDict):
2628
a: int
27-
b: str
29+
b: int
2830

2931

3032
@pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA])
@@ -33,18 +35,43 @@ def test_simple_roundtrip(
3335
):
3436
hook, hook2 = configure_list_from_dict(list[cls], "a", converter)
3537

36-
structured = [cls(a=1, b="2"), cls(a=3, b="4")]
38+
structured = [cls(a=1, b=2), cls(a=3, b=4)]
3739
unstructured = hook2(structured)
38-
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
40+
assert unstructured == {1: {"b": 2}, 3: {"b": 4}}
3941

4042
assert hook(unstructured) == structured
4143

4244

4345
def test_simple_roundtrip_attrs(converter: BaseConverter):
4446
hook, hook2 = configure_list_from_dict(list[AttrsA], fields(AttrsA).a, converter)
4547

46-
structured = [AttrsA(a=1, b="2"), AttrsA(a=3, b="4")]
48+
structured = [AttrsA(a=1, b=2), AttrsA(a=3, b=4)]
4749
unstructured = hook2(structured)
48-
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
50+
assert unstructured == {1: {"b": 2}, 3: {"b": 4}}
4951

5052
assert hook(unstructured) == structured
53+
54+
55+
def test_validation_errors():
56+
"""
57+
With detailed validation, validation errors should be adjusted for the
58+
extracted keys.
59+
"""
60+
conv = Converter(detailed_validation=True)
61+
hook, _ = configure_list_from_dict(list[AttrsA], "a", conv)
62+
63+
# Key failure
64+
with pytest.raises(IterableValidationError) as exc:
65+
hook({"a": {"b": "1"}})
66+
67+
assert transform_error(exc.value) == [
68+
"invalid value for type, expected int @ $['a']"
69+
]
70+
71+
# Value failure
72+
with pytest.raises(IterableValidationError) as exc:
73+
hook({1: {"b": "a"}})
74+
75+
assert transform_error(exc.value) == [
76+
"invalid value for type, expected int @ $[1].b"
77+
]

0 commit comments

Comments
 (0)