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/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/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 diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 0eda9947..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 @@ -404,18 +404,17 @@ 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 ( 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): @@ -515,10 +514,17 @@ def is_frozenset(type): type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet) ) - def is_mapping(type): - return type in (TypingMapping, dict) or ( - type.__class__ is _GenericAlias - and is_subclass(type.__origin__, TypingMapping) + 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) + ) + or is_subclass( + getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping) + ) ) bare_generic_args = { 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/converters.py b/src/cattrs/converters.py index 8a0b2b66..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 @@ -1289,8 +1291,16 @@ 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, + AbcMutableMapping, + Mapping, + AbcMapping, + ): # These default to dicts + structure_to = dict h = make_mapping_structure_fn( - cl, self, 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 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 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)