Skip to content

Commit c9d029d

Browse files
authored
Initial list strategy work (#540)
* Initial list strategy work * Black reformat * More docs * Changelog * More sets for cols * More history * Add test for better recursive structuring * Improve set handling on 3.8 * Docs * Docs
1 parent 17a7866 commit c9d029d

20 files changed

+664
-415
lines changed

HISTORY.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
2727
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))
30+
- Introduce and [document](https://catt.rs/en/latest/customizing.html#customizing-collections) the {mod}`cattrs.cols` module for better collection customizations.
31+
([#504](https://github.com/python-attrs/cattrs/issues/504) [#540](https://github.com/python-attrs/cattrs/pull/540))
3032
- Introduce the [_msgspec_](https://jcristharif.com/msgspec/) {mod}`preconf converter <cattrs.preconf.msgspec>`.
3133
Only JSON is supported for now, with other formats supported by _msgspec_ to come later.
3234
([#481](https://github.com/python-attrs/cattrs/pull/481))
@@ -46,8 +48,10 @@ can now be used as decorators and have gained new features.
4648
([#481](https://github.com/python-attrs/cattrs/pull/481))
4749
- The {class}`orjson preconf converter <cattrs.preconf.orjson.OrjsonConverter>` now passes through dates and datetimes to orjson while unstructuring, greatly improving speed.
4850
([#463](https://github.com/python-attrs/cattrs/pull/463))
49-
- `cattrs.gen` generators now attach metadata to the generated functions, making them introspectable.
51+
- {mod}`cattrs.gen` generators now attach metadata to the generated functions, making them introspectable.
5052
([#472](https://github.com/python-attrs/cattrs/pull/472))
53+
- Structure hook factories in {mod}`cattrs.gen` now handle recursive classes better.
54+
([#540](https://github.com/python-attrs/cattrs/pull/540))
5155
- The [tagged union strategy](https://catt.rs/en/stable/strategies.html#tagged-unions-strategy) now leaves the tags in the payload unless `forbid_extra_keys` is set.
5256
([#533](https://github.com/python-attrs/cattrs/issues/533) [#534](https://github.com/python-attrs/cattrs/pull/534))
5357
- More robust support for `Annotated` and `NotRequired` in TypedDicts.

docs/basics.md

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ To create a private converter, instantiate a {class}`cattrs.Converter`. Converte
1414

1515
The two main methods, {meth}`structure <cattrs.BaseConverter.structure>` and {meth}`unstructure <cattrs.BaseConverter.unstructure>`, are used to convert between _structured_ and _unstructured_ data.
1616

17-
```python
17+
```{doctest} basics
1818
>>> from cattrs import structure, unstructure
1919
>>> from attrs import define
2020
@@ -23,40 +23,39 @@ The two main methods, {meth}`structure <cattrs.BaseConverter.structure>` and {me
2323
... a: int
2424
2525
>>> unstructure(Model(1))
26-
{"a": 1}
26+
{'a': 1}
2727
>>> structure({"a": 1}, Model)
2828
Model(a=1)
2929
```
3030

3131
_cattrs_ comes with a rich library of un/structuring hooks by default but it excels at composing custom hooks with built-in ones.
3232

3333
The simplest approach to customization is writing a new hook from scratch.
34-
For example, we can write our own hook for the `int` class.
34+
For example, we can write our own hook for the `int` class and register it to a converter.
3535

36-
```python
37-
>>> def int_hook(value, type):
36+
```{doctest} basics
37+
>>> from cattrs import Converter
38+
39+
>>> converter = Converter()
40+
41+
>>> @converter.register_structure_hook
42+
... def int_hook(value, type) -> int:
3843
... if not isinstance(value, int):
3944
... raise ValueError('not an int!')
4045
... return value
4146
```
4247

43-
We can then register this hook to a converter and any other hook converting an `int` will use it.
44-
45-
```python
46-
>>> from cattrs import Converter
47-
48-
>>> converter = Converter()
49-
>>> converter.register_structure_hook(int, int_hook)
50-
```
48+
Now, any other hook converting an `int` will use it.
5149

52-
Another approach to customization is wrapping an existing hook with your own function.
50+
Another approach to customization is wrapping (composing) an existing hook with your own function.
5351
A base hook can be obtained from a converter and then be subjected to the very rich machinery of function composition that Python offers.
5452

5553

56-
```python
54+
```{doctest} basics
5755
>>> base_hook = converter.get_structure_hook(Model)
5856
59-
>>> def my_model_hook(value, type):
57+
>>> @converter.register_structure_hook
58+
... def my_model_hook(value, type) -> Model:
6059
... # Apply any preprocessing to the value.
6160
... result = base_hook(value, type)
6261
... # Apply any postprocessing to the model.
@@ -65,13 +64,6 @@ A base hook can be obtained from a converter and then be subjected to the very r
6564

6665
(`cattrs.structure({}, Model)` is equivalent to `cattrs.get_structure_hook(Model)({}, Model)`.)
6766

68-
This new hook can be used directly or registered to a converter (the original instance, or a different one):
69-
70-
```python
71-
>>> converter.register_structure_hook(Model, my_model_hook)
72-
```
73-
74-
7567
Now if we use this hook to structure a `Model`, through ✨the magic of function composition✨ that hook will use our old `int_hook`.
7668

7769
```python

docs/cattrs.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ Subpackages
1919
Submodules
2020
----------
2121

22+
cattrs.cols module
23+
------------------
24+
25+
.. automodule:: cattrs.cols
26+
:members:
27+
:undoc-members:
28+
:show-inheritance:
29+
2230
cattrs.disambiguators module
2331
----------------------------
2432

docs/conf.py

100755100644
File mode changed.

docs/customizing.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,76 @@ Here's an example of using an unstructure hook factory to handle unstructuring [
155155
[1, 2]
156156
```
157157

158+
## Customizing Collections
159+
160+
The {mod}`cattrs.cols` module contains predicates and hook factories useful for customizing collection handling.
161+
These hook factories can be wrapped to apply complex customizations.
162+
163+
Available predicates are:
164+
165+
* {meth}`is_any_set <cattrs.cols.is_any_set>`
166+
* {meth}`is_frozenset <cattrs.cols.is_frozenset>`
167+
* {meth}`is_set <cattrs.cols.is_set>`
168+
* {meth}`is_sequence <cattrs.cols.is_sequence>`
169+
* {meth}`is_namedtuple <cattrs.cols.is_namedtuple>`
170+
171+
````{tip}
172+
These predicates aren't _cattrs_-specific and may be useful in other contexts.
173+
```{doctest} predicates
174+
>>> from cattrs.cols import is_sequence
175+
176+
>>> is_sequence(list[str])
177+
True
178+
```
179+
````
180+
181+
182+
Available hook factories are:
183+
184+
* {meth}`iterable_unstructure_factory <cattrs.cols.iterable_unstructure_factory>`
185+
* {meth}`list_structure_factory <cattrs.cols.list_structure_factory>`
186+
* {meth}`namedtuple_structure_factory <cattrs.cols.namedtuple_structure_factory>`
187+
* {meth}`namedtuple_unstructure_factory <cattrs.cols.namedtuple_unstructure_factory>`
188+
189+
Additional predicates and hook factories will be added as requested.
190+
191+
For example, by default sequences are structured from any iterable into lists.
192+
This may be too lax, and additional validation may be applied by wrapping the default list structuring hook factory.
193+
194+
```{testcode} list-customization
195+
from cattrs.cols import is_sequence, list_structure_factory
196+
197+
c = Converter()
198+
199+
@c.register_structure_hook_factory(is_sequence)
200+
def strict_list_hook_factory(type, converter):
201+
202+
# First, we generate the default hook...
203+
list_hook = list_structure_factory(type, converter)
204+
205+
# Then, we wrap it with a function of our own...
206+
def strict_list_hook(value, type):
207+
if not isinstance(value, list):
208+
raise ValueError("Not a list!")
209+
return list_hook(value, type)
210+
211+
# And finally, we return our own composite hook.
212+
return strict_list_hook
213+
```
214+
215+
Now, all sequence structuring will be stricter:
216+
217+
```{doctest} list-customization
218+
>>> c.structure({"a", "b", "c"}, list[str])
219+
Traceback (most recent call last):
220+
...
221+
ValueError: Not a list!
222+
```
223+
224+
```{versionadded} 24.1.0
225+
226+
```
227+
158228
## Using `cattrs.gen` Generators
159229

160230
The {mod}`cattrs.gen` module allows for generating and compiling specialized hooks for unstructuring _attrs_ classes, dataclasses and typed dicts.

docs/indepth.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ The new copy may be changed through the `copy` arguments, but will retain all ma
2323
This feature is supported for Python 3.9 and later.
2424
```
2525

26+
```{tip}
27+
See [](customizing.md#customizing-collections) for a more modern and more powerful way of customizing collection handling.
28+
```
29+
2630
Overriding collection unstructuring in a generic way can be a very useful feature.
2731
A common example is using a JSON library that doesn't support sets, but expects lists and tuples instead.
2832

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ caption: Dev Guide
3737
history
3838
benchmarking
3939
contributing
40+
modindex
4041
```
4142

4243
```{include} ../README.md

docs/strategies.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ This strategy has been preapplied to the following preconfigured converters:
376376
- {py:class}`Cbor2Converter <cattrs.preconf.cbor2.Cbor2Converter>`
377377
- {py:class}`JsonConverter <cattrs.preconf.json.JsonConverter>`
378378
- {py:class}`MsgpackConverter <cattrs.preconf.msgpack.MsgpackConverter>`
379+
- {py:class}`MsgspecJsonConverter <cattrs.preconf.msgspec.MsgspecJsonConverter>`
379380
- {py:class}`OrjsonConverter <cattrs.preconf.orjson.OrjsonConverter>`
380381
- {py:class}`PyyamlConverter <cattrs.preconf.pyyaml.PyyamlConverter>`
381382
- {py:class}`TomlkitConverter <cattrs.preconf.tomlkit.TomlkitConverter>`

src/cattrs/_compat.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,11 @@ def get_notrequired_base(type) -> "Union[Any, Literal[NOTHING]]":
332332
return NOTHING
333333

334334
def is_sequence(type: Any) -> bool:
335+
"""A predicate function for sequences.
336+
337+
Matches lists, sequences, mutable sequences, deques and homogenous
338+
tuples.
339+
"""
335340
origin = getattr(type, "__origin__", None)
336341
return (
337342
type
@@ -366,7 +371,11 @@ def is_deque(type):
366371
or (getattr(type, "__origin__", None) is deque)
367372
)
368373

369-
def is_mutable_set(type):
374+
def is_mutable_set(type: Any) -> bool:
375+
"""A predicate function for (mutable) sets.
376+
377+
Matches built-in sets and sets from the typing module.
378+
"""
370379
return (
371380
type in (TypingSet, TypingMutableSet, set)
372381
or (
@@ -376,7 +385,11 @@ def is_mutable_set(type):
376385
or (getattr(type, "__origin__", None) in (set, AbcMutableSet, AbcSet))
377386
)
378387

379-
def is_frozenset(type):
388+
def is_frozenset(type: Any) -> bool:
389+
"""A predicate function for frozensets.
390+
391+
Matches built-in frozensets and frozensets from the typing module.
392+
"""
380393
return (
381394
type in (FrozenSet, frozenset)
382395
or (
@@ -491,9 +504,10 @@ def is_deque(type: Any) -> bool:
491504
or type.__origin__ is deque
492505
)
493506

494-
def is_mutable_set(type):
495-
return type is set or (
496-
type.__class__ is _GenericAlias and is_subclass(type.__origin__, MutableSet)
507+
def is_mutable_set(type) -> bool:
508+
return type in (set, TypingAbstractSet) or (
509+
type.__class__ is _GenericAlias
510+
and is_subclass(type.__origin__, (MutableSet, TypingAbstractSet))
497511
)
498512

499513
def is_frozenset(type):

0 commit comments

Comments
 (0)