Skip to content

Commit 3bfc2c9

Browse files
authored
WIP: concrete generics (#256)
* WIP * Adds concrete generics support * Fixes bug with `ABC` instances, annotates tests * Ignores phantom-types for python3.7
1 parent 8842dff commit 3bfc2c9

25 files changed

+781
-247
lines changed

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: 2
22

33
python:
4-
version: 3.7
4+
version: 3.8
55
install:
66
- requirements: docs/requirements.txt
77

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ We follow Semantic Versions since the `0.1.0` release.
77

88
### Features
99

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

1314
### Bugfixes
1415

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

1820

1921
## Version 0.3.0

classes/_registry.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from types import MethodType
2+
from typing import Callable, Dict, NoReturn, Optional
3+
4+
TypeRegistry = Dict[type, Callable]
5+
6+
7+
def choose_registry( # noqa: WPS211
8+
# It has multiple arguments, but I don't see an easy and performant way
9+
# to refactor it: I don't want to create extra structures
10+
# and I don't want to create a class with methods.
11+
typ: type,
12+
is_protocol: bool,
13+
delegate: Optional[type],
14+
concretes: TypeRegistry,
15+
instances: TypeRegistry,
16+
protocols: TypeRegistry,
17+
) -> TypeRegistry:
18+
"""
19+
Returns the appropriate registry to store the passed type.
20+
21+
It depends on how ``instance`` method is used and also on the type itself.
22+
"""
23+
if is_protocol:
24+
return protocols
25+
26+
is_concrete = (
27+
delegate is not None or
28+
isinstance(getattr(typ, '__instancecheck__', None), MethodType)
29+
)
30+
if is_concrete:
31+
# This means that this type has `__instancecheck__` defined,
32+
# which allows dynamic checks of what `isinstance` of this type.
33+
# That's why we also treat this type as a concrete.
34+
return concretes
35+
return instances
36+
37+
38+
def default_implementation(instance, *args, **kwargs) -> NoReturn:
39+
"""By default raises an exception."""
40+
raise NotImplementedError(
41+
'Missing matched typeclass instance for type: {0}'.format(
42+
type(instance).__qualname__,
43+
),
44+
)

classes/_typeclass.py

Lines changed: 79 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,12 @@
114114
115115
See our `official docs <https://classes.readthedocs.io>`_ to learn more!
116116
"""
117-
118-
from abc import get_cache_token
119117
from functools import _find_impl # type: ignore # noqa: WPS450
120-
from types import MethodType
121118
from typing import ( # noqa: WPS235
122119
TYPE_CHECKING,
123120
Callable,
124121
Dict,
125122
Generic,
126-
NoReturn,
127123
Optional,
128124
Type,
129125
TypeVar,
@@ -134,6 +130,12 @@
134130

135131
from typing_extensions import TypeGuard, final
136132

133+
from classes._registry import (
134+
TypeRegistry,
135+
choose_registry,
136+
default_implementation,
137+
)
138+
137139
_InstanceType = TypeVar('_InstanceType')
138140
_SignatureType = TypeVar('_SignatureType', bound=Callable)
139141
_AssociatedType = TypeVar('_AssociatedType')
@@ -305,12 +307,17 @@ class _TypeClass( # noqa: WPS214
305307
"""
306308

307309
__slots__ = (
310+
# Str:
308311
'_signature',
309312
'_associated_type',
313+
314+
# Registry:
315+
'_concretes',
310316
'_instances',
311317
'_protocols',
318+
319+
# Cache:
312320
'_dispatch_cache',
313-
'_cache_token',
314321
)
315322

316323
_dispatch_cache: Dict[type, Callable]
@@ -349,16 +356,17 @@ def __init__(
349356
The only exception is the first argument: it is polymorfic.
350357
351358
"""
352-
self._instances: Dict[type, Callable] = {}
353-
self._protocols: Dict[type, Callable] = {}
354-
355359
# We need this for `repr`:
356360
self._signature = signature
357361
self._associated_type = associated_type
358362

363+
# Registries:
364+
self._concretes: TypeRegistry = {}
365+
self._instances: TypeRegistry = {}
366+
self._protocols: TypeRegistry = {}
367+
359368
# Cache parts:
360369
self._dispatch_cache = WeakKeyDictionary() # type: ignore
361-
self._cache_token = None
362370

363371
def __call__(
364372
self,
@@ -410,7 +418,16 @@ def __call__(
410418
And all typeclasses that match ``Callable[[int, int], int]`` signature
411419
will typecheck.
412420
"""
413-
self._control_abc_cache()
421+
# At first, we try all our concrete types,
422+
# we don't cache it, because we cannot.
423+
# We only have runtime type info: `type([1]) == type(['a'])`.
424+
# It might be slow!
425+
# Don't add concrete types unless
426+
# you are absolutely know what you are doing.
427+
impl = self._dispatch_concrete(instance)
428+
if impl is not None:
429+
return impl(instance, *args, **kwargs)
430+
414431
instance_type = type(instance)
415432

416433
try:
@@ -419,7 +436,7 @@ def __call__(
419436
impl = self._dispatch(
420437
instance,
421438
instance_type,
422-
) or self._default_implementation
439+
) or default_implementation
423440
self._dispatch_cache[instance_type] = impl
424441
return impl(instance, *args, **kwargs)
425442

@@ -481,16 +498,24 @@ def supports(
481498
482499
See also: https://www.python.org/dev/peps/pep-0647
483500
"""
484-
self._control_abc_cache()
485-
501+
# Here we first check that instance is already in the cache
502+
# and only then we check concrete types.
503+
# Why?
504+
# Because if some type is already in the cache,
505+
# it means that it is not concrete.
506+
# So, this is simply faster.
486507
instance_type = type(instance)
487508
if instance_type in self._dispatch_cache:
488509
return True
489510

490-
# This only happens when we don't have a cache in place:
511+
# We never cache concrete types.
512+
if self._dispatch_concrete(instance) is not None:
513+
return True
514+
515+
# This only happens when we don't have a cache in place
516+
# and this is not a concrete generic:
491517
impl = self._dispatch(instance, instance_type)
492518
if impl is None:
493-
self._dispatch_cache[instance_type] = self._default_implementation
494519
return False
495520

496521
self._dispatch_cache[instance_type] = impl
@@ -503,14 +528,36 @@ def instance(
503528
# TODO: at one point I would like to remove `is_protocol`
504529
# and make this function decide whether this type is protocol or not.
505530
is_protocol: bool = False,
531+
delegate: Optional[type] = None,
506532
) -> '_TypeClassInstanceDef[_NewInstanceType, _TypeClassType]':
507533
"""
508534
We use this method to store implementation for each specific type.
509535
510-
The only setting we provide is ``is_protocol`` which is required
511-
when passing protocols. See our ``mypy`` plugin for that.
536+
Args:
537+
is_protocol - required when passing protocols.
538+
delegate - required when using concrete generics like ``List[str]``.
539+
540+
Returns:
541+
Decorator for instance handler.
542+
543+
.. note::
544+
545+
``is_protocol`` and ``delegate`` are mutually exclusive.
546+
547+
We don't use ``@overload`` decorator here
548+
(which makes our ``mypy`` plugin even more complex)
549+
because ``@overload`` functions do not
550+
work well with ``ctx.api.fail`` inside the plugin.
551+
They start to try other overloads, which produces wrong results.
512552
"""
513-
typ = type_argument or type(None) # `None` is a special case
553+
# This might seem like a strange line at first, let's dig into it:
554+
#
555+
# First, if `delegate` is passed, then we use delegate, not a real type.
556+
# We use delegates for concrete generics.
557+
# Then, we have a regular `type_argument`. It is used for most types.
558+
# Lastly, we have `type(None)` to handle cases
559+
# when we want to register `None` as a type / singleton value.
560+
typ = delegate or type_argument or type(None)
514561

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

521568
def decorator(implementation):
522-
container = self._protocols if is_protocol else self._instances
569+
container = choose_registry(
570+
typ=typ,
571+
is_protocol=is_protocol,
572+
delegate=delegate,
573+
concretes=self._concretes,
574+
instances=self._instances,
575+
protocols=self._protocols,
576+
)
523577
container[typ] = implementation
524578

525-
if isinstance(getattr(typ, '__instancecheck__', None), MethodType):
526-
# This means that this type has `__instancecheck__` defined,
527-
# which allows dynamic checks of what `isinstance` of this type.
528-
# That's why we also treat this type as a protocol.
529-
self._protocols[typ] = implementation
530-
531-
if self._cache_token is None: # pragma: no cover
532-
if getattr(typ, '__abstractmethods__', None):
533-
self._cache_token = get_cache_token()
534-
535579
self._dispatch_cache.clear()
536580
return implementation
537-
return decorator
538581

539-
def _control_abc_cache(self) -> None:
540-
"""
541-
Required to drop cache if ``abc`` type got new subtypes in runtime.
542-
543-
Copied from ``cpython``.
544-
"""
545-
if self._cache_token is not None:
546-
current_token = get_cache_token()
547-
if self._cache_token != current_token:
548-
self._dispatch_cache.clear()
549-
self._cache_token = current_token
582+
return decorator
550583

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

568601
return _find_impl(instance_type, self._instances)
569602

570-
def _default_implementation(self, instance, *args, **kwargs) -> NoReturn:
571-
"""By default raises an exception."""
572-
raise NotImplementedError(
573-
'Missing matched typeclass instance for type: {0}'.format(
574-
type(instance).__qualname__,
575-
),
576-
)
603+
def _dispatch_concrete(self, instance) -> Optional[Callable]:
604+
for concrete, callback in self._concretes.items():
605+
if isinstance(instance, concrete):
606+
return callback
607+
return None
577608

578609

579610
if TYPE_CHECKING:

classes/contrib/mypy/features/typeclass.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
TupleType,
1212
)
1313
from mypy.types import Type as MypyType
14-
from mypy.types import TypeOfAny
14+
from mypy.types import TypeOfAny, UninhabitedType
1515
from typing_extensions import final
1616

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

153+
# We need to unify how we represent passed arguments to our internals.
154+
# We use this convention: passed args are added as-is,
155+
# missing ones are passed as `NoReturn` (because we cannot pass `None`).
156+
passed_types = []
157+
for arg_pos in ctx.arg_types:
158+
if arg_pos:
159+
passed_types.extend(arg_pos)
160+
else:
161+
passed_types.append(UninhabitedType())
162+
153163
instance_args.mutate_typeclass_instance_def(
154164
ctx.default_return_type,
155165
ctx=ctx,
156166
typeclass=ctx.type,
157-
passed_types=[
158-
type_
159-
for args in ctx.arg_types
160-
for type_ in args
161-
],
167+
passed_types=passed_types,
162168
)
163169
return ctx.default_return_type
164170

classes/contrib/mypy/typeops/call_signatures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(
4343
ctx: MethodSigContext,
4444
) -> None:
4545
"""Context that we need."""
46-
self._signature = signature
46+
self._signature = signature.copy_modified()
4747
self._instance_type = instance_type
4848
self._associated_type = associated_type
4949
self._ctx = ctx

0 commit comments

Comments
 (0)