Skip to content

WIP: concrete generics #256

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

Merged
merged 4 commits into from
Jul 8, 2021
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
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: 2

python:
version: 3.7
version: 3.8
install:
- requirements: docs/requirements.txt

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ We follow Semantic Versions since the `0.1.0` release.

### Features

- Adds support for concrete generic types like `List[str]` and `Set[int]` #24
- Adds support for multiple type arguments in `Supports` type #244
- Adds support for types that have `__instancecheck__` defined #248

### Bugfixes

- Fixes that types referenced in multiple typeclasses
were not handling `Supports` properly #249
- Fixes typing bug with `ABC` and mutable typeclass signature #259


## Version 0.3.0
Expand Down
44 changes: 44 additions & 0 deletions classes/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from types import MethodType
from typing import Callable, Dict, NoReturn, Optional

TypeRegistry = Dict[type, Callable]


def choose_registry( # noqa: WPS211
# It has multiple arguments, but I don't see an easy and performant way
# to refactor it: I don't want to create extra structures
# and I don't want to create a class with methods.
typ: type,
is_protocol: bool,
delegate: Optional[type],
concretes: TypeRegistry,
instances: TypeRegistry,
protocols: TypeRegistry,
) -> TypeRegistry:
"""
Returns the appropriate registry to store the passed type.

It depends on how ``instance`` method is used and also on the type itself.
"""
if is_protocol:
return protocols

is_concrete = (
delegate is not None or
isinstance(getattr(typ, '__instancecheck__', None), MethodType)
)
if is_concrete:
# This means that this type has `__instancecheck__` defined,
# which allows dynamic checks of what `isinstance` of this type.
# That's why we also treat this type as a concrete.
return concretes
return instances


def default_implementation(instance, *args, **kwargs) -> NoReturn:
"""By default raises an exception."""
raise NotImplementedError(
'Missing matched typeclass instance for type: {0}'.format(
type(instance).__qualname__,
),
)
127 changes: 79 additions & 48 deletions classes/_typeclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,12 @@

See our `official docs <https://classes.readthedocs.io>`_ to learn more!
"""

from abc import get_cache_token
from functools import _find_impl # type: ignore # noqa: WPS450
from types import MethodType
from typing import ( # noqa: WPS235
TYPE_CHECKING,
Callable,
Dict,
Generic,
NoReturn,
Optional,
Type,
TypeVar,
Expand All @@ -134,6 +130,12 @@

from typing_extensions import TypeGuard, final

from classes._registry import (
TypeRegistry,
choose_registry,
default_implementation,
)

_InstanceType = TypeVar('_InstanceType')
_SignatureType = TypeVar('_SignatureType', bound=Callable)
_AssociatedType = TypeVar('_AssociatedType')
Expand Down Expand Up @@ -305,12 +307,17 @@ class _TypeClass( # noqa: WPS214
"""

__slots__ = (
# Str:
'_signature',
'_associated_type',

# Registry:
'_concretes',
'_instances',
'_protocols',

# Cache:
'_dispatch_cache',
'_cache_token',
)

_dispatch_cache: Dict[type, Callable]
Expand Down Expand Up @@ -349,16 +356,17 @@ def __init__(
The only exception is the first argument: it is polymorfic.

"""
self._instances: Dict[type, Callable] = {}
self._protocols: Dict[type, Callable] = {}

# We need this for `repr`:
self._signature = signature
self._associated_type = associated_type

# Registries:
self._concretes: TypeRegistry = {}
self._instances: TypeRegistry = {}
self._protocols: TypeRegistry = {}

# Cache parts:
self._dispatch_cache = WeakKeyDictionary() # type: ignore
self._cache_token = None

def __call__(
self,
Expand Down Expand Up @@ -410,7 +418,16 @@ def __call__(
And all typeclasses that match ``Callable[[int, int], int]`` signature
will typecheck.
"""
self._control_abc_cache()
# At first, we try all our concrete types,
# we don't cache it, because we cannot.
# We only have runtime type info: `type([1]) == type(['a'])`.
# It might be slow!
# Don't add concrete types unless
# you are absolutely know what you are doing.
impl = self._dispatch_concrete(instance)
if impl is not None:
return impl(instance, *args, **kwargs)

instance_type = type(instance)

try:
Expand All @@ -419,7 +436,7 @@ def __call__(
impl = self._dispatch(
instance,
instance_type,
) or self._default_implementation
) or default_implementation
self._dispatch_cache[instance_type] = impl
return impl(instance, *args, **kwargs)

Expand Down Expand Up @@ -481,16 +498,24 @@ def supports(

See also: https://www.python.org/dev/peps/pep-0647
"""
self._control_abc_cache()

# Here we first check that instance is already in the cache
# and only then we check concrete types.
# Why?
# Because if some type is already in the cache,
# it means that it is not concrete.
# So, this is simply faster.
instance_type = type(instance)
if instance_type in self._dispatch_cache:
return True

# This only happens when we don't have a cache in place:
# We never cache concrete types.
if self._dispatch_concrete(instance) is not None:
return True

# This only happens when we don't have a cache in place
# and this is not a concrete generic:
impl = self._dispatch(instance, instance_type)
if impl is None:
self._dispatch_cache[instance_type] = self._default_implementation
return False

self._dispatch_cache[instance_type] = impl
Expand All @@ -503,14 +528,36 @@ def instance(
# TODO: at one point I would like to remove `is_protocol`
# and make this function decide whether this type is protocol or not.
is_protocol: bool = False,
delegate: Optional[type] = None,
) -> '_TypeClassInstanceDef[_NewInstanceType, _TypeClassType]':
"""
We use this method to store implementation for each specific type.

The only setting we provide is ``is_protocol`` which is required
when passing protocols. See our ``mypy`` plugin for that.
Args:
is_protocol - required when passing protocols.
delegate - required when using concrete generics like ``List[str]``.

Returns:
Decorator for instance handler.

.. note::

``is_protocol`` and ``delegate`` are mutually exclusive.

We don't use ``@overload`` decorator here
(which makes our ``mypy`` plugin even more complex)
because ``@overload`` functions do not
work well with ``ctx.api.fail`` inside the plugin.
They start to try other overloads, which produces wrong results.
"""
typ = type_argument or type(None) # `None` is a special case
# This might seem like a strange line at first, let's dig into it:
#
# First, if `delegate` is passed, then we use delegate, not a real type.
# We use delegates for concrete generics.
# Then, we have a regular `type_argument`. It is used for most types.
# Lastly, we have `type(None)` to handle cases
# when we want to register `None` as a type / singleton value.
typ = delegate or type_argument or type(None)

# That's how we check for generics,
# generics that look like `List[int]` or `set[T]` will fail this check,
Expand All @@ -519,34 +566,20 @@ def instance(
isinstance(object(), typ)

def decorator(implementation):
container = self._protocols if is_protocol else self._instances
container = choose_registry(
typ=typ,
is_protocol=is_protocol,
delegate=delegate,
concretes=self._concretes,
instances=self._instances,
protocols=self._protocols,
)
container[typ] = implementation

if isinstance(getattr(typ, '__instancecheck__', None), MethodType):
# This means that this type has `__instancecheck__` defined,
# which allows dynamic checks of what `isinstance` of this type.
# That's why we also treat this type as a protocol.
self._protocols[typ] = implementation

if self._cache_token is None: # pragma: no cover
if getattr(typ, '__abstractmethods__', None):
self._cache_token = get_cache_token()

self._dispatch_cache.clear()
return implementation
return decorator

def _control_abc_cache(self) -> None:
"""
Required to drop cache if ``abc`` type got new subtypes in runtime.

Copied from ``cpython``.
"""
if self._cache_token is not None:
current_token = get_cache_token()
if self._cache_token != current_token:
self._dispatch_cache.clear()
self._cache_token = current_token
return decorator

def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:
"""
Expand All @@ -567,13 +600,11 @@ def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:

return _find_impl(instance_type, self._instances)

def _default_implementation(self, instance, *args, **kwargs) -> NoReturn:
"""By default raises an exception."""
raise NotImplementedError(
'Missing matched typeclass instance for type: {0}'.format(
type(instance).__qualname__,
),
)
def _dispatch_concrete(self, instance) -> Optional[Callable]:
for concrete, callback in self._concretes.items():
if isinstance(instance, concrete):
return callback
return None


if TYPE_CHECKING:
Expand Down
18 changes: 12 additions & 6 deletions classes/contrib/mypy/features/typeclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
TupleType,
)
from mypy.types import Type as MypyType
from mypy.types import TypeOfAny
from mypy.types import TypeOfAny, UninhabitedType
from typing_extensions import final

from classes.contrib.mypy.typeops import (
Expand Down Expand Up @@ -150,15 +150,21 @@ def instance_return_type(ctx: MethodContext) -> MypyType:
assert isinstance(ctx.default_return_type, Instance)
assert isinstance(ctx.type, Instance)

# We need to unify how we represent passed arguments to our internals.
# We use this convention: passed args are added as-is,
# missing ones are passed as `NoReturn` (because we cannot pass `None`).
passed_types = []
for arg_pos in ctx.arg_types:
if arg_pos:
passed_types.extend(arg_pos)
else:
passed_types.append(UninhabitedType())

instance_args.mutate_typeclass_instance_def(
ctx.default_return_type,
ctx=ctx,
typeclass=ctx.type,
passed_types=[
type_
for args in ctx.arg_types
for type_ in args
],
passed_types=passed_types,
)
return ctx.default_return_type

Expand Down
2 changes: 1 addition & 1 deletion classes/contrib/mypy/typeops/call_signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def __init__(
ctx: MethodSigContext,
) -> None:
"""Context that we need."""
self._signature = signature
self._signature = signature.copy_modified()
self._instance_type = instance_type
self._associated_type = associated_type
self._ctx = ctx
Expand Down
Loading