Skip to content

Commit e86ab97

Browse files
authored
Pull out and document is_mapping and mapping_structure_factory (#556)
* Pull out and document `is_mapping` and `mapping_structure_factory` * Tweak default hooks so immutables work * Default to dicts more * Fix collections.abc.Mapping handling on 3.8 * Docs and changelog
1 parent e2ce66b commit e86ab97

File tree

8 files changed

+61
-22
lines changed

8 files changed

+61
-22
lines changed

HISTORY.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2323
- Introduce {meth}`BaseConverter.get_structure_hook` and {meth}`BaseConverter.get_unstructure_hook` methods.
2424
([#432](https://github.com/python-attrs/cattrs/issues/432) [#472](https://github.com/python-attrs/cattrs/pull/472))
2525
- {meth}`BaseConverter.register_structure_hook`, {meth}`BaseConverter.register_unstructure_hook`,
26-
{meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory`
27-
can now be used as decorators and have gained new features.
26+
{meth}`BaseConverter.register_unstructure_hook_factory` and {meth}`BaseConverter.register_structure_hook_factory`
27+
can now be used as decorators and have gained new features.
2828
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.
2929
([#487](https://github.com/python-attrs/cattrs/pull/487))
3030
- Introduce and [document](https://catt.rs/en/latest/customizing.html#customizing-collections) the {mod}`cattrs.cols` module for better collection customizations.
3131
([#504](https://github.com/python-attrs/cattrs/issues/504) [#540](https://github.com/python-attrs/cattrs/pull/540))
32+
- Enhance the {func}`cattrs.cols.is_mapping` predicate function to also cover virtual subclasses of `abc.Mapping`.
33+
This enables map classes from libraries such as _immutables_ or _sortedcontainers_ to structure out-of-the-box.
34+
([#555](https://github.com/python-attrs/cattrs/issues/555) [#556](https://github.com/python-attrs/cattrs/pull/556))
3235
- Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter <cattrs.preconf.msgspec>`.
3336
Only JSON is supported for now, with other formats supported by _msgspec_ to come later.
3437
([#481](https://github.com/python-attrs/cattrs/pull/481))

docs/customizing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ Available predicates are:
166166
* {meth}`is_frozenset <cattrs.cols.is_frozenset>`
167167
* {meth}`is_set <cattrs.cols.is_set>`
168168
* {meth}`is_sequence <cattrs.cols.is_sequence>`
169+
* {meth}`is_mapping <cattrs.cols.is_mapping>`
169170
* {meth}`is_namedtuple <cattrs.cols.is_namedtuple>`
170171

171172
````{tip}
@@ -187,6 +188,7 @@ Available hook factories are:
187188
* {meth}`namedtuple_unstructure_factory <cattrs.cols.namedtuple_unstructure_factory>`
188189
* {meth}`namedtuple_dict_structure_factory <cattrs.cols.namedtuple_dict_structure_factory>`
189190
* {meth}`namedtuple_dict_unstructure_factory <cattrs.cols.namedtuple_dict_unstructure_factory>`
191+
* {meth}`mapping_structure_factory <cattrs.cols.mapping_structure_factory>`
190192

191193
Additional predicates and hook factories will be added as requested.
192194

docs/defaulthooks.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,13 @@ A useful use case for unstructuring collections is to create a deep copy of a co
156156
### Dictionaries
157157

158158
Dictionaries can be produced from other mapping objects.
159-
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.
159+
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,
160+
and be able to be passed to the `dict` constructor as an argument.
160161
Types converting to dictionaries are:
161162

162-
- `typing.Dict[K, V]`
163-
- `typing.MutableMapping[K, V]`
164-
- `typing.Mapping[K, V]`
165-
- `dict[K, V]`
163+
- `dict[K, V]` and `typing.Dict[K, V]`
164+
- `collections.abc.MutableMapping[K, V]` and `typing.MutableMapping[K, V]`
165+
- `collections.abc.Mapping[K, V]` and `typing.Mapping[K, V]`
166166

167167
In all cases, a new dict will be returned, so this operation can be used to copy a mapping into a dict.
168168
Any type parameters set to `typing.Any` will be passed through unconverted.
@@ -183,6 +183,10 @@ Both keys and values are converted.
183183
{'1': None, '2': 2}
184184
```
185185

186+
### 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)
187+
188+
If a class declares itself a virtual subclass of `collections.abc.Mapping` or `collections.abc.MutableMapping` and its initializer accepts a dictionary,
189+
_cattrs_ will be able to structure it by default.
186190

187191
### Homogeneous and Heterogeneous Tuples
188192

src/cattrs/_compat.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import sys
22
from collections import deque
3+
from collections.abc import Mapping as AbcMapping
4+
from collections.abc import MutableMapping as AbcMutableMapping
35
from collections.abc import MutableSet as AbcMutableSet
46
from collections.abc import Set as AbcSet
57
from dataclasses import MISSING, Field, is_dataclass
@@ -219,8 +221,6 @@ def get_final_base(type) -> Optional[type]:
219221

220222
if sys.version_info >= (3, 9):
221223
from collections import Counter
222-
from collections.abc import Mapping as AbcMapping
223-
from collections.abc import MutableMapping as AbcMutableMapping
224224
from collections.abc import MutableSequence as AbcMutableSequence
225225
from collections.abc import MutableSet as AbcMutableSet
226226
from collections.abc import Sequence as AbcSequence
@@ -404,18 +404,17 @@ def is_bare(type):
404404
not hasattr(type, "__origin__") and not hasattr(type, "__args__")
405405
)
406406

407-
def is_mapping(type):
407+
def is_mapping(type: Any) -> bool:
408+
"""A predicate function for mappings."""
408409
return (
409410
type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping)
410411
or (
411412
type.__class__ is _GenericAlias
412413
and is_subclass(type.__origin__, TypingMapping)
413414
)
414-
or (
415-
getattr(type, "__origin__", None)
416-
in (dict, AbcMutableMapping, AbcMapping)
415+
or is_subclass(
416+
getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping)
417417
)
418-
or is_subclass(type, dict)
419418
)
420419

421420
def is_counter(type):
@@ -515,10 +514,17 @@ def is_frozenset(type):
515514
type.__class__ is _GenericAlias and is_subclass(type.__origin__, FrozenSet)
516515
)
517516

518-
def is_mapping(type):
519-
return type in (TypingMapping, dict) or (
520-
type.__class__ is _GenericAlias
521-
and is_subclass(type.__origin__, TypingMapping)
517+
def is_mapping(type: Any) -> bool:
518+
"""A predicate function for mappings."""
519+
return (
520+
type in (TypingMapping, dict)
521+
or (
522+
type.__class__ is _GenericAlias
523+
and is_subclass(type.__origin__, TypingMapping)
524+
)
525+
or is_subclass(
526+
getattr(type, "__origin__", type), (dict, AbcMutableMapping, AbcMapping)
527+
)
522528
)
523529

524530
bare_generic_args = {

src/cattrs/cols.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from attrs import NOTHING, Attribute
1818

19-
from ._compat import ANIES, is_bare, is_frozenset, is_sequence, is_subclass
19+
from ._compat import ANIES, is_bare, is_frozenset, is_mapping, is_sequence, is_subclass
2020
from ._compat import is_mutable_set as is_set
2121
from .dispatch import StructureHook, UnstructureHook
2222
from .errors import IterableValidationError, IterableValidationNote
@@ -27,6 +27,7 @@
2727
make_dict_structure_fn_from_attrs,
2828
make_dict_unstructure_fn_from_attrs,
2929
make_hetero_tuple_unstructure_fn,
30+
mapping_structure_factory,
3031
)
3132
from .gen import make_iterable_unstructure_fn as iterable_unstructure_factory
3233

@@ -37,6 +38,7 @@
3738
"is_any_set",
3839
"is_frozenset",
3940
"is_namedtuple",
41+
"is_mapping",
4042
"is_set",
4143
"is_sequence",
4244
"iterable_unstructure_factory",
@@ -45,6 +47,7 @@
4547
"namedtuple_unstructure_factory",
4648
"namedtuple_dict_structure_factory",
4749
"namedtuple_dict_unstructure_factory",
50+
"mapping_structure_factory",
4851
]
4952

5053

src/cattrs/converters.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
from collections import Counter, deque
4+
from collections.abc import Mapping as AbcMapping
5+
from collections.abc import MutableMapping as AbcMutableMapping
46
from collections.abc import MutableSet as AbcMutableSet
57
from dataclasses import Field
68
from enum import Enum
@@ -1289,8 +1291,16 @@ def gen_structure_counter(self, cl: Any) -> MappingStructureFn[T]:
12891291
return h
12901292

12911293
def gen_structure_mapping(self, cl: Any) -> MappingStructureFn[T]:
1294+
structure_to = get_origin(cl) or cl
1295+
if structure_to in (
1296+
MutableMapping,
1297+
AbcMutableMapping,
1298+
Mapping,
1299+
AbcMapping,
1300+
): # These default to dicts
1301+
structure_to = dict
12921302
h = make_mapping_structure_fn(
1293-
cl, self, detailed_validation=self.detailed_validation
1303+
cl, self, structure_to, detailed_validation=self.detailed_validation
12941304
)
12951305
self._structure_func.register_cls_list([(cl, h)], direct=True)
12961306
return h

src/cattrs/gen/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,8 @@ def make_mapping_unstructure_fn(
898898
MappingStructureFn = Callable[[Mapping[Any, Any], Any], T]
899899

900900

901-
def make_mapping_structure_fn(
901+
# This factory is here for backwards compatibility and circular imports.
902+
def mapping_structure_factory(
902903
cl: type[T],
903904
converter: BaseConverter,
904905
structure_to: type = dict,
@@ -1018,6 +1019,9 @@ def make_mapping_structure_fn(
10181019
return globs[fn_name]
10191020

10201021

1022+
make_mapping_structure_fn: Final = mapping_structure_factory
1023+
1024+
10211025
# This factory is here for backwards compatibility and circular imports.
10221026
def iterable_unstructure_factory(
10231027
cl: Any, converter: BaseConverter, unstructure_to: Any = None

tests/test_cols.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Tests for the `cattrs.cols` module."""
22

3-
from cattrs import BaseConverter
3+
from immutables import Map
4+
5+
from cattrs import BaseConverter, Converter
46
from cattrs._compat import AbstractSet, FrozenSet
57
from cattrs.cols import is_any_set, iterable_unstructure_factory
68

@@ -19,3 +21,8 @@ def test_set_overriding(converter: BaseConverter):
1921
"b",
2022
"c",
2123
]
24+
25+
26+
def test_structuring_immutables_map(genconverter: Converter):
27+
"""This should work due to our new is_mapping predicate."""
28+
assert genconverter.structure({"a": 1}, Map[str, int]) == Map(a=1)

0 commit comments

Comments
 (0)