Skip to content

Commit f97018e

Browse files
authored
Namedtuples to/from dicts (#549)
Namedtuple dict un/structuring factories
1 parent 6190eb7 commit f97018e

14 files changed

+836
-476
lines changed

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ can now be used as decorators and have gained new features.
4242
([#512](https://github.com/python-attrs/cattrs/pull/512))
4343
- Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)).
4444
([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491))
45+
- Add support for optionally un/unstructuring named tuples using dictionaries.
46+
([#425](https://github.com/python-attrs/cattrs/issues/425) [#549](https://github.com/python-attrs/cattrs/pull/549))
4547
- The `include_subclasses` strategy now fetches the member hooks from the converter (making use of converter defaults) if overrides are not provided, instead of generating new hooks with no overrides.
4648
([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472))
4749
- The preconf `make_converter` factories are now correctly typed.

docs/customizing.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ Available hook factories are:
185185
* {meth}`list_structure_factory <cattrs.cols.list_structure_factory>`
186186
* {meth}`namedtuple_structure_factory <cattrs.cols.namedtuple_structure_factory>`
187187
* {meth}`namedtuple_unstructure_factory <cattrs.cols.namedtuple_unstructure_factory>`
188+
* {meth}`namedtuple_dict_structure_factory <cattrs.cols.namedtuple_dict_structure_factory>`
189+
* {meth}`namedtuple_dict_unstructure_factory <cattrs.cols.namedtuple_dict_unstructure_factory>`
188190

189191
Additional predicates and hook factories will be added as requested.
190192

@@ -225,6 +227,40 @@ ValueError: Not a list!
225227

226228
```
227229

230+
### Customizing Named Tuples
231+
232+
Named tuples can be un/structured using dictionaries using the {meth}`namedtuple_dict_structure_factory <cattrs.cols.namedtuple_dict_structure_factory>`
233+
and {meth}`namedtuple_dict_unstructure_factory <cattrs.cols.namedtuple_dict_unstructure_factory>`
234+
hook factories.
235+
236+
To unstructure _all_ named tuples into dictionaries:
237+
238+
```{doctest} namedtuples
239+
>>> from typing import NamedTuple
240+
241+
>>> from cattrs.cols import is_namedtuple, namedtuple_dict_unstructure_factory
242+
>>> c = Converter()
243+
244+
>>> c.register_unstructure_hook_factory(is_namedtuple, namedtuple_dict_unstructure_factory)
245+
<function namedtuple_dict_unstructure_factory at ...>
246+
247+
>>> class MyNamedTuple(NamedTuple):
248+
... a: int
249+
250+
>>> c.unstructure(MyNamedTuple(1))
251+
{'a': 1}
252+
```
253+
254+
To only un/structure _some_ named tuples into dictionaries,
255+
change the predicate function when registering the hook factory:
256+
257+
```{doctest} namedtuples
258+
>>> c.register_unstructure_hook_factory(
259+
... lambda t: t is MyNamedTuple,
260+
... namedtuple_dict_unstructure_factory,
261+
... )
262+
```
263+
228264
## Using `cattrs.gen` Generators
229265

230266
The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts.

docs/defaulthooks.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ Any type parameters set to `typing.Any` will be passed through unconverted.
210210

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

213+
```{seealso}
214+
[Support for typing.NamedTuple.](#typingnamedtuple)
215+
```
216+
213217
```{note}
214218
Structuring heterogenous tuples are not supported by the BaseConverter.
215219
```
@@ -511,6 +515,10 @@ When unstructuring, literals are passed through.
511515
### `typing.NamedTuple`
512516

513517
Named tuples with type hints (created from [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)) are supported.
518+
Named tuples are un/structured using tuples or lists by default.
519+
520+
The {mod}`cattrs.cols` module contains hook factories for un/structuring named tuples using dictionaries instead,
521+
[see here for details](customizing.md#customizing-named-tuples).
514522

515523
```{versionadded} 24.1.0
516524

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ ignore = [
148148
"DTZ006", # datetimes in tests
149149
]
150150

151+
[tool.ruff.lint.pyupgrade]
152+
# Preserve types, even if a file imports `from __future__ import annotations`.
153+
keep-runtime-typing = true
154+
151155
[tool.hatch.version]
152156
source = "vcs"
153157
raw-options = { local_scheme = "no-local-version" }

src/cattr/gen.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
from cattrs.cols import iterable_unstructure_factory as make_iterable_unstructure_fn
12
from cattrs.gen import (
23
make_dict_structure_fn,
34
make_dict_unstructure_fn,
45
make_hetero_tuple_unstructure_fn,
5-
make_iterable_unstructure_fn,
66
make_mapping_structure_fn,
77
make_mapping_unstructure_fn,
88
override,

src/cattrs/cols.py

Lines changed: 134 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,32 @@
33
from __future__ import annotations
44

55
from sys import version_info
6-
from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, Tuple, TypeVar
6+
from typing import (
7+
TYPE_CHECKING,
8+
Any,
9+
Iterable,
10+
Literal,
11+
NamedTuple,
12+
Tuple,
13+
TypeVar,
14+
get_type_hints,
15+
)
16+
17+
from attrs import NOTHING, Attribute
718

819
from ._compat import ANIES, is_bare, is_frozenset, is_sequence, is_subclass
920
from ._compat import is_mutable_set as is_set
1021
from .dispatch import StructureHook, UnstructureHook
1122
from .errors import IterableValidationError, IterableValidationNote
1223
from .fns import identity
13-
from .gen import make_hetero_tuple_unstructure_fn
24+
from .gen import (
25+
AttributeOverride,
26+
already_generating,
27+
make_dict_structure_fn_from_attrs,
28+
make_dict_unstructure_fn_from_attrs,
29+
make_hetero_tuple_unstructure_fn,
30+
)
31+
from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory
1432

1533
if TYPE_CHECKING:
1634
from .converters import BaseConverter
@@ -25,6 +43,8 @@
2543
"list_structure_factory",
2644
"namedtuple_structure_factory",
2745
"namedtuple_unstructure_factory",
46+
"namedtuple_dict_structure_factory",
47+
"namedtuple_dict_unstructure_factory",
2848
]
2949

3050

@@ -133,57 +153,134 @@ def structure_list(
133153
return structure_list
134154

135155

136-
def iterable_unstructure_factory(
137-
cl: Any, converter: BaseConverter, unstructure_to: Any = None
138-
) -> UnstructureHook:
139-
"""A hook factory for unstructuring iterables.
140-
141-
:param unstructure_to: Force unstructuring to this type, if provided.
142-
"""
143-
handler = converter.unstructure
144-
145-
# Let's try fishing out the type args
146-
# Unspecified tuples have `__args__` as empty tuples, so guard
147-
# against IndexError.
148-
if getattr(cl, "__args__", None) not in (None, ()):
149-
type_arg = cl.__args__[0]
150-
if isinstance(type_arg, TypeVar):
151-
type_arg = getattr(type_arg, "__default__", Any)
152-
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
153-
if handler == identity:
154-
# Save ourselves the trouble of iterating over it all.
155-
return unstructure_to or cl
156-
157-
def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler):
158-
return _seq_cl(_hook(i) for i in iterable)
159-
160-
return unstructure_iterable
161-
162-
163156
def namedtuple_unstructure_factory(
164-
type: type[tuple], converter: BaseConverter, unstructure_to: Any = None
157+
cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None
165158
) -> UnstructureHook:
166159
"""A hook factory for unstructuring namedtuples.
167160
168161
:param unstructure_to: Force unstructuring to this type, if provided.
169162
"""
170163

171-
if unstructure_to is None and _is_passthrough(type, converter):
164+
if unstructure_to is None and _is_passthrough(cl, converter):
172165
return identity
173166

174167
return make_hetero_tuple_unstructure_fn(
175-
type,
168+
cl,
176169
converter,
177170
unstructure_to=tuple if unstructure_to is None else unstructure_to,
178-
type_args=tuple(type.__annotations__.values()),
171+
type_args=tuple(cl.__annotations__.values()),
179172
)
180173

181174

182175
def namedtuple_structure_factory(
183-
type: type[tuple], converter: BaseConverter
176+
cl: type[tuple], converter: BaseConverter
184177
) -> StructureHook:
185-
"""A hook factory for structuring namedtuples."""
178+
"""A hook factory for structuring namedtuples from iterables."""
186179
# We delegate to the existing infrastructure for heterogenous tuples.
187-
hetero_tuple_type = Tuple[tuple(type.__annotations__.values())]
180+
hetero_tuple_type = Tuple[tuple(cl.__annotations__.values())]
188181
base_hook = converter.get_structure_hook(hetero_tuple_type)
189-
return lambda v, _: type(*base_hook(v, hetero_tuple_type))
182+
return lambda v, _: cl(*base_hook(v, hetero_tuple_type))
183+
184+
185+
def _namedtuple_to_attrs(cl: type[tuple]) -> list[Attribute]:
186+
"""Generate pseudo attributes for a namedtuple."""
187+
return [
188+
Attribute(
189+
name,
190+
cl._field_defaults.get(name, NOTHING),
191+
None,
192+
False,
193+
False,
194+
False,
195+
True,
196+
False,
197+
type=a,
198+
alias=name,
199+
)
200+
for name, a in get_type_hints(cl).items()
201+
]
202+
203+
204+
def namedtuple_dict_structure_factory(
205+
cl: type[tuple],
206+
converter: BaseConverter,
207+
detailed_validation: bool | Literal["from_converter"] = "from_converter",
208+
forbid_extra_keys: bool = False,
209+
use_linecache: bool = True,
210+
/,
211+
**kwargs: AttributeOverride,
212+
) -> StructureHook:
213+
"""A hook factory for hooks structuring namedtuples from dictionaries.
214+
215+
:param forbid_extra_keys: Whether the hook should raise a `ForbiddenExtraKeysError`
216+
if unknown keys are encountered.
217+
:param use_linecache: Whether to store the source code in the Python linecache.
218+
219+
.. versionadded:: 24.1.0
220+
"""
221+
try:
222+
working_set = already_generating.working_set
223+
except AttributeError:
224+
working_set = set()
225+
already_generating.working_set = working_set
226+
else:
227+
if cl in working_set:
228+
raise RecursionError()
229+
230+
working_set.add(cl)
231+
232+
try:
233+
return make_dict_structure_fn_from_attrs(
234+
_namedtuple_to_attrs(cl),
235+
cl,
236+
converter,
237+
_cattrs_forbid_extra_keys=forbid_extra_keys,
238+
_cattrs_use_detailed_validation=detailed_validation,
239+
_cattrs_use_linecache=use_linecache,
240+
**kwargs,
241+
)
242+
finally:
243+
working_set.remove(cl)
244+
if not working_set:
245+
del already_generating.working_set
246+
247+
248+
def namedtuple_dict_unstructure_factory(
249+
cl: type[tuple],
250+
converter: BaseConverter,
251+
omit_if_default: bool = False,
252+
use_linecache: bool = True,
253+
/,
254+
**kwargs: AttributeOverride,
255+
) -> UnstructureHook:
256+
"""A hook factory for hooks unstructuring namedtuples to dictionaries.
257+
258+
:param omit_if_default: When true, attributes equal to their default values
259+
will be omitted in the result dictionary.
260+
:param use_linecache: Whether to store the source code in the Python linecache.
261+
262+
.. versionadded:: 24.1.0
263+
"""
264+
try:
265+
working_set = already_generating.working_set
266+
except AttributeError:
267+
working_set = set()
268+
already_generating.working_set = working_set
269+
if cl in working_set:
270+
raise RecursionError()
271+
272+
working_set.add(cl)
273+
274+
try:
275+
return make_dict_unstructure_fn_from_attrs(
276+
_namedtuple_to_attrs(cl),
277+
cl,
278+
converter,
279+
_cattrs_omit_if_default=omit_if_default,
280+
_cattrs_use_linecache=use_linecache,
281+
**kwargs,
282+
)
283+
finally:
284+
working_set.remove(cl)
285+
if not working_set:
286+
del already_generating.working_set

src/cattrs/converters.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454
from .cols import (
5555
is_namedtuple,
56+
iterable_unstructure_factory,
5657
list_structure_factory,
5758
namedtuple_structure_factory,
5859
namedtuple_unstructure_factory,
@@ -83,7 +84,6 @@
8384
make_dict_structure_fn,
8485
make_dict_unstructure_fn,
8586
make_hetero_tuple_unstructure_fn,
86-
make_iterable_unstructure_fn,
8787
make_mapping_structure_fn,
8888
make_mapping_unstructure_fn,
8989
)
@@ -1248,7 +1248,7 @@ def gen_unstructure_iterable(
12481248
unstructure_to = self._unstruct_collection_overrides.get(
12491249
get_origin(cl) or cl, unstructure_to or list
12501250
)
1251-
h = make_iterable_unstructure_fn(cl, self, unstructure_to=unstructure_to)
1251+
h = iterable_unstructure_factory(cl, self, unstructure_to=unstructure_to)
12521252
self._unstructure_func.register_cls_list([(cl, h)], direct=True)
12531253
return h
12541254

0 commit comments

Comments
 (0)