Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Runtime implementation of TypedDict extension #2552

Merged
merged 7 commits into from
Dec 14, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 76 additions & 8 deletions extensions/mypy_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,84 @@

# NOTE: This module must support Python 2.7 in addition to Python 3.x

import sys
# _type_check is NOT a part of public typing API, it is used here only to mimic
# the (convenient) behavior of types provided by typing module.
from typing import _type_check # type: ignore
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using a non-public API here, but probably it is better than just copy the whole function here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure you want to depend on this internal API? It means that if in the future we change some version of typing.py it will break with this version of mypy. This may be acceptable given our plans here, but it's still a red flag...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand this is a bad idea to use the private API, this is why I added this comment #2552 (comment) But the function is really helpful, it takes care about forward references, callables, invalid types, etc. And it is not easy to reimplement this function here, since the implementation depends on other private typing API like _ForwardRef and _TypingBase. I think it is OK to keep this. What do you think?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, it's fine to keep, but please add a comment to the code for posterity. (I also worry that before we know it this will become a de-facto public API -- I've seen others ask for the same functionality, e.g. in the issue asking to restore get_type_hint() for Python 2.)


def TypedDict(typename, fields):
"""TypedDict creates a dictionary type that expects all of its

def _check_fails(cls, other):
try:
if sys._getframe(1).f_globals['__name__'] not in ['abc', 'functools']:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... If I comment out this line things still work, suggesting that it's not important that it returns False when invoked from those modules. I suppose this is from when this code was part of typing.py?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False will be returned also if sys._getframe() fails in non-CPython implementations. In general I think it is better if __subclasscheck__() and __instancecheck__() will return False rather than None.

# Typed dicts are only for static structural subtyping.
raise TypeError('TypedDict does not support instance and class checks')
except (AttributeError, ValueError):
pass
return False

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

def _typeddict_new(cls, _typename, _fields=None, **kwargs):
if _fields is None:
_fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
" but not both")
return _TypedDictMeta(_typename, (), {'__annotations__': dict(_fields)})

class _TypedDictMeta(type):
def __new__(cls, name, bases, ns):
# 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 syntaxes described in its docstring.
# Subclasses and instanes 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)
try:
# Setting correct module is necessary to make typed dict classes pickleable.
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


TypedDict = _TypedDictMeta('TypedDict', (dict,), {})
TypedDict.__module__ = __name__
TypedDict.__doc__ = \
"""A simple typed name space. 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, with each key
associated with a value of a consistent type. This expectation
is not checked at runtime but is only enforced by typecheckers.
"""
def new_dict(*args, **kwargs):
return dict(*args, **kwargs)
Usage::

Point2D = TypedDict('Point2D', {'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 could be accessed via Point2D.__annotations__. TypedDict
supports two additional equivalent forms::

new_dict.__name__ = typename
new_dict.__supertype__ = dict
return new_dict
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+, while two other
syntax forms work for Python 2.7 and 3.2+
"""
5 changes: 3 additions & 2 deletions mypy/semanal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1870,8 +1870,9 @@ def process_typeddict_definition(self, s: AssignmentStmt) -> None:
return
# Yes, it's a valid TypedDict definition. Add it to the symbol table.
node = self.lookup(name, s)
node.kind = GDEF # TODO locally defined TypedDict
node.node = typed_dict
if node:
node.kind = GDEF # TODO locally defined TypedDict
node.node = typed_dict

def check_typeddict(self, node: Expression, var_name: str = None) -> Optional[TypeInfo]:
"""Check if a call defines a TypedDict.
Expand Down
119 changes: 119 additions & 0 deletions mypy/test/testextensions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import sys
import pickle
import typing
try:
import collections.abc as collections_abc
except ImportError:
import collections as collections_abc # type: ignore # PY32 and earlier
from unittest import TestCase, main, skipUnless
sys.path[0:0] = ['extensions']
from mypy_extensions import TypedDict


class BaseTestCase(TestCase):

def assertIsSubclass(self, cls, class_or_tuple, msg=None):
if not issubclass(cls, class_or_tuple):
message = '%r is not a subclass of %r' % (cls, class_or_tuple)
if msg is not None:
message += ' : %s' % msg
raise self.failureException(message)

def assertNotIsSubclass(self, cls, class_or_tuple, msg=None):
if issubclass(cls, class_or_tuple):
message = '%r is a subclass of %r' % (cls, class_or_tuple)
if msg is not None:
message += ' : %s' % msg
raise self.failureException(message)


PY36 = sys.version_info[:2] >= (3, 6)

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

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

class LabelPoint2D(Point2D, Label): ...
"""

if PY36:
exec(PY36_TESTS)


class TypedDictTests(BaseTestCase):

def test_basics_iterable_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__, 'mypy.test.testextensions')
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)
self.assertIsSubclass(Emp, typing.MutableMapping)
self.assertNotIsSubclass(Emp, collections_abc.Sequence)
jim = Emp(name='Jim', id=1) # type: ignore # mypy doesn't support keyword syntax yet
self.assertIs(type(jim), dict)
self.assertEqual(jim['name'], 'Jim')
self.assertEqual(jim['id'], 1)
self.assertEqual(Emp.__name__, 'Emp')
self.assertEqual(Emp.__module__, 'mypy.test.testextensions')
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})
self.assertEqual(TypedDict.__module__, 'mypy_extensions')
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)

@skipUnless(PY36, 'Python 3.6 required')
def test_py36_class_syntax_usage(self):
self.assertEqual(LabelPoint2D.__annotations__, {'x': int, 'y': int, 'label': str}) # noqa
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added # noqa because flake8 complains about undefined name.

self.assertEqual(LabelPoint2D.__bases__, (dict,)) # noqa
self.assertNotIsSubclass(LabelPoint2D, typing.Sequence) # noqa
not_origin = Point2D(x=0, y=1) # noqa
self.assertEqual(not_origin['x'], 0)
self.assertEqual(not_origin['y'], 1)
other = LabelPoint2D(x=0, y=1, label='hi') # noqa
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)


if __name__ == '__main__':
main()
2 changes: 1 addition & 1 deletion runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def add_imports(driver: Driver) -> None:


PYTEST_FILES = ['mypy/test/{}.py'.format(name) for name in [
'testcheck',
'testcheck', 'testextensions',
]]


Expand Down