Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

list_from_dict initial try #609

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
6 changes: 5 additions & 1 deletion HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ 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`.
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
{func}`cattrs.cols.is_defaultdict`{func} and `cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
{func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
([#519](https://github.com/python-attrs/cattrs/issues/519) [#588](https://github.com/python-attrs/cattrs/pull/588))
- Many preconf converters (_bson_, stdlib JSON, _cbor2_, _msgpack_, _msgspec_, _orjson_, _ujson_) skip unstructuring `int` and `str` enums,
leaving them to the underlying libraries to handle with greater efficiency.
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 <cattrs.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
11 changes: 0 additions & 11 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"get_type_alias_base",
"has",
"is_type_alias",
"is_typeddict",
"TypeAlias",
"TypedDict",
]
Expand All @@ -71,11 +70,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 +101,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
2 changes: 2 additions & 0 deletions src/cattrs/strategies/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""High level strategies for converters."""

from ._class_methods import use_class_methods
from ._listfromdict import configure_list_from_dict
from ._subclasses import include_subclasses
from ._unions import configure_tagged_union, configure_union_passthrough

__all__ = [
"configure_list_from_dict",
"configure_tagged_union",
"configure_union_passthrough",
"include_subclasses",
Expand Down
146 changes: 146 additions & 0 deletions src/cattrs/strategies/_listfromdict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""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 ..errors import (
AttributeValidationNote,
ClassValidationError,
IterableValidationError,
IterableValidationNote,
)
from ..fns import identity
from ..gen.typeddicts import is_typeddict

T = TypeVar("T")


def configure_list_from_dict(
seq_type: list[T], field: str | Attribute, converter: BaseConverter
) -> tuple[SimpleStructureHook[Mapping, T], UnstructureHook]:
"""
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`

: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

if converter.detailed_validation:

def structure_hook(
value: Mapping,
_: Any = seq_type,
_arg_type=arg_type,
_arg_hook=arg_structure_hook,
_field=field,
) -> list[T]:
res = []
errors = []
for k, v in value.items():
try:
res.append(_arg_hook(v | {_field: k}, _arg_type))
except ClassValidationError as exc:
# Rewrite the notes of any errors relating to `_field`
non_key_exceptions = []
key_exceptions = []
for inner_exc in exc.exceptions:
if not (existing := getattr(inner_exc, "__notes__", [])):
non_key_exceptions.append(inner_exc)
continue
for note in existing:
if not isinstance(note, AttributeValidationNote):
continue
if note.name == _field:
inner_exc.__notes__.remove(note)
inner_exc.__notes__.append(
IterableValidationNote(
f"Structuring mapping key @ key {k!r}",
note.name,
note.type,
)
)
key_exceptions.append(inner_exc)
break
else:
non_key_exceptions.append(inner_exc)

if non_key_exceptions != exc.exceptions:
if non_key_exceptions:
errors.append(
new_exc := ClassValidationError(
exc.message, non_key_exceptions, exc.cl
)
)
new_exc.__notes__ = [
*getattr(exc, "__notes__", []),
IterableValidationNote(
"Structuring mapping value @ key {k!r}",
k,
_arg_type,
),
]
else:
exc.__notes__ = [
*getattr(exc, "__notes__", []),
IterableValidationNote(
"Structuring mapping value @ key {k!r}", k, _arg_type
),
]
errors.append(exc)
if key_exceptions:
errors.extend(key_exceptions)
if errors:
raise IterableValidationError("While structuring", errors, dict)
return res

else:

def structure_hook(
value: Mapping,
_: Any = seq_type,
_arg_type=arg_type,
_arg_hook=arg_structure_hook,
_field=field,
) -> list[T]:
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)

# 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
89 changes: 89 additions & 0 deletions tests/strategies/test_list_from_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Tests for the list-from-dict strategy."""

from dataclasses import dataclass
from typing import TypedDict, Union

import pytest
from attrs import define, fields

from cattrs import BaseConverter, transform_error
from cattrs.converters import Converter
from cattrs.errors import IterableValidationError
from cattrs.gen import make_dict_structure_fn
from cattrs.strategies import configure_list_from_dict


@define
class AttrsA:
a: int
b: int


@dataclass
class DataclassA:
a: int
b: int


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


@pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA])
def test_simple_roundtrip(
cls: Union[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


def test_validation_errors():
"""
With detailed validation, validation errors should be adjusted for the
extracted keys.
"""
conv = Converter(detailed_validation=True)
hook, _ = configure_list_from_dict(list[AttrsA], "a", conv)

# Key failure
with pytest.raises(IterableValidationError) as exc:
hook({"a": {"b": "1"}})

assert transform_error(exc.value) == [
"invalid value for type, expected int @ $['a']"
]

# Value failure
with pytest.raises(IterableValidationError) as exc:
hook({1: {"b": "a"}})

assert transform_error(exc.value) == [
"invalid value for type, expected int @ $[1].b"
]

conv.register_structure_hook(
AttrsA, make_dict_structure_fn(AttrsA, conv, _cattrs_forbid_extra_keys=True)
)
hook, _ = configure_list_from_dict(list[AttrsA], "a", conv)

# Value failure, not attribute related
with pytest.raises(IterableValidationError) as exc:
hook({1: {"b": 1, "c": 2}})

assert transform_error(exc.value) == ["extra fields found (c) @ $[1]"]
Loading