From c3a2419c1b74537e1a8b7af7257f68eb3e19dae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 8 Dec 2024 12:26:29 +0100 Subject: [PATCH] More work --- HISTORY.md | 4 ++ src/cattrs/_compat.py | 10 ----- src/cattrs/converters.py | 2 +- src/cattrs/gen/typeddicts.py | 18 ++++++++- src/cattrs/strategies/_listfromdict.py | 54 +++++++++++++++++++------ tests/strategies/test_from_from_dict.py | 22 ---------- tests/strategies/test_list_from_dict.py | 50 +++++++++++++++++++++++ 7 files changed, 113 insertions(+), 47 deletions(-) delete mode 100644 tests/strategies/test_from_from_dict.py create mode 100644 tests/strategies/test_list_from_dict.py diff --git a/HISTORY.md b/HISTORY.md index 5f20c100..7d0b231e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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`. @@ -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 ` 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. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 85b41a95..471b1f7b 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -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 @@ -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 diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 2559bca1..a5be25ff 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -49,7 +49,6 @@ is_sequence, is_tuple, is_type_alias, - is_typeddict, is_union_type, signature, ) @@ -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 diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index d5dcdab6..180bfb64 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -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 @@ -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) diff --git a/src/cattrs/strategies/_listfromdict.py b/src/cattrs/strategies/_listfromdict.py index db6edf65..d98d5dee 100644 --- a/src/cattrs/strategies/_listfromdict.py +++ b/src/cattrs/strategies/_listfromdict.py @@ -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 diff --git a/tests/strategies/test_from_from_dict.py b/tests/strategies/test_from_from_dict.py deleted file mode 100644 index 97464e9f..00000000 --- a/tests/strategies/test_from_from_dict.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Tests for the list-from-dict strategy.""" - -from attrs import define - -from cattrs import BaseConverter -from cattrs.strategies import configure_list_from_dict - - -@define -class A: - a: int - b: str - - -def test_simple_roundtrip(converter: BaseConverter): - hook, hook2 = configure_list_from_dict(list[A], "a", converter) - - structured = [A(1, "2"), A(3, "4")] - unstructured = hook2(structured) - assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}} - - assert hook(unstructured) == structured diff --git a/tests/strategies/test_list_from_dict.py b/tests/strategies/test_list_from_dict.py new file mode 100644 index 00000000..938199cc --- /dev/null +++ b/tests/strategies/test_list_from_dict.py @@ -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