Skip to content

Commit c3596e4

Browse files
authored
More test coverage (#603)
* test for default disambiguator with None * Clean up tuple structuring * Add test for error in default disambiguator * More BaseValidationErrors tests * Add exception note grouping test * Remove some dead code? * disambiguators: test edge case
1 parent dbe138b commit c3596e4

File tree

7 files changed

+152
-31
lines changed

7 files changed

+152
-31
lines changed

src/cattrs/converters.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -885,15 +885,15 @@ def _structure_optional(self, obj, union):
885885
# We can't actually have a Union of a Union, so this is safe.
886886
return self._structure_func.dispatch(other)(obj, other)
887887

888-
def _structure_tuple(self, obj: Any, tup: type[T]) -> T:
888+
def _structure_tuple(self, obj: Iterable, tup: type[T]) -> T:
889889
"""Deal with structuring into a tuple."""
890890
tup_params = None if tup in (Tuple, tuple) else tup.__args__
891891
has_ellipsis = tup_params and tup_params[-1] is Ellipsis
892892
if tup_params is None or (has_ellipsis and tup_params[0] in ANIES):
893893
# Just a Tuple. (No generic information.)
894894
return tuple(obj)
895895
if has_ellipsis:
896-
# We're dealing with a homogenous tuple, Tuple[int, ...]
896+
# We're dealing with a homogenous tuple, tuple[int, ...]
897897
tup_type = tup_params[0]
898898
conv = self._structure_func.dispatch(tup_type)
899899
if self.detailed_validation:
@@ -920,13 +920,6 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T:
920920

921921
# We're dealing with a heterogenous tuple.
922922
exp_len = len(tup_params)
923-
try:
924-
len_obj = len(obj)
925-
except TypeError:
926-
pass # most likely an unsized iterator, eg generator
927-
else:
928-
if len_obj > exp_len:
929-
exp_len = len_obj
930923
if self.detailed_validation:
931924
errors = []
932925
res = []
@@ -940,8 +933,8 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T:
940933
)
941934
exc.__notes__ = [*getattr(exc, "__notes__", []), msg]
942935
errors.append(exc)
943-
if len(res) < exp_len:
944-
problem = "Not enough" if len(res) < len(tup_params) else "Too many"
936+
if len(obj) != exp_len:
937+
problem = "Not enough" if len(res) < exp_len else "Too many"
945938
exc = ValueError(f"{problem} values in {obj!r} to structure as {tup!r}")
946939
msg = f"Structuring {tup}"
947940
exc.__notes__ = [*getattr(exc, "__notes__", []), msg]
@@ -950,13 +943,12 @@ def _structure_tuple(self, obj: Any, tup: type[T]) -> T:
950943
raise IterableValidationError(f"While structuring {tup!r}", errors, tup)
951944
return tuple(res)
952945

953-
res = tuple(
946+
if len(obj) != exp_len:
947+
problem = "Not enough" if len(obj) < len(tup_params) else "Too many"
948+
raise ValueError(f"{problem} values in {obj!r} to structure as {tup!r}")
949+
return tuple(
954950
[self._structure_func.dispatch(t)(e, t) for t, e in zip(tup_params, obj)]
955951
)
956-
if len(res) < exp_len:
957-
problem = "Not enough" if len(res) < len(tup_params) else "Too many"
958-
raise ValueError(f"{problem} values in {obj!r} to structure as {tup!r}")
959-
return res
960952

961953
def _get_dis_func(
962954
self,
@@ -971,11 +963,10 @@ def _get_dis_func(
971963
# logic.
972964
union_types = tuple(e for e in union_types if e is not NoneType)
973965

974-
# TODO: technically both disambiguators could support TypedDicts and
975-
# dataclasses...
966+
# TODO: technically both disambiguators could support TypedDicts too
976967
if not all(has(get_origin(e) or e) for e in union_types):
977968
raise StructureHandlerNotFoundError(
978-
"Only unions of attrs classes supported "
969+
"Only unions of attrs classes and dataclasses supported "
979970
"currently. Register a structure hook manually.",
980971
type_=union,
981972
)

src/cattrs/errors.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
from collections.abc import Sequence
12
from typing import Any, Optional, Union
23

4+
from typing_extensions import Self
5+
36
from cattrs._compat import ExceptionGroup
47

58

@@ -17,13 +20,13 @@ def __init__(self, message: str, type_: type) -> None:
1720
class BaseValidationError(ExceptionGroup):
1821
cl: type
1922

20-
def __new__(cls, message, excs, cl: type):
23+
def __new__(cls, message: str, excs: Sequence[Exception], cl: type):
2124
obj = super().__new__(cls, message, excs)
2225
obj.cl = cl
2326
return obj
2427

25-
def derive(self, excs):
26-
return ClassValidationError(self.message, excs, self.cl)
28+
def derive(self, excs: Sequence[Exception]) -> Self:
29+
return self.__class__(self.message, excs, self.cl)
2730

2831

2932
class IterableValidationNote(str):

src/cattrs/gen/__init__.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,6 @@ def make_dict_unstructure_fn(
245245
if is_generic(cl):
246246
mapping = generate_mapping(cl, mapping)
247247

248-
for base in getattr(origin, "__orig_bases__", ()):
249-
if is_generic(base) and not str(base).startswith("typing.Generic"):
250-
mapping = generate_mapping(base, mapping)
251-
break
252248
if origin is not None:
253249
cl = origin
254250

tests/test_disambiguators.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from cattrs import Converter
1212
from cattrs.disambiguators import create_default_dis_func, is_supported_union
13+
from cattrs.errors import StructureHandlerNotFoundError
1314
from cattrs.gen import make_dict_structure_fn, override
1415

1516
from .untyped import simple_classes
@@ -76,7 +77,40 @@ class H:
7677

7778
with pytest.raises(TypeError):
7879
# The discriminator chosen does not actually help
79-
create_default_dis_func(c, C, D)
80+
create_default_dis_func(c, G, H)
81+
82+
# Not an attrs class or dataclass
83+
class J:
84+
i: int
85+
86+
with pytest.raises(StructureHandlerNotFoundError):
87+
c.get_structure_hook(Union[A, J])
88+
89+
@define
90+
class K:
91+
x: Literal[2]
92+
93+
fn = create_default_dis_func(c, G, K)
94+
with pytest.raises(ValueError):
95+
# The input should be a mapping
96+
fn([])
97+
98+
# A normal class with a required attribute
99+
@define
100+
class L:
101+
b: str
102+
103+
# C and L both have a required attribute, so there will be no fallback.
104+
fn = create_default_dis_func(c, C, L)
105+
with pytest.raises(ValueError):
106+
# We can't disambiguate based on this payload, so we error
107+
fn({"c": 1})
108+
109+
# A has no attributes, so it ends up being the fallback
110+
fn = create_default_dis_func(c, A, C)
111+
with pytest.raises(ValueError):
112+
# The input should be a mapping
113+
fn([])
80114

81115

82116
@given(simple_classes(defaults=False))
@@ -232,6 +266,23 @@ class D:
232266
assert no_lits({"a": "a"}) is D
233267

234268

269+
def test_default_none():
270+
"""The default disambiguator can handle `None`."""
271+
c = Converter()
272+
273+
@define
274+
class A:
275+
a: int
276+
277+
@define
278+
class B:
279+
b: str
280+
281+
hook = c.get_structure_hook(Union[A, B, None])
282+
assert hook({"a": 1}, Union[A, B, None]) == A(1)
283+
assert hook(None, Union[A, B, None]) is None
284+
285+
235286
def test_converter_no_literals(converter: Converter):
236287
"""A converter can be configured to skip literals."""
237288

tests/test_generics.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,30 @@ class OuterStr:
201201
assert genconverter.structure(raw, OuterStr) == OuterStr(Inner("1"))
202202

203203

204+
def test_unstructure_generic_inheritance(genconverter):
205+
"""Classes inheriting from generic classes work."""
206+
genconverter.register_unstructure_hook(int, lambda v: v + 1)
207+
genconverter.register_unstructure_hook(str, lambda v: str(int(v) + 1))
208+
209+
@define
210+
class Parent(Generic[T]):
211+
a: T
212+
213+
@define
214+
class Child(Parent, Generic[T]):
215+
b: str
216+
217+
instance = Child(1, "2")
218+
assert genconverter.unstructure(instance, Child[int]) == {"a": 2, "b": "3"}
219+
220+
@define
221+
class ExplicitChild(Parent[int]):
222+
b: str
223+
224+
instance = ExplicitChild(1, "2")
225+
assert genconverter.unstructure(instance, ExplicitChild) == {"a": 2, "b": "3"}
226+
227+
204228
def test_unstructure_optional(genconverter):
205229
"""Generics with optional fields work."""
206230

tests/test_unions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Type, Union
1+
from typing import Union
22

33
import pytest
44
from attrs import define
@@ -9,7 +9,7 @@
99

1010

1111
@pytest.mark.parametrize("cls", (BaseConverter, Converter))
12-
def test_custom_union_toplevel_roundtrip(cls: Type[BaseConverter]):
12+
def test_custom_union_toplevel_roundtrip(cls: type[BaseConverter]):
1313
"""
1414
Test custom code union handling.
1515
@@ -42,7 +42,7 @@ class B:
4242

4343
@pytest.mark.skipif(not is_py310_plus, reason="3.10 union syntax")
4444
@pytest.mark.parametrize("cls", (BaseConverter, Converter))
45-
def test_310_custom_union_toplevel_roundtrip(cls: Type[BaseConverter]):
45+
def test_310_custom_union_toplevel_roundtrip(cls: type[BaseConverter]):
4646
"""
4747
Test custom code union handling.
4848
@@ -74,7 +74,7 @@ class B:
7474

7575

7676
@pytest.mark.parametrize("cls", (BaseConverter, Converter))
77-
def test_custom_union_clsfield_roundtrip(cls: Type[BaseConverter]):
77+
def test_custom_union_clsfield_roundtrip(cls: type[BaseConverter]):
7878
"""
7979
Test custom code union handling.
8080

tests/test_validation.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,59 @@ def test_notes_pickling():
222222
assert note == "foo"
223223
assert note.name == "name"
224224
assert note.type is int
225+
226+
227+
def test_error_derive():
228+
"""Our ExceptionGroups should derive properly."""
229+
c = Converter(detailed_validation=True)
230+
231+
@define
232+
class Test:
233+
a: int
234+
b: str = field(validator=in_(["a", "b"]))
235+
c: str
236+
237+
with pytest.raises(ClassValidationError) as exc:
238+
c.structure({"a": "a", "b": "c"}, Test)
239+
240+
match, rest = exc.value.split(KeyError)
241+
242+
assert len(match.exceptions) == 1
243+
assert len(rest.exceptions) == 1
244+
245+
assert match.cl == exc.value.cl
246+
assert rest.cl == exc.value.cl
247+
248+
249+
def test_iterable_note_grouping():
250+
"""IterableValidationErrors can group their subexceptions by notes."""
251+
exc1 = ValueError()
252+
exc2 = KeyError()
253+
exc3 = TypeError()
254+
255+
exc2.__notes__ = [note := IterableValidationNote("Test Note", 0, int)]
256+
exc3.__notes__ = ["A string note"]
257+
258+
exc = IterableValidationError("Test", [exc1, exc2, exc3], list[int])
259+
260+
with_notes, without_notes = exc.group_exceptions()
261+
262+
assert with_notes == [(exc2, note)]
263+
assert without_notes == [exc1, exc3]
264+
265+
266+
def test_class_note_grouping():
267+
"""ClassValidationErrors can group their subexceptions by notes."""
268+
exc1 = ValueError()
269+
exc2 = KeyError()
270+
exc3 = TypeError()
271+
272+
exc2.__notes__ = [note := AttributeValidationNote("Test Note", "a", int)]
273+
exc3.__notes__ = ["A string note"]
274+
275+
exc = ClassValidationError("Test", [exc1, exc2, exc3], int)
276+
277+
with_notes, without_notes = exc.group_exceptions()
278+
279+
assert with_notes == [(exc2, note)]
280+
assert without_notes == [exc1, exc3]

0 commit comments

Comments
 (0)