From 8d7781f0ba398ad8d37123d3c6d7a8728e8fa115 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 09:34:25 +0200 Subject: [PATCH 1/7] Allow named tuples to be merged and extended --- src/typing.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/typing.py b/src/typing.py index 1d7698fb..df7a7568 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2113,21 +2113,26 @@ def __new__(cls, typename, bases, ns): if not _PY36: raise TypeError("Class syntax for NamedTuple is only supported" " in Python 3.6+") - types = ns.get('__annotations__', {}) + types = {} + defaults_ns = {} + # merge field types and defaults in reversed order (matches how MRO works) + for base in reversed(bases): + if isinstance(base, NamedTupleMeta): + types.update(getattr(base, '__annotations__', {})) + defaults_ns.update(base.__dict__) + types.update(ns.get('__annotations__', {})) + defaults_ns.update(ns) nm_tpl = _make_nmtuple(typename, types.items()) - defaults = [] defaults_dict = {} for field_name in types: - if field_name in ns: - default_value = ns[field_name] - defaults.append(default_value) - defaults_dict[field_name] = default_value - elif defaults: + if field_name in defaults_ns: + defaults_dict[field_name] = defaults_ns[field_name] + elif defaults_dict: raise TypeError("Non-default namedtuple field {field_name} cannot " "follow default field(s) {default_names}" .format(field_name=field_name, default_names=', '.join(defaults_dict.keys()))) - nm_tpl.__new__.__defaults__ = tuple(defaults) + nm_tpl.__new__.__defaults__ = tuple(defaults_dict.values()) nm_tpl._field_defaults = defaults_dict # update from user namespace without overriding special namedtuple attributes for key in ns: From 365a7b199c78459c73f0892cb98163a8dc2a69d2 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 10:39:59 +0200 Subject: [PATCH 2/7] Add tests, explicitly use OrderedDict --- src/test_typing.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ src/typing.py | 4 +-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 5c1cc56d..872542ce 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1604,6 +1604,28 @@ def __str__(self): return f'{self.x} -> {self.y}' def __add__(self, other): return 0 + +class Base1(NamedTuple): + x: int + y: int + def method1(self): + return self.x + self.y + +class Base2(NamedTuple): + z: int = 0 + def method2(self): + return self.z + +class Derived1(Base1): + '''Named tuples can have doctrings.''' + label: str + +class Derived2(Base2, Base1): + pass + +class TwoDefaults(NamedTuple): + x: int = 0 + other: str = '' """ if PY36: @@ -2278,6 +2300,14 @@ def test_annotation_usage_with_default(self): self.assertEqual(CoolEmployeeWithDefault._fields, ('name', 'cool')) self.assertEqual(CoolEmployeeWithDefault._field_types, dict(name=str, cool=int)) self.assertEqual(CoolEmployeeWithDefault._field_defaults, dict(cool=0)) + self.assertEqual(TwoDefaults.__new__.__defaults__, (0, '')) + self.assertEqual(TwoDefaults().x, 0) + self.assertEqual(TwoDefaults().other, '') + self.assertEqual(TwoDefaults()[0], 0) + self.assertEqual(TwoDefaults()[1], '') + self.assertEqual(Derived2._fields, ('x', 'y', 'z')) + self.assertEqual(Derived2._field_types, dict(x=int, y=int, z=int)) + self.assertEqual(Derived2._field_defaults, dict(z=0)) with self.assertRaises(TypeError): exec(""" @@ -2285,6 +2315,16 @@ class NonDefaultAfterDefault(NamedTuple): x: int = 3 y: int """) + with self.assertRaises(TypeError): + exec(""" +class BadMerged(Base1, Base2): + pass +""") + with self.assertRaises(TypeError): + exec(""" +class BadExtended(Base2): + label: str +""") @skipUnless(PY36, 'Python 3.6 required') def test_annotation_usage_with_methods(self): @@ -2292,6 +2332,9 @@ def test_annotation_usage_with_methods(self): self.assertEqual(XMeth(42).x, XMeth(42)[0]) self.assertEqual(str(XRepr(42)), '42 -> 1') self.assertEqual(XRepr(1, 2) + XRepr(3), 0) + self.assertEqual(Derived1(1, 2, 'test').meth1(), 3) + self.assertEqual(Derived2(3, 4).meth1(), 7) + self.assertEqual(Derived2(3, 4, 5).meth2(), 5) with self.assertRaises(AttributeError): exec(""" @@ -2309,6 +2352,42 @@ def _source(self): return 'no chance for this as well' """) + @skipUnless(PY36, 'Python 3.6 required') + def test_namedtuple_extending(self): + example = Derived1(1, 2, 'test') + with self.assertRaises(TypeError): + Derived1('test') + self.assertIsInstance(example, Derived1) + self.assertIsInstance(example, tuple) + self.assertEqual(example.x, 1) + self.assertEqual(example.y, 2) + self.assertEqual(example.label, 'test') + self.assertEqual(Derived1.__name__, 'Derived1') + self.assertEqual(Derived1._fields, ('x', 'y', 'label')) + self.assertEqual(Derived1.__annotations__, + collections.OrderedDict(x=int, y=int, label=str)) + self.assertIs(Derived1._field_types, Derived1.__annotations__) + + @skipUnless(PY36, 'Python 3.6 required') + def test_namedtuple_merging(self): + example = Derived2(1, 2) + example2 = Derived2(1, 2, 3) + with self.assertRaises(TypeError): + Derived2(1) + with self.assertRaises(TypeError): + Derived2(1, 2, 3, 4) + self.assertIsInstance(example, Derived2) + self.assertIsInstance(example, tuple) + self.assertEqual(example.x, 1) + self.assertEqual(example.y, 2) + self.assertEqual(example.z, 0) + self.assertEqual(example2.z, 3) + self.assertEqual(Derived2.__name__, 'Derived2') + self.assertEqual(Derived2._fields, ('x', 'y', 'z')) + self.assertEqual(Derived2.__annotations__, + collections.OrderedDict(x=int, y=int, z=int)) + self.assertIs(Derived2._field_types, Derived2.__annotations__) + @skipUnless(PY36, 'Python 3.6 required') def test_namedtuple_keyword_usage(self): LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) diff --git a/src/typing.py b/src/typing.py index df7a7568..a95d3739 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2113,7 +2113,7 @@ def __new__(cls, typename, bases, ns): if not _PY36: raise TypeError("Class syntax for NamedTuple is only supported" " in Python 3.6+") - types = {} + types = collections.OrderedDict() defaults_ns = {} # merge field types and defaults in reversed order (matches how MRO works) for base in reversed(bases): @@ -2123,7 +2123,7 @@ def __new__(cls, typename, bases, ns): types.update(ns.get('__annotations__', {})) defaults_ns.update(ns) nm_tpl = _make_nmtuple(typename, types.items()) - defaults_dict = {} + defaults_dict = collections.OrderedDict() for field_name in types: if field_name in defaults_ns: defaults_dict[field_name] = defaults_ns[field_name] From 8ef1fc09aee80ba92ecea25c67267c16287cc3b3 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 10:47:49 +0200 Subject: [PATCH 3/7] Fix lint and tests --- src/test_typing.py | 2 +- src/typing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 872542ce..077108b1 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1634,7 +1634,7 @@ class TwoDefaults(NamedTuple): # fake names for the sake of static analysis ann_module = ann_module2 = ann_module3 = None A = B = CSub = G = CoolEmployee = CoolEmployeeWithDefault = object - XMeth = XRepr = NoneAndForward = object + XMeth = XRepr = NoneAndForward = Derived1 = Derived2 = TwoDefaults = object gth = get_type_hints diff --git a/src/typing.py b/src/typing.py index a95d3739..8d9836bb 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2117,7 +2117,7 @@ def __new__(cls, typename, bases, ns): defaults_ns = {} # merge field types and defaults in reversed order (matches how MRO works) for base in reversed(bases): - if isinstance(base, NamedTupleMeta): + if hasattr(base, '_field_types'): # New style named tuple types.update(getattr(base, '__annotations__', {})) defaults_ns.update(base.__dict__) types.update(ns.get('__annotations__', {})) From bdd11e7cc33404f2d0bd112f3458617f0cab0ce8 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 10:57:05 +0200 Subject: [PATCH 4/7] Another fix --- src/test_typing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 077108b1..6d00d157 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1616,11 +1616,11 @@ class Base2(NamedTuple): def method2(self): return self.z -class Derived1(Base1): +class Derived1(Base1, NamedTuple): '''Named tuples can have doctrings.''' label: str -class Derived2(Base2, Base1): +class Derived2(Base2, Base1, NamedTuple): pass class TwoDefaults(NamedTuple): @@ -2317,12 +2317,12 @@ class NonDefaultAfterDefault(NamedTuple): """) with self.assertRaises(TypeError): exec(""" -class BadMerged(Base1, Base2): +class BadMerged(Base1, Base2, NamedTuple): pass """) with self.assertRaises(TypeError): exec(""" -class BadExtended(Base2): +class BadExtended(Base2, NamedTuple): label: str """) From a4220c53abe742700b834e48b9a439a59b9bd89e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 11:08:48 +0200 Subject: [PATCH 5/7] Fix logic with defaults --- src/typing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typing.py b/src/typing.py index 8d9836bb..7ef0ee8e 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2118,8 +2118,8 @@ def __new__(cls, typename, bases, ns): # merge field types and defaults in reversed order (matches how MRO works) for base in reversed(bases): if hasattr(base, '_field_types'): # New style named tuple - types.update(getattr(base, '__annotations__', {})) - defaults_ns.update(base.__dict__) + types.update(base._field_types) + defaults_ns.update(base._field_defaults) types.update(ns.get('__annotations__', {})) defaults_ns.update(ns) nm_tpl = _make_nmtuple(typename, types.items()) From 0ea6a75037d0a799c8265d09f92430c490a6f19e Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 11:27:38 +0200 Subject: [PATCH 6/7] Fix method names --- src/test_typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 6d00d157..092458bd 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -2332,9 +2332,9 @@ def test_annotation_usage_with_methods(self): self.assertEqual(XMeth(42).x, XMeth(42)[0]) self.assertEqual(str(XRepr(42)), '42 -> 1') self.assertEqual(XRepr(1, 2) + XRepr(3), 0) - self.assertEqual(Derived1(1, 2, 'test').meth1(), 3) - self.assertEqual(Derived2(3, 4).meth1(), 7) - self.assertEqual(Derived2(3, 4, 5).meth2(), 5) + self.assertEqual(Derived1(1, 2, 'test').method1(), 3) + self.assertEqual(Derived2(3, 4).method1(), 7) + self.assertEqual(Derived2(3, 4, 5).method2(), 5) with self.assertRaises(AttributeError): exec(""" From a0dfc1e64b184ef624b633ef93260bcfd7f86a5b Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Fri, 9 Jun 2017 11:42:15 +0200 Subject: [PATCH 7/7] Abandon the idea of inheriting methods --- src/test_typing.py | 12 ++++-------- src/typing.py | 6 +++--- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 092458bd..0324c3af 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1608,20 +1608,17 @@ def __add__(self, other): class Base1(NamedTuple): x: int y: int - def method1(self): - return self.x + self.y class Base2(NamedTuple): z: int = 0 - def method2(self): - return self.z class Derived1(Base1, NamedTuple): '''Named tuples can have doctrings.''' label: str class Derived2(Base2, Base1, NamedTuple): - pass + def method(self): + return self.x + self.y class TwoDefaults(NamedTuple): x: int = 0 @@ -2332,9 +2329,8 @@ def test_annotation_usage_with_methods(self): self.assertEqual(XMeth(42).x, XMeth(42)[0]) self.assertEqual(str(XRepr(42)), '42 -> 1') self.assertEqual(XRepr(1, 2) + XRepr(3), 0) - self.assertEqual(Derived1(1, 2, 'test').method1(), 3) - self.assertEqual(Derived2(3, 4).method1(), 7) - self.assertEqual(Derived2(3, 4, 5).method2(), 5) + self.assertEqual(Derived2(1, 2).method(), 3) + self.assertEqual(Derived2(3, 4, 5).method(), 7) with self.assertRaises(AttributeError): exec(""" diff --git a/src/typing.py b/src/typing.py index 7ef0ee8e..968a1c3a 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2115,9 +2115,9 @@ def __new__(cls, typename, bases, ns): " in Python 3.6+") types = collections.OrderedDict() defaults_ns = {} - # merge field types and defaults in reversed order (matches how MRO works) + # Merge field types and defaults in reverse order (similar to how MRO works). for base in reversed(bases): - if hasattr(base, '_field_types'): # New style named tuple + if hasattr(base, '_field_types'): # new style named tuple types.update(base._field_types) defaults_ns.update(base._field_defaults) types.update(ns.get('__annotations__', {})) @@ -2134,7 +2134,7 @@ def __new__(cls, typename, bases, ns): default_names=', '.join(defaults_dict.keys()))) nm_tpl.__new__.__defaults__ = tuple(defaults_dict.values()) nm_tpl._field_defaults = defaults_dict - # update from user namespace without overriding special namedtuple attributes + # Update from user namespace without overriding special namedtuple attributes. for key in ns: if key in _prohibited: raise AttributeError("Cannot overwrite NamedTuple attribute " + key)