Skip to content

Commit

Permalink
bpo-37049: PEP 589: Add TypedDict to typing module (GH-13573)
Browse files Browse the repository at this point in the history
The implementation is straightforward and essentially is just copied from `typing_extensions`.
  • Loading branch information
ilevkivskyi authored May 26, 2019
1 parent b891c46 commit 135c6a5
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 1 deletion.
33 changes: 33 additions & 0 deletions Doc/library/typing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,39 @@ The module defines the following classes, functions and decorators:
The ``_field_types`` and ``__annotations__`` attributes are
now regular dictionaries instead of instances of ``OrderedDict``.

.. class:: TypedDict(dict)

A simple typed namespace. At runtime it is equivalent to
a plain :class:`dict`.

``TypedDict`` creates a dictionary type that expects all of its
instances to have a certain set of keys, where each key is
associated with a value of a consistent type. This expectation
is not checked at runtime but is only enforced by type checkers.
Usage::

class Point2D(TypedDict):
x: int
y: int
label: str

a: Point2D = {'x': 1, 'y': 2, 'label': 'good'} # OK
b: Point2D = {'z': 3, 'label': 'bad'} # Fails type check

assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first')

The type info for introspection can be accessed via ``Point2D.__annotations__``
and ``Point2D.__total__``. To allow using this feature with older versions
of Python that do not support :pep:`526`, ``TypedDict`` supports two additional
equivalent syntactic forms::

Point2D = TypedDict('Point2D', x=int, y=int, label=str)
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})

See :pep:`589` for more examples and detailed rules of using ``TypedDict``
with type checkers.

.. versionadded:: 3.8

.. function:: NewType(typ)

Expand Down
105 changes: 104 additions & 1 deletion Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1883,6 +1883,18 @@ def __str__(self):
def __add__(self, other):
return 0

Label = TypedDict('Label', [('label', str)])

class Point2D(TypedDict):
x: int
y: int

class LabelPoint2D(Point2D, Label): ...

class Options(TypedDict, total=False):
log_level: int
log_path: str

class HasForeignBaseClass(mod_generics_cache.A):
some_xrepr: 'XRepr'
other_a: 'mod_generics_cache.A'
Expand Down Expand Up @@ -2658,6 +2670,97 @@ def test_pickle(self):
self.assertEqual(jane2, jane)


class TypedDictTests(BaseTestCase):
def test_basics_functional_syntax(self):
Emp = TypedDict('Emp', {'name': str, 'id': int})
self.assertIsSubclass(Emp, dict)
self.assertIsSubclass(Emp, typing.MutableMapping)
self.assertNotIsSubclass(Emp, collections.abc.Sequence)
jim = Emp(name='Jim', id=1)
self.assertIs(type(jim), dict)
self.assertEqual(jim['name'], 'Jim')
self.assertEqual(jim['id'], 1)
self.assertEqual(Emp.__name__, 'Emp')
self.assertEqual(Emp.__module__, __name__)
self.assertEqual(Emp.__bases__, (dict,))
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
self.assertEqual(Emp.__total__, True)

def test_basics_keywords_syntax(self):
Emp = TypedDict('Emp', name=str, id=int)
self.assertIsSubclass(Emp, dict)
self.assertIsSubclass(Emp, typing.MutableMapping)
self.assertNotIsSubclass(Emp, collections.abc.Sequence)
jim = Emp(name='Jim', id=1)
self.assertIs(type(jim), dict)
self.assertEqual(jim['name'], 'Jim')
self.assertEqual(jim['id'], 1)
self.assertEqual(Emp.__name__, 'Emp')
self.assertEqual(Emp.__module__, __name__)
self.assertEqual(Emp.__bases__, (dict,))
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
self.assertEqual(Emp.__total__, True)

def test_typeddict_errors(self):
Emp = TypedDict('Emp', {'name': str, 'id': int})
self.assertEqual(TypedDict.__module__, 'typing')
jim = Emp(name='Jim', id=1)
with self.assertRaises(TypeError):
isinstance({}, Emp)
with self.assertRaises(TypeError):
isinstance(jim, 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_py36_class_syntax_usage(self):
self.assertEqual(LabelPoint2D.__name__, 'LabelPoint2D')
self.assertEqual(LabelPoint2D.__module__, __name__)
self.assertEqual(LabelPoint2D.__annotations__, {'x': int, 'y': int, 'label': str})
self.assertEqual(LabelPoint2D.__bases__, (dict,))
self.assertEqual(LabelPoint2D.__total__, True)
self.assertNotIsSubclass(LabelPoint2D, typing.Sequence)
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})
ZZ = pickle.dumps(EmpD, proto)
EmpDnew = pickle.loads(ZZ)
self.assertEqual(EmpDnew({'name': 'jane', 'id': 37}), jane)

def test_optional(self):
EmpD = TypedDict('EmpD', name=str, id=int)

self.assertEqual(typing.Optional[EmpD], typing.Union[None, EmpD])
self.assertNotEqual(typing.List[EmpD], typing.Tuple[EmpD])

def test_total(self):
D = TypedDict('D', {'x': int}, total=False)
self.assertEqual(D(), {})
self.assertEqual(D(x=1), {'x': 1})
self.assertEqual(D.__total__, False)

self.assertEqual(Options(), {})
self.assertEqual(Options(log_level=2), {'log_level': 2})
self.assertEqual(Options.__total__, False)


class IOTests(BaseTestCase):

def test_io(self):
Expand Down
84 changes: 84 additions & 0 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
'Set',
'FrozenSet',
'NamedTuple', # Not really a type.
'TypedDict', # Not really a type.
'Generator',

# One-off things.
Expand Down Expand Up @@ -1490,6 +1491,89 @@ def __new__(self, typename, fields=None, **kwargs):
return _make_nmtuple(typename, fields)


def _dict_new(cls, *args, **kwargs):
return dict(*args, **kwargs)


def _typeddict_new(cls, _typename, _fields=None, **kwargs):
total = kwargs.pop('total', True)
if _fields is None:
_fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
" but not both")

ns = {'__annotations__': dict(_fields), '__total__': total}
try:
# Setting correct module is necessary to make typed dict classes pickleable.
ns['__module__'] = sys._getframe(1).f_globals.get('__name__', '__main__')
except (AttributeError, ValueError):
pass

return _TypedDictMeta(_typename, (), ns)


def _check_fails(cls, other):
# Typed dicts are only for static structural subtyping.
raise TypeError('TypedDict does not support instance and class checks')


class _TypedDictMeta(type):
def __new__(cls, name, bases, ns, total=True):
"""Create new typed dict class object.
This method is called directly when TypedDict is subclassed,
or via _typeddict_new when TypedDict is instantiated. This way
TypedDict supports all three syntax forms described in its docstring.
Subclasses and instances of TypedDict return actual dictionaries
via _dict_new.
"""
ns['__new__'] = _typeddict_new if name == 'TypedDict' else _dict_new
tp_dict = super(_TypedDictMeta, cls).__new__(cls, name, (dict,), ns)

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
if not hasattr(tp_dict, '__total__'):
tp_dict.__total__ = total
return tp_dict

__instancecheck__ = __subclasscheck__ = _check_fails


class TypedDict(dict, metaclass=_TypedDictMeta):
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
TypedDict creates a dictionary type that expects all of its
instances to have a certain set of keys, where each key is
associated with a value of a consistent type. This expectation
is not checked at runtime but is only enforced by type checkers.
Usage::
class Point2D(TypedDict):
x: int
y: int
label: str
a: Point2D = {'x': 1, 'y': 2, 'label': 'good'} # OK
b: Point2D = {'z': 3, 'label': 'bad'} # Fails type check
assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first')
The type info can be accessed via Point2D.__annotations__. TypedDict
supports two additional equivalent forms::
Point2D = TypedDict('Point2D', x=int, y=int, label=str)
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})
The class syntax is only supported in Python 3.6+, while two other
syntax forms work for Python 2.7 and 3.2+
"""


def NewType(name, tp):
"""NewType creates simple unique types with almost zero
runtime overhead. NewType(name, tp) is considered a subtype of tp
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PEP 589: Add ``TypedDict`` to the ``typing`` module.

0 comments on commit 135c6a5

Please sign in to comment.