Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
- The msgspec converter now applies the same unstructuring hook to nested instances of recursive _attrs_ classes, so registered hooks and private attributes are no longer skipped below the top level.
([#803](https://github.com/python-attrs/cattrs/pull/803))
- 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.
Expand Down
9 changes: 7 additions & 2 deletions src/cattrs/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -786,12 +786,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."""
Expand Down
2 changes: 1 addition & 1 deletion tests/test_structure_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions tests/test_structure_attrs_fromtuple.py
Original file line number Diff line number Diff line change
@@ -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)