Skip to content

Commit

Permalink
Namedtuples to/from dicts (#549)
Browse files Browse the repository at this point in the history
Namedtuple dict un/structuring factories
  • Loading branch information
Tinche authored Jun 20, 2024
1 parent 6190eb7 commit f97018e
Show file tree
Hide file tree
Showing 14 changed files with 836 additions and 476 deletions.
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ can now be used as decorators and have gained new features.
([#512](https://github.com/python-attrs/cattrs/pull/512))
- 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))
- Add support for optionally un/unstructuring named tuples using dictionaries.
([#425](https://github.com/python-attrs/cattrs/issues/425) [#549](https://github.com/python-attrs/cattrs/pull/549))
- 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
36 changes: 36 additions & 0 deletions docs/customizing.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ Available hook factories are:
* {meth}`list_structure_factory <cattrs.cols.list_structure_factory>`
* {meth}`namedtuple_structure_factory <cattrs.cols.namedtuple_structure_factory>`
* {meth}`namedtuple_unstructure_factory <cattrs.cols.namedtuple_unstructure_factory>`
* {meth}`namedtuple_dict_structure_factory <cattrs.cols.namedtuple_dict_structure_factory>`
* {meth}`namedtuple_dict_unstructure_factory <cattrs.cols.namedtuple_dict_unstructure_factory>`

Additional predicates and hook factories will be added as requested.

Expand Down Expand Up @@ -225,6 +227,40 @@ ValueError: Not a list!

```

### Customizing Named Tuples

Named tuples can be un/structured using dictionaries using the {meth}`namedtuple_dict_structure_factory <cattrs.cols.namedtuple_dict_structure_factory>`
and {meth}`namedtuple_dict_unstructure_factory <cattrs.cols.namedtuple_dict_unstructure_factory>`
hook factories.

To unstructure _all_ named tuples into dictionaries:

```{doctest} namedtuples
>>> from typing import NamedTuple

>>> from cattrs.cols import is_namedtuple, namedtuple_dict_unstructure_factory
>>> c = Converter()

>>> c.register_unstructure_hook_factory(is_namedtuple, namedtuple_dict_unstructure_factory)
<function namedtuple_dict_unstructure_factory at ...>

>>> class MyNamedTuple(NamedTuple):
... a: int

>>> c.unstructure(MyNamedTuple(1))
{'a': 1}
```

To only un/structure _some_ named tuples into dictionaries,
change the predicate function when registering the hook factory:

```{doctest} namedtuples
>>> c.register_unstructure_hook_factory(
... lambda t: t is MyNamedTuple,
... namedtuple_dict_unstructure_factory,
... )
```

## Using `cattrs.gen` Generators

The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts.
Expand Down
8 changes: 8 additions & 0 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,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.

```{seealso}
[Support for typing.NamedTuple.](#typingnamedtuple)
```

```{note}
Structuring heterogenous tuples are not supported by the BaseConverter.
```
Expand Down Expand Up @@ -511,6 +515,10 @@ 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.
Named tuples are un/structured using tuples or lists by default.

The {mod}`cattrs.cols` module contains hook factories for un/structuring named tuples using dictionaries instead,
[see here for details](customizing.md#customizing-named-tuples).

```{versionadded} 24.1.0

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ ignore = [
"DTZ006", # datetimes in tests
]

[tool.ruff.lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`.
keep-runtime-typing = true

[tool.hatch.version]
source = "vcs"
raw-options = { local_scheme = "no-local-version" }
Expand Down
2 changes: 1 addition & 1 deletion src/cattr/gen.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from cattrs.cols import iterable_unstructure_factory as make_iterable_unstructure_fn
from cattrs.gen import (
make_dict_structure_fn,
make_dict_unstructure_fn,
make_hetero_tuple_unstructure_fn,
make_iterable_unstructure_fn,
make_mapping_structure_fn,
make_mapping_unstructure_fn,
override,
Expand Down
171 changes: 134 additions & 37 deletions src/cattrs/cols.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,32 @@
from __future__ import annotations

from sys import version_info
from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, Tuple, TypeVar
from typing import (
TYPE_CHECKING,
Any,
Iterable,
Literal,
NamedTuple,
Tuple,
TypeVar,
get_type_hints,
)

from attrs import NOTHING, Attribute

from ._compat import ANIES, is_bare, is_frozenset, is_sequence, is_subclass
from ._compat import is_mutable_set as is_set
from .dispatch import StructureHook, UnstructureHook
from .errors import IterableValidationError, IterableValidationNote
from .fns import identity
from .gen import make_hetero_tuple_unstructure_fn
from .gen import (
AttributeOverride,
already_generating,
make_dict_structure_fn_from_attrs,
make_dict_unstructure_fn_from_attrs,
make_hetero_tuple_unstructure_fn,
)
from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory

if TYPE_CHECKING:
from .converters import BaseConverter
Expand All @@ -25,6 +43,8 @@
"list_structure_factory",
"namedtuple_structure_factory",
"namedtuple_unstructure_factory",
"namedtuple_dict_structure_factory",
"namedtuple_dict_unstructure_factory",
]


Expand Down Expand Up @@ -133,57 +153,134 @@ def structure_list(
return structure_list


def iterable_unstructure_factory(
cl: Any, converter: BaseConverter, unstructure_to: Any = None
) -> UnstructureHook:
"""A hook factory for unstructuring iterables.
:param unstructure_to: Force unstructuring to this type, if provided.
"""
handler = converter.unstructure

# Let's try fishing out the type args
# Unspecified tuples have `__args__` as empty tuples, so guard
# against IndexError.
if getattr(cl, "__args__", None) not in (None, ()):
type_arg = cl.__args__[0]
if isinstance(type_arg, TypeVar):
type_arg = getattr(type_arg, "__default__", Any)
handler = converter.get_unstructure_hook(type_arg, cache_result=False)
if handler == identity:
# Save ourselves the trouble of iterating over it all.
return unstructure_to or cl

def unstructure_iterable(iterable, _seq_cl=unstructure_to or cl, _hook=handler):
return _seq_cl(_hook(i) for i in iterable)

return unstructure_iterable


def namedtuple_unstructure_factory(
type: type[tuple], converter: BaseConverter, unstructure_to: Any = None
cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None
) -> UnstructureHook:
"""A hook factory for unstructuring namedtuples.
:param unstructure_to: Force unstructuring to this type, if provided.
"""

if unstructure_to is None and _is_passthrough(type, converter):
if unstructure_to is None and _is_passthrough(cl, converter):
return identity

return make_hetero_tuple_unstructure_fn(
type,
cl,
converter,
unstructure_to=tuple if unstructure_to is None else unstructure_to,
type_args=tuple(type.__annotations__.values()),
type_args=tuple(cl.__annotations__.values()),
)


def namedtuple_structure_factory(
type: type[tuple], converter: BaseConverter
cl: type[tuple], converter: BaseConverter
) -> StructureHook:
"""A hook factory for structuring namedtuples."""
"""A hook factory for structuring namedtuples from iterables."""
# We delegate to the existing infrastructure for heterogenous tuples.
hetero_tuple_type = Tuple[tuple(type.__annotations__.values())]
hetero_tuple_type = Tuple[tuple(cl.__annotations__.values())]
base_hook = converter.get_structure_hook(hetero_tuple_type)
return lambda v, _: type(*base_hook(v, hetero_tuple_type))
return lambda v, _: cl(*base_hook(v, hetero_tuple_type))


def _namedtuple_to_attrs(cl: type[tuple]) -> list[Attribute]:
"""Generate pseudo attributes for a namedtuple."""
return [
Attribute(
name,
cl._field_defaults.get(name, NOTHING),
None,
False,
False,
False,
True,
False,
type=a,
alias=name,
)
for name, a in get_type_hints(cl).items()
]


def namedtuple_dict_structure_factory(
cl: type[tuple],
converter: BaseConverter,
detailed_validation: bool | Literal["from_converter"] = "from_converter",
forbid_extra_keys: bool = False,
use_linecache: bool = True,
/,
**kwargs: AttributeOverride,
) -> StructureHook:
"""A hook factory for hooks structuring namedtuples from dictionaries.
:param forbid_extra_keys: Whether the hook should raise a `ForbiddenExtraKeysError`
if unknown keys are encountered.
:param use_linecache: Whether to store the source code in the Python linecache.
.. versionadded:: 24.1.0
"""
try:
working_set = already_generating.working_set
except AttributeError:
working_set = set()
already_generating.working_set = working_set
else:
if cl in working_set:
raise RecursionError()

working_set.add(cl)

try:
return make_dict_structure_fn_from_attrs(
_namedtuple_to_attrs(cl),
cl,
converter,
_cattrs_forbid_extra_keys=forbid_extra_keys,
_cattrs_use_detailed_validation=detailed_validation,
_cattrs_use_linecache=use_linecache,
**kwargs,
)
finally:
working_set.remove(cl)
if not working_set:
del already_generating.working_set


def namedtuple_dict_unstructure_factory(
cl: type[tuple],
converter: BaseConverter,
omit_if_default: bool = False,
use_linecache: bool = True,
/,
**kwargs: AttributeOverride,
) -> UnstructureHook:
"""A hook factory for hooks unstructuring namedtuples to dictionaries.
:param omit_if_default: When true, attributes equal to their default values
will be omitted in the result dictionary.
:param use_linecache: Whether to store the source code in the Python linecache.
.. versionadded:: 24.1.0
"""
try:
working_set = already_generating.working_set
except AttributeError:
working_set = set()
already_generating.working_set = working_set
if cl in working_set:
raise RecursionError()

working_set.add(cl)

try:
return make_dict_unstructure_fn_from_attrs(
_namedtuple_to_attrs(cl),
cl,
converter,
_cattrs_omit_if_default=omit_if_default,
_cattrs_use_linecache=use_linecache,
**kwargs,
)
finally:
working_set.remove(cl)
if not working_set:
del already_generating.working_set
4 changes: 2 additions & 2 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
)
from .cols import (
is_namedtuple,
iterable_unstructure_factory,
list_structure_factory,
namedtuple_structure_factory,
namedtuple_unstructure_factory,
Expand Down Expand Up @@ -83,7 +84,6 @@
make_dict_structure_fn,
make_dict_unstructure_fn,
make_hetero_tuple_unstructure_fn,
make_iterable_unstructure_fn,
make_mapping_structure_fn,
make_mapping_unstructure_fn,
)
Expand Down Expand Up @@ -1248,7 +1248,7 @@ def gen_unstructure_iterable(
unstructure_to = self._unstruct_collection_overrides.get(
get_origin(cl) or cl, unstructure_to or list
)
h = make_iterable_unstructure_fn(cl, self, unstructure_to=unstructure_to)
h = iterable_unstructure_factory(cl, self, unstructure_to=unstructure_to)
self._unstructure_func.register_cls_list([(cl, h)], direct=True)
return h

Expand Down
Loading

0 comments on commit f97018e

Please sign in to comment.