Skip to content

Commit

Permalink
Support typed namedtuples (#491)
Browse files Browse the repository at this point in the history
* Support typed namedtuples

* Fix tests maybe?

* 3.8 fixes

* Fix some more

* msgspec tweaks for namedtuples

* msgspec rework
  • Loading branch information
Tinche authored Jan 28, 2024
1 parent 856fe63 commit 0ad5cae
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 97 deletions.
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ can now be used as decorators and have gained new features when used this way.
([#426](https://github.com/python-attrs/cattrs/issues/426) [#477](https://github.com/python-attrs/cattrs/pull/477))
- Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases.
([#452](https://github.com/python-attrs/cattrs/pull/452))
- Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)).
([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491))
- 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.
([#429](https://github.com/python-attrs/cattrs/issues/429) [#472](https://github.com/python-attrs/cattrs/pull/472))
- The preconf `make_converter` factories are now correctly typed.
Expand Down
11 changes: 11 additions & 0 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ Any type parameters set to `typing.Any` will be passed through unconverted.

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

```{note}
Structuring heterogenous tuples are not supported by the BaseConverter.
```

### Deques

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

```

### `typing.NamedTuple`

Named tuples with type hints (created from [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)) are supported.

```{versionadded} 24.1.0

```

### `typing.Final`

Expand Down
10 changes: 7 additions & 3 deletions docs/preconf.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ For example, to get a converter configured for BSON:

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

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

- `datetime.datetime`, `datetime.date`

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

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

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

Frozensets are serialized as lists, and deserialized back into frozensets. `date` s are serialized as ISO 8601 strings.

Frozensets are serialized as lists, and deserialized back into frozensets.
`date` s are serialized as ISO 8601 strings.
Typed named tuples are unstructured into ordinary tuples, and then into YAML arrays by _pyyaml_.

## _tomlkit_

Expand Down
14 changes: 13 additions & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dataclasses import Field
from enum import Enum
from inspect import Signature
from inspect import signature as inspect_signature
from pathlib import Path
from typing import Any, Callable, Iterable, Optional, Tuple, TypeVar, overload

Expand Down Expand Up @@ -81,6 +82,11 @@
)
from .gen.typeddicts import make_dict_structure_fn as make_typeddict_dict_struct_fn
from .gen.typeddicts import make_dict_unstructure_fn as make_typeddict_dict_unstruct_fn
from .tuples import (
is_namedtuple,
namedtuple_structure_factory,
namedtuple_unstructure_factory,
)

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

Expand Down Expand Up @@ -224,6 +230,7 @@ def __init__(
(is_mutable_set, self._structure_set),
(is_frozenset, self._structure_frozenset),
(is_tuple, self._structure_tuple),
(is_namedtuple, namedtuple_structure_factory, "extended"),
(is_mapping, self._structure_dict),
(is_supported_union, self._gen_attrs_union_structure, True),
(
Expand Down Expand Up @@ -365,7 +372,9 @@ def register_unstructure_hook_factory(

def decorator(factory):
# Is this an extended factory (takes a converter too)?
sig = signature(factory)
# We use the original `inspect.signature` to not evaluate string
# annotations.
sig = inspect_signature(factory)
if (
len(sig.parameters) >= 2
and (list(sig.parameters.values())[1]).default is Signature.empty
Expand Down Expand Up @@ -1095,6 +1104,9 @@ def __init__(
self.register_unstructure_hook_factory(
is_hetero_tuple, self.gen_unstructure_hetero_tuple
)
self.register_unstructure_hook_factory(is_namedtuple)(
namedtuple_unstructure_factory
)
self.register_unstructure_hook_factory(
is_sequence, self.gen_unstructure_iterable
)
Expand Down
18 changes: 16 additions & 2 deletions src/cattrs/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from attrs import Factory, define

from cattrs._compat import TypeAlias
from ._compat import TypeAlias

if TYPE_CHECKING:
from .converters import BaseConverter
Expand Down Expand Up @@ -36,6 +36,12 @@ class FunctionDispatch:
first argument in the method, and return True or False.
objects that help determine dispatch should be instantiated objects.
:param converter: A converter to be used for factories that require converters.
.. versionchanged:: 24.1.0
Support for factories that require converters, hence this requires a
converter when creating.
"""

_converter: BaseConverter
Expand Down Expand Up @@ -86,11 +92,15 @@ class MultiStrategyDispatch(Generic[Hook]):
MultiStrategyDispatch uses a combination of exact-match dispatch,
singledispatch, and FunctionDispatch.
:param converter: A converter to be used for factories that require converters.
:param fallback_factory: A hook factory to be called when a hook cannot be
produced.
.. versionchanged:: 23.2.0
.. versionchanged:: 23.2.0
Fallbacks are now factories.
.. versionchanged:: 24.1.0
Support for factories that require converters, hence this requires a
converter when creating.
"""

_fallback_factory: HookFactory[Hook]
Expand Down Expand Up @@ -150,6 +160,10 @@ def register_func_list(
"""
Register a predicate function to determine if the handler
should be used for the type.
:param pred_and_handler: The list of predicates and their associated
handlers. If a handler is registered in `extended` mode, it's a
factory that requires a converter.
"""
for tup in pred_and_handler:
if len(tup) == 2:
Expand Down
19 changes: 11 additions & 8 deletions src/cattrs/gen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
if TYPE_CHECKING: # pragma: no cover
from typing_extensions import Literal

from cattr.converters import BaseConverter
from ..converters import BaseConverter

__all__ = [
"make_dict_unstructure_fn",
Expand Down Expand Up @@ -698,18 +698,21 @@ def make_iterable_unstructure_fn(


def make_hetero_tuple_unstructure_fn(
cl: Any, converter: BaseConverter, unstructure_to: Any = None
cl: Any,
converter: BaseConverter,
unstructure_to: Any = None,
type_args: tuple | None = None,
) -> HeteroTupleUnstructureFn:
"""Generate a specialized unstructure function for a heterogenous tuple."""
"""Generate a specialized unstructure function for a heterogenous tuple.
:param type_args: If provided, override the type arguments.
"""
fn_name = "unstructure_tuple"

type_args = get_args(cl)
type_args = get_args(cl) if type_args is None else type_args

# We can do the dispatch here and now.
handlers = [
converter.get_unstructure_hook(type_arg, cache_result=False)
for type_arg in type_args
]
handlers = [converter.get_unstructure_hook(type_arg) for type_arg in type_args]

globs = {f"__cattr_u_{i}": h for i, h in enumerate(handlers)}
if unstructure_to is not tuple:
Expand Down
153 changes: 79 additions & 74 deletions src/cattrs/preconf/msgspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
from msgspec.json import Encoder, decode

from cattrs._compat import fields, get_origin, has, is_bare, is_mapping, is_sequence
from cattrs.dispatch import HookFactory, UnstructureHook
from cattrs.dispatch import UnstructureHook
from cattrs.fns import identity

from ..converters import Converter
from ..converters import BaseConverter, Converter
from ..gen import make_hetero_tuple_unstructure_fn
from ..strategies import configure_union_passthrough
from ..tuples import is_namedtuple
from . import wrap

T = TypeVar("T")
Expand Down Expand Up @@ -85,86 +87,89 @@ def configure_passthroughs(converter: Converter) -> None:
A passthrough is when we let msgspec handle something automatically.
"""
converter.register_unstructure_hook(bytes, to_builtins)
converter.register_unstructure_hook_factory(
is_mapping, make_unstructure_mapping_factory(converter)
)
converter.register_unstructure_hook_factory(
is_sequence, make_unstructure_seq_factory(converter)
)
converter.register_unstructure_hook_factory(
has, make_attrs_unstruct_factory(converter)
converter.register_unstructure_hook_factory(is_mapping)(mapping_unstructure_factory)
converter.register_unstructure_hook_factory(is_sequence)(seq_unstructure_factory)
converter.register_unstructure_hook_factory(has)(attrs_unstructure_factory)
converter.register_unstructure_hook_factory(is_namedtuple)(
namedtuple_unstructure_factory
)


def make_unstructure_seq_factory(converter: Converter) -> HookFactory[UnstructureHook]:
def unstructure_seq_factory(type) -> UnstructureHook:
if is_bare(type):
type_arg = Any
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
elif getattr(type, "__args__", None) not in (None, ()):
type_arg = type.__args__[0]
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
else:
handler = None

if handler in (identity, to_builtins):
return handler
return converter.gen_unstructure_iterable(type)

return unstructure_seq_factory


def make_unstructure_mapping_factory(
converter: Converter,
) -> HookFactory[UnstructureHook]:
def unstructure_mapping_factory(type) -> UnstructureHook:
if is_bare(type):
key_arg = Any
val_arg = Any
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
elif (args := getattr(type, "__args__", None)) not in (None, ()):
if len(args) == 2:
key_arg, val_arg = args
else:
# Probably a Counter
key_arg, val_arg = args, Any
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
def seq_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
if is_bare(type):
type_arg = Any
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
elif getattr(type, "__args__", None) not in (None, ()):
type_arg = type.__args__[0]
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
else:
handler = None

if handler in (identity, to_builtins):
return handler
return converter.gen_unstructure_iterable(type)


def mapping_unstructure_factory(type, converter: BaseConverter) -> UnstructureHook:
if is_bare(type):
key_arg = Any
val_arg = Any
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
elif (args := getattr(type, "__args__", None)) not in (None, ()):
if len(args) == 2:
key_arg, val_arg = args
else:
key_handler = value_handler = None
# Probably a Counter
key_arg, val_arg = args, Any
key_handler = converter.get_unstructure_hook(key_arg, cache_result=False)
value_handler = converter.get_unstructure_hook(val_arg, cache_result=False)
else:
key_handler = value_handler = None

if key_handler in (identity, to_builtins) and value_handler in (
identity,
to_builtins,
):
return to_builtins
return converter.gen_unstructure_mapping(type)


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

return unstructure_mapping_factory
if any(
attr.name.startswith("_")
or (
converter.get_unstructure_hook(attr.type, cache_result=False)
not in (identity, to_builtins)
)
for attr in attribs
):
return converter.gen_unstructure_attrs_fromdict(type)

return to_builtins

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

def attrs_factory(type: Any) -> UnstructureHook:
"""Choose whether to use msgspec handling or our own."""
origin = get_origin(type)
attribs = fields(origin or type)
if attrs_has(type) and any(isinstance(a.type, str) for a in attribs):
resolve_types(type)
attribs = fields(origin or type)

if any(
attr.name.startswith("_")
or (
converter.get_unstructure_hook(attr.type, cache_result=False)
not in (identity, to_builtins)
)
for attr in attribs
):
return converter.gen_unstructure_attrs_fromdict(type)
def namedtuple_unstructure_factory(
type: type[tuple], converter: BaseConverter
) -> UnstructureHook:
"""A hook factory for unstructuring namedtuples, modified for msgspec."""

return to_builtins
if all(
converter.get_unstructure_hook(t) in (identity, to_builtins)
for t in type.__annotations__.values()
):
return identity

return attrs_factory
return make_hetero_tuple_unstructure_fn(
type,
converter,
unstructure_to=tuple,
type_args=tuple(type.__annotations__.values()),
)
Loading

0 comments on commit 0ad5cae

Please sign in to comment.