Skip to content

Commit 979c757

Browse files
authored
Adds typeddict support (#300)
* Adds typeddict support, closes #277 * Fixes CI * Fixes CI * Fixes CI * Fixes CI * Fixes CI * Fixes CI * Fixes CI * Fixes CI
1 parent de3bec3 commit 979c757

File tree

6 files changed

+231
-30
lines changed

6 files changed

+231
-30
lines changed

classes/contrib/mypy/validation/validate_instance/validate_runtime.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from mypy.subtypes import is_subtype
77
from mypy.types import Instance
88
from mypy.types import Type as MypyType
9+
from mypy.types import TypedDictType
910
from typing_extensions import Final
1011

1112
from classes.contrib.mypy.typeops import type_queries
@@ -108,13 +109,18 @@ def _check_matching_types(
108109
# Without this check, we would have
109110
# to always use `ListOfStr` and not `List[str]`.
110111
# This is annoying for the user.
111-
instance_check = is_subtype(delegate, instance_type)
112+
instance_check = (
113+
is_subtype(instance_type, delegate)
114+
# When `instance` is a `TypedDict`, we need to rotate the compare:
115+
if isinstance(instance_type, TypedDictType)
116+
else is_subtype(delegate, instance_type)
117+
)
112118

113119
if not instance_check:
114120
ctx.api.fail(
115121
_INSTANCE_INFERRED_MISMATCH_MSG.format(
116122
instance_type,
117-
inferred_type,
123+
inferred_type if delegate is None else delegate,
118124
),
119125
ctx.context,
120126
)

docs/pages/generics.rst

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -122,39 +122,62 @@ Let's get back to ``get_item`` example and use a generic ``Supports`` type:
122122
reveal_type(get_item(strings, 0)) # Revealed type is "builtins.str*"
123123
124124
125-
Limitations
126-
-----------
125+
Complex concrete generics
126+
-------------------------
127127

128-
We are limited in generics support.
129-
We support them, but without concrete type parameters.
128+
There are several advanced techniques
129+
in using concrete generic types when working with ``delegate`` types.
130130

131-
- We support: ``X``, ``list``, ``List``, ``Dict``,
132-
``Mapping``, ``Iterable``, ``MyCustomGeneric``
133-
- We also support: ``Iterable[Any]``, ``List[X]``, ``Dict[X, Y]``, etc
134-
- We don't support ``List[int]``, ``Dict[str, str]``, etc
131+
Here's the collection of them.
135132

136-
Why? Because we cannot tell the difference
137-
between ``List[int]`` and ``List[str]`` in runtime.
133+
TypedDicts
134+
~~~~~~~~~~
138135

139-
Python just does not have this information.
140-
It requires types to be inferred by some other tool.
141-
And that's currently not supported.
136+
.. warning::
137+
This example only works for Python 3.7 and 3.8
138+
`Original bug report <https://bugs.python.org/issue44919>`_.
142139

143-
So, this would not work:
140+
At first, we need to define a typed dictionary itself:
144141

145142
.. code:: python
146143
147-
>>> from typing import List
144+
>>> from typing_extensions import TypedDict
148145
>>> from classes import typeclass
149146
150-
>>> @typeclass
151-
... def generic_typeclass(instance) -> str:
152-
... """We use this example to demonstrate the typing limitation."""
153-
154-
>>> @generic_typeclass.instance(List[int])
155-
... def _generic_typeclass_list_int(instance: List[int]):
156-
... ...
157-
...
158-
Traceback (most recent call last):
159-
...
160-
TypeError: ...
147+
>>> class _User(TypedDict):
148+
... name: str
149+
... registered: bool
150+
151+
Then, we need a special class with ``__instancecheck__`` defined.
152+
Because original ``TypedDict`` just raises
153+
a ``TypeError`` on ``isinstance(obj, User)``.
154+
155+
.. code:: python
156+
157+
class _UserDictMeta(type(TypedDict)):
158+
def __instancecheck__(cls, arg: object) -> bool:
159+
return (
160+
isinstance(arg, dict) and
161+
isinstance(arg.get('name'), str) and
162+
isinstance(arg.get('registered'), bool)
163+
)
164+
165+
class UserDict(_User, metaclass=_UserDictMeta):
166+
...
167+
168+
And finally we can use it!
169+
Take a note that we always use the resulting ``UserDict`` type,
170+
not the base ``_User``.
171+
172+
.. code:: python
173+
174+
@typeclass
175+
def get_name(instance) -> str:
176+
...
177+
178+
@get_name.instance(delegate=UserDict)
179+
def _get_name_user_dict(instance: UserDict) -> str:
180+
return instance['name']
181+
182+
user: UserDict = {'name': 'sobolevn', 'registered': True}
183+
assert get_name(user) == 'sobolevn'
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import sys
2+
3+
import pytest
4+
from typing_extensions import TypedDict
5+
6+
from classes import typeclass
7+
8+
if sys.version_info[:2] >= (3, 9): # noqa: C901
9+
pytestmark = pytest.mark.skip('Only python3.7 and python3.8 are supported')
10+
else:
11+
class _User(TypedDict):
12+
name: str
13+
registered: bool
14+
15+
class _UserDictMeta(type):
16+
def __instancecheck__(cls, arg: object) -> bool:
17+
return (
18+
isinstance(arg, dict) and
19+
isinstance(arg.get('name'), str) and
20+
isinstance(arg.get('registered'), bool)
21+
)
22+
23+
_Meta = type('_Meta', (_UserDictMeta, type(TypedDict)), {})
24+
25+
class UserDict(_User, metaclass=_Meta):
26+
"""We use this class to represent a typed dict with instance check."""
27+
28+
@typeclass
29+
def get_name(instance) -> str:
30+
"""Example typeclass."""
31+
32+
@get_name.instance(delegate=UserDict)
33+
def _get_name_user_dict(instance: UserDict) -> str:
34+
return instance['name']
35+
36+
def test_correct_typed_dict():
37+
"""Ensures that typed dict dispatch works."""
38+
user: UserDict = {'name': 'sobolevn', 'registered': True}
39+
assert get_name(user) == 'sobolevn'
40+
41+
@pytest.mark.parametrize('test_value', [
42+
[],
43+
{},
44+
{'name': 'sobolevn', 'registered': None},
45+
{'name': 'sobolevn'},
46+
{'registered': True},
47+
])
48+
def test_wrong_typed_dict(test_value):
49+
"""Ensures that typed dict dispatch works."""
50+
with pytest.raises(NotImplementedError):
51+
get_name(test_value)

typesafety/test_associated_type/test_validation/test_bodies.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,6 @@
3737
class MyType(AssociatedType):
3838
pass
3939
40-
@typeclass
40+
@typeclass(MyType)
4141
def sum_all(instance) -> int:
4242
pass

typesafety/test_typeclass/test_generics/test_generics_concrete.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,6 @@
188188
def _some_list_int(instance: List[int]) -> int:
189189
...
190190
out: |
191-
main:11: error: Instance "builtins.list[builtins.int]" does not match inferred type "builtins.list[builtins.int*]"
191+
main:11: error: Instance "builtins.list[builtins.int]" does not match inferred type "main.SomeDelegate"
192192
main:11: error: Only a single argument can be applied to `.instance`
193193
main:11: error: Regular type "builtins.str*" passed as a protocol
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
- case: typeclass_typed_dict1
2+
disable_cache: false
3+
main: |
4+
from classes import typeclass, AssociatedType, Supports
5+
from typing_extensions import TypedDict
6+
7+
class User(TypedDict):
8+
name: str
9+
registered: bool
10+
11+
class UserDictMeta(type):
12+
def __instancecheck__(cls, arg: object) -> bool:
13+
return (
14+
isinstance(arg, dict) and
15+
isinstance(arg.get('name'), str) and
16+
isinstance(arg.get('registered'), bool)
17+
)
18+
19+
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
20+
21+
class UserDict(User, metaclass=UserMeta):
22+
...
23+
24+
class GetName(AssociatedType):
25+
...
26+
27+
@typeclass(GetName)
28+
def get_name(instance) -> str:
29+
...
30+
31+
@get_name.instance(delegate=UserDict)
32+
def _get_name_user_dict(instance: UserDict) -> str:
33+
return instance['name']
34+
35+
def callback(instance: Supports[GetName]) -> str:
36+
return get_name(instance)
37+
38+
a: UserDict = {'name': 'sobolevn', 'registered': True}
39+
b: User = {'name': 'sobolevn', 'registered': True}
40+
c = {'name': 'sobolevn', 'registered': True}
41+
42+
callback(a) # ok
43+
callback(b)
44+
callback(c)
45+
callback({})
46+
out: |
47+
main:40: error: Argument 1 to "callback" has incompatible type "User"; expected "Supports[GetName]"
48+
main:41: error: Argument 1 to "callback" has incompatible type "Dict[str, object]"; expected "Supports[GetName]"
49+
main:42: error: Argument 1 to "callback" has incompatible type "Dict[<nothing>, <nothing>]"; expected "Supports[GetName]"
50+
51+
52+
- case: typeclass_typed_dict2
53+
disable_cache: false
54+
main: |
55+
from classes import typeclass
56+
from typing_extensions import TypedDict
57+
58+
class User(TypedDict):
59+
name: str
60+
registered: bool
61+
62+
class UserDictMeta(type):
63+
def __instancecheck__(cls, arg: object) -> bool:
64+
return (
65+
isinstance(arg, dict) and
66+
isinstance(arg.get('name'), str) and
67+
isinstance(arg.get('registered'), bool)
68+
)
69+
70+
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
71+
72+
class UserDict(User, metaclass=UserMeta):
73+
...
74+
75+
@typeclass
76+
def get_name(instance) -> str:
77+
...
78+
79+
@get_name.instance(delegate=UserDict)
80+
def _get_name_user_dict(instance: User) -> str:
81+
return instance['name']
82+
out: |
83+
main:25: error: Instance "TypedDict('main.User', {'name': builtins.str, 'registered': builtins.bool})" does not match inferred type "main.UserDict"
84+
85+
86+
- case: typeclass_typed_dict3
87+
disable_cache: false
88+
main: |
89+
from classes import typeclass
90+
from typing_extensions import TypedDict
91+
92+
class User(TypedDict):
93+
name: str
94+
registered: bool
95+
96+
class Other(TypedDict): # even has the same structure
97+
name: str
98+
registered: bool
99+
100+
class UserDictMeta(type):
101+
def __instancecheck__(cls, arg: object) -> bool:
102+
return (
103+
isinstance(arg, dict) and
104+
isinstance(arg.get('name'), str) and
105+
isinstance(arg.get('registered'), bool)
106+
)
107+
108+
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
109+
110+
class UserDict(User, metaclass=UserMeta):
111+
...
112+
113+
@typeclass
114+
def get_name(instance) -> str:
115+
...
116+
117+
@get_name.instance(delegate=UserDict)
118+
def _get_name_user_dict(instance: Other) -> str:
119+
return instance['name']
120+
out: |
121+
main:29: error: Instance "TypedDict('main.Other', {'name': builtins.str, 'registered': builtins.bool})" does not match inferred type "main.UserDict"

0 commit comments

Comments
 (0)