From fad8027bd68d4e2e88d13f4d2f2d95b48331235b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Tue, 23 Jul 2024 11:42:13 +0200 Subject: [PATCH 1/5] Pull out and document `is_mapping` and `mapping_structure_factory` --- docs/customizing.md | 2 ++ src/cattrs/_compat.py | 6 ++++-- src/cattrs/cols.py | 5 ++++- src/cattrs/gen/__init__.py | 6 +++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/customizing.md b/docs/customizing.md index f3066dd4..ec643e25 100644 --- a/docs/customizing.md +++ b/docs/customizing.md @@ -166,6 +166,7 @@ Available predicates are: * {meth}`is_frozenset ` * {meth}`is_set ` * {meth}`is_sequence ` +* {meth}`is_mapping ` * {meth}`is_namedtuple ` ````{tip} @@ -187,6 +188,7 @@ Available hook factories are: * {meth}`namedtuple_unstructure_factory ` * {meth}`namedtuple_dict_structure_factory ` * {meth}`namedtuple_dict_unstructure_factory ` +* {meth}`mapping_structure_factory ` Additional predicates and hook factories will be added as requested. diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 0eda9947..e3664eac 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -404,7 +404,8 @@ def is_bare(type): not hasattr(type, "__origin__") and not hasattr(type, "__args__") ) - def is_mapping(type): + def is_mapping(type: Any) -> bool: + """A predicate function for mappings.""" return ( type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping) or ( @@ -515,7 +516,8 @@ def is_frozenset(type): type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet) ) - def is_mapping(type): + def is_mapping(type: Any) -> bool: + """A predicate function for mappings.""" return type in (TypingMapping, dict) or ( type.__class__ is _GenericAlias and is_subclass(type.__origin__, TypingMapping) diff --git a/src/cattrs/cols.py b/src/cattrs/cols.py index 196c85ce..8ff5c0f0 100644 --- a/src/cattrs/cols.py +++ b/src/cattrs/cols.py @@ -16,7 +16,7 @@ from attrs import NOTHING, Attribute -from ._compat import ANIES, is_bare, is_frozenset, is_sequence, is_subclass +from ._compat import ANIES, is_bare, is_frozenset, is_mapping, is_sequence, is_subclass from ._compat import is_mutable_set as is_set from .dispatch import StructureHook, UnstructureHook from .errors import IterableValidationError, IterableValidationNote @@ -27,6 +27,7 @@ make_dict_structure_fn_from_attrs, make_dict_unstructure_fn_from_attrs, make_hetero_tuple_unstructure_fn, + mapping_structure_factory, ) from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory @@ -37,6 +38,7 @@ "is_any_set", "is_frozenset", "is_namedtuple", + "is_mapping", "is_set", "is_sequence", "iterable_unstructure_factory", @@ -45,6 +47,7 @@ "namedtuple_unstructure_factory", "namedtuple_dict_structure_factory", "namedtuple_dict_unstructure_factory", + "mapping_structure_factory", ] diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 4149217a..97d28769 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -898,7 +898,8 @@ def make_mapping_unstructure_fn( MappingStructureFn = Callable[[Mapping[Any, Any], Any], T] -def make_mapping_structure_fn( +# This factory is here for backwards compatibility and circular imports. +def mapping_structure_factory( cl: type[T], converter: BaseConverter, structure_to: type = dict, @@ -1018,6 +1019,9 @@ def make_mapping_structure_fn( return globs[fn_name] +make_mapping_structure_fn: Final = mapping_structure_factory + + # This factory is here for backwards compatibility and circular imports. def iterable_unstructure_factory( cl: Any, converter: BaseConverter, unstructure_to: Any = None From 5cb8fe5f1ecb094b6089bfe07c19935dc55bd656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 24 Jul 2024 18:07:27 +0200 Subject: [PATCH 2/5] Tweak default hooks so immutables work --- src/cattrs/_compat.py | 18 +++++++++++------- src/cattrs/converters.py | 2 +- tests/test_cols.py | 9 ++++++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index e3664eac..7b26dfd5 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -412,11 +412,9 @@ def is_mapping(type: Any) -> bool: type.__class__ is _GenericAlias and is_subclass(type.__origin__, TypingMapping) ) - or ( - getattr(type, "__origin__", None) - in (dict, AbcMutableMapping, AbcMapping) + or is_subclass( + getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) ) - or is_subclass(type, dict) ) def is_counter(type): @@ -518,9 +516,15 @@ def is_frozenset(type): def is_mapping(type: Any) -> bool: """A predicate function for mappings.""" - return type in (TypingMapping, dict) or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, TypingMapping) + return ( + type in (TypingMapping, dict) + or ( + type.__class__ is _GenericAlias + and is_subclass(type.__origin__, TypingMapping) + ) + or is_subclass( + getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) + ) ) bare_generic_args = { diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 8a0b2b66..44ab72e2 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1290,7 +1290,7 @@ def gen_structure_counter(self, cl: Any) -> MappingStructureFn[T]: def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]: h = make_mapping_structure_fn( - cl, self, detailed_validation=self.detailed_validation + cl, self, get_origin(cl) or cl, detailed_validation=self.detailed_validation ) self._structure_func.register_cls_list([(cl, h)], direct=True) return h diff --git a/tests/test_cols.py b/tests/test_cols.py index 5c596011..ea00bbac 100644 --- a/tests/test_cols.py +++ b/tests/test_cols.py @@ -1,6 +1,8 @@ """Tests for the `cattrs.cols` module.""" -from cattrs import BaseConverter +from immutables import Map + +from cattrs import BaseConverter, Converter from cattrs._compat import AbstractSet, FrozenSet from cattrs.cols import is_any_set, iterable_unstructure_factory @@ -19,3 +21,8 @@ def test_set_overriding(converter: BaseConverter): "b", "c", ] + + +def test_structuring_immutables_map(genconverter: Converter): + """This should work due to our new is_mapping predicate.""" + assert genconverter.structure({"a": 1}, Map[str, int]) == Map(a=1) From d231ede49474c58dc4547c8a8479e9ce3754b24b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 24 Jul 2024 18:25:33 +0200 Subject: [PATCH 3/5] Default to dicts more --- src/cattrs/converters.py | 41 +++++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 44ab72e2..65bac0d8 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -288,10 +288,12 @@ def unstruct_strat(self) -> UnstructureStrategy: ) @overload - def register_unstructure_hook(self) -> Callable[[UnstructureHook], None]: ... + def register_unstructure_hook(self) -> Callable[[UnstructureHook], None]: + ... @overload - def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: ... + def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: + ... def register_unstructure_hook( self, cls: Any = None, func: UnstructureHook | None = None @@ -339,22 +341,26 @@ def register_unstructure_hook_func( @overload def register_unstructure_hook_factory( self, predicate: Predicate - ) -> Callable[[UnstructureHookFactory], UnstructureHookFactory]: ... + ) -> Callable[[UnstructureHookFactory], UnstructureHookFactory]: + ... @overload def register_unstructure_hook_factory( self, predicate: Predicate - ) -> Callable[[ExtendedUnstructureHookFactory], ExtendedUnstructureHookFactory]: ... + ) -> Callable[[ExtendedUnstructureHookFactory], ExtendedUnstructureHookFactory]: + ... @overload def register_unstructure_hook_factory( self, predicate: Predicate, factory: UnstructureHookFactory - ) -> UnstructureHookFactory: ... + ) -> UnstructureHookFactory: + ... @overload def register_unstructure_hook_factory( self, predicate: Predicate, factory: ExtendedUnstructureHookFactory - ) -> ExtendedUnstructureHookFactory: ... + ) -> ExtendedUnstructureHookFactory: + ... def register_unstructure_hook_factory(self, predicate, factory=None): """ @@ -423,10 +429,12 @@ def get_unstructure_hook( ) @overload - def register_structure_hook(self) -> Callable[[StructureHook], None]: ... + def register_structure_hook(self) -> Callable[[StructureHook], None]: + ... @overload - def register_structure_hook(self, cl: Any, func: StructuredValue) -> None: ... + def register_structure_hook(self, cl: Any, func: StructuredValue) -> None: + ... def register_structure_hook( self, cl: Any, func: StructureHook | None = None @@ -476,22 +484,26 @@ def register_structure_hook_func( @overload def register_structure_hook_factory( self, predicate: Predicate - ) -> Callable[[StructureHookFactory, StructureHookFactory]]: ... + ) -> Callable[[StructureHookFactory, StructureHookFactory]]: + ... @overload def register_structure_hook_factory( self, predicate: Predicate - ) -> Callable[[ExtendedStructureHookFactory, ExtendedStructureHookFactory]]: ... + ) -> Callable[[ExtendedStructureHookFactory, ExtendedStructureHookFactory]]: + ... @overload def register_structure_hook_factory( self, predicate: Predicate, factory: StructureHookFactory - ) -> StructureHookFactory: ... + ) -> StructureHookFactory: + ... @overload def register_structure_hook_factory( self, predicate: Predicate, factory: ExtendedStructureHookFactory - ) -> ExtendedStructureHookFactory: ... + ) -> ExtendedStructureHookFactory: + ... def register_structure_hook_factory(self, predicate, factory=None): """ @@ -1289,8 +1301,11 @@ def gen_structure_counter(self, cl: Any) -> MappingStructureFn[T]: return h def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]: + structure_to = get_origin(cl) or cl + if structure_to in (MutableMapping, Mapping): # These default to dicts + structure_to = dict h = make_mapping_structure_fn( - cl, self, get_origin(cl) or cl, detailed_validation=self.detailed_validation + cl, self, structure_to, detailed_validation=self.detailed_validation ) self._structure_func.register_cls_list([(cl, h)], direct=True) return h From 9f0d5c473bd1ba066fdea04d4e39727090249d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Wed, 24 Jul 2024 23:24:34 +0200 Subject: [PATCH 4/5] Fix collections.abc.Mapping handling on 3.8 --- src/cattrs/_compat.py | 4 ++-- src/cattrs/converters.py | 45 ++++++++++++++++++---------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 7b26dfd5..027ef477 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -1,5 +1,7 @@ import sys from collections import deque +from collections.abc import Mapping as AbcMapping +from collections.abc import MutableMapping as AbcMutableMapping from collections.abc import MutableSet as AbcMutableSet from collections.abc import Set as AbcSet from dataclasses import MISSING, Field, is_dataclass @@ -219,8 +221,6 @@ def get_final_base(type) -> Optional[type]: if sys.version_info >= (3, 9): from collections import Counter - from collections.abc import Mapping as AbcMapping - from collections.abc import MutableMapping as AbcMutableMapping from collections.abc import MutableSequence as AbcMutableSequence from collections.abc import MutableSet as AbcMutableSet from collections.abc import Sequence as AbcSequence diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 65bac0d8..e4653fb3 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1,6 +1,8 @@ from __future__ import annotations from collections import Counter, deque +from collections.abc import Mapping as AbcMapping +from collections.abc import MutableMapping as AbcMutableMapping from collections.abc import MutableSet as AbcMutableSet from dataclasses import Field from enum import Enum @@ -288,12 +290,10 @@ def unstruct_strat(self) -> UnstructureStrategy: ) @overload - def register_unstructure_hook(self) -> Callable[[UnstructureHook], None]: - ... + def register_unstructure_hook(self) -> Callable[[UnstructureHook], None]: ... @overload - def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: - ... + def register_unstructure_hook(self, cls: Any, func: UnstructureHook) -> None: ... def register_unstructure_hook( self, cls: Any = None, func: UnstructureHook | None = None @@ -341,26 +341,22 @@ def register_unstructure_hook_func( @overload def register_unstructure_hook_factory( self, predicate: Predicate - ) -> Callable[[UnstructureHookFactory], UnstructureHookFactory]: - ... + ) -> Callable[[UnstructureHookFactory], UnstructureHookFactory]: ... @overload def register_unstructure_hook_factory( self, predicate: Predicate - ) -> Callable[[ExtendedUnstructureHookFactory], ExtendedUnstructureHookFactory]: - ... + ) -> Callable[[ExtendedUnstructureHookFactory], ExtendedUnstructureHookFactory]: ... @overload def register_unstructure_hook_factory( self, predicate: Predicate, factory: UnstructureHookFactory - ) -> UnstructureHookFactory: - ... + ) -> UnstructureHookFactory: ... @overload def register_unstructure_hook_factory( self, predicate: Predicate, factory: ExtendedUnstructureHookFactory - ) -> ExtendedUnstructureHookFactory: - ... + ) -> ExtendedUnstructureHookFactory: ... def register_unstructure_hook_factory(self, predicate, factory=None): """ @@ -429,12 +425,10 @@ def get_unstructure_hook( ) @overload - def register_structure_hook(self) -> Callable[[StructureHook], None]: - ... + def register_structure_hook(self) -> Callable[[StructureHook], None]: ... @overload - def register_structure_hook(self, cl: Any, func: StructuredValue) -> None: - ... + def register_structure_hook(self, cl: Any, func: StructuredValue) -> None: ... def register_structure_hook( self, cl: Any, func: StructureHook | None = None @@ -484,26 +478,22 @@ def register_structure_hook_func( @overload def register_structure_hook_factory( self, predicate: Predicate - ) -> Callable[[StructureHookFactory, StructureHookFactory]]: - ... + ) -> Callable[[StructureHookFactory, StructureHookFactory]]: ... @overload def register_structure_hook_factory( self, predicate: Predicate - ) -> Callable[[ExtendedStructureHookFactory, ExtendedStructureHookFactory]]: - ... + ) -> Callable[[ExtendedStructureHookFactory, ExtendedStructureHookFactory]]: ... @overload def register_structure_hook_factory( self, predicate: Predicate, factory: StructureHookFactory - ) -> StructureHookFactory: - ... + ) -> StructureHookFactory: ... @overload def register_structure_hook_factory( self, predicate: Predicate, factory: ExtendedStructureHookFactory - ) -> ExtendedStructureHookFactory: - ... + ) -> ExtendedStructureHookFactory: ... def register_structure_hook_factory(self, predicate, factory=None): """ @@ -1302,7 +1292,12 @@ def gen_structure_counter(self, cl: Any) -> MappingStructureFn[T]: def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]: structure_to = get_origin(cl) or cl - if structure_to in (MutableMapping, Mapping): # These default to dicts + if structure_to in ( + MutableMapping, + AbcMutableMapping, + Mapping, + AbcMapping, + ): # These default to dicts structure_to = dict h = make_mapping_structure_fn( cl, self, structure_to, detailed_validation=self.detailed_validation From 45acfa1f43db99b3d7a580d4cce45e0870a5730c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tin=20Tvrtkovi=C4=87?= Date: Thu, 25 Jul 2024 00:00:47 +0200 Subject: [PATCH 5/5] Docs and changelog --- HISTORY.md | 7 +++++-- docs/defaulthooks.md | 14 +++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 97997350..17612f43 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -23,12 +23,15 @@ Our backwards-compatibility policy can be found [here](https://github.com/python - Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods. ([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472)) - {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`, -{meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory` -can now be used as decorators and have gained new features. + {meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory` + can now be used as decorators and have gained new features. See [here](https://catt.rs/en/latest/customizing.html#use-as-decorators) and [here](https://catt.rs/en/latest/customizing.html#id1) for more details. ([#487](https://github.com/python-attrs/cattrs/pull/487)) - Introduce and [document](https://catt.rs/en/latest/customizing.html#customizing-collections) the {mod}`cattrs.cols` module for better collection customizations. ([#504](https://github.com/python-attrs/cattrs/issues/504) [#540](https://github.com/python-attrs/cattrs/pull/540)) +- Enhance the {func}`cattrs.cols.is_mapping` predicate function to also cover virtual subclasses of `abc.Mapping`. + This enables map classes from libraries such as _immutables_ or _sortedcontainers_ to structure out-of-the-box. + ([#555](https://github.com/python-attrs/cattrs/issues/555) [#556](https://github.com/python-attrs/cattrs/pull/556)) - Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter `. Only JSON is supported for now, with other formats supported by _msgspec_ to come later. ([#481](https://github.com/python-attrs/cattrs/pull/481)) diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 27997380..46b1fc56 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -156,13 +156,13 @@ A useful use case for unstructuring collections is to create a deep copy of a co ### Dictionaries Dictionaries can be produced from other mapping objects. -More precisely, the unstructured object must expose an [`items()`](https://docs.python.org/3/library/stdtypes.html#dict.items) method producing an iterable of key-value tuples, and be able to be passed to the `dict` constructor as an argument. +More precisely, the unstructured object must expose an [`items()`](https://docs.python.org/3/library/stdtypes.html#dict.items) method producing an iterable of key-value tuples, +and be able to be passed to the `dict` constructor as an argument. Types converting to dictionaries are: -- `typing.Dict[K, V]` -- `typing.MutableMapping[K, V]` -- `typing.Mapping[K, V]` -- `dict[K, V]` +- `dict[K, V]` and `typing.Dict[K, V]` +- `collections.abc.MutableMapping[K, V]` and `typing.MutableMapping[K, V]` +- `collections.abc.Mapping[K, V]` and `typing.Mapping[K, V]` In all cases, a new dict will be returned, so this operation can be used to copy a mapping into a dict. Any type parameters set to `typing.Any` will be passed through unconverted. @@ -183,6 +183,10 @@ Both keys and values are converted. {'1': None, '2': 2} ``` +### Virtual Subclasses of [`abc.Mapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Mapping) and [`abc.MutableMapping`](https://docs.python.org/3/library/collections.abc.html#collections.abc.MutableMapping) + +If a class declares itself a virtual subclass of `collections.abc.Mapping` or `collections.abc.MutableMapping` and its initializer accepts a dictionary, +_cattrs_ will be able to structure it by default. ### Homogeneous and Heterogeneous Tuples