diff --git a/HISTORY.md b/HISTORY.md index 725e6d4c..fd695e21 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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`. diff --git a/docs/defaulthooks.md b/docs/defaulthooks.md index 23fba82a..fb4a190c 100644 --- a/docs/defaulthooks.md +++ b/docs/defaulthooks.md @@ -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 + +``` diff --git a/src/cattrs/_generics.py b/src/cattrs/_generics.py index a982bb10..6f36e94f 100644 --- a/src/cattrs/_generics.py +++ b/src/cattrs/_generics.py @@ -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: @@ -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 ) diff --git a/src/cattrs/gen/__init__.py b/src/cattrs/gen/__init__.py index 7a562c47..3afa3b9b 100644 --- a/src/cattrs/gen/__init__.py +++ b/src/cattrs/gen/__init__.py @@ -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 ( @@ -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 @@ -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}']") @@ -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 @@ -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 @@ -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}):", diff --git a/src/cattrs/gen/typeddicts.py b/src/cattrs/gen/typeddicts.py index dcb58641..bca38a54 100644 --- a/src/cattrs/gen/typeddicts.py +++ b/src/cattrs/gen/typeddicts.py @@ -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 @@ -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( @@ -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) @@ -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) @@ -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 @@ -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: @@ -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 diff --git a/tests/test_self.py b/tests/test_self.py new file mode 100644 index 00000000..5ca4812a --- /dev/null +++ b/tests/test_self.py @@ -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