Skip to content

Commit

Permalink
Add unstructuring and structuring support for deque in standard lib (
Browse files Browse the repository at this point in the history
…#355)

* Implement `_structure_deque` and related tests, docs

* Adjust location of `test_seqs_deque`

* Fix compatibility issue for py3.7/py3.8 and improve docs

* Fix typo in docstring of `deques_of_primitives`

* Fix incorrect doctest output for example of deques
  • Loading branch information
lqhuang authored May 22, 2023
1 parent 27e9c0d commit cec0035
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 10 deletions.
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
27 changes: 27 additions & 0 deletions docs/structuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/unstructuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -327,8 +335,10 @@ def is_sequence(type: Any) -> bool:
TypingSequence,
TypingMutableSequence,
AbcMutableSequence,
Tuple,
tuple,
Tuple,
deque,
Deque,
)
or (
type.__class__ is _GenericAlias
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -7,6 +7,7 @@
from typing import (
Any,
Callable,
Deque,
Dict,
Iterable,
List,
Expand Down Expand Up @@ -46,6 +47,7 @@
is_annotated,
is_bare,
is_counter,
is_deque,
is_frozenset,
is_generic,
is_generic_attrs,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_converter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Test both structuring and unstructuring."""
from collections import deque
from typing import (
Deque,
FrozenSet,
List,
MutableSequence,
Expand Down Expand Up @@ -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),
]
),
Expand All @@ -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."""
Expand Down
15 changes: 14 additions & 1 deletion tests/test_generics.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]
):
Expand Down
11 changes: 11 additions & 0 deletions tests/test_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
lists_of_primitives,
primitive_strategies,
seqs_of_primitives,
deque_seqs_of_primitives,
)

NoneType = type(None)
Expand Down Expand Up @@ -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."""
Expand Down
15 changes: 14 additions & 1 deletion tests/untyped.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from enum import Enum
from typing import (
Any,
Deque,
Dict,
List,
Mapping,
Expand Down Expand Up @@ -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])


Expand All @@ -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."""
Expand Down Expand Up @@ -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())


Expand Down

0 comments on commit cec0035

Please sign in to comment.