diff --git a/changelog.d/1165.change.md b/changelog.d/1165.change.md new file mode 100644 index 000000000..fb99d3a98 --- /dev/null +++ b/changelog.d/1165.change.md @@ -0,0 +1 @@ +Fixed serialization of namedtuple fields using `attrs.asdict/astuple()` with `retain_collection_types=True`. diff --git a/src/attr/_funcs.py b/src/attr/_funcs.py index 7f5d9610f..22609b1d0 100644 --- a/src/attr/_funcs.py +++ b/src/attr/_funcs.py @@ -72,19 +72,25 @@ def asdict( ) elif isinstance(v, (tuple, list, set, frozenset)): cf = v.__class__ if retain_collection_types is True else list - rv[a.name] = cf( - [ - _asdict_anything( - i, - is_key=False, - filter=filter, - dict_factory=dict_factory, - retain_collection_types=retain_collection_types, - value_serializer=value_serializer, - ) - for i in v - ] - ) + items = [ + _asdict_anything( + i, + is_key=False, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ) + for i in v + ] + try: + rv[a.name] = cf(items) + except TypeError: + if not issubclass(cf, tuple): + raise + # Workaround for TypeError: cf.__new__() missing 1 required + # positional argument (which appears, for a namedturle) + rv[a.name] = cf(*items) elif isinstance(v, dict): df = dict_factory rv[a.name] = df( @@ -241,22 +247,26 @@ def astuple( ) elif isinstance(v, (tuple, list, set, frozenset)): cf = v.__class__ if retain is True else list - rv.append( - cf( - [ - astuple( - j, - recurse=True, - filter=filter, - tuple_factory=tuple_factory, - retain_collection_types=retain, - ) - if has(j.__class__) - else j - for j in v - ] + items = [ + astuple( + j, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, ) - ) + if has(j.__class__) + else j + for j in v + ] + try: + rv.append(cf(items)) + except TypeError: + if not issubclass(cf, tuple): + raise + # Workaround for TypeError: cf.__new__() missing 1 required + # positional argument (which appears, for a namedturle) + rv.append(cf(*items)) elif isinstance(v, dict): df = v.__class__ if retain is True else dict rv.append( diff --git a/tests/test_funcs.py b/tests/test_funcs.py index 82f0f64d5..044aaab2c 100644 --- a/tests/test_funcs.py +++ b/tests/test_funcs.py @@ -4,9 +4,10 @@ Tests for `attr._funcs`. """ +import re from collections import OrderedDict -from typing import Generic, TypeVar +from typing import Generic, NamedTuple, TypeVar import pytest @@ -232,6 +233,52 @@ class A: assert {"a": {(1,): 1}} == attr.asdict(instance) + def test_named_tuple_retain_type(self): + """ + Namedtuples can be serialized if retain_collection_types is True. + + See #1164 + """ + + class Coordinates(NamedTuple): + lat: float + lon: float + + @attr.s + class A: + coords: Coordinates = attr.ib() + + instance = A(Coordinates(50.419019, 30.516225)) + + assert {"coords": Coordinates(50.419019, 30.516225)} == attr.asdict( + instance, retain_collection_types=True + ) + + def test_type_error_with_retain_type(self): + """ + Serialization that fails with TypeError leaves the error through if + they're not tuples. + + See #1164 + """ + + message = "__new__() missing 1 required positional argument (asdict)" + + class Coordinates(list): + def __init__(self, first, *rest): + if isinstance(first, list): + raise TypeError(message) + super().__init__([first, *rest]) + + @attr.s + class A: + coords: Coordinates = attr.ib() + + instance = A(Coordinates(50.419019, 30.516225)) + + with pytest.raises(TypeError, match=re.escape(message)): + attr.asdict(instance, retain_collection_types=True) + class TestAsTuple: """ @@ -390,6 +437,52 @@ def test_sets_no_retain(self, C, set_type): assert (1, [1, 2, 3]) == d + def test_named_tuple_retain_type(self): + """ + Namedtuples can be serialized if retain_collection_types is True. + + See #1164 + """ + + class Coordinates(NamedTuple): + lat: float + lon: float + + @attr.s + class A: + coords: Coordinates = attr.ib() + + instance = A(Coordinates(50.419019, 30.516225)) + + assert (Coordinates(50.419019, 30.516225),) == attr.astuple( + instance, retain_collection_types=True + ) + + def test_type_error_with_retain_type(self): + """ + Serialization that fails with TypeError leaves the error through if + they're not tuples. + + See #1164 + """ + + message = "__new__() missing 1 required positional argument (astuple)" + + class Coordinates(list): + def __init__(self, first, *rest): + if isinstance(first, list): + raise TypeError(message) + super().__init__([first, *rest]) + + @attr.s + class A: + coords: Coordinates = attr.ib() + + instance = A(Coordinates(50.419019, 30.516225)) + + with pytest.raises(TypeError, match=re.escape(message)): + attr.astuple(instance, retain_collection_types=True) + class TestHas: """