Skip to content

Commit e9069a9

Browse files
authored
Support typing.Self (#627)
* Support `typing.Self` * Add docs * Update HISTORY
1 parent 0b3b5f6 commit e9069a9

File tree

6 files changed

+185
-21
lines changed

6 files changed

+185
-21
lines changed

HISTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
1818
([#577](https://github.com/python-attrs/cattrs/pull/577))
1919
- Add a [Migrations](https://catt.rs/en/latest/migrations.html) page, with instructions on migrating changed behavior for each version.
2020
([#577](https://github.com/python-attrs/cattrs/pull/577))
21+
- [`typing.Self`](https://docs.python.org/3/library/typing.html#typing.Self) is now supported in _attrs_ classes, dataclasses, TypedDicts and the dict NamedTuple factories.
22+
([#299](https://github.com/python-attrs/cattrs/issues/299) [#627](https://github.com/python-attrs/cattrs/pull/627))
2123
- Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`.
2224
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
2325
{func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.

docs/defaulthooks.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,16 @@ Protocols are unstructured according to the actual runtime type of the value.
656656
```{versionadded} 1.9.0
657657

658658
```
659+
660+
### `typing.Self`
661+
662+
Attributes annotated using [the Self type](https://docs.python.org/3/library/typing.html#typing.Self) are supported in _attrs_ classes, dataclasses, TypedDicts and NamedTuples
663+
(when using [the dict un/structure factories](customizing.md#customizing-named-tuples)).
664+
665+
```{note}
666+
Attributes annotated with `typing.Self` are not supported by the BaseConverter, as this is too complex for it.
667+
```
668+
669+
```{versionadded} 25.1.0
670+
671+
```

src/cattrs/_generics.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from collections.abc import Mapping
22
from typing import Any
33

4+
from attrs import NOTHING
5+
from typing_extensions import Self
6+
47
from ._compat import copy_with, get_args, is_annotated, is_generic
58

69

7-
def deep_copy_with(t, mapping: Mapping[str, Any]):
10+
def deep_copy_with(t, mapping: Mapping[str, Any], self_is=NOTHING):
811
args = get_args(t)
912
rest = ()
1013
if is_annotated(t) and args:
@@ -14,9 +17,13 @@ def deep_copy_with(t, mapping: Mapping[str, Any]):
1417
new_args = (
1518
tuple(
1619
(
17-
mapping[a.__name__]
18-
if hasattr(a, "__name__") and a.__name__ in mapping
19-
else (deep_copy_with(a, mapping) if is_generic(a) else a)
20+
self_is
21+
if a is Self and self_is is not NOTHING
22+
else (
23+
mapping[a.__name__]
24+
if hasattr(a, "__name__") and a.__name__ in mapping
25+
else (deep_copy_with(a, mapping, self_is) if is_generic(a) else a)
26+
)
2027
)
2128
for a in args
2229
)

src/cattrs/gen/__init__.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def make_dict_unstructure_fn_from_attrs(
132132
else:
133133
handler = converter.unstructure
134134
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
135-
t = deep_copy_with(t, typevar_map)
135+
t = deep_copy_with(t, typevar_map, cl)
136136

137137
if handler is None:
138138
if (
@@ -376,7 +376,7 @@ def make_dict_structure_fn_from_attrs(
376376
if isinstance(t, TypeVar):
377377
t = typevar_map.get(t.__name__, t)
378378
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
379-
t = deep_copy_with(t, typevar_map)
379+
t = deep_copy_with(t, typevar_map, cl)
380380

381381
# For each attribute, we try resolving the type here and now.
382382
# If a type is manually overwritten, this function should be
@@ -447,10 +447,8 @@ def make_dict_structure_fn_from_attrs(
447447
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'])"
448448
)
449449
else:
450-
tn = f"__c_type_{an}"
451-
internal_arg_parts[tn] = t
452450
lines.append(
453-
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})"
451+
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {type_name})"
454452
)
455453
else:
456454
lines.append(f"{i}res['{ian}'] = o['{kn}']")
@@ -510,7 +508,7 @@ def make_dict_structure_fn_from_attrs(
510508
if isinstance(t, TypeVar):
511509
t = typevar_map.get(t.__name__, t)
512510
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
513-
t = deep_copy_with(t, typevar_map)
511+
t = deep_copy_with(t, typevar_map, cl)
514512

515513
# For each attribute, we try resolving the type here and now.
516514
# If a type is manually overwritten, this function should be
@@ -576,7 +574,7 @@ def make_dict_structure_fn_from_attrs(
576574
if isinstance(t, TypeVar):
577575
t = typevar_map.get(t.__name__, t)
578576
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
579-
t = deep_copy_with(t, typevar_map)
577+
t = deep_copy_with(t, typevar_map, cl)
580578

581579
# For each attribute, we try resolving the type here and now.
582580
# If a type is manually overwritten, this function should be
@@ -652,8 +650,7 @@ def make_dict_structure_fn_from_attrs(
652650

653651
# At the end, we create the function header.
654652
internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts])
655-
for k, v in internal_arg_parts.items():
656-
globs[k] = v
653+
globs.update(internal_arg_parts)
657654

658655
total_lines = [
659656
f"def {fn_name}(o, _=__cl, {internal_arg_line}):",

src/cattrs/gen/typeddicts.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import re
44
import sys
55
from collections.abc import Mapping
6-
from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, TypeVar
6+
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar
77

88
from attrs import NOTHING, Attribute
99
from typing_extensions import _TypedDictMeta
@@ -47,7 +47,7 @@ def get_annots(cl) -> dict[str, Any]:
4747

4848
__all__ = ["make_dict_structure_fn", "make_dict_unstructure_fn"]
4949

50-
T = TypeVar("T", bound=TypedDict)
50+
T = TypeVar("T")
5151

5252

5353
def make_dict_unstructure_fn(
@@ -122,7 +122,7 @@ def make_dict_unstructure_fn(
122122
# Unbound typevars use late binding.
123123
handler = converter.unstructure
124124
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
125-
t = deep_copy_with(t, mapping)
125+
t = deep_copy_with(t, mapping, cl)
126126

127127
if handler is None:
128128
nrb = get_notrequired_base(t)
@@ -168,7 +168,7 @@ def make_dict_unstructure_fn(
168168
else:
169169
handler = converter.unstructure
170170
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
171-
t = deep_copy_with(t, mapping)
171+
t = deep_copy_with(t, mapping, cl)
172172

173173
if handler is None:
174174
nrb = get_notrequired_base(t)
@@ -334,14 +334,14 @@ def make_dict_structure_fn(
334334
if isinstance(t, TypeVar):
335335
t = mapping.get(t.__name__, t)
336336
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
337-
t = deep_copy_with(t, mapping)
337+
t = deep_copy_with(t, mapping, cl)
338338

339339
nrb = get_notrequired_base(t)
340340
if nrb is not NOTHING:
341341
t = nrb
342342

343343
if is_generic(t) and not is_bare(t) and not is_annotated(t):
344-
t = deep_copy_with(t, mapping)
344+
t = deep_copy_with(t, mapping, cl)
345345

346346
# For each attribute, we try resolving the type here and now.
347347
# If a type is manually overwritten, this function should be
@@ -411,7 +411,7 @@ def make_dict_structure_fn(
411411
if isinstance(t, TypeVar):
412412
t = mapping.get(t.__name__, t)
413413
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
414-
t = deep_copy_with(t, mapping)
414+
t = deep_copy_with(t, mapping, cl)
415415

416416
nrb = get_notrequired_base(t)
417417
if nrb is not NOTHING:
@@ -458,7 +458,7 @@ def make_dict_structure_fn(
458458
if isinstance(t, TypeVar):
459459
t = mapping.get(t.__name__, t)
460460
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
461-
t = deep_copy_with(t, mapping)
461+
t = deep_copy_with(t, mapping, cl)
462462

463463
if override.struct_hook is not None:
464464
handler = override.struct_hook

tests/test_self.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Tests for `typing.Self`."""
2+
3+
from dataclasses import dataclass
4+
from typing import NamedTuple, Optional, TypedDict
5+
6+
from attrs import define
7+
from typing_extensions import Self
8+
9+
from cattrs import Converter
10+
from cattrs.cols import (
11+
namedtuple_dict_structure_factory,
12+
namedtuple_dict_unstructure_factory,
13+
)
14+
15+
16+
@define
17+
class WithSelf:
18+
myself: Optional[Self]
19+
myself_with_default: Optional[Self] = None
20+
21+
22+
@define
23+
class WithSelfSubclass(WithSelf):
24+
pass
25+
26+
27+
@dataclass
28+
class WithSelfDataclass:
29+
myself: Optional[Self]
30+
31+
32+
@dataclass
33+
class WithSelfDataclassSubclass(WithSelfDataclass):
34+
pass
35+
36+
37+
@define
38+
class WithListOfSelf:
39+
myself: Optional[Self]
40+
selves: list[WithSelf]
41+
42+
43+
class WithSelfTypedDict(TypedDict):
44+
field: int
45+
myself: Optional[Self]
46+
47+
48+
class WithSelfNamedTuple(NamedTuple):
49+
myself: Optional[Self]
50+
51+
52+
def test_self_roundtrip(genconverter):
53+
"""A simple roundtrip works."""
54+
initial = WithSelf(WithSelf(None, WithSelf(None)))
55+
raw = genconverter.unstructure(initial)
56+
57+
assert raw == {
58+
"myself": {
59+
"myself": None,
60+
"myself_with_default": {"myself": None, "myself_with_default": None},
61+
},
62+
"myself_with_default": None,
63+
}
64+
65+
assert genconverter.structure(raw, WithSelf) == initial
66+
67+
68+
def test_self_roundtrip_dataclass(genconverter):
69+
"""A simple roundtrip works for dataclasses."""
70+
initial = WithSelfDataclass(WithSelfDataclass(None))
71+
raw = genconverter.unstructure(initial)
72+
73+
assert raw == {"myself": {"myself": None}}
74+
75+
assert genconverter.structure(raw, WithSelfDataclass) == initial
76+
77+
78+
def test_self_roundtrip_typeddict(genconverter):
79+
"""A simple roundtrip works for TypedDicts."""
80+
genconverter.register_unstructure_hook(int, str)
81+
82+
initial: WithSelfTypedDict = {"field": 1, "myself": {"field": 2, "myself": None}}
83+
raw = genconverter.unstructure(initial)
84+
85+
assert raw == {"field": "1", "myself": {"field": "2", "myself": None}}
86+
87+
assert genconverter.structure(raw, WithSelfTypedDict) == initial
88+
89+
90+
def test_self_roundtrip_namedtuple(genconverter):
91+
"""A simple roundtrip works for NamedTuples."""
92+
genconverter.register_unstructure_hook_factory(
93+
lambda t: t is WithSelfNamedTuple, namedtuple_dict_unstructure_factory
94+
)
95+
genconverter.register_structure_hook_factory(
96+
lambda t: t is WithSelfNamedTuple, namedtuple_dict_structure_factory
97+
)
98+
99+
initial = WithSelfNamedTuple(WithSelfNamedTuple(None))
100+
raw = genconverter.unstructure(initial)
101+
102+
assert raw == {"myself": {"myself": None}}
103+
104+
assert genconverter.structure(raw, WithSelfNamedTuple) == initial
105+
106+
107+
def test_subclass_roundtrip(genconverter):
108+
"""A simple roundtrip works for a dataclass subclass."""
109+
initial = WithSelfSubclass(WithSelfSubclass(None))
110+
raw = genconverter.unstructure(initial)
111+
112+
assert raw == {
113+
"myself": {"myself": None, "myself_with_default": None},
114+
"myself_with_default": None,
115+
}
116+
117+
assert genconverter.structure(raw, WithSelfSubclass) == initial
118+
119+
120+
def test_subclass_roundtrip_dataclass(genconverter):
121+
"""A simple roundtrip works for a dataclass subclass."""
122+
initial = WithSelfDataclassSubclass(WithSelfDataclassSubclass(None))
123+
raw = genconverter.unstructure(initial)
124+
125+
assert raw == {"myself": {"myself": None}}
126+
127+
assert genconverter.structure(raw, WithSelfDataclassSubclass) == initial
128+
129+
130+
def test_nested_roundtrip(genconverter: Converter):
131+
"""A more complex roundtrip, with several Self classes."""
132+
initial = WithListOfSelf(WithListOfSelf(None, []), [WithSelf(WithSelf(None))])
133+
raw = genconverter.unstructure(initial)
134+
135+
assert raw == {
136+
"myself": {"myself": None, "selves": []},
137+
"selves": [
138+
{
139+
"myself": {"myself": None, "myself_with_default": None},
140+
"myself_with_default": None,
141+
}
142+
],
143+
}
144+
145+
assert genconverter.structure(raw, WithListOfSelf) == initial

0 commit comments

Comments
 (0)