Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python
([#577](https://github.com/python-attrs/cattrs/pull/577))
- Add a [Migrations](https://catt.rs/en/latest/migrations.html) page, with instructions on migrating changed behavior for each version.
([#577](https://github.com/python-attrs/cattrs/pull/577))
- [`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.
([#299](https://github.com/python-attrs/cattrs/issues/299) [#627](https://github.com/python-attrs/cattrs/pull/627))
- Expose {func}`cattrs.cols.mapping_unstructure_factory` through {mod}`cattrs.cols`.
- Some `defaultdicts` are now [supported by default](https://catt.rs/en/latest/defaulthooks.html#defaultdicts), and
{func}`cattrs.cols.is_defaultdict` and {func}`cattrs.cols.defaultdict_structure_factory` are exposed through {mod}`cattrs.cols`.
Expand Down
13 changes: 13 additions & 0 deletions docs/defaulthooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -656,3 +656,16 @@ Protocols are unstructured according to the actual runtime type of the value.
```{versionadded} 1.9.0

```

### `typing.Self`

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
(when using [the dict un/structure factories](customizing.md#customizing-named-tuples)).

```{note}
Attributes annotated with `typing.Self` are not supported by the BaseConverter, as this is too complex for it.
```

```{versionadded} 25.1.0

```
15 changes: 11 additions & 4 deletions src/cattrs/_generics.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from collections.abc import Mapping
from typing import Any

from attrs import NOTHING
from typing_extensions import Self

from ._compat import copy_with, get_args, is_annotated, is_generic


def deep_copy_with(t, mapping: Mapping[str, Any]):
def deep_copy_with(t, mapping: Mapping[str, Any], self_is=NOTHING):
args = get_args(t)
rest = ()
if is_annotated(t) and args:
Expand All @@ -14,9 +17,13 @@ def deep_copy_with(t, mapping: Mapping[str, Any]):
new_args = (
tuple(
(
mapping[a.__name__]
if hasattr(a, "__name__") and a.__name__ in mapping
else (deep_copy_with(a, mapping) if is_generic(a) else a)
self_is
if a is Self and self_is is not NOTHING
else (
mapping[a.__name__]
if hasattr(a, "__name__") and a.__name__ in mapping
else (deep_copy_with(a, mapping, self_is) if is_generic(a) else a)
)
)
for a in args
)
Expand Down
15 changes: 6 additions & 9 deletions src/cattrs/gen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def make_dict_unstructure_fn_from_attrs(
else:
handler = converter.unstructure
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, typevar_map)
t = deep_copy_with(t, typevar_map, cl)

if handler is None:
if (
Expand Down Expand Up @@ -376,7 +376,7 @@ def make_dict_structure_fn_from_attrs(
if isinstance(t, TypeVar):
t = typevar_map.get(t.__name__, t)
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, typevar_map)
t = deep_copy_with(t, typevar_map, cl)

# For each attribute, we try resolving the type here and now.
# If a type is manually overwritten, this function should be
Expand Down Expand Up @@ -447,10 +447,8 @@ def make_dict_structure_fn_from_attrs(
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'])"
)
else:
tn = f"__c_type_{an}"
internal_arg_parts[tn] = t
lines.append(
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {tn})"
f"{i}res['{ian}'] = {struct_handler_name}(o['{kn}'], {type_name})"
)
else:
lines.append(f"{i}res['{ian}'] = o['{kn}']")
Expand Down Expand Up @@ -510,7 +508,7 @@ def make_dict_structure_fn_from_attrs(
if isinstance(t, TypeVar):
t = typevar_map.get(t.__name__, t)
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, typevar_map)
t = deep_copy_with(t, typevar_map, cl)

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

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

# At the end, we create the function header.
internal_arg_line = ", ".join([f"{i}={i}" for i in internal_arg_parts])
for k, v in internal_arg_parts.items():
globs[k] = v
globs.update(internal_arg_parts)

total_lines = [
f"def {fn_name}(o, _=__cl, {internal_arg_line}):",
Expand Down
16 changes: 8 additions & 8 deletions src/cattrs/gen/typeddicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import re
import sys
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Callable, Literal, TypedDict, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Literal, TypeVar

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

__all__ = ["make_dict_structure_fn", "make_dict_unstructure_fn"]

T = TypeVar("T", bound=TypedDict)
T = TypeVar("T")


def make_dict_unstructure_fn(
Expand Down Expand Up @@ -122,7 +122,7 @@ def make_dict_unstructure_fn(
# Unbound typevars use late binding.
handler = converter.unstructure
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, mapping)
t = deep_copy_with(t, mapping, cl)

if handler is None:
nrb = get_notrequired_base(t)
Expand Down Expand Up @@ -168,7 +168,7 @@ def make_dict_unstructure_fn(
else:
handler = converter.unstructure
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, mapping)
t = deep_copy_with(t, mapping, cl)

if handler is None:
nrb = get_notrequired_base(t)
Expand Down Expand Up @@ -334,14 +334,14 @@ def make_dict_structure_fn(
if isinstance(t, TypeVar):
t = mapping.get(t.__name__, t)
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, mapping)
t = deep_copy_with(t, mapping, cl)

nrb = get_notrequired_base(t)
if nrb is not NOTHING:
t = nrb

if is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, mapping)
t = deep_copy_with(t, mapping, cl)

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

nrb = get_notrequired_base(t)
if nrb is not NOTHING:
Expand Down Expand Up @@ -458,7 +458,7 @@ def make_dict_structure_fn(
if isinstance(t, TypeVar):
t = mapping.get(t.__name__, t)
elif is_generic(t) and not is_bare(t) and not is_annotated(t):
t = deep_copy_with(t, mapping)
t = deep_copy_with(t, mapping, cl)

if override.struct_hook is not None:
handler = override.struct_hook
Expand Down
145 changes: 145 additions & 0 deletions tests/test_self.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Tests for `typing.Self`."""

from dataclasses import dataclass
from typing import NamedTuple, Optional, TypedDict

from attrs import define
from typing_extensions import Self

from cattrs import Converter
from cattrs.cols import (
namedtuple_dict_structure_factory,
namedtuple_dict_unstructure_factory,
)


@define
class WithSelf:
myself: Optional[Self]
myself_with_default: Optional[Self] = None


@define
class WithSelfSubclass(WithSelf):
pass


@dataclass
class WithSelfDataclass:
myself: Optional[Self]


@dataclass
class WithSelfDataclassSubclass(WithSelfDataclass):
pass


@define
class WithListOfSelf:
myself: Optional[Self]
selves: list[WithSelf]


class WithSelfTypedDict(TypedDict):
field: int
myself: Optional[Self]


class WithSelfNamedTuple(NamedTuple):
myself: Optional[Self]


def test_self_roundtrip(genconverter):
"""A simple roundtrip works."""
initial = WithSelf(WithSelf(None, WithSelf(None)))
raw = genconverter.unstructure(initial)

assert raw == {
"myself": {
"myself": None,
"myself_with_default": {"myself": None, "myself_with_default": None},
},
"myself_with_default": None,
}

assert genconverter.structure(raw, WithSelf) == initial


def test_self_roundtrip_dataclass(genconverter):
"""A simple roundtrip works for dataclasses."""
initial = WithSelfDataclass(WithSelfDataclass(None))
raw = genconverter.unstructure(initial)

assert raw == {"myself": {"myself": None}}

assert genconverter.structure(raw, WithSelfDataclass) == initial


def test_self_roundtrip_typeddict(genconverter):
"""A simple roundtrip works for TypedDicts."""
genconverter.register_unstructure_hook(int, str)

initial: WithSelfTypedDict = {"field": 1, "myself": {"field": 2, "myself": None}}
raw = genconverter.unstructure(initial)

assert raw == {"field": "1", "myself": {"field": "2", "myself": None}}

assert genconverter.structure(raw, WithSelfTypedDict) == initial


def test_self_roundtrip_namedtuple(genconverter):
"""A simple roundtrip works for NamedTuples."""
genconverter.register_unstructure_hook_factory(
lambda t: t is WithSelfNamedTuple, namedtuple_dict_unstructure_factory
)
genconverter.register_structure_hook_factory(
lambda t: t is WithSelfNamedTuple, namedtuple_dict_structure_factory
)

initial = WithSelfNamedTuple(WithSelfNamedTuple(None))
raw = genconverter.unstructure(initial)

assert raw == {"myself": {"myself": None}}

assert genconverter.structure(raw, WithSelfNamedTuple) == initial


def test_subclass_roundtrip(genconverter):
"""A simple roundtrip works for a dataclass subclass."""
initial = WithSelfSubclass(WithSelfSubclass(None))
raw = genconverter.unstructure(initial)

assert raw == {
"myself": {"myself": None, "myself_with_default": None},
"myself_with_default": None,
}

assert genconverter.structure(raw, WithSelfSubclass) == initial


def test_subclass_roundtrip_dataclass(genconverter):
"""A simple roundtrip works for a dataclass subclass."""
initial = WithSelfDataclassSubclass(WithSelfDataclassSubclass(None))
raw = genconverter.unstructure(initial)

assert raw == {"myself": {"myself": None}}

assert genconverter.structure(raw, WithSelfDataclassSubclass) == initial


def test_nested_roundtrip(genconverter: Converter):
"""A more complex roundtrip, with several Self classes."""
initial = WithListOfSelf(WithListOfSelf(None, []), [WithSelf(WithSelf(None))])
raw = genconverter.unstructure(initial)

assert raw == {
"myself": {"myself": None, "selves": []},
"selves": [
{
"myself": {"myself": None, "myself_with_default": None},
"myself_with_default": None,
}
],
}

assert genconverter.structure(raw, WithListOfSelf) == initial