Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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