From 9cbbf8bb326a1c21668005678bc8a78ee10a98b9 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 11:59:22 +0200 Subject: [PATCH 01/11] Add test for type resolution using generics and forward references --- tests/forwardrefs.py | 14 ++++++++++++++ tests/test_generics.py | 8 ++++++++ 2 files changed, 22 insertions(+) create mode 100644 tests/forwardrefs.py diff --git a/tests/forwardrefs.py b/tests/forwardrefs.py new file mode 100644 index 00000000..d196c3a7 --- /dev/null +++ b/tests/forwardrefs.py @@ -0,0 +1,14 @@ +"""Class definitions using forward references.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from attrs import define + +T = TypeVar("T") + + +@define +class GenericClass(Generic[T]): + t: T diff --git a/tests/test_generics.py b/tests/test_generics.py index 466c4134..8680aa63 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -4,6 +4,7 @@ import pytest from attrs import asdict, define +import cattrs from cattrs import BaseConverter, Converter from cattrs._compat import Protocol from cattrs._generics import deep_copy_with @@ -11,6 +12,7 @@ from cattrs.gen._generics import generate_mapping from ._compat import Dict_origin, List_origin, is_py310_plus, is_py311_plus +from .forwardrefs import GenericClass T = TypeVar("T") T2 = TypeVar("T2") @@ -358,3 +360,9 @@ class GenericEntity(GenericProtocol[int]): assert generate_mapping(GenericEntity) == {"T": int} assert converter.structure({"a": 1}, GenericEntity) == GenericEntity(1) + + +def test_generics_with_forward_refs(): + """Type resolution works with forward references.""" + converter = cattrs.Converter() + converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) From 05d823b25223ee9fc88322d270fb466b731499cd Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 12:00:04 +0200 Subject: [PATCH 02/11] Fix type resolution call --- src/cattrs/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index aa895552..ee04e653 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -1252,7 +1252,7 @@ def gen_unstructure_attrs_fromdict( attribs = fields(origin or cl) if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): # PEP 563 annotations - need to be resolved. - resolve_types(cl) + resolve_types(origin or cl) attrib_overrides = { a.name: self.type_overrides[a.type] for a in attribs From e777bca3d981ef6a8b66f094a92b231cda81a490 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 14:06:53 +0200 Subject: [PATCH 03/11] Add assert statement --- tests/test_generics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index 8680aa63..8d3fb8ad 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -365,4 +365,5 @@ class GenericEntity(GenericProtocol[int]): def test_generics_with_forward_refs(): """Type resolution works with forward references.""" converter = cattrs.Converter() - converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) + dct = converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) + assert dct == {"t": 42} From 5467a2a1f7d2dad2023fbe3d501bb25af7d9999f Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 14:07:12 +0200 Subject: [PATCH 04/11] Avoid unnecessary import --- tests/test_generics.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index 8d3fb8ad..cf5647d6 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -4,7 +4,6 @@ import pytest from attrs import asdict, define -import cattrs from cattrs import BaseConverter, Converter from cattrs._compat import Protocol from cattrs._generics import deep_copy_with @@ -364,6 +363,6 @@ class GenericEntity(GenericProtocol[int]): def test_generics_with_forward_refs(): """Type resolution works with forward references.""" - converter = cattrs.Converter() + converter = Converter() dct = converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) assert dct == {"t": 42} From 839bd974af53bb23319a9f0cdd97806498d8b795 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 14:07:31 +0200 Subject: [PATCH 05/11] Adjust docstring --- tests/test_generics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index cf5647d6..bc019f6a 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -362,7 +362,7 @@ class GenericEntity(GenericProtocol[int]): def test_generics_with_forward_refs(): - """Type resolution works with forward references.""" + """Type resolution works with stringified annotations.""" converter = Converter() dct = converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) assert dct == {"t": 42} From 729afed6868fb69232dd52b2c6890826010278a6 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 14:12:16 +0200 Subject: [PATCH 06/11] Update changelog --- HISTORY.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index 2c56c85d..e1460476 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,6 +19,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now. If you're using these functions directly, the old behavior can be restored by passing in the desired value directly. ([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660)) +- Fix unstructuring of generic classes with stringified annotations + ([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)) ## 25.1.1 (2025-06-04) From fcbe6685444ab7393f8063bfeb99bf93d1c429a1 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Thu, 26 Jun 2025 14:14:56 +0200 Subject: [PATCH 07/11] Rename test --- tests/test_generics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index bc019f6a..840c7139 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -361,7 +361,7 @@ class GenericEntity(GenericProtocol[int]): assert converter.structure({"a": 1}, GenericEntity) == GenericEntity(1) -def test_generics_with_forward_refs(): +def test_generics_with_stringified_annotations(): """Type resolution works with stringified annotations.""" converter = Converter() dct = converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) From dafd559b8d234316919851969e14166c924786cc Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 27 Jun 2025 09:20:26 +0200 Subject: [PATCH 08/11] Add structuring operation to test --- tests/test_generics.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_generics.py b/tests/test_generics.py index 840c7139..d6218b83 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -364,5 +364,7 @@ class GenericEntity(GenericProtocol[int]): def test_generics_with_stringified_annotations(): """Type resolution works with stringified annotations.""" converter = Converter() - dct = converter.unstructure(GenericClass(42), unstructure_as=GenericClass[int]) + inst = GenericClass(42) + dct = converter.unstructure(inst, unstructure_as=GenericClass[int]) assert dct == {"t": 42} + assert converter.structure(dct, GenericClass[int]) From 7e4196b3f4c4aba9510235c19f4d99df8187a268 Mon Sep 17 00:00:00 2001 From: Adrian Sosic Date: Fri, 27 Jun 2025 09:22:54 +0200 Subject: [PATCH 09/11] Add full stop to changelog --- HISTORY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index e1460476..ea27e568 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -19,7 +19,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python and {func}`cattrs.gen.typeddicts.make_dict_structure_fn` will use the value for the `use_alias` parameter from the given converter by default now. If you're using these functions directly, the old behavior can be restored by passing in the desired value directly. ([#596](https://github.com/python-attrs/cattrs/issues/596) [#660](https://github.com/python-attrs/cattrs/pull/660)) -- Fix unstructuring of generic classes with stringified annotations +- Fix unstructuring of generic classes with stringified annotations. ([#661](https://github.com/python-attrs/cattrs/issues/661) [#662](https://github.com/python-attrs/cattrs/issues/662)) ## 25.1.1 (2025-06-04) From 967155c7926febcc6e0ec19189a35a46edaa2a56 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 27 Jun 2025 09:25:08 +0200 Subject: [PATCH 10/11] Rename forwardrefs.py to generics.py --- tests/{forwardrefs.py => generics.py} | 2 +- tests/test_generics.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename tests/{forwardrefs.py => generics.py} (77%) diff --git a/tests/forwardrefs.py b/tests/generics.py similarity index 77% rename from tests/forwardrefs.py rename to tests/generics.py index d196c3a7..b5682e02 100644 --- a/tests/forwardrefs.py +++ b/tests/generics.py @@ -1,4 +1,4 @@ -"""Class definitions using forward references.""" +"""Generic classes for testing.""" from __future__ import annotations diff --git a/tests/test_generics.py b/tests/test_generics.py index d6218b83..d24b56ce 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -11,7 +11,7 @@ from cattrs.gen._generics import generate_mapping from ._compat import Dict_origin, List_origin, is_py310_plus, is_py311_plus -from .forwardrefs import GenericClass +from .generics import GenericClass T = TypeVar("T") T2 = TypeVar("T2") From 414799b7e127db3fe3f7bbb2d97469e8177b28f8 Mon Sep 17 00:00:00 2001 From: AdrianSosic Date: Fri, 27 Jun 2025 09:46:13 +0200 Subject: [PATCH 11/11] Put everything under test_generics_649.py --- tests/generics.py | 14 -------------- tests/test_generics.py | 10 ---------- tests/test_generics_649.py | 25 +++++++++++++++++++++++++ 3 files changed, 25 insertions(+), 24 deletions(-) delete mode 100644 tests/generics.py create mode 100644 tests/test_generics_649.py diff --git a/tests/generics.py b/tests/generics.py deleted file mode 100644 index b5682e02..00000000 --- a/tests/generics.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Generic classes for testing.""" - -from __future__ import annotations - -from typing import Generic, TypeVar - -from attrs import define - -T = TypeVar("T") - - -@define -class GenericClass(Generic[T]): - t: T diff --git a/tests/test_generics.py b/tests/test_generics.py index d24b56ce..466c4134 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -11,7 +11,6 @@ from cattrs.gen._generics import generate_mapping from ._compat import Dict_origin, List_origin, is_py310_plus, is_py311_plus -from .generics import GenericClass T = TypeVar("T") T2 = TypeVar("T2") @@ -359,12 +358,3 @@ class GenericEntity(GenericProtocol[int]): assert generate_mapping(GenericEntity) == {"T": int} assert converter.structure({"a": 1}, GenericEntity) == GenericEntity(1) - - -def test_generics_with_stringified_annotations(): - """Type resolution works with stringified annotations.""" - converter = Converter() - inst = GenericClass(42) - dct = converter.unstructure(inst, unstructure_as=GenericClass[int]) - assert dct == {"t": 42} - assert converter.structure(dct, GenericClass[int]) diff --git a/tests/test_generics_649.py b/tests/test_generics_649.py new file mode 100644 index 00000000..57e829cb --- /dev/null +++ b/tests/test_generics_649.py @@ -0,0 +1,25 @@ +"""Tests for PEP 649 (Deferred Evaluation Of Annotations Using Descriptors).""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from attrs import define + +from cattrs import Converter + +T = TypeVar("T") + + +@define +class GenericClass(Generic[T]): + t: T + + +def test_generics_with_stringified_annotations(): + """Type resolution works with stringified annotations.""" + converter = Converter() + inst = GenericClass(42) + dct = converter.unstructure(inst, unstructure_as=GenericClass[int]) + assert dct == {"t": 42} + assert converter.structure(dct, GenericClass[int])