From bd6b7ad6f46f1e11c08f85d5001a9dae50e075ba Mon Sep 17 00:00:00 2001 From: Jason Myers Date: Sat, 24 Feb 2024 21:57:25 -0500 Subject: [PATCH 1/3] Add support for TypeVar with default (PEP696) --- src/cattrs/gen/_generics.py | 22 ++++++++++++++-- tests/test_generics_696.py | 50 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 tests/test_generics_696.py 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..dce8a18e --- /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) From 8f8202cbf8334d270e33817fb659e8e6441f0ddf Mon Sep 17 00:00:00 2001 From: Jason Myers Date: Mon, 26 Feb 2024 20:29:59 -0500 Subject: [PATCH 2/3] Add changelog entry --- HISTORY.md | 2 ++ 1 file changed, 2 insertions(+) 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. From 0088ccf42dec48a7df8ba0873dbe2ecce7be3337 Mon Sep 17 00:00:00 2001 From: Jason Myers Date: Mon, 26 Feb 2024 20:32:15 -0500 Subject: [PATCH 3/3] Fix test --- tests/test_generics_696.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generics_696.py b/tests/test_generics_696.py index dce8a18e..c4643321 100644 --- a/tests/test_generics_696.py +++ b/tests/test_generics_696.py @@ -1,4 +1,4 @@ -+"""Tests for generics under PEP 696 (type defaults).""" +"""Tests for generics under PEP 696 (type defaults).""" from typing import Generic import pytest