Skip to content

Commit 6bc4708

Browse files
Tinchehynek
andauthored
Support overrides in annotated attributes (#717)
* Support overrides in annotated attributes * Tests for overrides * More tests * Fix import * Fix coverage * Fix * Docs * Update docs/customizing.md Co-authored-by: Hynek Schlawack <hs@ox.cx> --------- Co-authored-by: Hynek Schlawack <hs@ox.cx>
1 parent 309e9d1 commit 6bc4708

File tree

8 files changed

+355
-53
lines changed

8 files changed

+355
-53
lines changed

HISTORY.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@ The third number is for emergencies when we need to start branches for older rel
1111

1212
Our backwards-compatibility policy can be found [here](https://github.com/python-attrs/cattrs/blob/main/.github/SECURITY.md).
1313

14-
## 25.4.0 (UNRELEASED)
14+
## NEXT (UNRELEASED)
1515

1616
- Add the {mod}`tomllib <cattrs.preconf.tomllib>` preconf converter.
1717
See [here](https://catt.rs/en/latest/preconf.html#tomllib) for details.
1818
([#716](https://github.com/python-attrs/cattrs/pull/716))
19+
- Customizing un/structuring of _attrs_ classes, dataclasses, TypedDicts and dict NamedTuples is now possible by using `Annotated[T, override()]` on fields.
20+
See [here](https://catt.rs/en/stable/customizing.html#using-typing-annotated-t-override) for more details.
21+
([#717](https://github.com/python-attrs/cattrs/pull/717))
1922
- Fix structuring of nested generic classes with stringified annotations.
2023
([#688](https://github.com/python-attrs/cattrs/pull/688))
2124
- Python 3.9 is no longer supported, as it is end-of-life. Use previous versions on this Python version.

docs/customizing.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,42 @@ ClassWithInitFalse(number=2)
410410

411411
```
412412

413+
## Using `typing.Annotated[T, override(...)]`
414+
415+
The un/structuring process for _attrs_ classes, dataclasses, TypedDicts and dict NamedTuples can be customized by annotating the fields using `typing.Annotated[T, override()]`.
416+
417+
```{doctest}
418+
>>> from typing import Annotated
419+
420+
>>> @define
421+
... class ExampleClass:
422+
... klass: Annotated[int, cattrs.override(rename="class")]
423+
424+
>>> cattrs.unstructure(ExampleClass(1))
425+
{'class': 1}
426+
>>> cattrs.structure({'class': 1}, ExampleClass)
427+
ExampleClass(klass=1)
428+
```
429+
430+
These customizations are automatically recognized by every {class}`Converter <cattrs.Converter>`.
431+
They can still be overriden explicitly, see [](#custom-un-structuring-hooks).
432+
433+
```{attention}
434+
One of the fundamental [design decisions](why.md#design-decisions) of _cattrs_ is that serialization rules should be separate from the models themselves;
435+
by using this feature you're going against the spirit of this design decision.
436+
437+
However, software is written in many different context and with different constraints; and practicality _sometimes_ beats purity.
438+
_Sometimes_, it's not worth introducing a mapping layer to rename one field.
439+
_Sometimes_, you're busy prototyping and will clean up your code later.
440+
441+
The danger is not any single compromise, but their accumulation.
442+
One of the most important skills as a software engineer is knowing when the cost of a trade-off has crossed the line and it's time to do things properly.
443+
```
444+
445+
```{versionadded} NEXT
446+
447+
```
448+
413449
## Customizing Collections
414450

415451
```{currentmodule} cattrs.cols

docs/defaulthooks.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,11 +452,11 @@ Tuples can be structured into classes using {meth}`structure_attrs_fromtuple() <
452452
A(a='string', b=2)
453453
```
454454

455-
Loading from tuples can be made the default by creating a new {class}`Converter <cattrs.Converter>` with `unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE`.
455+
Loading from tuples can be made the default by creating a new {class}`Converter <cattrs.Converter>` with `unstruct_strat=cattrs.UnstructureStrategy.AS_TUPLE`.
456456

457457
```{doctest}
458458

459-
>>> converter = cattrs.Converter(unstruct_strat=cattr.UnstructureStrategy.AS_TUPLE)
459+
>>> converter = cattrs.Converter(unstruct_strat=cattrs.UnstructureStrategy.AS_TUPLE)
460460
>>> @define
461461
... class A:
462462
... a: str
@@ -620,6 +620,12 @@ The {mod}`cattrs.cols` module contains hook factories for un/structuring named t
620620

621621
[PEP 593](https://www.python.org/dev/peps/pep-0593/) annotations (`typing.Annotated[type, ...]`) are supported and are handled using the first type present in the annotated type.
622622

623+
Additionally, `typing.Annotated` types containing `cattrs.override()` are recognized and used by the _attrs_, dataclass, TypedDict and dict NamedTuple hook factories.
624+
625+
```{versionchanged} NEXT
626+
`Annotated[T, override()]` is now used by the _attrs_, dataclass, TypedDict and dict NamedTuple hook factories.
627+
```
628+
623629
```{versionadded} 1.4.0
624630

625631
```

src/cattrs/cols.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,15 @@
55
from collections import defaultdict
66
from collections.abc import Callable, Iterable
77
from functools import partial
8-
from typing import (
9-
TYPE_CHECKING,
10-
Any,
11-
DefaultDict,
12-
Literal,
13-
NamedTuple,
14-
TypeVar,
15-
get_type_hints,
16-
)
8+
from typing import TYPE_CHECKING, Any, DefaultDict, Literal, NamedTuple, TypeVar
179

1810
from attrs import NOTHING, Attribute, NothingType
1911

2012
from ._compat import (
2113
ANIES,
2214
AbcSet,
2315
get_args,
16+
get_full_type_hints,
2417
get_origin,
2518
is_bare,
2619
is_frozenset,
@@ -246,7 +239,7 @@ def _namedtuple_to_attrs(cl: type[tuple]) -> list[Attribute]:
246239
type=a,
247240
alias=name,
248241
)
249-
for name, a in get_type_hints(cl).items()
242+
for name, a in get_full_type_hints(cl).items()
250243
]
251244

252245

src/cattrs/gen/__init__.py

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from ._consts import AttributeOverride, already_generating, neutral
3434
from ._generics import generate_mapping
3535
from ._lc import generate_unique_filename
36-
from ._shared import find_structure_handler
36+
from ._shared import _annotated_override_or_default, find_structure_handler
3737

3838
if TYPE_CHECKING:
3939
from ..converters import BaseConverter
@@ -95,10 +95,13 @@ def make_dict_unstructure_fn_from_attrs(
9595
:param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False`
9696
will be included.
9797
98-
.. versionadded:: 24.1.0
99-
.. versionchanged:: 25.2.0
98+
.. versionadded:: 24.1.0
99+
.. versionchanged:: 25.2.0
100100
The `_cattrs_use_alias` parameter takes its value from the given converter
101101
by default.
102+
.. versionchanged:: NEXT
103+
`typing.Annotated[T, override()]` is now recognized and can be used to customize
104+
unstructuring.
102105
.. versionchanged:: NEXT
103106
When `_cattrs_omit_if_default` is true and the attribute has an attrs converter
104107
specified, the converter is applied to the default value before checking if it
@@ -117,7 +120,13 @@ def make_dict_unstructure_fn_from_attrs(
117120

118121
for a in attrs:
119122
attr_name = a.name
120-
override = kwargs.get(attr_name, neutral)
123+
if attr_name in kwargs:
124+
override = kwargs[attr_name]
125+
else:
126+
override = _annotated_override_or_default(a.type, neutral)
127+
if override != neutral:
128+
kwargs[attr_name] = override
129+
121130
if override.omit:
122131
continue
123132
if override.omit is None and not a.init and not _cattrs_include_init_false:
@@ -264,11 +273,14 @@ def make_dict_unstructure_fn(
264273
:param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False`
265274
will be included.
266275
267-
.. versionadded:: 23.2.0 *_cattrs_use_alias*
268-
.. versionadded:: 23.2.0 *_cattrs_include_init_false*
269-
.. versionchanged:: 25.2.0
276+
.. versionadded:: 23.2.0 *_cattrs_use_alias*
277+
.. versionadded:: 23.2.0 *_cattrs_include_init_false*
278+
.. versionchanged:: 25.2.0
270279
The `_cattrs_use_alias` parameter takes its value from the given converter
271280
by default.
281+
.. versionchanged:: NEXT
282+
`typing.Annotated[T, override()]` is now recognized and can be used to customize
283+
unstructuring.
272284
"""
273285
origin = get_origin(cl)
274286
attrs = adapted_fields(origin or cl) # type: ignore
@@ -349,10 +361,13 @@ def make_dict_structure_fn_from_attrs(
349361
:param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False`
350362
will be included.
351363
352-
.. versionadded:: 24.1.0
353-
.. versionchanged:: 25.2.0
364+
.. versionadded:: 24.1.0
365+
.. versionchanged:: 25.2.0
354366
The `_cattrs_use_alias` parameter takes its value from the given converter
355367
by default.
368+
.. versionchanged:: NEXT
369+
`typing.Annotated[T, override()]` is now recognized and can be used to customize
370+
unstructuring.
356371
"""
357372

358373
cl_name = cl.__name__
@@ -408,7 +423,13 @@ def make_dict_structure_fn_from_attrs(
408423
internal_arg_parts["__c_avn"] = AttributeValidationNote
409424
for a in attrs:
410425
an = a.name
411-
override = kwargs.get(an, neutral)
426+
if an in kwargs:
427+
override = kwargs[an]
428+
else:
429+
override = _annotated_override_or_default(a.type, neutral)
430+
if override != neutral:
431+
kwargs[an] = override
432+
412433
if override.omit:
413434
continue
414435
if override.omit is None and not a.init and not _cattrs_include_init_false:
@@ -539,14 +560,24 @@ def make_dict_structure_fn_from_attrs(
539560
# The first loop deals with required args.
540561
for a in attrs:
541562
an = a.name
542-
override = kwargs.get(an, neutral)
563+
564+
if an in kwargs:
565+
override = kwargs[an]
566+
else:
567+
override = _annotated_override_or_default(a.type, neutral)
568+
if override != neutral:
569+
kwargs[an] = override
570+
543571
if override.omit:
544572
continue
545573
if override.omit is None and not a.init and not _cattrs_include_init_false:
546574
continue
575+
547576
if a.default is not NOTHING:
548577
non_required.append(a)
578+
# The next loop will handle it.
549579
continue
580+
550581
t = a.type
551582
if isinstance(t, TypeVar):
552583
t = typevar_map.get(t.__name__, t)
@@ -753,17 +784,20 @@ def make_dict_structure_fn(
753784
:param _cattrs_include_init_false: If true, _attrs_ fields marked as `init=False`
754785
will be included.
755786
756-
.. versionadded:: 23.2.0 *_cattrs_use_alias*
757-
.. versionadded:: 23.2.0 *_cattrs_include_init_false*
758-
.. versionchanged:: 23.2.0
787+
.. versionadded:: 23.2.0 *_cattrs_use_alias*
788+
.. versionadded:: 23.2.0 *_cattrs_include_init_false*
789+
.. versionchanged:: 23.2.0
759790
The `_cattrs_forbid_extra_keys` and `_cattrs_detailed_validation` parameters
760791
take their values from the given converter by default.
761-
.. versionchanged:: 24.1.0
792+
.. versionchanged:: 24.1.0
762793
The `_cattrs_prefer_attrib_converters` parameter takes its value from the given
763794
converter by default.
764-
.. versionchanged:: 25.2.0
795+
.. versionchanged:: 25.2.0
765796
The `_cattrs_use_alias` parameter takes its value from the given converter
766797
by default.
798+
.. versionchanged:: NEXT
799+
`typing.Annotated[T, override()]` is now recognized and can be used to customize
800+
unstructuring.
767801
"""
768802

769803
mapping = {}

src/cattrs/gen/_shared.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,31 @@
44

55
from attrs import NOTHING, Attribute, Factory
66

7-
from .._compat import is_bare_final
7+
from .._compat import get_args, is_annotated, is_bare_final
88
from ..dispatch import StructureHook
99
from ..errors import StructureHandlerNotFoundError
1010
from ..fns import raise_error
11+
from ._consts import AttributeOverride
1112

1213
if TYPE_CHECKING:
1314
from ..converters import BaseConverter
1415

1516

17+
def _annotated_override_or_default(
18+
type: Any, default: AttributeOverride
19+
) -> AttributeOverride:
20+
"""
21+
If the type is Annotated containing an AttributeOverride, return it.
22+
Otherwise, return the default.
23+
"""
24+
if is_annotated(type):
25+
for arg in get_args(type):
26+
if isinstance(arg, AttributeOverride):
27+
return arg
28+
29+
return default
30+
31+
1632
def find_structure_handler(
1733
a: Attribute, type: Any, c: BaseConverter, prefer_attrs_converters: bool = False
1834
) -> StructureHook | None:

0 commit comments

Comments
 (0)