Skip to content

Commit

Permalink
More work
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche committed Dec 8, 2024
1 parent d633703 commit c3a2419
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 47 deletions.
4 changes: 4 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
This helps surfacing problems with missing hooks sooner.
See [Migrations](https://catt.rs/en/latest/migrations.html#the-default-structure-hook-fallback-factory) for steps to restore legacy behavior.
([#577](https://github.com/python-attrs/cattrs/pull/577))
- Introduce the `list_from_dict` strategy.
([#609](https://github.com/python-attrs/cattrs/pull/609))
- Add a [Migrations](https://catt.rs/en/latest/migrations.html) page, with instructions on migrating changed behavior for each version.
([#577](https://github.com/python-attrs/cattrs/pull/577))
- Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`.
Expand All @@ -30,6 +32,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
- Preconf converters now handle dictionaries with literal keys properly.
([#599](https://github.com/python-attrs/cattrs/pull/599))
- Replace `cattrs.gen.MappingStructureFn` with `cattrs.SimpleStructureHook[In, T]`.
- The {func}`is_typeddict <catrs.gen.typeddicts.is_typeddict>` predicate function is now exposed through the {mod}`cattrs.gen.typeddicts` module.
([#609](https://github.com/python-attrs/cattrs/pull/609))
- Python 3.13 is now supported.
([#543](https://github.com/python-attrs/cattrs/pull/543) [#547](https://github.com/python-attrs/cattrs/issues/547))
- Python 3.8 is no longer supported, as it is end-of-life. Use previous versions on this Python version.
Expand Down
10 changes: 0 additions & 10 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,6 @@
else:
from exceptiongroup import ExceptionGroup

try:
from typing_extensions import is_typeddict as _is_typeddict
except ImportError: # pragma: no cover
assert sys.version_info >= (3, 10)
from typing import is_typeddict as _is_typeddict

try:
from typing_extensions import TypeAlias
Expand Down Expand Up @@ -107,11 +102,6 @@ def is_optional(typ: Any) -> bool:
return is_union_type(typ) and NoneType in typ.__args__ and len(typ.__args__) == 2


def is_typeddict(cls: Any):
"""Thin wrapper around typing(_extensions).is_typeddict"""
return _is_typeddict(getattr(cls, "__origin__", cls))


def is_type_alias(type: Any) -> bool:
"""Is this a PEP 695 type alias?"""
return False
Expand Down
2 changes: 1 addition & 1 deletion src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
is_sequence,
is_tuple,
is_type_alias,
is_typeddict,
is_union_type,
signature,
)
Expand Down Expand Up @@ -89,6 +88,7 @@
make_dict_unstructure_fn,
make_hetero_tuple_unstructure_fn,
)
from .gen.typeddicts import is_typeddict
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 .literals import is_literal_containing_enums
Expand Down
18 changes: 16 additions & 2 deletions src/cattrs/gen/typeddicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import re
import sys
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Literal, TypeVar

from attrs import NOTHING, Attribute
from typing_extensions import _TypedDictMeta
Expand Down Expand Up @@ -42,10 +43,23 @@ def get_annots(cl) -> dict[str, Any]:
from ._lc import generate_unique_filename
from ._shared import find_structure_handler

try:
from typing_extensions import is_typeddict as _is_typeddict
except ImportError: # pragma: no cover
assert sys.version_info >= (3, 10)
from typing import is_typeddict as _is_typeddict


if TYPE_CHECKING:
from ..converters import BaseConverter

__all__ = ["make_dict_unstructure_fn", "make_dict_structure_fn"]
__all__ = ["is_typeddict", "make_dict_unstructure_fn", "make_dict_structure_fn"]


def is_typeddict(cls: Any) -> bool:
"""Is this type a TypedDict?"""
return _is_typeddict(getattr(cls, "__origin__", cls))


T = TypeVar("T", bound=TypedDict)

Expand Down
54 changes: 42 additions & 12 deletions src/cattrs/strategies/_listfromdict.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,70 @@
"""The list-from-dict implementation."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any, TypeVar, get_args

from attrs import Attribute

from .. import BaseConverter, SimpleStructureHook
from ..dispatch import UnstructureHook
from ..fns import identity
from ..gen.typeddicts import is_typeddict

T = TypeVar("T")


def configure_list_from_dict(
seq_type: list[T], field: str, converter: BaseConverter
seq_type: list[T], field: str | Attribute, converter: BaseConverter
) -> tuple[SimpleStructureHook[Mapping, T], UnstructureHook]:
"""
Configure a list subtype to be structured and unstructured using a dictionary.
Configure a list subtype to be structured and unstructured into a dictionary,
using a single field of the element as the dictionary key. This effectively
ensures the resulting list is unique with regard to that field.
List elements have to be able to be structured/unstructured using mappings.
One field of the element is extracted into a dictionary key; the rest of the
data is stored under that key.
The types un/structuring into dictionaries by default are:
* attrs classes and dataclasses
* TypedDicts
* named tuples when using the `namedtuple_dict_un/structure_factory`
List elements have to be an attrs class or a dataclass. One field of the element
type is extracted into a dictionary key; the rest of the data is stored under that
key.
:param field: The name of the field to extract. When working with _attrs_ classes,
consider passing in the attribute (as returned by `attrs.field(cls)`) for
added safety.
:return: A tuple of generated structure and unstructure hooks.
.. versionadded:: 24.2.0
"""
arg_type = get_args(seq_type)[0]

arg_structure_hook = converter.get_structure_hook(arg_type, cache_result=False)

if isinstance(field, Attribute):
field = field.name

def structure_hook(
value: Mapping, type: Any = seq_type, _arg_type=arg_type
value: Mapping,
_: Any = seq_type,
_arg_type=arg_type,
_arg_hook=arg_structure_hook,
_field=field,
) -> list[T]:
return [arg_structure_hook(v | {field: k}, _arg_type) for k, v in value.items()]
return [_arg_hook(v | {_field: k}, _arg_type) for k, v in value.items()]

arg_unstructure_hook = converter.get_unstructure_hook(arg_type, cache_result=False)

def unstructure_hook(val: list[T]) -> dict:
return {
(unstructured := arg_unstructure_hook(v)).pop(field): unstructured
for v in val
}
# TypedDicts can end up being unstructured via identity, in that case we make a copy
# so we don't destroy the original.
if is_typeddict(arg_type) and arg_unstructure_hook == identity:
arg_unstructure_hook = dict

def unstructure_hook(val: list[T], _arg_hook=arg_unstructure_hook) -> dict:
return {(unstructured := _arg_hook(v)).pop(field): unstructured for v in val}

return structure_hook, unstructure_hook
22 changes: 0 additions & 22 deletions tests/strategies/test_from_from_dict.py

This file was deleted.

50 changes: 50 additions & 0 deletions tests/strategies/test_list_from_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Tests for the list-from-dict strategy."""

from dataclasses import dataclass
from typing import TypedDict

import pytest
from attrs import define, fields

from cattrs import BaseConverter
from cattrs.strategies import configure_list_from_dict


@define
class AttrsA:
a: int
b: str


@dataclass
class DataclassA:
a: int
b: str


class TypedDictA(TypedDict):
a: int
b: str


@pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA])
def test_simple_roundtrip(
cls: type[AttrsA] | type[DataclassA], converter: BaseConverter
):
hook, hook2 = configure_list_from_dict(list[cls], "a", converter)

structured = [cls(a=1, b="2"), cls(a=3, b="4")]
unstructured = hook2(structured)
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}

assert hook(unstructured) == structured


def test_simple_roundtrip_attrs(converter: BaseConverter):
hook, hook2 = configure_list_from_dict(list[AttrsA], fields(AttrsA).a, converter)

structured = [AttrsA(a=1, b="2"), AttrsA(a=3, b="4")]
unstructured = hook2(structured)
assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}}

assert hook(unstructured) == structured

0 comments on commit c3a2419

Please sign in to comment.