Skip to content

Commit

Permalink
Adds typeddict support (#300)
Browse files Browse the repository at this point in the history
* Adds typeddict support, closes #277

* Fixes CI

* Fixes CI

* Fixes CI

* Fixes CI

* Fixes CI

* Fixes CI

* Fixes CI

* Fixes CI
  • Loading branch information
sobolevn authored Aug 15, 2021
1 parent de3bec3 commit 979c757
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mypy.subtypes import is_subtype
from mypy.types import Instance
from mypy.types import Type as MypyType
from mypy.types import TypedDictType
from typing_extensions import Final

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

if not instance_check:
ctx.api.fail(
_INSTANCE_INFERRED_MISMATCH_MSG.format(
instance_type,
inferred_type,
inferred_type if delegate is None else delegate,
),
ctx.context,
)
Expand Down
75 changes: 49 additions & 26 deletions docs/pages/generics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,39 +122,62 @@ Let's get back to ``get_item`` example and use a generic ``Supports`` type:
reveal_type(get_item(strings, 0)) # Revealed type is "builtins.str*"
Limitations
-----------
Complex concrete generics
-------------------------

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

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

Why? Because we cannot tell the difference
between ``List[int]`` and ``List[str]`` in runtime.
TypedDicts
~~~~~~~~~~

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

So, this would not work:
At first, we need to define a typed dictionary itself:

.. code:: python
>>> from typing import List
>>> from typing_extensions import TypedDict
>>> from classes import typeclass
>>> @typeclass
... def generic_typeclass(instance) -> str:
... """We use this example to demonstrate the typing limitation."""
>>> @generic_typeclass.instance(List[int])
... def _generic_typeclass_list_int(instance: List[int]):
... ...
...
Traceback (most recent call last):
...
TypeError: ...
>>> class _User(TypedDict):
... name: str
... registered: bool
Then, we need a special class with ``__instancecheck__`` defined.
Because original ``TypedDict`` just raises
a ``TypeError`` on ``isinstance(obj, User)``.

.. code:: python
class _UserDictMeta(type(TypedDict)):
def __instancecheck__(cls, arg: object) -> bool:
return (
isinstance(arg, dict) and
isinstance(arg.get('name'), str) and
isinstance(arg.get('registered'), bool)
)
class UserDict(_User, metaclass=_UserDictMeta):
...
And finally we can use it!
Take a note that we always use the resulting ``UserDict`` type,
not the base ``_User``.

.. code:: python
@typeclass
def get_name(instance) -> str:
...
@get_name.instance(delegate=UserDict)
def _get_name_user_dict(instance: UserDict) -> str:
return instance['name']
user: UserDict = {'name': 'sobolevn', 'registered': True}
assert get_name(user) == 'sobolevn'
51 changes: 51 additions & 0 deletions tests/test_typeclass/test_typed_dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import sys

import pytest
from typing_extensions import TypedDict

from classes import typeclass

if sys.version_info[:2] >= (3, 9): # noqa: C901
pytestmark = pytest.mark.skip('Only python3.7 and python3.8 are supported')
else:
class _User(TypedDict):
name: str
registered: bool

class _UserDictMeta(type):
def __instancecheck__(cls, arg: object) -> bool:
return (
isinstance(arg, dict) and
isinstance(arg.get('name'), str) and
isinstance(arg.get('registered'), bool)
)

_Meta = type('_Meta', (_UserDictMeta, type(TypedDict)), {})

class UserDict(_User, metaclass=_Meta):
"""We use this class to represent a typed dict with instance check."""

@typeclass
def get_name(instance) -> str:
"""Example typeclass."""

@get_name.instance(delegate=UserDict)
def _get_name_user_dict(instance: UserDict) -> str:
return instance['name']

def test_correct_typed_dict():
"""Ensures that typed dict dispatch works."""
user: UserDict = {'name': 'sobolevn', 'registered': True}
assert get_name(user) == 'sobolevn'

@pytest.mark.parametrize('test_value', [
[],
{},
{'name': 'sobolevn', 'registered': None},
{'name': 'sobolevn'},
{'registered': True},
])
def test_wrong_typed_dict(test_value):
"""Ensures that typed dict dispatch works."""
with pytest.raises(NotImplementedError):
get_name(test_value)
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@
class MyType(AssociatedType):
pass
@typeclass
@typeclass(MyType)
def sum_all(instance) -> int:
pass
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,6 @@
def _some_list_int(instance: List[int]) -> int:
...
out: |
main:11: error: Instance "builtins.list[builtins.int]" does not match inferred type "builtins.list[builtins.int*]"
main:11: error: Instance "builtins.list[builtins.int]" does not match inferred type "main.SomeDelegate"
main:11: error: Only a single argument can be applied to `.instance`
main:11: error: Regular type "builtins.str*" passed as a protocol
121 changes: 121 additions & 0 deletions typesafety/test_typeclass/test_generics/test_typed_dict.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
- case: typeclass_typed_dict1
disable_cache: false
main: |
from classes import typeclass, AssociatedType, Supports
from typing_extensions import TypedDict
class User(TypedDict):
name: str
registered: bool
class UserDictMeta(type):
def __instancecheck__(cls, arg: object) -> bool:
return (
isinstance(arg, dict) and
isinstance(arg.get('name'), str) and
isinstance(arg.get('registered'), bool)
)
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
class UserDict(User, metaclass=UserMeta):
...
class GetName(AssociatedType):
...
@typeclass(GetName)
def get_name(instance) -> str:
...
@get_name.instance(delegate=UserDict)
def _get_name_user_dict(instance: UserDict) -> str:
return instance['name']
def callback(instance: Supports[GetName]) -> str:
return get_name(instance)
a: UserDict = {'name': 'sobolevn', 'registered': True}
b: User = {'name': 'sobolevn', 'registered': True}
c = {'name': 'sobolevn', 'registered': True}
callback(a) # ok
callback(b)
callback(c)
callback({})
out: |
main:40: error: Argument 1 to "callback" has incompatible type "User"; expected "Supports[GetName]"
main:41: error: Argument 1 to "callback" has incompatible type "Dict[str, object]"; expected "Supports[GetName]"
main:42: error: Argument 1 to "callback" has incompatible type "Dict[<nothing>, <nothing>]"; expected "Supports[GetName]"
- case: typeclass_typed_dict2
disable_cache: false
main: |
from classes import typeclass
from typing_extensions import TypedDict
class User(TypedDict):
name: str
registered: bool
class UserDictMeta(type):
def __instancecheck__(cls, arg: object) -> bool:
return (
isinstance(arg, dict) and
isinstance(arg.get('name'), str) and
isinstance(arg.get('registered'), bool)
)
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
class UserDict(User, metaclass=UserMeta):
...
@typeclass
def get_name(instance) -> str:
...
@get_name.instance(delegate=UserDict)
def _get_name_user_dict(instance: User) -> str:
return instance['name']
out: |
main:25: error: Instance "TypedDict('main.User', {'name': builtins.str, 'registered': builtins.bool})" does not match inferred type "main.UserDict"
- case: typeclass_typed_dict3
disable_cache: false
main: |
from classes import typeclass
from typing_extensions import TypedDict
class User(TypedDict):
name: str
registered: bool
class Other(TypedDict): # even has the same structure
name: str
registered: bool
class UserDictMeta(type):
def __instancecheck__(cls, arg: object) -> bool:
return (
isinstance(arg, dict) and
isinstance(arg.get('name'), str) and
isinstance(arg.get('registered'), bool)
)
UserMeta = type('UserMeta', (UserDictMeta, type(TypedDict)), {})
class UserDict(User, metaclass=UserMeta):
...
@typeclass
def get_name(instance) -> str:
...
@get_name.instance(delegate=UserDict)
def _get_name_user_dict(instance: Other) -> str:
return instance['name']
out: |
main:29: error: Instance "TypedDict('main.Other', {'name': builtins.str, 'registered': builtins.bool})" does not match inferred type "main.UserDict"

0 comments on commit 979c757

Please sign in to comment.