diff --git a/HISTORY.md b/HISTORY.md index 294f12c8..6927bbf5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -18,6 +18,8 @@ ([#350](https://github.com/python-attrs/cattrs/issues/350) [#353](https://github.com/python-attrs/cattrs/pull/353)) - Subclasses structuring and unstructuring is now supported via a custom `include_subclasses` strategy. ([#312](https://github.com/python-attrs/cattrs/pull/312)) +- Add unstructuring and structuring support to `deque` in standard lib. + ([#355](https://github.com/python-attrs/cattrs/issues/355)) ## 22.2.0 (2022-10-03) diff --git a/docs/structuring.md b/docs/structuring.md index 5d9254c7..57b35848 100644 --- a/docs/structuring.md +++ b/docs/structuring.md @@ -146,6 +146,33 @@ These generic types are composable with all other converters. ['1', None, '3'] ``` +### Deques + +Deques can be produced from any iterable object. Types converting +to deques are: + +- `Deque[T]` +- `deque[T]` + +In all cases, a new **unbounded** deque (`maxlen=None`) will be returned, +so this operation can be used to copy an iterable into a deque. +If you want to convert into bounded `deque`, registering a custom structuring hook is a good approach. + +```{doctest} +>>> cattrs.structure((1, 2, 3), deque[int]) +deque([1, 2, 3]) +``` + +These generic types are composable with all other converters. + +```{doctest} +>>> cattrs.structure((1, None, 3), deque[Optional[str]]) +deque(['1', None, '3']) +``` + +```{versionadded} 23.1.0 +``` + ### Sets and Frozensets Sets and frozensets can be produced from any iterable object. Types converting diff --git a/docs/unstructuring.md b/docs/unstructuring.md index 474cc82a..99e1f6ce 100644 --- a/docs/unstructuring.md +++ b/docs/unstructuring.md @@ -80,9 +80,9 @@ unstructure all sets into lists, try the following: Going even further, the Converter contains heuristics to support the following Python types, in order of decreasing generality: -- `Sequence`, `MutableSequence`, `list`, `tuple` +- `Sequence`, `MutableSequence`, `list`, `deque`, `tuple` - `Set`, `frozenset`, `MutableSet`, `set` -- `Mapping`, `MutableMapping`, `dict`, `Counter` +- `Mapping`, `MutableMapping`, `dict`, `defaultdict`, `OrderedDict`, `Counter` For example, if you override the unstructure type for `Sequence`, but not for `MutableSequence`, `list` or `tuple`, the override will also affect those diff --git a/src/cattrs/_compat.py b/src/cattrs/_compat.py index 18106eff..17c6784d 100644 --- a/src/cattrs/_compat.py +++ b/src/cattrs/_compat.py @@ -1,12 +1,13 @@ import builtins import sys +from collections import deque from collections.abc import MutableSet as AbcMutableSet from collections.abc import Set as AbcSet from dataclasses import MISSING from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass from typing import AbstractSet as TypingAbstractSet -from typing import Any, Dict, FrozenSet, List +from typing import Any, Deque, Dict, FrozenSet, List from typing import Mapping as TypingMapping from typing import MutableMapping as TypingMutableMapping from typing import MutableSequence as TypingMutableSequence @@ -177,6 +178,13 @@ def is_sequence(type: Any) -> bool: or (type.__origin__ in (Tuple, tuple) and type.__args__[1] is ...) ) + def is_deque(type: Any) -> bool: + return ( + type in (deque, Deque) + or (type.__class__ is _GenericAlias and issubclass(type.__origin__, deque)) + or type.__origin__ is deque + ) + def is_mutable_set(type): return type is set or ( type.__class__ is _GenericAlias and issubclass(type.__origin__, MutableSet) @@ -327,8 +335,10 @@ def is_sequence(type: Any) -> bool: TypingSequence, TypingMutableSequence, AbcMutableSequence, - Tuple, tuple, + Tuple, + deque, + Deque, ) or ( type.__class__ is _GenericAlias @@ -339,10 +349,17 @@ def is_sequence(type: Any) -> bool: and type.__args__[1] is ... ) ) - or (origin in (list, AbcMutableSequence, AbcSequence)) + or (origin in (list, deque, AbcMutableSequence, AbcSequence)) or (origin is tuple and type.__args__[1] is ...) ) + def is_deque(type): + return ( + type in (deque, Deque) + or (type.__class__ is _GenericAlias and issubclass(type.__origin__, deque)) + or (getattr(type, "__origin__", None) is deque) + ) + def is_mutable_set(type): return ( type in (TypingSet, TypingMutableSet, set) @@ -370,7 +387,7 @@ def is_bare(type): def is_mapping(type): return ( - type in (TypingMapping, Dict, TypingMutableMapping, dict, AbcMutableMapping) + type in (dict, Dict, TypingMapping, TypingMutableMapping, AbcMutableMapping) or ( type.__class__ is _GenericAlias and issubclass(type.__origin__, TypingMapping) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index 64016c73..e770b406 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1,4 +1,4 @@ -from collections import Counter +from collections import Counter, deque from collections.abc import MutableSet as AbcMutableSet from dataclasses import Field from enum import Enum @@ -7,6 +7,7 @@ from typing import ( Any, Callable, + Deque, Dict, Iterable, List, @@ -46,6 +47,7 @@ is_annotated, is_bare, is_counter, + is_deque, is_frozenset, is_generic, is_generic_attrs, @@ -193,6 +195,7 @@ def __init__( (is_literal, self._structure_simple_literal), (is_literal_containing_enums, self._structure_enum_literal), (is_sequence, self._structure_list), + (is_deque, self._structure_deque), (is_mutable_set, self._structure_set), (is_frozenset, self._structure_frozenset), (is_tuple, self._structure_tuple), @@ -326,7 +329,6 @@ def register_structure_hook_factory( def structure(self, obj: Any, cl: Type[T]) -> T: """Convert unstructured Python data structures to structured data.""" - return self._structure_func.dispatch(cl)(obj, cl) # Classes to Python primitives. @@ -545,6 +547,36 @@ def _structure_list(self, obj: Iterable[T], cl: Any) -> List[T]: res = [handler(e, elem_type) for e in obj] return res + def _structure_deque(self, obj: Iterable[T], cl: Any) -> Deque[T]: + """Convert an iterable to a potentially generic deque.""" + if is_bare(cl) or cl.__args__[0] is Any: + res = deque(e for e in obj) + else: + elem_type = cl.__args__[0] + handler = self._structure_func.dispatch(elem_type) + if self.detailed_validation: + errors = [] + res = deque() + ix = 0 # Avoid `enumerate` for performance. + for e in obj: + try: + res.append(handler(e, elem_type)) + except Exception as e: + msg = IterableValidationNote( + f"Structuring {cl} @ index {ix}", ix, elem_type + ) + e.__notes__ = getattr(e, "__notes__", []) + [msg] + errors.append(e) + finally: + ix += 1 + if errors: + raise IterableValidationError( + f"While structuring {cl!r}", errors, cl + ) + else: + res = deque(handler(e, elem_type) for e in obj) + return res + def _structure_set( self, obj: Iterable[T], cl: Any, structure_to: type = set ) -> Set[T]: @@ -823,6 +855,8 @@ def __init__( if MutableSequence in co: if list not in co: co[list] = co[MutableSequence] + if deque not in co: + co[deque] = co[MutableSequence] # abc.Mapping overrides, if defined, can apply to MutableMappings if Mapping in co: diff --git a/tests/test_converter.py b/tests/test_converter.py index fc441017..eeff70d5 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,5 +1,7 @@ """Test both structuring and unstructuring.""" +from collections import deque from typing import ( + Deque, FrozenSet, List, MutableSequence, @@ -524,20 +526,24 @@ class Outer: (tuple, tuple), (list, list), (list, List), + (deque, Deque), (set, Set), (set, set), (frozenset, frozenset), (frozenset, FrozenSet), (list, MutableSequence), + (deque, MutableSequence), (tuple, Sequence), ] if is_py39_plus else [ (tuple, Tuple), (list, List), + (deque, Deque), (set, Set), (frozenset, FrozenSet), (list, MutableSequence), + (deque, MutableSequence), (tuple, Sequence), ] ), @@ -563,6 +569,57 @@ def test_seq_of_simple_classes_unstructure(cls_and_vals, seq_type_and_annotation assert all(e == test_val for e in outputs) +@given( + sampled_from( + [ + (tuple, Tuple), + (tuple, tuple), + (list, list), + (list, List), + (deque, deque), + (deque, Deque), + (set, Set), + (set, set), + (frozenset, frozenset), + (frozenset, FrozenSet), + ] + if is_py39_plus + else [ + (tuple, Tuple), + (list, List), + (deque, Deque), + (set, Set), + (frozenset, FrozenSet), + ] + ) +) +def test_seq_of_bare_classes_structure(seq_type_and_annotation): + """Structure iterable of values to a sequence of primitives.""" + converter = Converter() + + bare_classes = ((int, (1,)), (float, (1.0,)), (str, ("test",)), (bool, (True,))) + seq_type, annotation = seq_type_and_annotation + + for cl, vals in bare_classes: + + @define(frozen=True) + class C: + a: cl + b: cl + + inputs = [{"a": cl(*vals), "b": cl(*vals)} for _ in range(5)] + outputs = converter.structure( + inputs, + cl=annotation[C] + if annotation not in (Tuple, tuple) + else annotation[C, ...], + ) + expected = seq_type(C(a=cl(*vals), b=cl(*vals)) for _ in range(5)) + + assert type(outputs) == seq_type + assert outputs == expected + + @pytest.mark.skipif(not is_py39_plus, reason="3.9+ only") def test_annotated_attrs(): """Annotation support works for attrs classes.""" diff --git a/tests/test_generics.py b/tests/test_generics.py index a7801b6a..c9c3d252 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -1,4 +1,5 @@ -from typing import Dict, Generic, List, Optional, TypeVar, Union +from collections import deque +from typing import Deque, Dict, Generic, List, Optional, TypeVar, Union import pytest from attr import asdict, attrs, define @@ -145,6 +146,18 @@ class TClass2(Generic[T]): assert res == data +def test_structure_deque_of_generic_unions(converter): + @attrs(auto_attribs=True) + class TClass2(Generic[T]): + c: T + + data = deque((TClass2(c="string"), TClass(1, 2))) + res = converter.structure( + [asdict(x) for x in data], Deque[Union[TClass[int, int], TClass2[str]]] + ) + assert res == data + + def test_raises_if_no_generic_params_supplied( converter: Union[Converter, BaseConverter] ): diff --git a/tests/test_structure.py b/tests/test_structure.py index 9d10f78f..9f460af8 100644 --- a/tests/test_structure.py +++ b/tests/test_structure.py @@ -30,6 +30,7 @@ lists_of_primitives, primitive_strategies, seqs_of_primitives, + deque_seqs_of_primitives, ) NoneType = type(None) @@ -85,6 +86,16 @@ def test_structuring_seqs(seq_and_type): assert x == y +@given(deque_seqs_of_primitives) +def test_structuring_seqs_to_deque(seq_and_type): + """Test structuring sequence generic types.""" + converter = BaseConverter() + iterable, t = seq_and_type + converted = converter.structure(iterable, t) + for x, y in zip(iterable, converted): + assert x == y + + @given(sets_of_primitives, set_types) def test_structuring_sets(set_and_type, set_type): """Test structuring generic sets.""" diff --git a/tests/untyped.py b/tests/untyped.py index 98ed7d85..073ba910 100644 --- a/tests/untyped.py +++ b/tests/untyped.py @@ -5,6 +5,7 @@ from enum import Enum from typing import ( Any, + Deque, Dict, List, Mapping, @@ -57,6 +58,7 @@ def enums_of_primitives(draw): list_types = st.sampled_from([List, Sequence, MutableSequence]) +deque_types = st.sampled_from([Deque, Sequence, MutableSequence]) set_types = st.sampled_from([Set, MutableSet]) @@ -71,6 +73,17 @@ def lists_of_primitives(draw): return draw(st.lists(prim_strat)), list_t +@st.composite +def deques_of_primitives(draw): + """Generate a strategy that yields tuples of list of primitives and types. + + For example, a sample value might be ([1,2], Deque[int]). + """ + prim_strat, t = draw(primitive_strategies) + deque_t = draw(deque_types.map(lambda deque_t: deque_t[t]) | deque_types) + return draw(st.lists(prim_strat)), deque_t + + @st.composite def mut_sets_of_primitives(draw): """A strategy that generates mutable sets of primitives.""" @@ -98,7 +111,7 @@ def frozen_sets_of_primitives(draw): dict_types = st.sampled_from([Dict, MutableMapping, Mapping]) seqs_of_primitives = st.one_of(lists_of_primitives(), h_tuples_of_primitives) - +deque_seqs_of_primitives = st.one_of(deques_of_primitives(), h_tuples_of_primitives) sets_of_primitives = st.one_of(mut_sets_of_primitives(), frozen_sets_of_primitives())