Skip to content

Commit 01e0fb0

Browse files
authored
Improve coverage (#606)
* Improve coverage * Clean up dead code * Improve `v` test coverage * Improve tagged_union coverage * Improve disambiguators coverage * Fail CI under 100% coverage
1 parent 3cc9419 commit 01e0fb0

11 files changed

+86
-31
lines changed

.github/workflows/main.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979
echo "total=$TOTAL" >> $GITHUB_ENV
8080
8181
# Report again and fail if under the threshold.
82-
python -Im coverage report --fail-under=99
82+
python -Im coverage report --fail-under=100
8383
8484
- name: "Upload HTML report."
8585
uses: "actions/upload-artifact@v4"

src/cattrs/converters.py

-1
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,6 @@ def _get_dis_func(
963963
# logic.
964964
union_types = tuple(e for e in union_types if e is not NoneType)
965965

966-
# TODO: technically both disambiguators could support TypedDicts too
967966
if not all(has(get_origin(e) or e) for e in union_types):
968967
raise StructureHandlerNotFoundError(
969968
"Only unions of attrs classes and dataclasses supported "

src/cattrs/disambiguators.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131

3232
def is_supported_union(typ: Any) -> bool:
33-
"""Whether the type is a union of attrs classes."""
33+
"""Whether the type is a union of attrs classes or dataclasses."""
3434
return is_union_type(typ) and all(
3535
e is NoneType or has(get_origin(e) or e) for e in typ.__args__
3636
)

src/cattrs/gen/_generics.py

+1-8
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,7 @@ def generate_mapping(cl: type, old_mapping: dict[str, type] = {}) -> dict[str, t
3636
origin = get_origin(cl)
3737

3838
if origin is not None:
39-
# To handle the cases where classes in the typing module are using
40-
# the GenericAlias structure but aren't a Generic and hence
41-
# end up in this function but do not have an `__parameters__`
42-
# attribute. These classes are interface types, for example
43-
# `typing.Hashable`.
44-
parameters = getattr(get_origin(cl), "__parameters__", None)
45-
if parameters is None:
46-
return dict(old_mapping)
39+
parameters = origin.__parameters__
4740

4841
for p, t in zip(parameters, get_args(cl)):
4942
if isinstance(t, TypeVar):

src/cattrs/strategies/_subclasses.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def include_subclasses(
8484
def _include_subclasses_without_union_strategy(
8585
cl,
8686
converter: BaseConverter,
87-
parent_subclass_tree: tuple[type],
87+
parent_subclass_tree: tuple[type, ...],
8888
overrides: dict[str, AttributeOverride] | None,
8989
):
9090
# The iteration approach is required if subclasses are more than one level deep:

tests/strategies/test_include_subclasses.py

+26-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import typing
22
from copy import deepcopy
33
from functools import partial
4-
from typing import List, Tuple
54

65
import pytest
76
from attrs import define
87

98
from cattrs import Converter, override
10-
from cattrs.errors import ClassValidationError
9+
from cattrs.errors import ClassValidationError, StructureHandlerNotFoundError
1110
from cattrs.strategies import configure_tagged_union, include_subclasses
1211

1312

@@ -148,7 +147,7 @@ def conv_w_subclasses(request):
148147
"struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT
149148
)
150149
def test_structuring_with_inheritance(
151-
conv_w_subclasses: Tuple[Converter, bool], struct_unstruct
150+
conv_w_subclasses: tuple[Converter, bool], struct_unstruct
152151
) -> None:
153152
structured, unstructured = struct_unstruct
154153

@@ -219,7 +218,7 @@ def test_circular_reference(conv_w_subclasses):
219218
"struct_unstruct", IDS_TO_STRUCT_UNSTRUCT.values(), ids=IDS_TO_STRUCT_UNSTRUCT
220219
)
221220
def test_unstructuring_with_inheritance(
222-
conv_w_subclasses: Tuple[Converter, bool], struct_unstruct
221+
conv_w_subclasses: tuple[Converter, bool], struct_unstruct
223222
):
224223
structured, unstructured = struct_unstruct
225224
converter, included_subclasses_param = conv_w_subclasses
@@ -389,5 +388,27 @@ class Derived(A):
389388
"_type": "Derived",
390389
}
391390
],
392-
List[A],
391+
list[A],
393392
) == [Derived(9, Derived(99, A(999)))]
393+
394+
395+
def test_unsupported_class(genconverter: Converter):
396+
"""Non-attrs/dataclass classes raise proper errors."""
397+
398+
class NewParent:
399+
"""Not an attrs class."""
400+
401+
a: int
402+
403+
@define
404+
class NewChild(NewParent):
405+
pass
406+
407+
@define
408+
class NewChild2(NewParent):
409+
pass
410+
411+
genconverter.register_structure_hook(NewParent, lambda v, _: NewParent(v))
412+
413+
with pytest.raises(StructureHandlerNotFoundError):
414+
include_subclasses(NewParent, genconverter)

tests/strategies/test_tagged_unions.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,10 @@ class B:
161161
configure_tagged_union(Union[A, B], c, default=A)
162162

163163
data = c.unstructure(A(), Union[A, B])
164-
c.structure(data, Union[A, B])
164+
assert c.structure(data, Union[A, B]) == A()
165+
166+
data.pop("_type")
167+
assert c.structure(data, Union[A, B]) == A()
165168

166169

167170
def test_nested_sequence_union():

tests/test_converter.py

+34-4
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
)
1717

1818
import pytest
19-
from attrs import Factory, define, fields, has, make_class
19+
from attrs import Factory, define, field, fields, has, make_class
2020
from hypothesis import HealthCheck, assume, given, settings
2121
from hypothesis.strategies import booleans, just, lists, one_of, sampled_from
2222

@@ -27,6 +27,7 @@
2727
ForbiddenExtraKeysError,
2828
StructureHandlerNotFoundError,
2929
)
30+
from cattrs.fns import raise_error
3031
from cattrs.gen import make_dict_structure_fn, override
3132

3233
from ._compat import is_py310_plus
@@ -423,9 +424,9 @@ def test_type_overrides(cl_and_vals):
423424
inst = cl(*vals, **kwargs)
424425
unstructured = converter.unstructure(inst)
425426

426-
for field, val in zip(fields(cl), vals):
427-
if field.type is int and field.default is not None and field.default == val:
428-
assert field.name not in unstructured
427+
for attr, val in zip(fields(cl), vals):
428+
if attr.type is int and attr.default is not None and attr.default == val:
429+
assert attr.name not in unstructured
429430

430431

431432
def test_calling_back():
@@ -744,6 +745,35 @@ class Test:
744745
assert isinstance(c.structure({}, Test), Test)
745746

746747

748+
def test_legacy_structure_fallbacks(converter_cls: Type[BaseConverter]):
749+
"""Restoring legacy behavior works."""
750+
751+
class Test:
752+
"""Unsupported by default."""
753+
754+
def __init__(self, a):
755+
self.a = a
756+
757+
c = converter_cls(
758+
structure_fallback_factory=lambda _: raise_error, detailed_validation=False
759+
)
760+
761+
# We can get the hook, but...
762+
hook = c.get_structure_hook(Test)
763+
764+
# it won't work.
765+
with pytest.raises(StructureHandlerNotFoundError):
766+
hook({}, Test)
767+
768+
# If a field has a converter, we honor that instead.
769+
@define
770+
class Container:
771+
a: Test = field(converter=Test)
772+
773+
hook = c.get_structure_hook(Container)
774+
hook({"a": 1}, Container)
775+
776+
747777
def test_fallback_chaining(converter_cls: Type[BaseConverter]):
748778
"""Converters can be chained using fallback hooks."""
749779

tests/test_converter_inheritance.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import collections
2-
import typing
2+
from typing import Hashable, Iterable, Reversible
33

44
import pytest
55
from attrs import define
@@ -41,9 +41,7 @@ class B(A):
4141
assert converter.structure({"i": 1}, B) == B(2)
4242

4343

44-
@pytest.mark.parametrize(
45-
"typing_cls", [typing.Hashable, typing.Iterable, typing.Reversible]
46-
)
44+
@pytest.mark.parametrize("typing_cls", [Hashable, Iterable, Reversible])
4745
def test_inherit_typing(converter: BaseConverter, typing_cls):
4846
"""Stuff from typing.* resolves to runtime to collections.abc.*.
4947

tests/test_gen_dict.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ def test_init_false_no_structure_hook(converter: BaseConverter):
558558
@define
559559
class A:
560560
a: int = field(converter=int, init=False)
561+
b: int = field(converter=int, init=False, default=5)
561562

562563
converter.register_structure_hook(
563564
A,
@@ -636,22 +637,26 @@ class A:
636637
converter.structure({"a": "a"}, A)
637638

638639

639-
@given(prefer=...)
640-
def test_prefer_converters_from_converter(prefer: bool):
640+
@given(prefer=..., dv=...)
641+
def test_prefer_converters_from_converter(prefer: bool, dv: bool):
641642
"""
642643
`prefer_attrs_converters` is taken from the converter by default.
643644
"""
644645

645646
@define
646647
class A:
647648
a: int = field(converter=lambda x: x + 1)
649+
b: int = field(converter=lambda x: x + 1, default=5)
648650

649651
converter = BaseConverter(prefer_attrib_converters=prefer)
650652
converter.register_structure_hook(int, lambda x, _: x + 1)
651-
converter.register_structure_hook(A, make_dict_structure_fn(A, converter))
653+
converter.register_structure_hook(
654+
A, make_dict_structure_fn(A, converter, _cattrs_detailed_validation=dv)
655+
)
652656

653657
if prefer:
654-
assert converter.structure({"a": 1}, A).a == 2
658+
assert converter.structure({"a": 1, "b": 2}, A).a == 2
659+
assert converter.structure({"a": 1, "b": 2}, A).b == 3
655660
else:
656661
assert converter.structure({"a": 1}, A).a == 3
657662

tests/test_v.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515

1616
from cattrs import Converter, transform_error
1717
from cattrs._compat import Mapping, TypedDict
18+
from cattrs.errors import IterableValidationError
1819
from cattrs.gen import make_dict_structure_fn
1920
from cattrs.v import format_exception
2021

2122

2223
@fixture
2324
def c() -> Converter:
2425
"""We need only converters with detailed_validation=True."""
25-
return Converter()
26+
return Converter(detailed_validation=True)
2627

2728

2829
def test_attribute_errors(c: Converter) -> None:
@@ -190,6 +191,11 @@ class C:
190191
"invalid value for type, expected int @ $.b[1][2]",
191192
]
192193

194+
# IterableValidationErrors with subexceptions without notes
195+
exc = IterableValidationError("Test", [TypeError("Test")], list[str])
196+
197+
assert transform_error(exc) == ["invalid type (Test) @ $"]
198+
193199

194200
def test_mapping_errors(c: Converter) -> None:
195201
try:

0 commit comments

Comments
 (0)