Skip to content

Commit 6bae1d0

Browse files
committed
Improve coverage for init false attributes (#486)
* Improve coverage for init false attributes * One more test for coverage * Some more coverage, update Pendulum * Speed up heterogenous tuple unstructuring * More init=False tests
1 parent 82aa581 commit 6bae1d0

File tree

9 files changed

+282
-45
lines changed

9 files changed

+282
-45
lines changed

Diff for: .github/workflows/main.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
echo "total=$TOTAL" >> $GITHUB_ENV
7272
7373
# Report again and fail if under the threshold.
74-
python -Im coverage report --fail-under=97
74+
python -Im coverage report --fail-under=98
7575
7676
- name: "Upload HTML report."
7777
uses: "actions/upload-artifact@v3"

Diff for: HISTORY.md

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1515
Previously this behavior was underspecified and inconsistent, but followed this rule in the majority of cases.
1616
Reverting old behavior is very dependent on the actual case; ask on the issue tracker if in doubt.
1717
([#473](https://github.com/python-attrs/cattrs/pull/473))
18+
- **Minor change**: Heterogeneous tuples are now unstructured into tuples instead of lists by default; this is significantly faster and widely supported by serialization libraries.
19+
([#486](https://github.com/python-attrs/cattrs/pull/486))
1820
- Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods.
1921
([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472))
2022
- Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter <cattrs.preconf.msgspec>`.

Diff for: docs/defaulthooks.md

+1
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ Any type parameters set to `typing.Any` will be passed through unconverted.
194194
(1, '2', 3.0)
195195
```
196196

197+
When unstructuring, heterogeneous tuples unstructure into tuples since it's faster and virtually all serialization libraries support tuples natively.
197198

198199
### Deques
199200

Diff for: pdm.lock

+187-23
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: src/cattrs/converters.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1054,7 +1054,7 @@ def gen_unstructure_hetero_tuple(
10541054
self, cl: Any, unstructure_to: Any = None
10551055
) -> HeteroTupleUnstructureFn:
10561056
unstructure_to = self._unstruct_collection_overrides.get(
1057-
get_origin(cl) or cl, unstructure_to or list
1057+
get_origin(cl) or cl, unstructure_to or tuple
10581058
)
10591059
h = make_hetero_tuple_unstructure_fn(cl, self, unstructure_to=unstructure_to)
10601060
self._unstructure_func.register_cls_list([(cl, h)], direct=True)

Diff for: src/cattrs/gen/__init__.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,8 @@ def make_dict_structure_fn(
372372
)
373373

374374
struct_handler_name = f"__c_structure_{an}"
375-
internal_arg_parts[struct_handler_name] = handler
375+
if handler is not None:
376+
internal_arg_parts[struct_handler_name] = handler
376377

377378
ian = a.alias
378379
if override.rename is None:
@@ -391,7 +392,7 @@ def make_dict_structure_fn(
391392
i = f"{i} "
392393
type_name = f"__c_type_{an}"
393394
internal_arg_parts[type_name] = t
394-
if handler:
395+
if handler is not None:
395396
if handler == converter._structure_call:
396397
internal_arg_parts[struct_handler_name] = t
397398
pi_lines.append(
@@ -511,7 +512,7 @@ def make_dict_structure_fn(
511512
allowed_fields.add(kn)
512513

513514
if not a.init:
514-
if handler:
515+
if handler is not None:
515516
struct_handler_name = f"__c_structure_{an}"
516517
internal_arg_parts[struct_handler_name] = handler
517518
if handler == converter._structure_call:
@@ -803,7 +804,7 @@ def make_mapping_structure_fn(
803804
val_type=NOTHING,
804805
detailed_validation: bool = True,
805806
) -> MappingStructureFn[T]:
806-
"""Generate a specialized unstructure function for a mapping."""
807+
"""Generate a specialized structure function for a mapping."""
807808
fn_name = "structure_mapping"
808809

809810
globs: dict[str, type] = {"__cattr_mapping_cl": structure_to}

Diff for: src/cattrs/gen/_shared.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any, Callable
3+
from typing import TYPE_CHECKING, Any
44

55
from attrs import NOTHING, Attribute, Factory
66

77
from .._compat import is_bare_final
8+
from ..dispatch import StructureHook
89
from ..fns import raise_error
910

1011
if TYPE_CHECKING: # pragma: no cover
@@ -13,7 +14,7 @@
1314

1415
def find_structure_handler(
1516
a: Attribute, type: Any, c: BaseConverter, prefer_attrs_converters: bool = False
16-
) -> Callable[[Any, Any], Any] | None:
17+
) -> StructureHook | None:
1718
"""Find the appropriate structure handler to use.
1819
1920
Return `None` if no handler should be used.

Diff for: tests/test_gen_collections.py

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Tests for collections in `cattrs.gen`."""
2+
from typing import Generic, Mapping, NewType, Tuple, TypeVar
3+
4+
from cattrs import Converter
5+
from cattrs.gen import make_hetero_tuple_unstructure_fn, make_mapping_structure_fn
6+
7+
8+
def test_structuring_mappings(genconverter: Converter):
9+
"""The `key_type` parameter works for generics with 1 type variable."""
10+
T = TypeVar("T")
11+
12+
class MyMapping(Generic[T], Mapping[str, T]):
13+
pass
14+
15+
def key_hook(value, _):
16+
return f"{value}1"
17+
18+
Key = NewType("Key", str)
19+
20+
genconverter.register_structure_hook(Key, key_hook)
21+
22+
fn = make_mapping_structure_fn(MyMapping[int], genconverter, key_type=Key)
23+
24+
assert fn({"a": 1}, MyMapping[int]) == {"a1": 1}
25+
26+
27+
def test_unstructure_hetero_tuple_to_tuple(genconverter: Converter):
28+
"""`make_hetero_tuple_unstructure_fn` works when unstructuring to tuple."""
29+
fn = make_hetero_tuple_unstructure_fn(Tuple[int, str, int], genconverter, tuple)
30+
31+
assert fn((1, "1", 2)) == (1, "1", 2)

Diff for: tests/test_gen_dict.py

+51-14
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Dict, Type
33

44
import pytest
5-
from attrs import NOTHING, Factory, define, field
5+
from attrs import NOTHING, Factory, define, field, frozen
66
from hypothesis import assume, given
77
from hypothesis.strategies import data, just, one_of, sampled_from
88

@@ -450,12 +450,18 @@ class A:
450450
def test_init_false_overridden(converter: BaseConverter) -> None:
451451
"""init=False handling can be overriden."""
452452

453+
@frozen
454+
class Inner:
455+
a: int
456+
453457
@define
454458
class A:
455459
a: int
456460
b: int = field(init=False)
457461
_c: int = field(init=False)
458-
d: int = field(init=False, default=4)
462+
d: Inner = field(init=False)
463+
e: int = field(init=False, default=4)
464+
f: Inner = field(init=False, default=Inner(1))
459465

460466
converter.register_unstructure_hook(
461467
A, make_dict_unstructure_fn(A, converter, _cattrs_include_init_false=True)
@@ -464,28 +470,36 @@ class A:
464470
a = A(1)
465471
a.b = 2
466472
a._c = 3
473+
a.d = Inner(4)
467474

468-
assert converter.unstructure(a) == {"a": 1, "b": 2, "_c": 3, "d": 4}
475+
assert converter.unstructure(a) == {
476+
"a": 1,
477+
"b": 2,
478+
"_c": 3,
479+
"d": {"a": 4},
480+
"e": 4,
481+
"f": {"a": 1},
482+
}
469483

470484
converter.register_structure_hook(
471-
A,
472-
make_dict_structure_fn(
473-
A,
474-
converter,
475-
_cattrs_include_init_false=True,
476-
_cattrs_detailed_validation=converter.detailed_validation,
477-
),
485+
A, make_dict_structure_fn(A, converter, _cattrs_include_init_false=True)
478486
)
479487

480-
structured = converter.structure({"a": 1, "b": 2, "_c": 3}, A)
488+
structured = converter.structure({"a": 1, "b": 2, "_c": 3, "d": {"a": 1}}, A)
481489
assert structured.b == 2
482490
assert structured._c == 3
483-
assert structured.d == 4
491+
assert structured.d == Inner(1)
492+
assert structured.e == 4
493+
assert structured.f == Inner(1)
484494

485-
structured = converter.structure({"a": 1, "b": 2, "_c": 3, "d": -4}, A)
495+
structured = converter.structure(
496+
{"a": 1, "b": 2, "_c": 3, "d": {"a": 5}, "e": -4, "f": {"a": 2}}, A
497+
)
486498
assert structured.b == 2
487499
assert structured._c == 3
488-
assert structured.d == -4
500+
assert structured.d == Inner(5)
501+
assert structured.e == -4
502+
assert structured.f == Inner(2)
489503

490504

491505
def test_init_false_field_override(converter: BaseConverter) -> None:
@@ -538,6 +552,29 @@ class A:
538552
assert structured.d == -4
539553

540554

555+
def test_init_false_no_structure_hook(converter: BaseConverter):
556+
"""init=False attributes with converters and `prefer_attrs_converters` work."""
557+
558+
@define
559+
class A:
560+
a: int = field(converter=int, init=False)
561+
562+
converter.register_structure_hook(
563+
A,
564+
make_dict_structure_fn(
565+
A,
566+
converter,
567+
_cattrs_prefer_attrib_converters=True,
568+
_cattrs_include_init_false=True,
569+
),
570+
)
571+
572+
res = A()
573+
res.a = 5
574+
575+
assert converter.structure({"a": "5"}, A) == res
576+
577+
541578
@given(forbid_extra_keys=..., detailed_validation=...)
542579
def test_forbid_extra_keys_from_converter(
543580
forbid_extra_keys: bool, detailed_validation: bool

0 commit comments

Comments
 (0)