Skip to content

bpo-34363: dataclasses.asdict() and .astuple() now handle fields which are namedtuples. #9151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
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
40 changes: 38 additions & 2 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,11 +1026,36 @@ def _asdict_inner(obj, dict_factory):
value = _asdict_inner(getattr(obj, f.name), dict_factory)
result.append((f.name, value))
return dict_factory(result)
elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
# obj is a namedtuple. Recurse into it, but the returned
# object is another namedtuple of the same type. This is
# similar to how other list- or tuple-derived classes are
# treated (see below), but we just need to create them
# differently because a namedtuple's __init__ needs to be
# called differently (see bpo-34363).

# I'm not using namedtuple's _asdict()
# method, because:
# - it does not recurse in to the namedtuple fields and
# convert them to dicts (using dict_factory).
# - I don't actually want to return a dict here. The the main
# use case here is json.dumps, and it handles converting
# namedtuples to lists. Admittedly we're losing some
# information here when we produce a json list instead of a
# dict. Note that if we returned dicts here instead of
# namedtuples, we could no longer call asdict() on a data
# structure where a namedtuple was used as a dict key.

return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
elif isinstance(obj, (list, tuple)):
# Assume we can create an object of this type by passing in a
# generator (which is not true for namedtuples, handled
# above).
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
elif isinstance(obj, dict):
return type(obj)((_asdict_inner(k, dict_factory), _asdict_inner(v, dict_factory))
for k, v in obj.items())
return type(obj)((_asdict_inner(k, dict_factory),
_asdict_inner(v, dict_factory))
for k, v in obj.items())
else:
return copy.deepcopy(obj)

Expand Down Expand Up @@ -1066,7 +1091,18 @@ def _astuple_inner(obj, tuple_factory):
value = _astuple_inner(getattr(obj, f.name), tuple_factory)
result.append(value)
return tuple_factory(result)
elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
# obj is a namedtuple. Recurse into it, but the returned
# object is another namedtuple of the same type. This is
# similar to how other list- or tuple-derived classes are
# treated (see below), but we just need to create them
# differently because a namedtuple's __init__ needs to be
# called differently (see bpo-34363).
return type(obj)(*[_astuple_inner(v, tuple_factory) for v in obj])
elif isinstance(obj, (list, tuple)):
# Assume we can create an object of this type by passing in a
# generator (which is not true for namedtuples, handled
# above).
return type(obj)(_astuple_inner(v, tuple_factory) for v in obj)
elif isinstance(obj, dict):
return type(obj)((_astuple_inner(k, tuple_factory), _astuple_inner(v, tuple_factory))
Expand Down
79 changes: 79 additions & 0 deletions Lib/test/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,70 @@ class C:
self.assertEqual(d, OrderedDict([('x', 42), ('y', 2)]))
self.assertIs(type(d), OrderedDict)

def test_helper_asdict_namedtuple(self):
T = namedtuple('T', 'a b c')
@dataclass
class C:
x: str
y: T
c = C('outer', T(1, C('inner', T(11, 12, 13)), 2))

d = asdict(c)
self.assertEqual(d, {'x': 'outer',
'y': T(1,
{'x': 'inner',
'y': T(11, 12, 13)},
2),
}
)

# Now with a dict_factory. OrderedDict is convenient, but
# since it compares to dicts, we also need to have separate
# assertIs tests.
d = asdict(c, dict_factory=OrderedDict)
self.assertEqual(d, {'x': 'outer',
'y': T(1,
{'x': 'inner',
'y': T(11, 12, 13)},
2),
}
)

# Make sure that the returned dicts are actuall OrderedDicts.
self.assertIs(type(d), OrderedDict)
self.assertIs(type(d['y'][1]), OrderedDict)

def test_helper_asdict_namedtuple_key(self):
# Ensure that a field that contains a dict which has a
# namedtuple as a key works with asdict().

@dataclass
class C:
f: dict
T = namedtuple('T', 'a')

c = C({T('an a'): 0})

self.assertEqual(asdict(c), {'f': {T(a='an a'): 0}})

def test_helper_asdict_namedtuple_derived(self):
class T(namedtuple('Tbase', 'a')):
def my_a(self):
return self.a

@dataclass
class C:
f: T

t = T(6)
c = C(t)

d = asdict(c)
self.assertEqual(d, {'f': T(a=6)})
# Make sure that t has been copied, not used directly.
self.assertIsNot(d['f'], t)
self.assertEqual(d['f'].my_a(), 6)

def test_helper_astuple(self):
# Basic tests for astuple(), it should return a new tuple.
@dataclass
Expand Down Expand Up @@ -1541,6 +1605,21 @@ def nt(lst):
self.assertEqual(t, NT(42, 2))
self.assertIs(type(t), NT)

def test_helper_astuple_namedtuple(self):
T = namedtuple('T', 'a b c')
@dataclass
class C:
x: str
y: T
c = C('outer', T(1, C('inner', T(11, 12, 13)), 2))

t = astuple(c)
self.assertEqual(t, ('outer', T(1, ('inner', (11, 12, 13)), 2)))

# Now, using a tuple_factory. list is convenient here.
t = astuple(c, tuple_factory=list)
self.assertEqual(t, ['outer', T(1, ['inner', T(11, 12, 13)], 2)])

def test_dynamic_class_creation(self):
cls_dict = {'__annotations__': {'x': int, 'y': int},
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dataclasses.asdict() and .astuple() now handle namedtuples correctly.