Skip to content

Commit 0ad5cae

Browse files
authored
Support typed namedtuples (#491)
* Support typed namedtuples * Fix tests maybe? * 3.8 fixes * Fix some more * msgspec tweaks for namedtuples * msgspec rework
1 parent 856fe63 commit 0ad5cae

13 files changed

Lines changed: 334 additions & 97 deletions

File tree

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ can now be used as decorators and have gained new features when used this way.
3333
([#426](https://github.com/python-attrs/cattrs/issues/426) [#477](https://github.com/python-attrs/cattrs/pull/477))
3434
- Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases.
3535
([#452](https://github.com/python-attrs/cattrs/pull/452))
36+
- Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)).
37+
([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491))
3638
- 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.
3739
([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472))
3840
- The preconf `make_converter` factories are now correctly typed.

docs/defaulthooks.md

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

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

199+
```{note}
200+
Structuring heterogenous tuples are not supported by the BaseConverter.
201+
```
202+
199203
### Deques
200204

201205
Deques can be structured from any iterable object.
@@ -490,6 +494,13 @@ When unstructuring, literals are passed through.
490494

491495
```
492496

497+
### `typing.NamedTuple`
498+
499+
Named tuples with type hints (created from [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)) are supported.
500+
501+
```{versionadded} 24.1.0
502+
503+
```
493504

494505
### `typing.Final`
495506

docs/preconf.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ For example, to get a converter configured for BSON:
1313

1414
Converters obtained this way can be customized further, just like any other converter.
1515

16-
These converters support the following additional classes and type annotations, both for structuring and unstructuring:
16+
These converters support all [default hooks](defaulthooks.md)
17+
and the following additional classes and type annotations,
18+
both for structuring and unstructuring:
1719

1820
- `datetime.datetime`, `datetime.date`
1921

@@ -66,6 +68,7 @@ Found at {mod}`cattrs.preconf.orjson`.
6668
Bytes are un/structured as base 85 strings.
6769
Sets are unstructured into lists, and structured back into sets.
6870
`datetime` s and `date` s are passed through to be unstructured into RFC 3339 by _orjson_ itself.
71+
Typed named tuples are unstructured into ordinary tuples, and then into JSON arrays by _orjson_.
6972

7073
_orjson_ doesn't support integers less than -9223372036854775808, and greater than 9223372036854775807.
7174
_orjson_ only supports mappings with string keys so mappings will have their keys stringified before serialization, and destringified during deserialization.
@@ -180,8 +183,9 @@ When encoding and decoding, the library needs to be passed `codec_options=bson.C
180183

181184
Found at {mod}`cattrs.preconf.pyyaml`.
182185

183-
Frozensets are serialized as lists, and deserialized back into frozensets. `date` s are serialized as ISO 8601 strings.
184-
186+
Frozensets are serialized as lists, and deserialized back into frozensets.
187+
`date` s are serialized as ISO 8601 strings.
188+
Typed named tuples are unstructured into ordinary tuples, and then into YAML arrays by _pyyaml_.
185189

186190
## _tomlkit_
187191

src/cattrs/converters.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import Field
66
from enum import Enum
77
from inspect import Signature
8+
from inspect import signature as inspect_signature
89
from pathlib import Path
910
from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar, overload
1011

@@ -81,6 +82,11 @@
8182
)
8283
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
8384
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
85+
from .tuples import (
86+
is_namedtuple,
87+
namedtuple_structure_factory,
88+
namedtuple_unstructure_factory,
89+
)
8490

8591
__all__ = ["UnstructureStrategy", "BaseConverter", "Converter", "GenConverter"]
8692

@@ -224,6 +230,7 @@ def __init__(
224230
(is_mutable_set, self._structure_set),
225231
(is_frozenset, self._structure_frozenset),
226232
(is_tuple, self._structure_tuple),
233+
(is_namedtuple, namedtuple_structure_factory, "extended"),
227234
(is_mapping, self._structure_dict),
228235
(is_supported_union, self._gen_attrs_union_structure, True),
229236
(
@@ -365,7 +372,9 @@ def register_unstructure_hook_factory(
365372

366373
def decorator(factory):
367374
# Is this an extended factory (takes a converter too)?
368-
sig = signature(factory)
375+
# We use the original `inspect.signature` to not evaluate string
376+
# annotations.
377+
sig = inspect_signature(factory)
369378
if (
370379
len(sig.parameters) >= 2
371380
and (list(sig.parameters.values())[1]).default is Signature.empty
@@ -1095,6 +1104,9 @@ def __init__(
10951104
self.register_unstructure_hook_factory(
10961105
is_hetero_tuple, self.gen_unstructure_hetero_tuple
10971106
)
1107+
self.register_unstructure_hook_factory(is_namedtuple)(
1108+
namedtuple_unstructure_factory
1109+
)
10981110
self.register_unstructure_hook_factory(
10991111
is_sequence, self.gen_unstructure_iterable
11001112
)

src/cattrs/dispatch.py

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

66
from attrs import Factory, define
77

8-
from cattrs._compat import TypeAlias
8+
from ._compat import TypeAlias
99

1010
if TYPE_CHECKING:
1111
from .converters import BaseConverter
@@ -36,6 +36,12 @@ class FunctionDispatch:
3636
first argument in the method, and return True or False.
3737
3838
objects that help determine dispatch should be instantiated objects.
39+
40+
:param converter: A converter to be used for factories that require converters.
41+
42+
.. versionchanged:: 24.1.0
43+
Support for factories that require converters, hence this requires a
44+
converter when creating.
3945
"""
4046

4147
_converter: BaseConverter
@@ -86,11 +92,15 @@ class MultiStrategyDispatch(Generic[Hook]):
8692
MultiStrategyDispatch uses a combination of exact-match dispatch,
8793
singledispatch, and FunctionDispatch.
8894
95+
:param converter: A converter to be used for factories that require converters.
8996
:param fallback_factory: A hook factory to be called when a hook cannot be
9097
produced.
9198
92-
.. versionchanged:: 23.2.0
99+
.. versionchanged:: 23.2.0
93100
Fallbacks are now factories.
101+
.. versionchanged:: 24.1.0
102+
Support for factories that require converters, hence this requires a
103+
converter when creating.
94104
"""
95105

96106
_fallback_factory: HookFactory[Hook]
@@ -150,6 +160,10 @@ def register_func_list(
150160
"""
151161
Register a predicate function to determine if the handler
152162
should be used for the type.
163+
164+
:param pred_and_handler: The list of predicates and their associated
165+
handlers. If a handler is registered in `extended` mode, it's a
166+
factory that requires a converter.
153167
"""
154168
for tup in pred_and_handler:
155169
if len(tup) == 2:

src/cattrs/gen/__init__.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
if TYPE_CHECKING: # pragma: no cover
3535
from typing_extensions import Literal
3636

37-
from cattr.converters import BaseConverter
37+
from ..converters import BaseConverter
3838

3939
__all__ = [
4040
"make_dict_unstructure_fn",
@@ -698,18 +698,21 @@ def make_iterable_unstructure_fn(
698698

699699

700700
def make_hetero_tuple_unstructure_fn(
701-
cl: Any, converter: BaseConverter, unstructure_to: Any = None
701+
cl: Any,
702+
converter: BaseConverter,
703+
unstructure_to: Any = None,
704+
type_args: tuple | None = None,
702705
) -> HeteroTupleUnstructureFn:
703-
"""Generate a specialized unstructure function for a heterogenous tuple."""
706+
"""Generate a specialized unstructure function for a heterogenous tuple.
707+
708+
:param type_args: If provided, override the type arguments.
709+
"""
704710
fn_name = "unstructure_tuple"
705711

706-
type_args = get_args(cl)
712+
type_args = get_args(cl) if type_args is None else type_args
707713

708714
# We can do the dispatch here and now.
709-
handlers = [
710-
converter.get_unstructure_hook(type_arg, cache_result=False)
711-
for type_arg in type_args
712-
]
715+
handlers = [converter.get_unstructure_hook(type_arg) for type_arg in type_args]
713716

714717
globs = {f"__cattr_u_{i}": h for i, h in enumerate(handlers)}
715718
if unstructure_to is not tuple:

src/cattrs/preconf/msgspec.py

Lines changed: 79 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
from msgspec.json import Encoder, decode
1313

1414
from cattrs._compat import fields, get_origin, has, is_bare, is_mapping, is_sequence
15-
from cattrs.dispatch import HookFactory, UnstructureHook
15+
from cattrs.dispatch import UnstructureHook
1616
from cattrs.fns import identity
1717

18-
from ..converters import Converter
18+
from ..converters import BaseConverter, Converter
19+
from ..gen import make_hetero_tuple_unstructure_fn
1920
from ..strategies import configure_union_passthrough
21+
from ..tuples import is_namedtuple
2022
from . import wrap
2123

2224
T = TypeVar("T")
@@ -85,86 +87,89 @@ def configure_passthroughs(converter: Converter) -> None:
8587
A passthrough is when we let msgspec handle something automatically.
8688
"""
8789
converter.register_unstructure_hook(bytes, to_builtins)
88-
converter.register_unstructure_hook_factory(
89-
is_mapping, make_unstructure_mapping_factory(converter)
90-
)
91-
converter.register_unstructure_hook_factory(
92-
is_sequence, make_unstructure_seq_factory(converter)
93-
)
94-
converter.register_unstructure_hook_factory(
95-
has, make_attrs_unstruct_factory(converter)
90+
converter.register_unstructure_hook_factory(is_mapping)(mapping_unstructure_factory)
91+
converter.register_unstructure_hook_factory(is_sequence)(seq_unstructure_factory)
92+
converter.register_unstructure_hook_factory(has)(attrs_unstructure_factory)
93+
converter.register_unstructure_hook_factory(is_namedtuple)(
94+
namedtuple_unstructure_factory
9695
)
9796

9897

99-
def make_unstructure_seq_factory(converter: Converter) -> HookFactory[UnstructureHook]:
100-
def unstructure_seq_factory(type) -> UnstructureHook:
101-
if is_bare(type):
102-
type_arg = Any
103-
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
104-
elif getattr(type, "__args__", None) not in (None, ()):
105-
type_arg = type.__args__[0]
106-
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
107-
else:
108-
handler = None
109-
110-
if handler in (identity, to_builtins):
111-
return handler
112-
return converter.gen_unstructure_iterable(type)
113-
114-
return unstructure_seq_factory
115-
116-
117-
def make_unstructure_mapping_factory(
118-
converter: Converter,
119-
) -> HookFactory[UnstructureHook]:
120-
def unstructure_mapping_factory(type) -> UnstructureHook:
121-
if is_bare(type):
122-
key_arg = Any
123-
val_arg = Any
124-
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
125-
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
126-
elif (args := getattr(type, "__args__", None)) not in (None, ()):
127-
if len(args) == 2:
128-
key_arg, val_arg = args
129-
else:
130-
# Probably a Counter
131-
key_arg, val_arg = args, Any
132-
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
133-
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
98+
def seq_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
99+
if is_bare(type):
100+
type_arg = Any
101+
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
102+
elif getattr(type, "__args__", None) not in (None, ()):
103+
type_arg = type.__args__[0]
104+
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
105+
else:
106+
handler = None
107+
108+
if handler in (identity, to_builtins):
109+
return handler
110+
return converter.gen_unstructure_iterable(type)
111+
112+
113+
def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
114+
if is_bare(type):
115+
key_arg = Any
116+
val_arg = Any
117+
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
118+
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
119+
elif (args := getattr(type, "__args__", None)) not in (None, ()):
120+
if len(args) == 2:
121+
key_arg, val_arg = args
134122
else:
135-
key_handler = value_handler = None
123+
# Probably a Counter
124+
key_arg, val_arg = args, Any
125+
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
126+
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
127+
else:
128+
key_handler = value_handler = None
129+
130+
if key_handler in (identity, to_builtins) and value_handler in (
131+
identity,
132+
to_builtins,
133+
):
134+
return to_builtins
135+
return converter.gen_unstructure_mapping(type)
136+
136137

137-
if key_handler in (identity, to_builtins) and value_handler in (
138-
identity,
139-
to_builtins,
140-
):
141-
return to_builtins
142-
return converter.gen_unstructure_mapping(type)
138+
def attrs_unstructure_factory(type: Any, converter: BaseConverter) -> UnstructureHook:
139+
"""Choose whether to use msgspec handling or our own."""
140+
origin = get_origin(type)
141+
attribs = fields(origin or type)
142+
if attrs_has(type) and any(isinstance(a.type, str) for a in attribs):
143+
resolve_types(type)
144+
attribs = fields(origin or type)
143145

144-
return unstructure_mapping_factory
146+
if any(
147+
attr.name.startswith("_")
148+
or (
149+
converter.get_unstructure_hook(attr.type, cache_result=False)
150+
not in (identity, to_builtins)
151+
)
152+
for attr in attribs
153+
):
154+
return converter.gen_unstructure_attrs_fromdict(type)
145155

156+
return to_builtins
146157

147-
def make_attrs_unstruct_factory(converter: Converter) -> HookFactory[UnstructureHook]:
148-
"""Short-circuit attrs and dataclass handling if it matches msgspec."""
149158

150-
def attrs_factory(type: Any) -> UnstructureHook:
151-
"""Choose whether to use msgspec handling or our own."""
152-
origin = get_origin(type)
153-
attribs = fields(origin or type)
154-
if attrs_has(type) and any(isinstance(a.type, str) for a in attribs):
155-
resolve_types(type)
156-
attribs = fields(origin or type)
157-
158-
if any(
159-
attr.name.startswith("_")
160-
or (
161-
converter.get_unstructure_hook(attr.type, cache_result=False)
162-
not in (identity, to_builtins)
163-
)
164-
for attr in attribs
165-
):
166-
return converter.gen_unstructure_attrs_fromdict(type)
159+
def namedtuple_unstructure_factory(
160+
type: type[tuple], converter: BaseConverter
161+
) -> UnstructureHook:
162+
"""A hook factory for unstructuring namedtuples, modified for msgspec."""
167163

168-
return to_builtins
164+
if all(
165+
converter.get_unstructure_hook(t) in (identity, to_builtins)
166+
for t in type.__annotations__.values()
167+
):
168+
return identity
169169

170-
return attrs_factory
170+
return make_hetero_tuple_unstructure_fn(
171+
type,
172+
converter,
173+
unstructure_to=tuple,
174+
type_args=tuple(type.__annotations__.values()),
175+
)

0 commit comments

Comments
 (0)