Skip to content

Commit c3a2419

Browse files
committed
More work
1 parent d633703 commit c3a2419

File tree

7 files changed

+113
-47
lines changed

7 files changed

+113
-47
lines changed

HISTORY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1616
This helps surfacing problems with missing hooks sooner.
1717
See [Migrations](https://catt.rs/en/latest/migrations.html#the-default-structure-hook-fallback-factory) for steps to restore legacy behavior.
1818
([#577](https://github.com/python-attrs/cattrs/pull/577))
19+
- Introduce the `list_from_dict` strategy.
20+
([#609](https://github.com/python-attrs/cattrs/pull/609))
1921
- Add a [Migrations](https://catt.rs/en/latest/migrations.html) page, with instructions on migrating changed behavior for each version.
2022
([#577](https://github.com/python-attrs/cattrs/pull/577))
2123
- Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`.
@@ -30,6 +32,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
3032
- Preconf converters now handle dictionaries with literal keys properly.
3133
([#599](https://github.com/python-attrs/cattrs/pull/599))
3234
- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`.
35+
- The {func}`is_typeddict <catrs.gen.typeddicts.is_typeddict>` predicate function is now exposed through the {mod}`cattrs.gen.typeddicts` module.
36+
([#609](https://github.com/python-attrs/cattrs/pull/609))
3337
- Python 3.13 is now supported.
3438
([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547))
3539
- Python 3.8 is no longer supported, as it is end-of-life. Use previous versions on this Python version.

src/cattrs/_compat.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,6 @@
7171
else:
7272
from exceptiongroup import ExceptionGroup
7373

74-
try:
75-
from typing_extensions import is_typeddict as _is_typeddict
76-
except ImportError: # pragma: no cover
77-
assert sys.version_info >= (3, 10)
78-
from typing import is_typeddict as _is_typeddict
7974

8075
try:
8176
from typing_extensions import TypeAlias
@@ -107,11 +102,6 @@ def is_optional(typ: Any) -> bool:
107102
return is_union_type(typ) and NoneType in typ.__args__ and len(typ.__args__) == 2
108103

109104

110-
def is_typeddict(cls: Any):
111-
"""Thin wrapper around typing(_extensions).is_typeddict"""
112-
return _is_typeddict(getattr(cls, "__origin__", cls))
113-
114-
115105
def is_type_alias(type: Any) -> bool:
116106
"""Is this a PEP 695 type alias?"""
117107
return False

src/cattrs/converters.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
is_sequence,
5050
is_tuple,
5151
is_type_alias,
52-
is_typeddict,
5352
is_union_type,
5453
signature,
5554
)
@@ -89,6 +88,7 @@
8988
make_dict_unstructure_fn,
9089
make_hetero_tuple_unstructure_fn,
9190
)
91+
from .gen.typeddicts import is_typeddict
9292
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
9393
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
9494
from .literals import is_literal_containing_enums

src/cattrs/gen/typeddicts.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import re
44
import sys
5-
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
5+
from collections.abc import Callable
6+
from typing import TYPE_CHECKING, Any, Literal, TypeVar
67

78
from attrs import NOTHING, Attribute
89
from typing_extensions import _TypedDictMeta
@@ -42,10 +43,23 @@ def get_annots(cl) -> dict[str, Any]:
4243
from ._lc import generate_unique_filename
4344
from ._shared import find_structure_handler
4445

46+
try:
47+
from typing_extensions import is_typeddict as _is_typeddict
48+
except ImportError: # pragma: no cover
49+
assert sys.version_info >= (3, 10)
50+
from typing import is_typeddict as _is_typeddict
51+
52+
4553
if TYPE_CHECKING:
4654
from ..converters import BaseConverter
4755

48-
__all__ = ["make_dict_unstructure_fn", "make_dict_structure_fn"]
56+
__all__ = ["is_typeddict", "make_dict_unstructure_fn", "make_dict_structure_fn"]
57+
58+
59+
def is_typeddict(cls: Any) -> bool:
60+
"""Is this type a TypedDict?"""
61+
return _is_typeddict(getattr(cls, "__origin__", cls))
62+
4963

5064
T = TypeVar("T", bound=TypedDict)
5165

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,70 @@
11
"""The list-from-dict implementation."""
22

3+
from __future__ import annotations
4+
35
from collections.abc import Mapping
46
from typing import Any, TypeVar, get_args
57

8+
from attrs import Attribute
9+
610
from .. import BaseConverter, SimpleStructureHook
711
from ..dispatch import UnstructureHook
12+
from ..fns import identity
13+
from ..gen.typeddicts import is_typeddict
814

915
T = TypeVar("T")
1016

1117

1218
def configure_list_from_dict(
13-
seq_type: list[T], field: str, converter: BaseConverter
19+
seq_type: list[T], field: str | Attribute, converter: BaseConverter
1420
) -> tuple[SimpleStructureHook[Mapping, T], UnstructureHook]:
1521
"""
16-
Configure a list subtype to be structured and unstructured using a dictionary.
22+
Configure a list subtype to be structured and unstructured into a dictionary,
23+
using a single field of the element as the dictionary key. This effectively
24+
ensures the resulting list is unique with regard to that field.
25+
26+
List elements have to be able to be structured/unstructured using mappings.
27+
One field of the element is extracted into a dictionary key; the rest of the
28+
data is stored under that key.
29+
30+
The types un/structuring into dictionaries by default are:
31+
* attrs classes and dataclasses
32+
* TypedDicts
33+
* named tuples when using the `namedtuple_dict_un/structure_factory`
1734
18-
List elements have to be an attrs class or a dataclass. One field of the element
19-
type is extracted into a dictionary key; the rest of the data is stored under that
20-
key.
35+
:param field: The name of the field to extract. When working with _attrs_ classes,
36+
consider passing in the attribute (as returned by `attrs.field(cls)`) for
37+
added safety.
38+
39+
:return: A tuple of generated structure and unstructure hooks.
40+
41+
.. versionadded:: 24.2.0
2142
2243
"""
2344
arg_type = get_args(seq_type)[0]
2445

2546
arg_structure_hook = converter.get_structure_hook(arg_type, cache_result=False)
2647

48+
if isinstance(field, Attribute):
49+
field = field.name
50+
2751
def structure_hook(
28-
value: Mapping, type: Any = seq_type, _arg_type=arg_type
52+
value: Mapping,
53+
_: Any = seq_type,
54+
_arg_type=arg_type,
55+
_arg_hook=arg_structure_hook,
56+
_field=field,
2957
) -> list[T]:
30-
return [arg_structure_hook(v | {field: k}, _arg_type) for k, v in value.items()]
58+
return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()]
3159

3260
arg_unstructure_hook = converter.get_unstructure_hook(arg_type, cache_result=False)
3361

34-
def unstructure_hook(val: list[T]) -> dict:
35-
return {
36-
(unstructured := arg_unstructure_hook(v)).pop(field): unstructured
37-
for v in val
38-
}
62+
# TypedDicts can end up being unstructured via identity, in that case we make a copy
63+
# so we don't destroy the original.
64+
if is_typeddict(arg_type) and arg_unstructure_hook == identity:
65+
arg_unstructure_hook = dict
66+
67+
def unstructure_hook(val: list[T], _arg_hook=arg_unstructure_hook) -> dict:
68+
return {(unstructured := _arg_hook(v)).pop(field): unstructured for v in val}
3969

4070
return structure_hook, unstructure_hook

tests/strategies/test_from_from_dict.py

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Tests for the list-from-dict strategy."""
2+
3+
from dataclasses import dataclass
4+
from typing import TypedDict
5+
6+
import pytest
7+
from attrs import define, fields
8+
9+
from cattrs import BaseConverter
10+
from cattrs.strategies import configure_list_from_dict
11+
12+
13+
@define
14+
class AttrsA:
15+
a: int
16+
b: str
17+
18+
19+
@dataclass
20+
class DataclassA:
21+
a: int
22+
b: str
23+
24+
25+
class TypedDictA(TypedDict):
26+
a: int
27+
b: str
28+
29+
30+
@pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA])
31+
def test_simple_roundtrip(
32+
cls: type[AttrsA] | type[DataclassA], converter: BaseConverter
33+
):
34+
hook, hook2 = configure_list_from_dict(list[cls], "a", converter)
35+
36+
structured = [cls(a=1, b="2"), cls(a=3, b="4")]
37+
unstructured = hook2(structured)
38+
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
39+
40+
assert hook(unstructured) == structured
41+
42+
43+
def test_simple_roundtrip_attrs(converter: BaseConverter):
44+
hook, hook2 = configure_list_from_dict(list[AttrsA], fields(AttrsA).a, converter)
45+
46+
structured = [AttrsA(a=1, b="2"), AttrsA(a=3, b="4")]
47+
unstructured = hook2(structured)
48+
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}
49+
50+
assert hook(unstructured) == structured

0 commit comments

Comments
 (0)