diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..782b66ac 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,7 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Fix converter type overrides being ignored for dataclass fields with stringified annotations. - Dict unstructuring hook factories now inherit the converter's `omit_if_default` setting when `_cattrs_omit_if_default` is omitted. Explicit `True` or `False` values and per-attribute overrides retain their precedence. ([#238](https://github.com/python-attrs/cattrs/issues/238)) - Support the [`frozendict`](https://docs.python.org/3.15/builtins/stdtypes.html#frozendict) built-in on Python 3.15+. diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py index b537deac..6cbe9a78 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -32,6 +32,7 @@ Sequence, Set, TypeAlias, + adapted_fields, fields, frozendict, get_final_base, @@ -44,7 +45,6 @@ is_counter, is_deque, is_frozenset, - is_generic, is_generic_attrs, is_hetero_tuple, is_literal, @@ -1321,10 +1321,7 @@ def gen_unstructure_attrs_fromdict( self, cl: type[T] ) -> Callable[[T], dict[str, Any]]: origin = get_origin(cl) - 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(origin or cl) + attribs = adapted_fields(origin or cl) attrib_overrides = { a.name: self.type_overrides[a.type] for a in attribs @@ -1367,10 +1364,7 @@ def gen_structure_attrs_fromdict( self, cl: type[T] ) -> Callable[[Mapping[str, Any], Any], T]: origin = get_origin(cl) - attribs = fields(origin or cl if is_generic(cl) else cl) - if attrs_has(cl) and any(isinstance(a.type, str) for a in attribs): - # PEP 563 annotations - need to be resolved. - resolve_types(origin or cl) + attribs = adapted_fields(origin or cl) attrib_overrides = { a.name: self.type_overrides[a.type] for a in attribs diff --git a/tests/test_converter.py b/tests/test_converter.py index ab86d55e..d4e04054 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -1,6 +1,7 @@ """Test both structuring and unstructuring.""" from collections import deque +from dataclasses import make_dataclass from typing import ( Annotated, Any, @@ -431,6 +432,31 @@ def test_type_overrides(cl_and_vals): assert attr.name not in unstructured +@pytest.mark.parametrize("annotation", [int, "int"]) +def test_type_overrides_dataclass_omit_default(annotation): + """Type overrides use resolved dataclass field types.""" + cl = make_dataclass("Settings", [("retries", annotation, 3), ("label", str, "app")]) + converter = Converter(type_overrides={int: override(omit_if_default=True)}) + + assert converter.unstructure(cl()) == {"label": "app"} + assert converter.unstructure(cl(5)) == {"retries": 5, "label": "app"} + + +@pytest.mark.parametrize("annotation", [int, "int"]) +@pytest.mark.parametrize("detailed_validation", [True, False]) +def test_type_overrides_dataclass_rename(annotation, detailed_validation): + """Renamed dataclass fields work in both directions without prior unstructuring.""" + cl = make_dataclass("Settings", [("retries", annotation), ("label", str)]) + converter = Converter( + type_overrides={int: override(rename="attempts")}, + detailed_validation=detailed_validation, + ) + + inst = converter.structure({"attempts": "5", "label": "app"}, cl) + assert inst == cl(5, "app") + assert converter.unstructure(inst) == {"attempts": 5, "label": "app"} + + def test_calling_back(): """ Calling unstructure_attrs_asdict from a hook should not override a manual hook.