From 3c96a99196cd2f23d5c56143b6749ab5b4815015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 7 Dec 2024 22:56:41 +0100 Subject: [PATCH 1/6] `list_from_dict` initial try --- src/cattrs/strategies/__init__.py | 2 ++ src/cattrs/strategies/_listfromdict.py | 40 +++++++++++++++++++++++++ tests/strategies/test_from_from_dict.py | 22 ++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 src/cattrs/strategies/_listfromdict.py create mode 100644 tests/strategies/test_from_from_dict.py diff --git a/src/cattrs/strategies/__init__.py b/src/cattrs/strategies/__init__.py index 9caf0732..c7da58c9 100644 --- a/src/cattrs/strategies/__init__.py +++ b/src/cattrs/strategies/__init__.py @@ -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", diff --git a/src/cattrs/strategies/_listfromdict.py b/src/cattrs/strategies/_listfromdict.py new file mode 100644 index 00000000..db6edf65 --- /dev/null +++ b/src/cattrs/strategies/_listfromdict.py @@ -0,0 +1,40 @@ +"""The list-from-dict implementation.""" + +from collections.abc import Mapping +from typing import Any, TypeVar, get_args + +from .. import BaseConverter, SimpleStructureHook +from ..dispatch import UnstructureHook + +T = TypeVar("T") + + +def configure_list_from_dict( + seq_type: list[T], field: str, converter: BaseConverter +) -> tuple[SimpleStructureHook[Mapping, T], UnstructureHook]: + """ + Configure a list subtype to be structured and unstructured using a dictionary. + + 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. + + """ + arg_type = get_args(seq_type)[0] + + arg_structure_hook = converter.get_structure_hook(arg_type, cache_result=False) + + def structure_hook( + value: Mapping, type: Any = seq_type, _arg_type=arg_type + ) -> list[T]: + return [arg_structure_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 + } + + return structure_hook, unstructure_hook diff --git a/tests/strategies/test_from_from_dict.py b/tests/strategies/test_from_from_dict.py new file mode 100644 index 00000000..97464e9f --- /dev/null +++ b/tests/strategies/test_from_from_dict.py @@ -0,0 +1,22 @@ +"""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 From b3623e5cc929f325d8ed8e6e0acb2e46be8f9f67 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 2/6] 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 From 428b4e77f98e4550da5bdcddafa85de9056b9d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sun, 8 Dec 2024 12:30:43 +0100 Subject: [PATCH 3/6] Fix HISTORY --- HISTORY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 7d0b231e..c912401b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -22,7 +22,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ([#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. @@ -32,7 +32,7 @@ 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. +- 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)) From 1bf96f2f303c64f87b6f73acff3bef341381609a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Tue, 24 Dec 2024 23:32:16 +0100 Subject: [PATCH 4/6] Old union --- tests/strategies/test_list_from_dict.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/strategies/test_list_from_dict.py b/tests/strategies/test_list_from_dict.py index 938199cc..b67d7339 100644 --- a/tests/strategies/test_list_from_dict.py +++ b/tests/strategies/test_list_from_dict.py @@ -1,7 +1,7 @@ """Tests for the list-from-dict strategy.""" from dataclasses import dataclass -from typing import TypedDict +from typing import TypedDict, Union import pytest from attrs import define, fields @@ -29,7 +29,7 @@ class TypedDictA(TypedDict): @pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA]) def test_simple_roundtrip( - cls: type[AttrsA] | type[DataclassA], converter: BaseConverter + cls: Union[type[AttrsA], type[DataclassA]], converter: BaseConverter ): hook, hook2 = configure_list_from_dict(list[cls], "a", converter) From 31b6412e9677d70c0177d05800bffff93a85a838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Tue, 24 Dec 2024 23:38:34 +0100 Subject: [PATCH 5/6] Remove moved symbol from `__all__` --- src/cattrs/_compat.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 471b1f7b..6ba158b1 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -56,7 +56,6 @@ "get_type_alias_base", "has", "is_type_alias", - "is_typeddict", "TypeAlias", "TypedDict", ] From 6f7832a43552b1d5a15920daa8c5ca760d4a3e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Sat, 28 Dec 2024 23:01:08 +0100 Subject: [PATCH 6/6] Add test --- src/cattrs/strategies/_listfromdict.py | 92 ++++++++++++++++++++++--- tests/strategies/test_list_from_dict.py | 55 ++++++++++++--- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/src/cattrs/strategies/_listfromdict.py b/src/cattrs/strategies/_listfromdict.py index d98d5dee..6b82be4d 100644 --- a/src/cattrs/strategies/_listfromdict.py +++ b/src/cattrs/strategies/_listfromdict.py @@ -9,6 +9,12 @@ 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 @@ -48,14 +54,84 @@ def configure_list_from_dict( if isinstance(field, Attribute): field = field.name - 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()] + 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) diff --git a/tests/strategies/test_list_from_dict.py b/tests/strategies/test_list_from_dict.py index b67d7339..58a63629 100644 --- a/tests/strategies/test_list_from_dict.py +++ b/tests/strategies/test_list_from_dict.py @@ -6,25 +6,28 @@ import pytest from attrs import define, fields -from cattrs import BaseConverter +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: str + b: int @dataclass class DataclassA: a: int - b: str + b: int class TypedDictA(TypedDict): a: int - b: str + b: int @pytest.mark.parametrize("cls", [AttrsA, DataclassA, TypedDictA]) @@ -33,9 +36,9 @@ def test_simple_roundtrip( ): hook, hook2 = configure_list_from_dict(list[cls], "a", converter) - structured = [cls(a=1, b="2"), cls(a=3, b="4")] + structured = [cls(a=1, b=2), cls(a=3, b=4)] unstructured = hook2(structured) - assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}} + assert unstructured == {1: {"b": 2}, 3: {"b": 4}} assert hook(unstructured) == structured @@ -43,8 +46,44 @@ def test_simple_roundtrip( 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")] + structured = [AttrsA(a=1, b=2), AttrsA(a=3, b=4)] unstructured = hook2(structured) - assert unstructured == {1: {"b": "2"}, 3: {"b": "4"}} + 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]"]