diff --git a/HISTORY.md b/HISTORY.md index 5d818a21..e76cff20 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -33,6 +33,8 @@ can now be used as decorators and have gained new features. ([#426](https://github.com/python-attrs/cattrs/issues/426) [#477](https://github.com/python-attrs/cattrs/pull/477)) - Add support for [PEP 695](https://peps.python.org/pep-0695/) type aliases. ([#452](https://github.com/python-attrs/cattrs/pull/452)) +- Add support for [PEP 696](https://peps.python.org/pep-0696/) `TypeVar`s with defaults. + ([#512](https://github.com/python-attrs/cattrs/pull/512)) - Add support for named tuples with type metadata ([`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple)). ([#425](https://github.com/python-attrs/cattrs/issues/425) [#491](https://github.com/python-attrs/cattrs/pull/491)) - The `include_subclasses` strategy now fetches the member hooks from the converter (making use of converter defaults) if overrides are not provided, instead of generating new hooks with no overrides. diff --git a/src/cattrs/gen/_generics.py b/src/cattrs/gen/_generics.py index 5c1fefda..877393b8 100644 --- a/src/cattrs/gen/_generics.py +++ b/src/cattrs/gen/_generics.py @@ -33,9 +33,27 @@ def generate_mapping(cl: type, old_mapping: dict[str, type] = {}) -> dict[str, t if not hasattr(base, "__args__"): continue base_args = base.__args__ - if not hasattr(base.__origin__, "__parameters__"): + if hasattr(base.__origin__, "__parameters__"): + base_params = base.__origin__.__parameters__ + elif any( + getattr(base_arg, "__default__", None) is not None + for base_arg in base_args + ): + # TypeVar with a default e.g. PEP 696 + # https://www.python.org/dev/peps/pep-0696/ + # Extract the defaults for the TypeVars and insert + # them into the mapping + mapping_params = [ + (base_arg, base_arg.__default__) + for base_arg in base_args + # Note: None means no default was provided, since + # TypeVar("T", default=None) sets NoneType as the default + if getattr(base_arg, "__default__", None) is not None + ] + base_params, base_args = zip(*mapping_params) + else: continue - base_params = base.__origin__.__parameters__ + for param, arg in zip(base_params, base_args): mapping[param.__name__] = arg diff --git a/tests/test_generics_696.py b/tests/test_generics_696.py new file mode 100644 index 00000000..c4643321 --- /dev/null +++ b/tests/test_generics_696.py @@ -0,0 +1,50 @@ +"""Tests for generics under PEP 696 (type defaults).""" +from typing import Generic + +import pytest +from attrs import define, fields +from typing_extensions import TypeVar + +from cattrs.errors import StructureHandlerNotFoundError +from cattrs.gen import generate_mapping + +T = TypeVar("T") +TD = TypeVar("TD", default=str) + + +def test_structure_typevar_default(genconverter): + """Generics with defaulted TypeVars work.""" + + @define + class C(Generic[T]): + a: T + + c_mapping = generate_mapping(C) + atype = fields(C).a.type + assert atype.__name__ not in c_mapping + + with pytest.raises(StructureHandlerNotFoundError): + # Missing type for generic argument + genconverter.structure({"a": "1"}, C) + + c_mapping = generate_mapping(C[str]) + atype = fields(C[str]).a.type + assert c_mapping[atype.__name__] == str + + assert genconverter.structure({"a": "1"}, C[str]) == C("1") + + @define + class D(Generic[TD]): + a: TD + + d_mapping = generate_mapping(D) + atype = fields(D).a.type + assert d_mapping[atype.__name__] == str + + # Defaults to string + assert d_mapping[atype.__name__] == str + assert genconverter.structure({"a": "1"}, D) == D("1") + + # But allows other types + assert genconverter.structure({"a": "1"}, D[str]) == D("1") + assert genconverter.structure({"a": 1}, D[int]) == D(1)