From 9b9d97dd139a799d28ff8bc90d118b1cac190b03 Mon Sep 17 00:00:00 2001 From: "Eric V. Smith" Date: Fri, 14 Sep 2018 11:32:16 -0400 Subject: [PATCH] bpo-34363: dataclasses.asdict() and .astuple() now handle fields which are namedtuples. (GH-9151) --- Lib/dataclasses.py | 40 +++++++++- Lib/test/test_dataclasses.py | 79 +++++++++++++++++++ .../2018-09-10-21-09-34.bpo-34363.YuSb0T.rst | 1 + 3 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2018-09-10-21-09-34.bpo-34363.YuSb0T.rst diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index a43d07693ac..28e9f75127b 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -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) @@ -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)) diff --git a/Lib/test/test_dataclasses.py b/Lib/test/test_dataclasses.py index 4c93513956a..6efe785bc32 100755 --- a/Lib/test/test_dataclasses.py +++ b/Lib/test/test_dataclasses.py @@ -1429,6 +1429,70 @@ class TestCase(unittest.TestCase): 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 @@ -1541,6 +1605,21 @@ class TestCase(unittest.TestCase): 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}, } diff --git a/Misc/NEWS.d/next/Library/2018-09-10-21-09-34.bpo-34363.YuSb0T.rst b/Misc/NEWS.d/next/Library/2018-09-10-21-09-34.bpo-34363.YuSb0T.rst new file mode 100644 index 00000000000..5691efb1e27 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2018-09-10-21-09-34.bpo-34363.YuSb0T.rst @@ -0,0 +1 @@ +dataclasses.asdict() and .astuple() now handle namedtuples correctly.