Skip to content

Commit 98c2e02

Browse files
committed
Structure sequences into tuples
1 parent 30fb43e commit 98c2e02

7 files changed

Lines changed: 122 additions & 23 deletions

File tree

HISTORY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ 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+
- Add a `use_alias` parameter to {class}`cattrs.Converter`.
1720
{func}`cattrs.gen.make_dict_unstructure_fn_from_attrs`, {func}`cattrs.gen.make_dict_unstructure_fn`,
1821
{func}`cattrs.gen.make_dict_structure_fn_from_attrs`, {func}`cattrs.gen.make_dict_structure_fn`
1922
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/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
@@ -25,6 +25,7 @@
2525
is_frozenset,
2626
is_mapping,
2727
is_sequence,
28+
is_mutable_sequence,
2829
is_subclass,
2930
)
3031
from ._compat import is_mutable_set as is_set
@@ -52,10 +53,12 @@
5253
"is_frozenset",
5354
"is_mapping",
5455
"is_namedtuple",
56+
"is_mutable_sequence",
5557
"is_sequence",
5658
"is_set",
5759
"iterable_unstructure_factory",
5860
"list_structure_factory",
61+
"homogenous_tuple_structure_factory",
5962
"mapping_structure_factory",
6063
"mapping_unstructure_factory",
6164
"namedtuple_dict_structure_factory",
@@ -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
@@ -46,22 +46,24 @@
4646
is_mutable_set,
4747
is_optional,
4848
is_protocol,
49-
is_sequence,
5049
is_tuple,
5150
is_typeddict,
5251
is_union_type,
5352
signature,
53+
is_mutable_sequence,
5454
)
5555
from .cols import (
5656
defaultdict_structure_factory,
5757
is_defaultdict,
5858
is_namedtuple,
5959
iterable_unstructure_factory,
6060
list_structure_factory,
61+
homogenous_tuple_structure_factory,
6162
mapping_structure_factory,
6263
mapping_unstructure_factory,
6364
namedtuple_structure_factory,
6465
namedtuple_unstructure_factory,
66+
is_sequence,
6567
)
6668
from .disambiguators import create_default_dis_func, is_supported_union
6769
from .dispatch import (
@@ -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
@@ -11,6 +11,8 @@
1111
is_any_set,
1212
iterable_unstructure_factory,
1313
mapping_unstructure_factory,
14+
is_sequence,
15+
list_structure_factory,
1416
)
1517

1618
from ._compat import is_py310_plus
@@ -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]

0 commit comments

Comments
 (0)