Skip to content

Commit d5db7b5

Browse files
authored
Structure sequences into tuples (#663)
* Structure sequences into tuples * Fix test * Fix lint * Improve coverage * Reformat * More docs
1 parent 30fb43e commit d5db7b5

11 files changed

Lines changed: 187 additions & 67 deletions

File tree

HISTORY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1313

1414
## 25.2.0 (unreleased)
1515

16-
- Add a `use_alias` parameter to {class}`cattrs.Converter`.
16+
- **Potentially breaking**: Sequences are now structured into tuples.
17+
This allows hashability, better immutability and is more consistent with the [`collections.abc.Sequence`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence) type.
18+
See [Migrations](https://catt.rs/en/latest/migrations.html#sequences-structuring-into-tuples) for steps to restore legacy behavior.
19+
([#663](https://github.com/python-attrs/cattrs/pull/663))
20+
- Add a `use_alias` parameter to {class}`cattrs.Converter`.
1721
{func}`cattrs.gen.make_dict_unstructure_fn_from_attrs`, {func}`cattrs.gen.make_dict_unstructure_fn`,
1822
{func}`cattrs.gen.make_dict_structure_fn_from_attrs`, {func}`cattrs.gen.make_dict_structure_fn`
1923
and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now.

docs/customizing.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ Available predicates are:
413413
- {meth}`is_any_set`
414414
- {meth}`is_frozenset`
415415
- {meth}`is_set`
416+
- {meth}`is_mutable_sequence`
416417
- {meth}`is_sequence`
417418
- {meth}`is_mapping`
418419
- {meth}`is_namedtuple`
@@ -432,6 +433,7 @@ Available hook factories are:
432433

433434
- {meth}`iterable_unstructure_factory`
434435
- {meth}`list_structure_factory`
436+
- {meth}`homogenous_tuple_structure_factory`
435437
- {meth}`namedtuple_structure_factory`
436438
- {meth}`namedtuple_unstructure_factory`
437439
- {meth}`namedtuple_dict_structure_factory`
@@ -442,15 +444,15 @@ Available hook factories are:
442444

443445
Additional predicates and hook factories will be added as requested.
444446

445-
For example, by default sequences are structured from any iterable into lists.
447+
For example, by default mutable sequences are structured from any iterable into lists.
446448
This may be too lax, and additional validation may be applied by wrapping the default list structuring hook factory.
447449

448450
```{testcode} list-customization
449-
from cattrs.cols import is_sequence, list_structure_factory
451+
from cattrs.cols import is_mutable_sequence, list_structure_factory
450452

451453
c = Converter()
452454

453-
@c.register_structure_hook_factory(is_sequence)
455+
@c.register_structure_hook_factory(is_mutable_sequence)
454456
def strict_list_hook_factory(type, converter):
455457

456458
# First, we generate the default hook...
@@ -466,7 +468,7 @@ def strict_list_hook_factory(type, converter):
466468
return strict_list_hook
467469
```
468470

469-
Now, all sequence structuring will be stricter:
471+
Now, all mutable sequence structuring will be stricter:
470472

471473
```{doctest} list-customization
472474
>>> c.structure({"a", "b", "c"}, list[str])
@@ -477,6 +479,9 @@ ValueError: Not a list!
477479

478480
```{versionadded} 24.1.0
479481

482+
```
483+
```{versionchanged} 25.2.0
484+
Added the {meth}`is_mutable_sequence` predicate and {meth}`homogenous_tuple_structure_factory` hook factory.
480485
```
481486

482487
### Customizing Named Tuples

docs/defaulthooks.md

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ None
137137
Lists can be structured from any iterable object.
138138
Types converting to lists are:
139139

140-
- `typing.Sequence[T]`
141-
- `typing.MutableSequence[T]`
140+
- `collections.abc.MutableSequence[T]`
142141
- `typing.List[T]`
143142
- `list[T]`
144143

@@ -154,6 +153,10 @@ A bare type, for example `Sequence` instead of `Sequence[int]`, is equivalent to
154153
When unstructuring, lists are copied and their contents are handled according to their inner type.
155154
A useful use case for unstructuring collections is to create a deep copy of a complex or recursive collection.
156155

156+
```{versionchanged} 25.2.0
157+
Sequences are no longer structured into lists by default, but tuples.
158+
```
159+
157160
### Dictionaries
158161

159162
Dictionaries can be produced from other mapping objects.
@@ -162,8 +165,8 @@ and be able to be passed to the `dict` constructor as an argument.
162165
Types converting to dictionaries are:
163166

164167
- `dict[K, V]` and `typing.Dict[K, V]`
165-
- `collections.abc.MutableMapping[K, V]` and `typing.MutableMapping[K, V]`
166-
- `collections.abc.Mapping[K, V]` and `typing.Mapping[K, V]`
168+
- `collections.abc.MutableMapping[K, V]`
169+
- `collections.abc.Mapping[K, V]`
167170

168171
In all cases, a new dict will be returned, so this operation can be used to copy a mapping into a dict.
169172
Any type parameters set to `typing.Any` will be passed through unconverted.
@@ -234,14 +237,15 @@ _cattrs_ will be able to structure it by default.
234237
Homogeneous and heterogeneous tuples can be structured from iterable objects.
235238
Heterogeneous tuples require an iterable with the number of elements matching the number of type parameters exactly.
236239

237-
Use:
240+
Heterogenous tuples use:
238241

239-
- `Tuple[A, B, C, D]`
242+
- `typing.Tuple[A, B, C, D]`
240243
- `tuple[A, B, C, D]`
241244

242245
Homogeneous tuples use:
243246

244-
- `Tuple[T, ...]`
247+
- `collections.abc.Sequence[T]`
248+
- `typing.Tuple[T, ...]`
245249
- `tuple[T, ...]`
246250

247251
In all cases a tuple will be produced.
@@ -263,6 +267,10 @@ When unstructuring, heterogeneous tuples unstructure into tuples since it's fast
263267
Structuring heterogenous tuples are not supported by the BaseConverter.
264268
```
265269

270+
```{versionchanged} 25.2.0
271+
Sequences are now structured into tuples.
272+
```
273+
266274
### Deques
267275

268276
Deques can be structured from any iterable object.
@@ -293,13 +301,12 @@ Deques are unstructured into lists, or into deques when using the {class}`BaseCo
293301
Sets and frozensets can be structured from any iterable object.
294302
Types converting to sets are:
295303

296-
- `typing.Set[T]`
297-
- `typing.MutableSet[T]`
304+
- `collections.abc.Set[T]`
305+
- `collections.abc.MutableSet[T]`
298306
- `set[T]`
299307

300308
Types converting to frozensets are:
301309

302-
- `typing.FrozenSet[T]`
303310
- `frozenset[T]`
304311

305312
In all cases, a new set or frozenset will be returned.

docs/migrations.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
_cattrs_ sometimes changes in backwards-incompatible ways.
44
This page contains guidance for changes and workarounds for restoring legacy behavior.
55

6+
## 25.2.0
7+
8+
### Sequences structuring into tuples
9+
10+
Sequences were changed to structure into tuples instead of lists.
11+
12+
The old behavior can be restored by registering the `list_structure_factory` using the `is_sequence` predicate on a converter.
13+
14+
```python
15+
>>> from cattrs.cols import is_sequence, list_structure_factory
16+
17+
>>> converter.register_structure_hook_factory(is_sequence, list_structure_factory)
18+
```
19+
620
## 24.2.0
721

822
### The default structure hook fallback factory
@@ -24,4 +38,4 @@ The old behavior can be restored by explicitly passing in the old hook fallback
2438

2539
The internal `cattrs.gen.MappingStructureFn` and `cattrs.gen.DictStructureFn` types were replaced by a more general type, `cattrs.SimpleStructureHook[In, T]`.
2640
If you were using `MappingStructureFn`, use `SimpleStructureHook[Mapping[Any, Any], T]` instead.
27-
If you were using `DictStructureFn`, use `SimpleStructureHook[Mapping[str, Any], T]` instead.
41+
If you were using `DictStructureFn`, use `SimpleStructureHook[Mapping[str, Any], T]` instead.

src/cattrs/_compat.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -306,34 +306,42 @@ def get_notrequired_base(type) -> Union[Any, NothingType]:
306306
return NOTHING
307307

308308

309+
def is_mutable_sequence(type: Any) -> bool:
310+
"""A predicate function for mutable sequences.
311+
312+
Matches lists, mutable sequences, and deques.
313+
"""
314+
origin = getattr(type, "__origin__", None)
315+
return (
316+
type in (List, list, TypingMutableSequence, AbcMutableSequence, deque, Deque)
317+
or (
318+
type.__class__ is _GenericAlias
319+
and (
320+
((origin is not tuple) and is_subclass(origin, TypingMutableSequence))
321+
or (origin is tuple and type.__args__[1] is ...)
322+
)
323+
)
324+
or (origin in (list, deque, AbcMutableSequence))
325+
)
326+
327+
309328
def is_sequence(type: Any) -> bool:
310329
"""A predicate function for sequences.
311330
312331
Matches lists, sequences, mutable sequences, deques and homogenous
313332
tuples.
314333
"""
315334
origin = getattr(type, "__origin__", None)
316-
return (
317-
type
318-
in (
319-
List,
320-
list,
321-
TypingSequence,
322-
TypingMutableSequence,
323-
AbcMutableSequence,
324-
tuple,
325-
Tuple,
326-
deque,
327-
Deque,
328-
)
335+
return is_mutable_sequence(type) or (
336+
type in (TypingSequence, tuple, Tuple)
329337
or (
330338
type.__class__ is _GenericAlias
331339
and (
332340
((origin is not tuple) and is_subclass(origin, TypingSequence))
333341
or (origin is tuple and type.__args__[1] is ...)
334342
)
335343
)
336-
or (origin in (list, deque, AbcMutableSequence, AbcSequence))
344+
or (origin is AbcSequence)
337345
or (origin is tuple and type.__args__[1] is ...)
338346
)
339347

src/cattrs/cols.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
is_bare,
2525
is_frozenset,
2626
is_mapping,
27+
is_mutable_sequence,
2728
is_sequence,
2829
is_subclass,
2930
)
@@ -47,10 +48,12 @@
4748

4849
__all__ = [
4950
"defaultdict_structure_factory",
51+
"homogenous_tuple_structure_factory",
5052
"is_any_set",
5153
"is_defaultdict",
5254
"is_frozenset",
5355
"is_mapping",
56+
"is_mutable_sequence",
5457
"is_namedtuple",
5558
"is_sequence",
5659
"is_set",
@@ -151,6 +154,47 @@ def structure_list(
151154
return structure_list
152155

153156

157+
def homogenous_tuple_structure_factory(
158+
type: type, converter: BaseConverter
159+
) -> StructureHook:
160+
"""A hook factory for homogenous (all elements the same, indeterminate length) tuples.
161+
162+
Converts any given iterable into a tuple.
163+
"""
164+
165+
if is_bare(type) or type.__args__[0] in ANIES:
166+
167+
def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]:
168+
return tuple(obj)
169+
170+
return structure_tuple
171+
172+
elem_type = type.__args__[0]
173+
174+
try:
175+
handler = converter.get_structure_hook(elem_type)
176+
except RecursionError:
177+
# Break the cycle by using late binding.
178+
handler = converter.structure
179+
180+
if converter.detailed_validation:
181+
182+
# We have to structure into a list first anyway.
183+
list_structure = list_structure_factory(type, converter)
184+
185+
def structure_tuple(obj: Iterable[T], _: type = type) -> tuple[T, ...]:
186+
return tuple(list_structure(obj, _))
187+
188+
else:
189+
190+
def structure_tuple(
191+
obj: Iterable[T], _: type = type, _handler=handler, _elem_type=elem_type
192+
) -> tuple[T, ...]:
193+
return tuple([_handler(e, _elem_type) for e in obj])
194+
195+
return structure_tuple
196+
197+
154198
def namedtuple_unstructure_factory(
155199
cl: type[tuple], converter: BaseConverter, unstructure_to: Any = None
156200
) -> UnstructureHook:

src/cattrs/converters.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,21 @@
4343
is_hetero_tuple,
4444
is_literal,
4545
is_mapping,
46+
is_mutable_sequence,
4647
is_mutable_set,
4748
is_optional,
4849
is_protocol,
49-
is_sequence,
5050
is_tuple,
5151
is_typeddict,
5252
is_union_type,
5353
signature,
5454
)
5555
from .cols import (
5656
defaultdict_structure_factory,
57+
homogenous_tuple_structure_factory,
5758
is_defaultdict,
5859
is_namedtuple,
60+
is_sequence,
5961
iterable_unstructure_factory,
6062
list_structure_factory,
6163
mapping_structure_factory,
@@ -271,7 +273,8 @@ def __init__(
271273
),
272274
(is_literal, self._structure_simple_literal),
273275
(is_literal_containing_enums, self._structure_enum_literal),
274-
(is_sequence, list_structure_factory, "extended"),
276+
(is_sequence, homogenous_tuple_structure_factory, "extended"),
277+
(is_mutable_sequence, list_structure_factory, "extended"),
275278
(is_deque, self._structure_deque),
276279
(is_mutable_set, self._structure_set),
277280
(is_frozenset, self._structure_frozenset),

tests/test_cols.py

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

3-
from collections.abc import Set
3+
from collections.abc import MutableSequence, Sequence, Set
44
from typing import Dict
55

66
from immutables import Map
@@ -9,7 +9,9 @@
99
from cattrs._compat import FrozenSet
1010
from cattrs.cols import (
1111
is_any_set,
12+
is_sequence,
1213
iterable_unstructure_factory,
14+
list_structure_factory,
1315
mapping_unstructure_factory,
1416
)
1517

@@ -53,3 +55,23 @@ def test_mapping_unstructure_to(genconverter: Converter):
5355
"""`unstructure_to` works."""
5456
hook = mapping_unstructure_factory(Dict[str, str], genconverter, unstructure_to=Map)
5557
assert hook({"a": "a"}).__class__ is Map
58+
59+
60+
def test_structure_sequences(converter: BaseConverter):
61+
"""Sequences are structured to tuples."""
62+
63+
assert converter.structure(["1", 2, 3.0], Sequence[int]) == (1, 2, 3)
64+
65+
66+
def test_structure_sequences_override(converter: BaseConverter):
67+
"""Sequences can be overriden to structure to lists, as previously."""
68+
69+
converter.register_structure_hook_factory(is_sequence, list_structure_factory)
70+
71+
assert converter.structure(["1", 2, 3.0], Sequence[int]) == [1, 2, 3]
72+
73+
74+
def test_structure_mut_sequences(converter: BaseConverter):
75+
"""Mutable sequences are structured to lists."""
76+
77+
assert converter.structure(["1", 2, 3.0], MutableSequence[int]) == [1, 2, 3]

tests/test_generics.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections import deque
2+
from collections.abc import Sequence
23
from typing import Deque, Dict, Generic, List, Optional, TypeVar, Union
34

45
import pytest
@@ -109,9 +110,16 @@ class GenericCols(Generic[T]):
109110
(
110111
(TClass[int, int, int], str, int, TClass(TClass(1, 2), "a")),
111112
(List[TClass[int, int, int]], str, int, TClass([TClass(1, 2)], "a")),
113+
(
114+
Sequence[TClass[str, str, str]],
115+
str,
116+
str,
117+
TClass((TClass("a", "b", "c"),), "b", "c"),
118+
),
112119
),
113120
)
114121
def test_structure_nested_generics(converter: BaseConverter, t, t2, t3, result):
122+
"""Structuring nested generics works."""
115123
res = converter.structure(asdict(result), TClass[t, t2, t3])
116124

117125
assert res == result

0 commit comments

Comments
 (0)