From d258db7241b317c18f1a552e75cd9088fcf24200 Mon Sep 17 00:00:00 2001 From: leeshengis Date: Wed, 21 Oct 2026 14:05:38 +0800 Subject: [PATCH] Fix tuple structuring of keyword-only fields Preserve tuple field order while using constructor aliases for keyword-only parameters. Add attrs and dataclass regression tests and include keyword-only classes in the tuple property test. Fixes #234. --- HISTORY.md | 2 + src/cattrs/converters.py | 9 ++- tests/test_structure_attrs.py | 2 +- tests/test_structure_attrs_fromtuple.py | 101 ++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/test_structure_attrs_fromtuple.py diff --git a/HISTORY.md b/HISTORY.md index a4464ea6..78bf9e73 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,8 @@ Our backwards-compatibility policy can be found [here](https://github.com/python ## NEXT +- Fix structuring attrs classes and dataclasses with keyword-only fields from tuples. + ([#234](https://github.com/python-attrs/cattrs/issues/234)) - 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..6a513c38 100644 --- a/src/cattrs/converters.py +++ b/src/cattrs/converters.py @@ -765,12 +765,17 @@ def _structure_final_factory(self, type): def structure_attrs_fromtuple(self, obj: tuple[Any, ...], cl: type[T]) -> T: """Load an attrs class from a sequence (tuple).""" conv_obj = [] # A list of converter parameters. + conv_kwargs = {} # Keyword-only constructor parameters. for a, value in zip(fields(cl), obj): # We detect the type by the metadata. converted = self._structure_attribute(a, value) - conv_obj.append(converted) + if a.kw_only: + # Dataclass fields do not have aliases. + conv_kwargs[getattr(a, "alias", a.name)] = converted + else: + conv_obj.append(converted) - return cl(*conv_obj) + return cl(*conv_obj, **conv_kwargs) def _structure_attribute(self, a: Attribute | Field, value: Any) -> Any: """Handle an individual attrs attribute.""" diff --git a/tests/test_structure_attrs.py b/tests/test_structure_attrs.py index 3de7e9f8..468173c0 100644 --- a/tests/test_structure_attrs.py +++ b/tests/test_structure_attrs.py @@ -67,7 +67,7 @@ def test_roundtrip(cl_and_vals): assert obj == loaded -@given(simple_classes(kw_only=False)) +@given(simple_classes()) def test_structure_tuple(cl_and_vals): """Test loading from a tuple, by registering the loader.""" converter = BaseConverter() diff --git a/tests/test_structure_attrs_fromtuple.py b/tests/test_structure_attrs_fromtuple.py new file mode 100644 index 00000000..50c37e51 --- /dev/null +++ b/tests/test_structure_attrs_fromtuple.py @@ -0,0 +1,101 @@ +"""Structuring attrs classes and dataclasses from tuples.""" + +from dataclasses import dataclass +from dataclasses import field as dc_field + +import pytest +from attrs import define, field + +from cattrs import UnstructureStrategy + + +@pytest.fixture +def tuple_converter(converter_cls): + return converter_cls(unstruct_strat=UnstructureStrategy.AS_TUPLE) + + +@pytest.mark.parametrize("class_decorator", [define, dataclass]) +def test_kw_only(class_decorator, tuple_converter): + """Class-wide keyword-only fields can be structured directly and dispatched.""" + + @class_decorator(kw_only=True) + class C: + a: int + b: int + + expected = C(a=1, b=2) + assert tuple_converter.structure_attrs_fromtuple(("1", "2"), C) == expected + assert tuple_converter.structure(("1", "2"), C) == expected + assert ( + tuple_converter.structure(tuple_converter.unstructure(expected), C) == expected + ) + + +@pytest.mark.parametrize( + "class_decorator, field_factory", [(define, field), (dataclass, dc_field)] +) +def test_kw_only_inheritance(class_decorator, field_factory, tuple_converter): + """Inherited and interleaved keyword-only fields retain their tuple positions.""" + + @class_decorator(kw_only=True) + class Parent: + a: int + + @class_decorator + class Child(Parent): + b: int + c: int = field_factory(kw_only=True) + d: int + + expected = Child(2, 4, a=1, c=3) + assert tuple_converter.unstructure(expected) == (1, 2, 3, 4) + assert tuple_converter.structure(("1", "2", "3", "4"), Child) == expected + + +def test_kw_only_aliases(tuple_converter): + """Use constructor aliases, including attrs' default private-field aliases.""" + + @define + class C: + positional: int = field(alias="arg") + _private: int = field(kw_only=True) + named: int = field(kw_only=True, alias="renamed") + _explicit: int = field(kw_only=True, alias="_explicit") + + expected = C(1, private=2, renamed=3, _explicit=4) + assert tuple_converter.structure(("1", "2", "3", "4"), C) == expected + assert ( + tuple_converter.structure(tuple_converter.unstructure(expected), C) == expected + ) + + +@pytest.mark.parametrize( + "class_decorator, field_factory", [(define, field), (dataclass, dc_field)] +) +@pytest.mark.parametrize("values", [(), ("1",), ("1", "2"), ("1", "2", "3")]) +def test_defaults(class_decorator, field_factory, values, tuple_converter): + """A short tuple still leaves omitted constructor arguments at their defaults.""" + + @class_decorator + class C: + a: int = 1 + b: int = field_factory(kw_only=True, default=2) + c: int = 3 + + assert tuple_converter.structure(values, C) == C() + + +@pytest.mark.parametrize("prefer_attrib_converters", [False, True]) +def test_kw_only_attrib_converters(converter_cls, prefer_attrib_converters): + """Keyword-only fields retain structuring-hook and attrs-converter precedence.""" + converter = converter_cls( + unstruct_strat=UnstructureStrategy.AS_TUPLE, + prefer_attrib_converters=prefer_attrib_converters, + ) + converter.register_structure_hook(int, lambda value, _: int(value) * 2) + + @define + class C: + a: int = field(kw_only=True, converter=lambda value: int(value) + 1) + + assert converter.structure(("3",), C).a == (4 if prefer_attrib_converters else 7)