From 9395206e81e012c88c7abcd83c6b48d77f6771bc Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 6 Nov 2016 00:24:19 +0100 Subject: [PATCH 1/6] Some first thoughts --- src/typing.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/typing.py b/src/typing.py index 5303d405..cd20dd75 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2001,6 +2001,20 @@ def NamedTuple(typename, fields): return _make_nmtuple(typename, fields) +class TypedDictMeta(type): + def __new__(cls, typename, bases, ns): + if ns.get('_root') is True: + del ns['_root'] + return super().__new__(cls, typename, bases, ns) + return make_typed_dict + + +class TypedDict(metaclass=TypedDictMeta): + _root = True + def __new__(cls, *args, **kwargs): + return dict(*args, **kwargs) + + def NewType(name, tp): """NewType creates simple unique types with almost zero runtime overhead. NewType(name, tp) is considered a subtype of tp From 2c41219e74c70170060b8508e0e7d4681c45c697 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 6 Nov 2016 16:49:20 +0100 Subject: [PATCH 2/6] Actual implementation + tests --- src/test_typing.py | 64 +++++++++++++++++++++++++++++++++++++++++++++- src/typing.py | 50 +++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/test_typing.py b/src/test_typing.py index 7a5b415b..619a79fa 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -18,7 +18,7 @@ from typing import no_type_check, no_type_check_decorator from typing import Type from typing import NewType -from typing import NamedTuple +from typing import NamedTuple, TypedDict from typing import IO, TextIO, BinaryIO from typing import Pattern, Match import abc @@ -1309,6 +1309,14 @@ class G(Generic[T]): class CoolEmployee(NamedTuple): name: str cool: int + +Label = TypedDict('Label', [('label', str)]) + +class Point2D(TypedDict): + x: int + y: int + +class LabelPoint2D(Point2D, Label): ... """ if PY36: @@ -1805,6 +1813,60 @@ class D(UserName): pass +class TypedDictTests(BaseTestCase): + + def test_basics_fields_syntax(self): + Emp = TypedDict('Emp', [('name', str), ('id', int)]) + self.assertIsSubclass(Emp, dict) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(jim, Emp) + self.assertIsInstance(jim, dict) + self.assertEqual(jim['name'], 'Jim') + self.assertEqual(jim['id'], 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__bases__, (dict,)) + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + + def test_basics_keywords_syntax(self): + Emp = TypedDict('Emp', name=str, id=int) + self.assertIsSubclass(Emp, dict) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(jim, Emp) + self.assertIsInstance(jim, dict) + self.assertEqual(jim['name'], 'Jim') + self.assertEqual(jim['id'], 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__bases__, (dict,)) + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + + def test_typeddict_errors(self): + with self.assertRaises(TypeError): + TypedDict('Hi', x=1) + with self.assertRaises(TypeError): + TypedDict('Hi', [('x', int), ('y', 1)]) + with self.assertRaises(TypeError): + TypedDict('Hi', [('x', int)], y=int) + + @skipUnless(PY36, 'Python 3.6 required') + def test_class_syntax_usage(self): + self.assertEqual(LabelPoint2D.__annotations__, {'x': int, 'y': int, 'label': str}) + self.assertEqual(LabelPoint2D.__bases__, (dict,)) + not_origin = Point2D(x=0, y=1) + self.assertEqual(not_origin['x'], 0) + self.assertEqual(not_origin['y'], 1) + other = LabelPoint2D(x=0, y=1, label='hi') + self.assertEqual(not_origin['label'], 'hi') + + def test_pickle(self): + global EmpD # pickle wants to reference the class by name + EmpD = TypedDict('EmpD', name=str, id=int) + jane = EmpD({'name': 'jane', 'id': 37}) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(jane, proto) + jane2 = pickle.loads(z) + self.assertEqual(jane2, jane) + + class NamedTupleTests(BaseTestCase): def test_basics(self): diff --git a/src/typing.py b/src/typing.py index cd20dd75..96015504 100644 --- a/src/typing.py +++ b/src/typing.py @@ -67,6 +67,7 @@ 'Set', 'FrozenSet', 'NamedTuple', # Not really a type. + 'TypedDict', 'Generator', # One-off things. @@ -2002,17 +2003,52 @@ def NamedTuple(typename, fields): class TypedDictMeta(type): - def __new__(cls, typename, bases, ns): - if ns.get('_root') is True: - del ns['_root'] - return super().__new__(cls, typename, bases, ns) - return make_typed_dict + + def __new__(cls, name, bases, ns): + if ns.get('_root', False): + return super().__new__(cls, name, bases, ns) + tp_dict = super().__new__(cls, name, (dict,), ns) + try: + tp_dict.__module__ = sys._getframe(2).f_globals.get('__name__', '__main__') + except (AttributeError, ValueError): + pass + anns = ns.get('__annotations__', {}) + msg = "TypedDict('Name', [(f0, t0), (f1, t1), ...]); each t must be a type" + anns = {n: _type_check(tp, msg) for n, tp in anns.items()} + for base in bases: + anns.update(base.__dict__.get('__annotations__', {})) + tp_dict.__annotations__ = anns + return tp_dict class TypedDict(metaclass=TypedDictMeta): + """A simple typed name space. At runtime it is equivalent to a plain dict. + Usage:: + + Point2D = TypedDict('Point2D', [('x', int), ('y', int), ('label', str)]) + assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') + + The type info could be accessed via Point2D.__annotations__. TypedDict supports + two equivalent forms:: + + Point2D = TypedDict('Point2D', x=int, y=int, label=str) + + class Point2D(TypedDict): + x: int + y: int + label: str + + The latter syntax is only supported in Python 3.6+ + """ _root = True - def __new__(cls, *args, **kwargs): - return dict(*args, **kwargs) + + def __new__(cls, _typename, fields=None, **kwargs): + if fields is None: + fields = kwargs + elif kwargs: + raise TypeError("Either list of fields or keywords" + " can be provided to TypedDict, not both") + return cls.__class__(_typename, (), {'__annotations__': dict(fields)}) def NewType(name, tp): From 3509b3c8bc9ebf144844c454bdf83175eb76a455 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 6 Nov 2016 17:01:43 +0100 Subject: [PATCH 3/6] Backport TypedDict to Python 2 --- python2/test_typing.py | 47 +++++++++++++++++++++++++++++++++++++++++- python2/typing.py | 40 +++++++++++++++++++++++++++++++++++ src/test_typing.py | 2 +- src/typing.py | 5 +---- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/python2/test_typing.py b/python2/test_typing.py index 5de042a3..896c72e3 100644 --- a/python2/test_typing.py +++ b/python2/test_typing.py @@ -17,7 +17,7 @@ from typing import cast from typing import Type from typing import NewType -from typing import NamedTuple +from typing import NamedTuple, TypedDict from typing import IO, TextIO, BinaryIO from typing import Pattern, Match import abc @@ -1432,6 +1432,51 @@ class D(UserName): pass +class TypedDictTests(BaseTestCase): + + def test_basics_fields_syntax(self): + Emp = TypedDict('Emp', [('name', str), ('id', int)]) + self.assertIsSubclass(Emp, dict) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(jim, Emp) + self.assertIsInstance(jim, dict) + self.assertEqual(jim['name'], 'Jim') + self.assertEqual(jim['id'], 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__bases__, (dict,)) + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + + def test_basics_keywords_syntax(self): + Emp = TypedDict('Emp', name=str, id=int) + self.assertIsSubclass(Emp, dict) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(jim, Emp) + self.assertIsInstance(jim, dict) + self.assertEqual(jim['name'], 'Jim') + self.assertEqual(jim['id'], 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__bases__, (dict,)) + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + + def test_typeddict_errors(self): + with self.assertRaises(TypeError): + TypedDict('Hi', x=1) + with self.assertRaises(TypeError): + TypedDict('Hi', [('x', int), ('y', 1)]) + with self.assertRaises(TypeError): + TypedDict('Hi', [('x', int)], y=int) + + def test_pickle(self): + global EmpD # pickle wants to reference the class by name + EmpD = TypedDict('EmpD', name=str, id=int) + jane = EmpD({'name': 'jane', 'id': 37}) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(jane, proto) + jane2 = pickle.loads(z) + self.assertEqual(jane2, jane) + self.assertEqual(jane2, {'name': 'jane', 'id': 37}) + + class NamedTupleTests(BaseTestCase): def test_basics(self): diff --git a/python2/typing.py b/python2/typing.py index 5df0062a..6059d8ad 100644 --- a/python2/typing.py +++ b/python2/typing.py @@ -1813,6 +1813,46 @@ def NamedTuple(typename, fields): return cls +class TypedDictMeta(type): + + def __new__(cls, name, bases, ns): + tp_dict = super(TypedDictMeta, cls).__new__(cls, str(name), (dict,), ns) + try: + tp_dict.__module__ = sys._getframe(2).f_globals.get('__name__', '__main__') + except (AttributeError, ValueError): + pass + anns = ns.get('__annotations__', {}) + msg = "TypedDict('Name', [(f0, t0), (f1, t1), ...]); each t must be a type" + anns = {n: _type_check(tp, msg) for n, tp in anns.items()} + for base in bases: + anns.update(base.__dict__.get('__annotations__', {})) + tp_dict.__annotations__ = anns + return tp_dict + + +class TypedDict(object): + """A simple typed name space. At runtime it is equivalent to a plain dict. + Usage:: + + Point2D = TypedDict('Point2D', [('x', int), ('y', int), ('label', str)]) + assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') + + The type info could be accessed via Point2D.__annotations__. TypedDict supports + one additional equivalent form:: + + Point2D = TypedDict('Point2D', x=int, y=int, label=str) + """ + __metaclass__ = TypedDictMeta + + def __new__(cls, _typename, fields=None, **kwargs): + if fields is None: + fields = kwargs + elif kwargs: + raise TypeError("Either list of fields or keywords" + " can be provided to TypedDict, not both") + return cls.__class__(_typename, (), {'__annotations__': dict(fields)}) + + def NewType(name, tp): """NewType creates simple unique types with almost zero runtime overhead. NewType(name, tp) is considered a subtype of tp diff --git a/src/test_typing.py b/src/test_typing.py index 619a79fa..c61e6561 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1865,7 +1865,7 @@ def test_pickle(self): z = pickle.dumps(jane, proto) jane2 = pickle.loads(z) self.assertEqual(jane2, jane) - + self.assertEqual(jane2, {'name': 'jane', 'id': 37}) class NamedTupleTests(BaseTestCase): diff --git a/src/typing.py b/src/typing.py index 96015504..d86dfb3d 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2005,8 +2005,6 @@ def NamedTuple(typename, fields): class TypedDictMeta(type): def __new__(cls, name, bases, ns): - if ns.get('_root', False): - return super().__new__(cls, name, bases, ns) tp_dict = super().__new__(cls, name, (dict,), ns) try: tp_dict.__module__ = sys._getframe(2).f_globals.get('__name__', '__main__') @@ -2029,7 +2027,7 @@ class TypedDict(metaclass=TypedDictMeta): assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') The type info could be accessed via Point2D.__annotations__. TypedDict supports - two equivalent forms:: + two additional equivalent forms:: Point2D = TypedDict('Point2D', x=int, y=int, label=str) @@ -2040,7 +2038,6 @@ class Point2D(TypedDict): The latter syntax is only supported in Python 3.6+ """ - _root = True def __new__(cls, _typename, fields=None, **kwargs): if fields is None: From c30d3498d70222630755cacea2b6cb39273b95e4 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 6 Nov 2016 17:04:31 +0100 Subject: [PATCH 4/6] Fix typo in 3.6 test --- src/test_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test_typing.py b/src/test_typing.py index c61e6561..dd346681 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1855,7 +1855,7 @@ def test_class_syntax_usage(self): self.assertEqual(not_origin['x'], 0) self.assertEqual(not_origin['y'], 1) other = LabelPoint2D(x=0, y=1, label='hi') - self.assertEqual(not_origin['label'], 'hi') + self.assertEqual(other['label'], 'hi') def test_pickle(self): global EmpD # pickle wants to reference the class by name From 32a3735fe1100e0b3a455bf51f5480c00581501b Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 6 Nov 2016 18:07:19 +0100 Subject: [PATCH 5/6] Nicer docstrings, add TypedDict to __all__ in PY2 --- python2/test_typing.py | 2 ++ python2/typing.py | 5 +++-- src/test_typing.py | 4 +++- src/typing.py | 4 ++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/python2/test_typing.py b/python2/test_typing.py index 896c72e3..3b88947b 100644 --- a/python2/test_typing.py +++ b/python2/test_typing.py @@ -1435,7 +1435,9 @@ class D(UserName): class TypedDictTests(BaseTestCase): def test_basics_fields_syntax(self): + # Check that two iterables allowed Emp = TypedDict('Emp', [('name', str), ('id', int)]) + Emp = TypedDict('Emp', {'name': str, 'id': int}) self.assertIsSubclass(Emp, dict) jim = Emp(name='Jim', id=1) self.assertIsInstance(jim, Emp) diff --git a/python2/typing.py b/python2/typing.py index 6059d8ad..3f167483 100644 --- a/python2/typing.py +++ b/python2/typing.py @@ -57,6 +57,7 @@ 'Set', 'FrozenSet', 'NamedTuple', # Not really a type. + 'TypedDict', 'Generator', # One-off things. @@ -1822,7 +1823,7 @@ def __new__(cls, name, bases, ns): except (AttributeError, ValueError): pass anns = ns.get('__annotations__', {}) - msg = "TypedDict('Name', [(f0, t0), (f1, t1), ...]); each t must be a type" + msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" anns = {n: _type_check(tp, msg) for n, tp in anns.items()} for base in bases: anns.update(base.__dict__.get('__annotations__', {})) @@ -1834,7 +1835,7 @@ class TypedDict(object): """A simple typed name space. At runtime it is equivalent to a plain dict. Usage:: - Point2D = TypedDict('Point2D', [('x', int), ('y', int), ('label', str)]) + Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') The type info could be accessed via Point2D.__annotations__. TypedDict supports diff --git a/src/test_typing.py b/src/test_typing.py index dd346681..1c6d1aab 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1815,8 +1815,10 @@ class D(UserName): class TypedDictTests(BaseTestCase): - def test_basics_fields_syntax(self): + def test_basics_iterable_syntax(self): + # Check that two iterables allowed Emp = TypedDict('Emp', [('name', str), ('id', int)]) + Emp = TypedDict('Emp', {'name': str, 'id': int}) self.assertIsSubclass(Emp, dict) jim = Emp(name='Jim', id=1) self.assertIsInstance(jim, Emp) diff --git a/src/typing.py b/src/typing.py index d86dfb3d..7e6732a6 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2011,7 +2011,7 @@ def __new__(cls, name, bases, ns): except (AttributeError, ValueError): pass anns = ns.get('__annotations__', {}) - msg = "TypedDict('Name', [(f0, t0), (f1, t1), ...]); each t must be a type" + msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" anns = {n: _type_check(tp, msg) for n, tp in anns.items()} for base in bases: anns.update(base.__dict__.get('__annotations__', {})) @@ -2023,7 +2023,7 @@ class TypedDict(metaclass=TypedDictMeta): """A simple typed name space. At runtime it is equivalent to a plain dict. Usage:: - Point2D = TypedDict('Point2D', [('x', int), ('y', int), ('label', str)]) + Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') The type info could be accessed via Point2D.__annotations__. TypedDict supports From 14690357109b048458fc21243f1eb7a9ae0bd2c3 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Thu, 17 Nov 2016 16:56:08 +0100 Subject: [PATCH 6/6] Prohbit isinstance and issubclass for TypedDict --- python2/test_typing.py | 5 +++++ python2/typing.py | 7 +++++++ src/test_typing.py | 5 +++++ src/typing.py | 7 +++++++ 4 files changed, 24 insertions(+) diff --git a/python2/test_typing.py b/python2/test_typing.py index 3b88947b..718c0b46 100644 --- a/python2/test_typing.py +++ b/python2/test_typing.py @@ -1461,6 +1461,11 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) def test_typeddict_errors(self): + Emp = TypedDict('Emp', {'name': str, 'id': int}) + with self.assertRaises(TypeError): + isinstance({}, Emp) + with self.assertRaises(TypeError): + issubclass(dict, Emp) with self.assertRaises(TypeError): TypedDict('Hi', x=1) with self.assertRaises(TypeError): diff --git a/python2/typing.py b/python2/typing.py index 3f167483..64f81485 100644 --- a/python2/typing.py +++ b/python2/typing.py @@ -1814,6 +1814,11 @@ def NamedTuple(typename, fields): return cls +def _check_fails(cls, other): + if sys._getframe(1).f_globals['__name__'] not in ['abc', 'functools']: + raise TypeError('TypedDict does not support instance and class checks') + + class TypedDictMeta(type): def __new__(cls, name, bases, ns): @@ -1830,6 +1835,8 @@ def __new__(cls, name, bases, ns): tp_dict.__annotations__ = anns return tp_dict + __instancecheck__ = __subclasscheck__ = _check_fails + class TypedDict(object): """A simple typed name space. At runtime it is equivalent to a plain dict. diff --git a/src/test_typing.py b/src/test_typing.py index 1c6d1aab..d3cf5d38 100644 --- a/src/test_typing.py +++ b/src/test_typing.py @@ -1842,6 +1842,11 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) def test_typeddict_errors(self): + Emp = TypedDict('Emp', {'name': str, 'id': int}) + with self.assertRaises(TypeError): + isinstance({}, Emp) + with self.assertRaises(TypeError): + issubclass(dict, Emp) with self.assertRaises(TypeError): TypedDict('Hi', x=1) with self.assertRaises(TypeError): diff --git a/src/typing.py b/src/typing.py index 7e6732a6..78bae4d1 100644 --- a/src/typing.py +++ b/src/typing.py @@ -2002,6 +2002,11 @@ def NamedTuple(typename, fields): return _make_nmtuple(typename, fields) +def _check_fails(cls, other): + if sys._getframe(1).f_globals['__name__'] not in ['abc', 'functools']: + raise TypeError('TypedDict does not support instance and class checks') + + class TypedDictMeta(type): def __new__(cls, name, bases, ns): @@ -2018,6 +2023,8 @@ def __new__(cls, name, bases, ns): tp_dict.__annotations__ = anns return tp_dict + __instancecheck__ = __subclasscheck__ = _check_fails + class TypedDict(metaclass=TypedDictMeta): """A simple typed name space. At runtime it is equivalent to a plain dict.