Skip to content

Commit 23ed04b

Browse files
committed
WIP
1 parent b274a09 commit 23ed04b

File tree

9 files changed

+273
-84
lines changed

9 files changed

+273
-84
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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

classes/_registry.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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 argumnets, 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+
if is_protocol:
20+
return protocols
21+
22+
is_concrete = (
23+
delegate is not None or
24+
isinstance(getattr(typ, '__instancecheck__', None), MethodType)
25+
)
26+
if is_concrete:
27+
# This means that this type has `__instancecheck__` defined,
28+
# which allows dynamic checks of what `isinstance` of this type.
29+
# That's why we also treat this type as a conrete.
30+
return concretes
31+
return instances
32+
33+
34+
def default_implementation(instance, *args, **kwargs) -> NoReturn:
35+
"""By default raises an exception."""
36+
raise NotImplementedError(
37+
'Missing matched typeclass instance for type: {0}'.format(
38+
type(instance).__qualname__,
39+
),
40+
)

classes/_typeclass.py

Lines changed: 54 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@
134134

135135
from typing_extensions import TypeGuard, final
136136

137+
from classes._registry import (
138+
TypeRegistry,
139+
choose_registry,
140+
default_implementation,
141+
)
142+
137143
_InstanceType = TypeVar('_InstanceType')
138144
_SignatureType = TypeVar('_SignatureType', bound=Callable)
139145
_AssociatedType = TypeVar('_AssociatedType')
@@ -305,12 +311,17 @@ class _TypeClass( # noqa: WPS214
305311
"""
306312

307313
__slots__ = (
314+
# Str:
308315
'_signature',
309316
'_associated_type',
317+
318+
# Registry:
319+
'_concretes',
310320
'_instances',
311321
'_protocols',
322+
323+
# Cache:
312324
'_dispatch_cache',
313-
'_cache_token',
314325
)
315326

316327
_dispatch_cache: Dict[type, Callable]
@@ -349,16 +360,17 @@ def __init__(
349360
The only exception is the first argument: it is polymorfic.
350361
351362
"""
352-
self._instances: Dict[type, Callable] = {}
353-
self._protocols: Dict[type, Callable] = {}
354-
355363
# We need this for `repr`:
356364
self._signature = signature
357365
self._associated_type = associated_type
358366

367+
# Registries:
368+
self._concretes: TypeRegistry = {}
369+
self._instances: TypeRegistry = {}
370+
self._protocols: TypeRegistry = {}
371+
359372
# Cache parts:
360373
self._dispatch_cache = WeakKeyDictionary() # type: ignore
361-
self._cache_token = None
362374

363375
def __call__(
364376
self,
@@ -410,16 +422,25 @@ def __call__(
410422
And all typeclasses that match ``Callable[[int, int], int]`` signature
411423
will typecheck.
412424
"""
413-
self._control_abc_cache()
425+
# At first, we try all our conrete types,
426+
# we don't cache it, because we cannot.
427+
# We only have runtime type info: `type([1]) == type(['a'])`.
428+
# It might be slow!
429+
# Don't add concrete types unless
430+
# you are absolutely know what you are doing.
431+
impl = self._dispatch_concrete(instance)
432+
if impl is not None:
433+
return impl(instance, *args, **kwargs)
434+
414435
instance_type = type(instance)
415436

416437
try:
417438
impl = self._dispatch_cache[instance_type]
418439
except KeyError:
419-
impl = self._dispatch( # TODO: impl, store_cache = self._dispatch
440+
impl = self._dispatch(
420441
instance,
421442
instance_type,
422-
) or self._default_implementation
443+
) or default_implementation
423444
self._dispatch_cache[instance_type] = impl
424445
return impl(instance, *args, **kwargs)
425446

@@ -481,16 +502,24 @@ def supports(
481502
482503
See also: https://www.python.org/dev/peps/pep-0647
483504
"""
484-
self._control_abc_cache()
485-
505+
# Here we first check that instance is already in the cache
506+
# and only then we check concrete types.
507+
# Why?
508+
# Because if some type is already in the cache,
509+
# it means that it is not concrete.
510+
# So, this is simply faster.
486511
instance_type = type(instance)
487512
if instance_type in self._dispatch_cache:
488513
return True
489514

490-
# This only happens when we don't have a cache in place:
515+
# We never cache concrete types.
516+
if self._dispatch_concrete(instance) is not None:
517+
return True
518+
519+
# This only happens when we don't have a cache in place
520+
# and this is not a concrete generic:
491521
impl = self._dispatch(instance, instance_type)
492522
if impl is None:
493-
self._dispatch_cache[instance_type] = self._default_implementation
494523
return False
495524

496525
self._dispatch_cache[instance_type] = impl
@@ -541,35 +570,21 @@ def instance(
541570
isinstance(object(), typ)
542571

543572
def decorator(implementation):
544-
container = self._protocols if is_protocol else self._instances
573+
container = choose_registry(
574+
typ=typ,
575+
is_protocol=is_protocol,
576+
delegate=delegate,
577+
concretes=self._concretes,
578+
instances=self._instances,
579+
protocols=self._protocols,
580+
)
545581
container[typ] = implementation
546582

547-
if isinstance(getattr(typ, '__instancecheck__', None), MethodType):
548-
# This means that this type has `__instancecheck__` defined,
549-
# which allows dynamic checks of what `isinstance` of this type.
550-
# That's why we also treat this type as a protocol.
551-
self._protocols[typ] = implementation
552-
553-
if self._cache_token is None: # pragma: no cover
554-
if getattr(typ, '__abstractmethods__', None):
555-
self._cache_token = get_cache_token()
556583
self._dispatch_cache.clear()
557584
return implementation
558585

559586
return decorator
560587

561-
def _control_abc_cache(self) -> None:
562-
"""
563-
Required to drop cache if ``abc`` type got new subtypes in runtime.
564-
565-
Copied from ``cpython``.
566-
"""
567-
if self._cache_token is not None:
568-
current_token = get_cache_token()
569-
if self._cache_token != current_token:
570-
self._dispatch_cache.clear()
571-
self._cache_token = current_token
572-
573588
def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:
574589
"""
575590
Dispatches a function by its type.
@@ -589,13 +604,11 @@ def _dispatch(self, instance, instance_type: type) -> Optional[Callable]:
589604

590605
return _find_impl(instance_type, self._instances)
591606

592-
def _default_implementation(self, instance, *args, **kwargs) -> NoReturn:
593-
"""By default raises an exception."""
594-
raise NotImplementedError(
595-
'Missing matched typeclass instance for type: {0}'.format(
596-
type(instance).__qualname__,
597-
),
598-
)
607+
def _dispatch_concrete(self, instance) -> Optional[Callable]:
608+
for concrete, callback in self._concretes.items():
609+
if isinstance(instance, concrete):
610+
return callback
611+
return None
599612

600613

601614
if TYPE_CHECKING:

docs/pages/api-docs.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Typeclass
22
=========
33

4+
Caching
5+
-------
6+
7+
API
8+
---
9+
410
Here are the technical docs about ``typeclass`` and how to use it.
511

612
.. automodule:: classes._typeclass

docs/pages/concept.rst

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ Instead, we are going to learn about
142142
how this feature can be used to model
143143
your domain model precisely with delegates.
144144

145+
Performance considerations
146+
~~~~~~~~~~~~~~~~~~~~~~~~~~
147+
148+
Types that are matched via ``__instancecheck__`` are the first one we try.
149+
So, the worst case complexity of this is ``O(n)``
150+
where ``n`` is the number of types to try.
151+
152+
We also always try them first and do not cache the result.
153+
This feature is here because we need to handle concrete generics.
154+
But, we recommend to think at least
155+
twice about the performance side of this feature.
156+
Maybe you can just write a function?
157+
145158

146159
Delegates
147160
---------
@@ -168,8 +181,9 @@ We need some custom type inference mechanism:
168181
>>> class _ListOfIntMeta(type):
169182
... def __instancecheck__(self, arg) -> bool:
170183
... return (
171-
... isinstance(other, list) and
172-
... all(isinstance(item, int) for item in arg
184+
... isinstance(arg, list) and
185+
... bool(arg) and # we need to have at least one `int` element
186+
... all(isinstance(item, int) for item in arg)
173187
... )
174188
175189
>>> class ListOfInt(List[int], metaclass=_ListOfIntMeta):
@@ -198,7 +212,7 @@ And now we can use it with ``classes``:
198212
199213
>>> your_list = [1, 2, 3]
200214
>>> if isinstance(your_list, ListOfInt):
201-
... sum_all(your_list)
215+
... assert sum_all(your_list) == 6
202216
203217
This solution still has several problems:
204218

@@ -219,21 +233,30 @@ First, you need to define a "phantom" type
219233
.. code:: python
220234
221235
>>> from phantom import Phantom
222-
>>> from phantom.predicates import collection, generic
236+
>>> from phantom.predicates import boolean, collection, generic, numeric
223237
224238
>>> class ListOfInt(
225239
... List[int],
226240
... Phantom,
227-
... predicate=collection.every(generic.of_type(int)),
241+
... predicate=boolean.both(
242+
... collection.count(numeric.greater(0)),
243+
... collection.every(generic.of_type(int)),
244+
... ),
228245
... ):
229246
... ...
230247
231248
>>> assert isinstance([1, 2, 3], ListOfInt)
232249
>>> assert type([1, 2, 3]) is list
233250
234-
Short, easy, and readable.
251+
Short, easy, and readable:
252+
253+
- By defining ``predicate`` we ensure
254+
that all non-empty lists with ``int`` elements
255+
will be treated as ``ListOfInt``
256+
- In runtime ``ListOfInt`` does not exist, because it is phantom!
257+
In reality it is just ``List[int]``
235258

236-
Now, we can define our typeclass with "phantom" type support:
259+
Now, we can define our typeclass with ``phantom`` type support:
237260

238261
.. code:: python
239262
@@ -272,15 +295,18 @@ Type resolution order
272295

273296
Here's how typeclass resolve types:
274297

275-
1. We try to resolve exact match by a passed type
276-
2. Then we try to match passed type with ``isinstance``
277-
against types that support it, like protocols and delegates,
298+
1. At first we try to resolve types via delegates and ``isinstance`` checks
299+
2. We try to resolve exact match by a passed type
300+
3. Then we try to match passed type with ``isinstance``
301+
against protocol types,
278302
first match wins
279-
3. Then we traverse ``mro`` entries of a given type,
303+
4. Then we traverse ``mro`` entries of a given type,
280304
looking for ones we can handle,
281305
first match wins
282306

283-
We use cache, so calling typeclasses with same object types is fast.
307+
We use cache for all parts of algorithm except the first step
308+
(it is never cached),
309+
so calling typeclasses with same object types is fast.
284310

285311
In other words, it can fallback to more common types:
286312

tests/conftest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import pytest
2+
from contextlib import contextmanager
3+
4+
5+
@pytest.fixture(scope='session')
6+
def clear_cache():
7+
"""Fixture to clear typeclass'es cache before and after."""
8+
@contextmanager
9+
def factory(typeclass):
10+
typeclass._dispatch_cache.clear() # noqa: WPS437
11+
yield
12+
typeclass._dispatch_cache.clear() # noqa: WPS437
13+
return factory

0 commit comments

Comments
 (0)