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
1 change: 1 addition & 0 deletions changelog.d/1647.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`attrs.astuple()` and `attr.astuple()` now recurse into collections that are nested inside other collections, so *attrs* classes that are nested deeper than one collection are converted into tuples -- just like `attrs.asdict()` does.
129 changes: 102 additions & 27 deletions src/attr/_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,8 @@ def astuple(
inst: Instance of an *attrs*-decorated class.

recurse (bool):
Recurse into classes that are also *attrs*-decorated.
Recurse into classes that are also *attrs*-decorated -- including
those that are nested inside collections.

filter (~typing.Callable):
A callable whose return code determines whether an attribute or
Expand All @@ -268,6 +269,10 @@ def astuple(
If *cls* is not an *attrs* class.

.. versionadded:: 16.2.0
.. versionchanged:: 26.2.0
Collections nested inside collections are recursed into now -- just
like `attrs.asdict` does -- so *attrs* classes that are nested deeper
than one collection are serialized as tuples, too.
"""
attrs = fields(inst.__class__)
rv = []
Expand All @@ -293,16 +298,12 @@ def astuple(
elif issubclass(value_type, (tuple, list, set, frozenset)):
cf = v.__class__ if retain is True else list
items = [
(
astuple(
j,
recurse=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
if type(j) not in _ATOMIC_TYPES and has(j.__class__)
else j
_astuple_anything(
j,
is_key=False,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
for j in v
]
Expand All @@ -319,23 +320,19 @@ def astuple(
rv.append(
df(
(
(
astuple(
kk,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
if has(kk.__class__)
else kk
_astuple_anything(
kk,
is_key=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
),
(
astuple(
vv,
tuple_factory=tuple_factory,
retain_collection_types=retain,
)
if has(vv.__class__)
else vv
_astuple_anything(
vv,
is_key=False,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain,
),
)
for kk, vv in v.items()
Expand All @@ -349,6 +346,84 @@ def astuple(
return rv if tuple_factory is list else tuple_factory(rv)


def _astuple_anything(
val,
is_key,
filter,
tuple_factory,
retain_collection_types,
):
"""
``astuple`` only works on attrs instances, this works on anything.
"""
val_type = type(val)
if val_type in _ATOMIC_TYPES:
rv = val
elif has(val_type):
rv = astuple(
val,
recurse=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain_collection_types,
)
elif issubclass(val_type, (tuple, list, set, frozenset)):
if retain_collection_types is True:
cf = val.__class__
elif is_key:
cf = tuple
else:
cf = list

items = [
_astuple_anything(
i,
is_key=False,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain_collection_types,
)
for i in val
]
try:
rv = cf(items)
except TypeError:
if not issubclass(cf, tuple):
raise
# Workaround for TypeError: cf.__new__() missing 1 required
# positional argument (which appears, for a namedtuple)
rv = cf(*items)
elif issubclass(val_type, dict):
df = val.__class__ if retain_collection_types is True else dict
mapping = {
_astuple_anything(
kk,
is_key=True,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain_collection_types,
): _astuple_anything(
vv,
is_key=False,
filter=filter,
tuple_factory=tuple_factory,
retain_collection_types=retain_collection_types,
)
for kk, vv in val.items()
}
try:
rv = df(mapping)
except TypeError:
# Not every mapping can be built from a mapping object, e.g.
# `defaultdict`. Fall back to a plain dict, just like `asdict`
# does.
rv = mapping
else:
rv = val

return rv


def has(cls):
"""
Check whether *cls* is a class with *attrs* attributes.
Expand Down
123 changes: 122 additions & 1 deletion tests/test_funcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import re

from collections import OrderedDict
from collections import OrderedDict, defaultdict
from typing import Generic, NamedTuple, TypeVar

import pytest
Expand Down Expand Up @@ -409,6 +409,127 @@ def test_dicts(self, C, tuple_factory):
assert tuple_factory([1, {"a": tuple_factory([4, 5])}]) == res
assert isinstance(res, tuple_factory)

def test_nested_lists(self, C):
"""
Test unstructuring deeply nested lists.
"""
inner = C(1, 2)
outer = C([[inner]], None)

assert ([[(1, 2)]], None) == astuple(outer)

def test_nested_lists_retain_type(self, C):
"""
Deeply nested lists and tuples keep their types if
retain_collection_types is True.
"""
inner = C(1, 2)
outer = C([[inner], (inner,)], None)

assert ([[(1, 2)], ((1, 2),)], None) == astuple(
outer, retain_collection_types=True
)

def test_nested_dicts(self, C):
"""
Test unstructuring deeply nested dictionaries.
"""
inner = C(1, 2)
outer = C({1: {2: inner}, "l": [inner]}, None)

assert ({1: {2: (1, 2)}, "l": [(1, 2)]}, None) == astuple(outer)

def test_nested_dicts_retain_type(self, C):
"""
Deeply nested mappings keep their types if
retain_collection_types is True.
"""
inner = C(1, 2)
outer = C(OrderedDict({1: OrderedDict({2: inner})}), None)

assert (OrderedDict({1: OrderedDict({2: (1, 2)})}), None) == astuple(
outer, retain_collection_types=True
)

def test_nested_defaultdict(self, C):
"""
Nested mappings that can't be built from key-value pairs are
converted into dicts instead of raising a TypeError.
"""
inner = C(1, 2)
outer = C([defaultdict(list, {1: inner})], None)

assert ([{1: (1, 2)}], None) == astuple(
outer, retain_collection_types=True
)

def test_nested_named_tuple_retain_type(self, C):
"""
Namedtuples nested in collections can be serialized if
retain_collection_types is True.

See #1164
"""

class Pair(NamedTuple):
inner: object
n: int

inner = C(1, 2)
outer = C([Pair(inner, 3)], None)

assert ([(Pair((1, 2), 3))], None) == astuple(
outer, retain_collection_types=True
)

def test_nested_non_atomic_types(self, C):
"""
Non-atomic types nested in collections are left alone.
"""

class Int(int):
pass

i = Int(1)
outer = C([i, [i]], None)

result = astuple(outer)

assert ([i, [i]], None) == result
assert type(result[0][0]) is Int
assert type(result[0][1][0]) is Int

def test_nested_tuple_keys(self, C):
"""
Containers nested in keys of nested mappings are converted into
tuples to stay hashable.
"""
inner = C(1, 2)
outer = C({1: {(2, 3): inner}}, None)

assert ({1: {(2, 3): (1, 2)}}, None) == astuple(outer)

def test_nested_type_error_with_retain_type(self, C):
"""
Serialization that fails with TypeError leaves the error through if
they're not tuples, even when nested in another collection.

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])

outer = C([Coordinates(50.419019, 30.516225)], None)

with pytest.raises(TypeError, match=re.escape(message)):
astuple(outer, retain_collection_types=True)

@given(container=st.sampled_from(SEQUENCE_TYPES))
def test_lists_tuples_retain_type(self, container, C):
"""
Expand Down
Loading