diff --git a/python2/test_typing.py b/python2/test_typing.py index 5de042a3..718c0b46 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,58 @@ class D(UserName): pass +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) + 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): + 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): + 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..64f81485 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. @@ -1813,6 +1814,53 @@ 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): + 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 + + __instancecheck__ = __subclasscheck__ = _check_fails + + +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 7a5b415b..d3cf5d38 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,67 @@ class D(UserName): pass +class TypedDictTests(BaseTestCase): + + 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) + 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): + 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): + 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(other['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) + self.assertEqual(jane2, {'name': 'jane', 'id': 37}) + class NamedTupleTests(BaseTestCase): def test_basics(self): diff --git a/src/typing.py b/src/typing.py index 5303d405..78bae4d1 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. @@ -2001,6 +2002,59 @@ 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): + 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 + + __instancecheck__ = __subclasscheck__ = _check_fails + + +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 additional 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+ + """ + + 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