diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ee488..7b1d2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ 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 types that have `__instancecheck__` defined + via `delegate` argument #248 - Adds support for multiple type arguments in `Supports` type #244 -- Adds support for types that have `__instancecheck__` defined #248 ### Bugfixes diff --git a/classes/_registry.py b/classes/_registry.py index 6649fbe..cd8e8fd 100644 --- a/classes/_registry.py +++ b/classes/_registry.py @@ -1,4 +1,3 @@ -from types import MethodType from typing import Callable, Dict, NoReturn, Optional TypeRegistry = Dict[type, Callable] @@ -11,7 +10,7 @@ def choose_registry( # noqa: WPS211 typ: type, is_protocol: bool, delegate: Optional[type], - concretes: TypeRegistry, + delegates: TypeRegistry, instances: TypeRegistry, protocols: TypeRegistry, ) -> TypeRegistry: @@ -21,20 +20,12 @@ def choose_registry( # noqa: WPS211 It depends on how ``instance`` method is used and also on the type itself. """ if is_protocol and delegate is not None: - raise ValueError('Both `is_protocol` and `delegated` are passed') + raise ValueError('Both `is_protocol` and `delegate` are passed') 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 + elif delegate is not None: + return delegates return instances diff --git a/classes/_typeclass.py b/classes/_typeclass.py index 4b9e0f2..90c5ef7 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -312,7 +312,7 @@ class _TypeClass( # noqa: WPS214 '_associated_type', # Registry: - '_concretes', + '_delegates', '_instances', '_protocols', @@ -361,7 +361,7 @@ def __init__( self._associated_type = associated_type # Registries: - self._concretes: TypeRegistry = {} + self._delegates: TypeRegistry = {} self._instances: TypeRegistry = {} self._protocols: TypeRegistry = {} @@ -418,13 +418,13 @@ def __call__( And all typeclasses that match ``Callable[[int, int], int]`` signature will typecheck. """ - # At first, we try all our concrete types, - # we don't cache it, because we cannot. + # At first, we try all our delegate types, + # we don't cache it, because it is impossible. # We only have runtime type info: `type([1]) == type(['a'])`. # It might be slow! - # Don't add concrete types unless + # Don't add any delegate types unless # you are absolutely know what you are doing. - impl = self._dispatch_concrete(instance) + impl = self._dispatch_delegate(instance) if impl is not None: return impl(instance, *args, **kwargs) @@ -499,21 +499,21 @@ def supports( See also: https://www.python.org/dev/peps/pep-0647 """ # Here we first check that instance is already in the cache - # and only then we check concrete types. + # and only then we check delegate types. # Why? # Because if some type is already in the cache, - # it means that it is not concrete. + # it means that it is not a delegate. # So, this is simply faster. instance_type = type(instance) if instance_type in self._dispatch_cache: return True - # We never cache concrete types. - if self._dispatch_concrete(instance) is not None: + # We never cache delegate types. + if self._dispatch_delegate(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: + # and this is not a delegate type: impl = self._dispatch(instance, instance_type) if impl is None: return False @@ -534,8 +534,9 @@ def instance( We use this method to store implementation for each specific type. Args: - is_protocol - required when passing protocols. - delegate - required when using concrete generics like ``List[str]``. + is_protocol: required when passing protocols. + delegate: required when using delegate types, for example, + when working with concrete generics like ``List[str]``. Returns: Decorator for instance handler. @@ -570,7 +571,7 @@ def decorator(implementation): typ=typ, is_protocol=is_protocol, delegate=delegate, - concretes=self._concretes, + delegates=self._delegates, instances=self._instances, protocols=self._protocols, ) @@ -600,9 +601,9 @@ def _dispatch(self, instance, instance_type: type) -> Optional[Callable]: return _find_impl(instance_type, self._instances) - def _dispatch_concrete(self, instance) -> Optional[Callable]: - for concrete, callback in self._concretes.items(): - if isinstance(instance, concrete): + def _dispatch_delegate(self, instance) -> Optional[Callable]: + for delegate, callback in self._delegates.items(): + if isinstance(instance, delegate): return callback return None diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 44a88f2..f084109 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -21,9 +21,10 @@ mro, type_loader, ) +from classes.contrib.mypy.typeops.instance_context import InstanceContext from classes.contrib.mypy.validation import ( validate_associated_type, - validate_typeclass, + validate_instance, validate_typeclass_def, ) @@ -201,33 +202,21 @@ def __call__(self, ctx: MethodContext) -> MypyType: if not isinstance(instance_signature, CallableType): return ctx.default_return_type - # We need to add `Supports` metadata before typechecking, - # because it will affect type hierarchies. - metadata = mro.MetadataInjector( - typeclass.args[2], - instance_signature.arg_types[0], - ctx, - ) - metadata.add_supports_metadata() - - is_proper_typeclass = validate_typeclass.check_typeclass( + instance_context = InstanceContext.build( typeclass_signature=typeclass.args[1], instance_signature=instance_signature, + passed_args=ctx.type.args[0], + associated_type=typeclass.args[2], fullname=fullname, - passed_types=ctx.type.args[0], ctx=ctx, ) - if not is_proper_typeclass: - # Since the typeclass is not valid, - # we undo the metadata manipulation, - # otherwise we would spam with invalid `Supports[]` base types: - metadata.remove_supports_metadata() + if not self._run_validation(instance_context): return AnyType(TypeOfAny.from_error) # If typeclass is checked, than it is safe to add new instance types: self._add_new_instance_type( typeclass=typeclass, - new_type=instance_signature.arg_types[0], + new_type=instance_context.instance_type, ctx=ctx, ) return ctx.default_return_type @@ -247,6 +236,25 @@ def _load_typeclass( assert isinstance(typeclass, Instance) return typeclass, typeclass_ref.args[3].value + def _run_validation(self, instance_context: InstanceContext) -> bool: + # We need to add `Supports` metadata before typechecking, + # because it will affect type hierarchies. + metadata = mro.MetadataInjector( + associated_type=instance_context.associated_type, + instance_type=instance_context.instance_type, + delegate=instance_context.delegate, + ctx=instance_context.ctx, + ) + metadata.add_supports_metadata() + + is_proper_instance = validate_instance.check_type(instance_context) + if not is_proper_instance: + # Since the typeclass is not valid, + # we undo the metadata manipulation, + # otherwise we would spam with invalid `Supports[]` base types: + metadata.remove_supports_metadata() + return is_proper_instance + def _add_new_instance_type( self, typeclass: Instance, diff --git a/classes/contrib/mypy/typeops/instance_context.py b/classes/contrib/mypy/typeops/instance_context.py new file mode 100644 index 0000000..940dd50 --- /dev/null +++ b/classes/contrib/mypy/typeops/instance_context.py @@ -0,0 +1,184 @@ +from typing import NamedTuple, Optional, Tuple + +from mypy.plugin import MethodContext +from mypy.sametypes import is_same_type +from mypy.types import ( + CallableType, + FunctionLike, + Instance, + LiteralType, + TupleType, +) +from mypy.types import Type as MypyType +from mypy.types import UninhabitedType +from typing_extensions import final + +from classes.contrib.mypy.typeops import inference + + +@final +class InstanceContext(NamedTuple): + """ + Instance definition context. + + We use it to store all important types and data in one place + to help with validation and type manipulations. + """ + + # Signatures: + typeclass_signature: CallableType + instance_signature: CallableType + infered_signature: CallableType + + # Instance / runtime types: + instance_type: MypyType + runtime_type: MypyType + + # Passed arguments: + passed_args: TupleType + is_protocol: Optional[bool] + delegate: Optional[MypyType] + + # Meta: + fullname: str + associated_type: MypyType + + # Mypy context: + ctx: MethodContext + + @classmethod # noqa: WPS211 + def build( # noqa: WPS211 + # It has a lot of arguments, but I don't see how I can simply it. + # I don't want to add steps or intermediate types. + # It is okay for this method to have a lot arguments, + # because it store a lot of data. + cls, + typeclass_signature: CallableType, + instance_signature: CallableType, + passed_args: TupleType, + associated_type: MypyType, + fullname: str, + ctx: MethodContext, + ) -> 'InstanceContext': + """ + Builds instance context. + + It also infers several missing parts from the present data. + Like real ``instance_type`` and arg types. + """ + runtime_type = inference.infer_runtime_type_from_context( + fallback=passed_args.items[0], + fullname=fullname, + ctx=ctx, + ) + + infered_signature = inference.try_to_apply_generics( + signature=typeclass_signature, + runtime_type=runtime_type, + ctx=ctx, + ) + + is_protocol, delegate = _ArgumentInference(passed_args)() + instance_type = _infer_instance_type( + instance_type=instance_signature.arg_types[0], + runtime_type=runtime_type, + delegate=delegate, + ) + + return InstanceContext( + typeclass_signature=typeclass_signature, + instance_signature=instance_signature, + infered_signature=infered_signature, + instance_type=instance_type, + runtime_type=runtime_type, + passed_args=passed_args, + is_protocol=is_protocol, + delegate=delegate, + associated_type=associated_type, + fullname=fullname, + ctx=ctx, + ) + + +@final +class _ArgumentInference(object): + __slots__ = ('_passed_args',) + + def __init__(self, passed_args: TupleType) -> None: + self._passed_args = passed_args + + def __call__(self) -> Tuple[Optional[bool], Optional[MypyType]]: + _, is_protocol, delegate = self._passed_args.items + return ( + self._infer_protocol_arg(is_protocol), + self._infer_delegate_arg(delegate), + ) + + def _infer_protocol_arg( + self, + is_protocol: MypyType, + ) -> Optional[bool]: + if isinstance(is_protocol, UninhabitedType): + return False + + is_protocol_bool = ( + isinstance(is_protocol, Instance) and + isinstance(is_protocol.last_known_value, LiteralType) and + isinstance(is_protocol.last_known_value.value, bool) + ) + if is_protocol_bool: + return is_protocol.last_known_value.value # type: ignore + return None + + def _infer_delegate_arg( + self, + delegate: MypyType, + ) -> Optional[MypyType]: + if isinstance(delegate, FunctionLike) and delegate.is_type_obj(): + return delegate.items()[-1].ret_type + return None + + +def _infer_instance_type( + instance_type: MypyType, + runtime_type: MypyType, + delegate: Optional[MypyType], +) -> MypyType: + """ + Infers real instance type. + + We have three options here. + First one, ``delegate`` is not set at all: + + .. code:: python + + @some.instance(list) + def _some_list(instance: list) -> int: + ... + + Then, inferred instance type is just ``list``. + + Second, we have a delegate of its own: + + .. code:: python + + @some.instance(list, delegate=SomeDelegate) + def _some_list(instance: list) -> int: + ... + + Then, inferred instance type is ``list`` as well. + + Lastly, we can have this case, + when ``delegate`` type is used for instance annotation: + + .. code:: python + + @some.instance(list, delegate=SomeDelegate) + def _some_list(instance: SomeDelegate) -> int: + ... + + In this case, we will use runtime type ``list`` for instance type. + """ + if delegate is not None and is_same_type(instance_type, delegate): + return runtime_type + return instance_type diff --git a/classes/contrib/mypy/typeops/mro.py b/classes/contrib/mypy/typeops/mro.py index 03d0db3..67a2248 100644 --- a/classes/contrib/mypy/typeops/mro.py +++ b/classes/contrib/mypy/typeops/mro.py @@ -2,7 +2,7 @@ from mypy.plugin import MethodContext from mypy.subtypes import is_equivalent -from mypy.types import Instance +from mypy.types import Instance, ProperType from mypy.types import Type as MypyType from mypy.types import TypeVarType, UnionType, union_items from typing_extensions import final @@ -57,12 +57,19 @@ class MetadataInjector(object): But, ``convert_to_string(None)`` will raise a type error. """ - __slots__ = ('_associated_type', '_instance_types', '_ctx', '_added_types') + __slots__ = ( + '_associated_type', + '_instance_types', + '_delegate', + '_ctx', + '_added_types', + ) def __init__( self, associated_type: MypyType, instance_type: MypyType, + delegate: Optional[MypyType], ctx: MethodContext, ) -> None: """ @@ -72,9 +79,17 @@ def __init__( It supports ``Instance`` and ``Union`` types. """ self._associated_type = associated_type - self._instance_types = union_items(instance_type) + self._delegate = delegate self._ctx = ctx + # If delegate is passed, we don't add any types to `instance`'s mro. + # Why? See our `Delegate` docs. + if delegate is None: + self._instance_types = union_items(instance_type) + else: + assert isinstance(delegate, ProperType) + self._instance_types = [delegate] + # Why do we store added types in a mutable global state? # Because, these types are hard to replicate without the proper context. # So, we just keep them here. Based on usage, it is fine. @@ -86,34 +101,33 @@ def add_supports_metadata(self) -> None: return for instance_type in self._instance_types: - assert isinstance(instance_type, Instance) - - supports_spec = self._associated_type.copy_modified(args=[ - TypeVarType(var_def) - for var_def in instance_type.type.defn.type_vars - ]) - supports_spec = type_loader.load_supports_type( - supports_spec, - self._ctx, + if not isinstance(instance_type, Instance): + continue + + supports_type = _load_supports_type( + associated_type=self._associated_type, + instance_type=instance_type, + delegate=self._delegate, + ctx=self._ctx, ) - index = self._find_supports_index(instance_type, supports_spec) + index = self._find_supports_index(instance_type, supports_type) if index is not None: # We already have `Supports` base class inserted, # it means that we need to unify them: # `Supports[A] + Supports[B] == Supports[Union[A, B]]` - self._add_unified_type(instance_type, supports_spec, index) + self._add_unified_type(instance_type, supports_type, index) else: # This is the first time this type is referenced in # a typeclass'es instance defintinion. # Just inject `Supports` with no extra steps: - instance_type.type.bases.append(supports_spec) + instance_type.type.bases.append(supports_type) - if supports_spec.type not in instance_type.type.mro: + if supports_type.type not in instance_type.type.mro: # We only need to add `Supports` type to `mro` once: - instance_type.type.mro.append(supports_spec.type) + instance_type.type.mro.append(supports_type.type) - self._added_types.append(supports_spec) + self._added_types.append(supports_type) def remove_supports_metadata(self) -> None: """Removes ``Supports`` metadata from instance types' mro.""" @@ -121,8 +135,8 @@ def remove_supports_metadata(self) -> None: return for instance_type in self._instance_types: - assert isinstance(instance_type, Instance) - self._clean_instance_type(instance_type) + if isinstance(instance_type, Instance): + self._clean_instance_type(instance_type) self._added_types = [] def _clean_instance_type(self, instance_type: Instance) -> None: @@ -148,40 +162,61 @@ def _clean_instance_type(self, instance_type: Instance) -> None: def _find_supports_index( self, instance_type: Instance, - supports_spec: Instance, + supports_type: Instance, ) -> Optional[int]: for index, base in enumerate(instance_type.type.bases): - if is_equivalent(base, supports_spec, ignore_type_params=True): + if is_equivalent(base, supports_type, ignore_type_params=True): return index return None def _add_unified_type( self, instance_type: Instance, - supports_spec: Instance, + supports_type: Instance, index: int, ) -> None: unified_arg = UnionType.make_union([ - *supports_spec.args, + *supports_type.args, *instance_type.type.bases[index].args, ]) - instance_type.type.bases[index] = supports_spec.copy_modified( + instance_type.type.bases[index] = supports_type.copy_modified( args=[unified_arg], ) def _remove_unified_type( self, instance_type: Instance, - supports_spec: Instance, + supports_type: Instance, index: int, ) -> bool: base = instance_type.type.bases[index] union_types = [ type_arg for type_arg in union_items(base.args[0]) - if type_arg not in supports_spec.args + if type_arg not in supports_type.args ] - instance_type.type.bases[index] = supports_spec.copy_modified( + instance_type.type.bases[index] = supports_type.copy_modified( args=[UnionType.make_union(union_types)], ) return not bool(union_types) + + +def _load_supports_type( + associated_type: Instance, + instance_type: Instance, + delegate: Optional[MypyType], + ctx: MethodContext, +) -> Instance: + # Why do have to modify args of `associated_type`? + # Because `mypy` requires `type_var.id` to match, + # otherwise, they would be treated as different variables. + # That's why we copy the typevar definition from instance itself. + supports_spec = associated_type.copy_modified(args=[ + TypeVarType(var_def) + for var_def in instance_type.type.defn.type_vars + ]) + + return type_loader.load_supports_type( + supports_spec, + ctx, + ) diff --git a/classes/contrib/mypy/validation/validate_instance/__init__.py b/classes/contrib/mypy/validation/validate_instance/__init__.py new file mode 100644 index 0000000..6f607e5 --- /dev/null +++ b/classes/contrib/mypy/validation/validate_instance/__init__.py @@ -0,0 +1,15 @@ +from classes.contrib.mypy.typeops.instance_context import InstanceContext +from classes.contrib.mypy.validation.validate_instance import ( + validate_instance_args, + validate_runtime, + validate_signature, +) + + +def check_type(instance_context: InstanceContext) -> bool: + """Checks that instance definition is correct.""" + return all([ + validate_signature.check_type(instance_context), + validate_runtime.check_type(instance_context), + validate_instance_args.check_type(instance_context), + ]) diff --git a/classes/contrib/mypy/validation/validate_instance/validate_instance_args.py b/classes/contrib/mypy/validation/validate_instance/validate_instance_args.py new file mode 100644 index 0000000..bf401ad --- /dev/null +++ b/classes/contrib/mypy/validation/validate_instance/validate_instance_args.py @@ -0,0 +1,67 @@ +from typing import Optional + +from mypy.plugin import MethodContext +from mypy.types import TupleType, UninhabitedType +from typing_extensions import Final + +from classes.contrib.mypy.typeops.instance_context import InstanceContext + +# Messages: + +_IS_PROTOCOL_LITERAL_BOOL_MSG: Final = ( + 'Use literal bool for "is_protocol" argument, got: "{0}"' +) + +_PROTOCOL_AND_DELEGATE_PASSED_MSG: Final = ( + 'Both "is_protocol" and "delegate" arguments passed, they are exclusive' +) + + +def check_type( + instance_context: InstanceContext, +) -> bool: + """ + Checks that args to ``.instance`` method are correct. + + We cannot use ``@overload`` on ``.instance`` because ``mypy`` + does not correctly handle ``ctx.api.fail`` on ``@overload`` items: + it then tries new ones, which produce incorrect results. + So, that's why we need this custom checker. + """ + return all([ + _check_protocol_arg( + instance_context.is_protocol, + instance_context.passed_args, + instance_context.ctx, + ), + _check_all_args(instance_context.passed_args, instance_context.ctx), + ]) + + +def _check_protocol_arg( + is_protocol: Optional[bool], + passed_args: TupleType, + ctx: MethodContext, +) -> bool: + if is_protocol is None: + ctx.api.fail( + _IS_PROTOCOL_LITERAL_BOOL_MSG.format(passed_args.items[1]), + ctx.context, + ) + return False + return True + + +def _check_all_args( + passed_args: TupleType, + ctx: MethodContext, +) -> bool: + fake_args = [ + passed_arg + for passed_arg in passed_args.items[1:] + if isinstance(passed_arg, UninhabitedType) + ] + if not fake_args: + ctx.api.fail(_PROTOCOL_AND_DELEGATE_PASSED_MSG, ctx.context) + return False + return True diff --git a/classes/contrib/mypy/validation/validate_runtime.py b/classes/contrib/mypy/validation/validate_instance/validate_runtime.py similarity index 58% rename from classes/contrib/mypy/validation/validate_runtime.py rename to classes/contrib/mypy/validation/validate_instance/validate_runtime.py index 456ce30..b193bee 100644 --- a/classes/contrib/mypy/validation/validate_runtime.py +++ b/classes/contrib/mypy/validation/validate_instance/validate_runtime.py @@ -1,14 +1,15 @@ -from typing import NamedTuple, Optional +from typing import Optional from mypy.erasetype import erase_type from mypy.plugin import MethodContext from mypy.sametypes import is_same_type +from mypy.subtypes import is_subtype from mypy.types import CallableType, Instance, TupleType from mypy.types import Type as MypyType -from typing_extensions import Final, final +from typing_extensions import Final -from classes.contrib.mypy.typeops import inference, type_queries -from classes.contrib.mypy.validation import validate_instance_args +from classes.contrib.mypy.typeops import type_queries +from classes.contrib.mypy.typeops.instance_context import InstanceContext # Messages: @@ -24,6 +25,11 @@ 'Regular types must be passed with "is_protocol=False"' ) +_DELEGATE_STRICT_SUBTYPE_MSG: Final = ( + 'Delegate types used for instance annotation "{0}" ' + + 'must be a direct subtype of runtime type "{1}"' +) + _CONCRETE_GENERIC_MSG: Final = ( 'Instance "{0}" has concrete generic type, ' + 'it is not supported during runtime' @@ -38,21 +44,9 @@ ) -@final -class _RuntimeValidationContext(NamedTuple): - """Structure to return required things into other validations.""" - - runtime_type: MypyType - is_protocol: bool - check_result: bool - - -def check_instance_definition( - passed_types: TupleType, - instance_signature: CallableType, - fullname: str, - ctx: MethodContext, -) -> _RuntimeValidationContext: +def check_type( + instance_context: InstanceContext, +) -> bool: """ Checks runtime type. @@ -66,47 +60,72 @@ def check_instance_definition( 4. We check that ``is_protocol`` is passed correctly """ - runtime_type = inference.infer_runtime_type_from_context( - fallback=passed_types.items[0], - fullname=fullname, - ctx=ctx, - ) + return all([ + _check_matching_types( + instance_context.runtime_type, + instance_context.instance_type, + instance_context.delegate, + instance_context.ctx, + ), + _check_runtime_protocol( + instance_context.runtime_type, + is_protocol=instance_context.is_protocol, + ctx=instance_context.ctx, + ), + _check_delegate_type( + instance_context.runtime_type, + instance_context.instance_signature, + instance_context.delegate, + instance_context.ctx, + ), + _check_concrete_generics( + instance_context.runtime_type, + instance_context.instance_type, + instance_context.delegate, + instance_context.ctx, + ), + _check_tuple_size( + instance_context.instance_type, + instance_context.delegate, + instance_context.ctx, + ), + ]) - args_check = validate_instance_args.check_type(passed_types, ctx) - instance_type = instance_signature.arg_types[0] +def _check_matching_types( + runtime_type: MypyType, + instance_type: MypyType, + delegate: Optional[MypyType], + ctx: MethodContext, +) -> bool: instance_check = is_same_type( erase_type(instance_type), erase_type(runtime_type), ) + + if not instance_check and delegate is not None: + instance_check = is_same_type( + instance_type, + delegate, + ) + if not instance_check: ctx.api.fail( _INSTANCE_RUNTIME_MISMATCH_MSG.format(instance_type, runtime_type), ctx.context, ) - - return _RuntimeValidationContext(runtime_type, args_check.is_protocol, all([ - args_check.check_result, - instance_check, - - _check_runtime_protocol( - runtime_type, ctx, is_protocol=args_check.is_protocol, - ), - _check_concrete_generics( - runtime_type, instance_type, args_check.delegate, ctx, - ), - _check_tuple_size(instance_type, ctx), - ])) + return False + return True def _check_runtime_protocol( runtime_type: MypyType, ctx: MethodContext, *, - is_protocol: bool, + is_protocol: Optional[bool], ) -> bool: if isinstance(runtime_type, Instance) and runtime_type.type: - if not is_protocol and runtime_type.type.is_protocol: + if is_protocol is False and runtime_type.type.is_protocol: ctx.api.fail(_IS_PROTOCOL_MISSING_MSG, ctx.context) return False elif is_protocol and not runtime_type.type.is_protocol: @@ -115,6 +134,31 @@ def _check_runtime_protocol( return True +def _check_delegate_type( + runtime_type: MypyType, + instance_signature: CallableType, + delegate: Optional[MypyType], + ctx: MethodContext, +) -> bool: + if delegate is None: + return True + + # We use direct `instance_signature` type here, + # because `instance_context.instance_type` is a complicated beast. + instance_type = instance_signature.arg_types[0] + if not is_same_type(instance_type, delegate): + return True + + is_runtime_subtype = is_subtype(delegate, runtime_type) + if not is_runtime_subtype: + ctx.api.fail( + _DELEGATE_STRICT_SUBTYPE_MSG.format(delegate, runtime_type), + ctx.context, + ) + return False + return True + + def _check_concrete_generics( runtime_type: MypyType, instance_type: MypyType, @@ -146,6 +190,7 @@ def _check_concrete_generics( def _check_tuple_size( instance_type: MypyType, + delegate: Optional[MypyType], ctx: MethodContext, ) -> bool: if isinstance(instance_type, TupleType): diff --git a/classes/contrib/mypy/validation/validate_typeclass.py b/classes/contrib/mypy/validation/validate_instance/validate_signature.py similarity index 81% rename from classes/contrib/mypy/validation/validate_typeclass.py rename to classes/contrib/mypy/validation/validate_instance/validate_signature.py index 9b23bd4..dc9cd65 100644 --- a/classes/contrib/mypy/validation/validate_typeclass.py +++ b/classes/contrib/mypy/validation/validate_instance/validate_signature.py @@ -1,10 +1,11 @@ + from mypy.plugin import MethodContext from mypy.subtypes import is_subtype -from mypy.types import AnyType, CallableType, TupleType, TypeOfAny +from mypy.types import AnyType, CallableType, TypeOfAny from typing_extensions import Final from classes.contrib.mypy.typeops import inference -from classes.contrib.mypy.validation import validate_runtime +from classes.contrib.mypy.typeops.instance_context import InstanceContext _INCOMPATIBLE_INSTANCE_SIGNATURE_MSG: Final = ( 'Instance callback is incompatible "{0}"; expected "{1}"' @@ -19,40 +20,26 @@ ) -def check_typeclass( - typeclass_signature: CallableType, - instance_signature: CallableType, - fullname: str, - passed_types: TupleType, - ctx: MethodContext, +def check_type( + instance_context: InstanceContext, ) -> bool: """ We need to typecheck passed functions in order to build correct typeclasses. Please, see docs on each step. """ - runtime_check = validate_runtime.check_instance_definition( - passed_types, - instance_signature, - fullname, - ctx, - ) - - infered_signature = inference.try_to_apply_generics( - typeclass_signature, - runtime_check.runtime_type, - ctx, - ) - return all([ - runtime_check.check_result, _check_typeclass_signature( - infered_signature, - instance_signature, - ctx, + instance_context.infered_signature, + instance_context.instance_signature, + instance_context.ctx, + ), + _check_instance_type( + instance_context.infered_signature, + instance_context.instance_signature, + instance_context.ctx, ), - _check_instance_type(infered_signature, instance_signature, ctx), - _check_same_typeclass(fullname, ctx), + _check_same_typeclass(instance_context.fullname, instance_context.ctx), ]) @@ -147,10 +134,15 @@ def some(instance: B): ... .. code:: python - (instance: B) - (instance: C) + @some.instance(B) + def _some_b(instance: B): + ... + + @some.instance(C) + def _some_c(instance: C): + ... - Any other cases will raise an error. + Any types that are not subtypes of ``B`` will raise a type error. """ instance_check = is_subtype( instance_signature.arg_types[0], @@ -183,7 +175,7 @@ def _check_same_typeclass( @some.instance(str) @other.instance(int) - def some(instance: Union[str, int]) -> + def some(instance: Union[str, int]) -> None: ... We don't allow this way of instance definition. diff --git a/classes/contrib/mypy/validation/validate_instance_args.py b/classes/contrib/mypy/validation/validate_instance_args.py deleted file mode 100644 index 0b0a05a..0000000 --- a/classes/contrib/mypy/validation/validate_instance_args.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import NamedTuple, Optional, Tuple - -from mypy.plugin import MethodContext -from mypy.types import FunctionLike, Instance, LiteralType, TupleType -from mypy.types import Type as MypyType -from mypy.types import UninhabitedType -from typing_extensions import Final, final - -# Messages: - -_IS_PROTOCOL_LITERAL_BOOL_MSG: Final = ( - 'Use literal bool for "is_protocol" argument, got: "{0}"' -) - -_PROTOCOL_AND_DELEGATE_PASSED_MSG: Final = ( - 'Both "is_protocol" and "delegate" arguments passed, they are exclusive' -) - - -@final -class _ArgValidationContext(NamedTuple): - """Context for instance arg validation.""" - - is_protocol: bool - delegate: Optional[MypyType] - check_result: bool - - -def check_type( - passed_types: TupleType, - ctx: MethodContext, -) -> _ArgValidationContext: - """ - Checks that args to ``.instance`` method are correct. - - We cannot use ``@overload`` on ``.instance`` because ``mypy`` - does not correctly handle ``ctx.api.fail`` on ``@overload`` items: - it then tries new ones, which produce incorrect results. - So, that's why we need this custom checker. - """ - passed_args = passed_types.items - - is_protocol, protocol_check = _check_protocol_arg(passed_args[1], ctx) - delegate, delegate_check = _check_delegate_arg(passed_args[2], ctx) - - return _ArgValidationContext( - is_protocol=is_protocol, - delegate=delegate, - check_result=all([ - protocol_check, - delegate_check, - _check_all_args(passed_types, ctx), - ]), - ) - - -def _check_protocol_arg( - is_protocol: MypyType, - ctx: MethodContext, -) -> Tuple[bool, bool]: - if isinstance(is_protocol, UninhabitedType): - return False, True - - is_protocol_bool = ( - isinstance(is_protocol, Instance) and - isinstance(is_protocol.last_known_value, LiteralType) and - isinstance(is_protocol.last_known_value.value, bool) - ) - if is_protocol_bool: - return is_protocol.last_known_value.value, True # type: ignore - - ctx.api.fail( - _IS_PROTOCOL_LITERAL_BOOL_MSG.format(is_protocol), - ctx.context, - ) - return False, False - - -def _check_delegate_arg( - delegate: MypyType, - ctx: MethodContext, -) -> Tuple[Optional[MypyType], bool]: - if isinstance(delegate, FunctionLike) and delegate.is_type_obj(): - return delegate.items()[-1].ret_type, True - return None, True - - -def _check_all_args( - passed_types: TupleType, - ctx: MethodContext, -) -> bool: - fake_args = [ - passed_arg - for passed_arg in passed_types.items[1:] - if isinstance(passed_arg, UninhabitedType) - ] - if not fake_args: - ctx.api.fail(_PROTOCOL_AND_DELEGATE_PASSED_MSG, ctx.context) - return False - return True diff --git a/docs/conf.py b/docs/conf.py index 1897504..0cdf4dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,21 +15,8 @@ import os import sys -import sphinx - sys.path.insert(0, os.path.abspath('..')) -# TODO: Removes the whole if statement when the below PR is merged: -# https://github.com/mgaitan/sphinxcontrib-mermaid/pull/71 -# From `sphinx>=4` the `ENOENT` constant was fully deprecated, -# in order to make the things work with `sphinxcontrib-mermaid` -# we need to mokey patch that constant. -if sphinx.version_info[0] >= 4: - import errno # noqa: WPS433 - - import sphinx.util.osutil # noqa: I003, WPS301, WPS433 - sphinx.util.osutil.ENOENT = errno.ENOENT - # -- Project information ----------------------------------------------------- diff --git a/docs/pages/concept.rst b/docs/pages/concept.rst index a0c91bc..563bf88 100644 --- a/docs/pages/concept.rst +++ b/docs/pages/concept.rst @@ -95,67 +95,6 @@ to be specified on ``.instance()`` call: >>> assert to_json([1, 'a', None]) == '[1, "a", null]' -``__instancecheck__`` magic method ----------------------------------- - -We also support types that have ``__instancecheck__`` magic method defined, -like `phantom-types `_. - -We treat them similar to ``Protocol`` types, by checking passed values -with ``isinstance`` for each type with ``__instancecheck__`` defined. -First match wins. - -Example: - -.. code:: python - - >>> from classes import typeclass - - >>> class Meta(type): - ... def __instancecheck__(self, other) -> bool: - ... return other == 1 - - >>> class Some(object, metaclass=Meta): - ... ... - - >>> @typeclass - ... def some(instance) -> int: - ... ... - - >>> @some.instance(Some) - ... def _some_some(instance: Some) -> int: - ... return 2 - - >>> argument = 1 - >>> assert isinstance(argument, Some) - >>> assert some(argument) == 2 - -.. warning:: - - It is impossible for ``mypy`` to understand that ``1`` has ``Some`` - type in this example. Be careful, it might break your code! - -This example is not really useful on its own, -because as it was said, it can break things. - -Instead, we are going to learn about -how this feature can be used to model -your domain model precisely with delegates. - -Performance considerations -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Types that are matched via ``__instancecheck__`` are the first one we try. -So, the worst case complexity of this is ``O(n)`` -where ``n`` is the number of types to try. - -We also always try them first and do not cache the result. -This feature is here because we need to handle concrete generics. -But, we recommend to think at least -twice about the performance side of this feature. -Maybe you can just write a function? - - Delegates --------- @@ -172,6 +111,9 @@ that some ``list`` is ``List[int]`` or ``List[str]``: ... TypeError: Subscripted generics cannot be used with class and instance checks +``__instancecheck__`` magic method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + We need some custom type inference mechanism: .. code:: python @@ -197,40 +139,10 @@ Now we can be sure that our ``List[int]`` can be checked in runtime: >>> assert isinstance([1, 'a'], ListOfInt) is False >>> assert isinstance([], ListOfInt) is False # empty -And now we can use it with ``classes``: +``delegate`` argument +~~~~~~~~~~~~~~~~~~~~~ -.. code:: python - - >>> from classes import typeclass - - >>> @typeclass - ... def sum_all(instance) -> int: - ... ... - - >>> @sum_all.instance(ListOfInt) - ... def _sum_all_list_int(instance: ListOfInt) -> int: - ... return sum(instance) - - >>> your_list = [1, 2, 3] - >>> if isinstance(your_list, ListOfInt): - ... assert sum_all(your_list) == 6 - -This solution still has several problems: - -1. Notice, that you have to use ``if isinstance`` or ``assert isinstance`` here. - Because otherwise ``mypy`` won't be happy without it, - type won't be narrowed to ``ListOfInt`` from ``List[int]``. - This does not feel right. -2. ``ListOfInt`` is very verbose, it even has a metaclass! -3. There's a typing mismatch: in runtime ``your_list`` would be ``List[int]`` - and ``mypy`` thinks that it is ``ListOfInt`` - (a fake type that we are not ever using directly) - -delegate argument -~~~~~~~~~~~~~~~~~ - -To solve the first problem, -we can use ``delegate=`` argument to ``.instance`` call: +And now we can use it with ``classes``: .. code:: python @@ -252,8 +164,8 @@ What happens here? When defining an instance with ``delegate`` argument, what we really do is: we add our ``delegate`` into a special registry inside ``sum_all`` typeclass. -This registry is using ``isinstance`` -to find handler that fit the defined predicate. +This registry is using ``isinstance`` function +to find handler that fits the defined predicate. It has the highest priority among other dispatch methods. This allows to sync both runtime and ``mypy`` behavior: @@ -271,9 +183,9 @@ This allows to sync both runtime and ``mypy`` behavior: Phantom types ~~~~~~~~~~~~~ -To solve problems ``2`` and ``3`` we recommend to use ``phantom-types`` package. +Notice, that ``ListOfInt`` is very verbose, it even has an explicit metaclass! -First, you need to define a "phantom" type +There's a better way, you need to define a "phantom" type (it is called "phantom" because it does not exist in runtime): .. code:: python @@ -306,6 +218,19 @@ Now, we can define our typeclass with ``phantom`` type support: .. code:: python + >>> from phantom import Phantom + >>> from phantom.predicates import boolean, collection, generic, numeric + + >>> class ListOfInt( + ... List[int], + ... Phantom, + ... predicate=boolean.both( + ... collection.count(numeric.greater(0)), + ... collection.every(generic.of_type(int)), + ... ), + ... ): + ... ... + >>> from classes import typeclass >>> @typeclass @@ -325,8 +250,12 @@ we delegate all the runtime type checking to ``ListOfInt`` phantom type. Performance considerations ~~~~~~~~~~~~~~~~~~~~~~~~~~ +Types that are matched via ``__instancecheck__`` are the first one we try. Traversing the whole list to check that all elements are of the given type can be really slow. +The worst case complexity of this is ``O(n)`` +where ``n`` is the number of types to try. +We also always try them first and do not cache the result. You might need a different algorithm. Take a look at `beartype `_. @@ -335,13 +264,17 @@ with negligible constant factors. Take a look at their docs to learn more. +We recommend to think at least +twice about the performance side of this feature. +Maybe you can just write a function? + Type resolution order --------------------- Here's how typeclass resolve types: -1. At first we try to resolve types via delegates and ``isinstance`` checks +1. At first we try to resolve types via delegates and ``isinstance`` check 2. We try to resolve exact match by a passed type 3. Then we try to match passed type with ``isinstance`` against protocol types, diff --git a/docs/pages/supports.rst b/docs/pages/supports.rst index ed2393a..3085d07 100644 --- a/docs/pages/supports.rst +++ b/docs/pages/supports.rst @@ -6,6 +6,13 @@ Supports We also have a special type to help you specifying that you want to work with only types that are a part of a specific typeclass. +.. warning:: + ``Supports`` only works with typeclasses defined with associated types. + + +Regular types +------------- + For example, you might want to work with only types that are able to be converted to JSON. @@ -51,6 +58,10 @@ And this will fail (both in runtime and during type checking): ... NotImplementedError: Missing matched typeclass instance for type: NoneType + +Supports for instance annotations +--------------------------------- + You can also use ``Supports`` as a type annotation for defining typeclasses: .. code:: python @@ -74,5 +85,103 @@ One more tip, our team would recommend this style: ... class MyFeature(AssociatedType): ... """Tell us, what this typeclass is about.""" -.. warning:: - ``Supports`` only works with typeclasses defined with associated types. + +Supports and delegates +---------------------- + +``Supports`` type has a special handling of ``delegate`` types. +Let's see an example. We would start with defining a ``delegate`` type: + +.. code:: python + + >>> from typing import List + >>> from classes import AssociatedType, Supports, typeclass + + >>> class ListOfIntMeta(type): + ... def __instancecheck__(cls, arg) -> bool: + ... return ( + ... isinstance(arg, list) and + ... bool(arg) and + ... all(isinstance(list_item, int) for list_item in arg) + ... ) + + >>> class ListOfInt(List[int], metaclass=ListOfIntMeta): + ... ... + +Now, let's define a typeclass: + +.. code:: python + + >>> class SumAll(AssociatedType): + ... ... + + >>> @typeclass(SumAll) + ... def sum_all(instance) -> int: + ... ... + + >>> @sum_all.instance(List[int], delegate=ListOfInt) + ... def _sum_all_list_int( + ... # It can be either `List[int]` or `ListOfInt` + ... instance: List[int], + ... ) -> int: + ... return sum(instance) + +And a function with ``Supports`` type: + +.. code:: python + + >>> def test(to_sum: Supports[SumAll]) -> int: + ... return sum_all(to_sum) + +This will not make ``mypy`` happy: + +.. code:: python + + >>> list1 = [1, 2, 3] + >>> assert test(list1) == 6 # Argument 1 to "test" has incompatible type "List[int]"; expected "Supports[SumAll]" + +It will be treated the same as unsupported cases, like ``List[str]``: + +.. code:: python + + list2: List[str] + test(list2) # Argument 1 to "test" has incompatible type "List[int]"; expected "Supports[SumAll]" + +But, this will work correctly: + +.. code:: python + + >>> list_of_int = ListOfInt([1, 2, 3]) + >>> assert test(list_of_int) == 6 # ok + + >>> list1 = [1, 2, 3] + >>> if isinstance(list1, ListOfInt): + ... assert test(list1) == 6 # ok + +This happens because we don't treat ``List[int]`` as ``Supports[SumAll]``. +This is by design. + +But, we treat ``ListOfInt`` as ``Supports[SumAll]``. +So, you would need to narrow ``List[int]`` to ``ListOfInt`` to make it work. + +General cases +~~~~~~~~~~~~~ + +One way to make ``List[int]`` to work without explicit type narrowing +is to define a generic case for all ``list`` subtypes: + +.. code:: python + + >>> @sum_all.instance(list) + ... def _sum_all_list(instance: list) -> int: + ... return 0 + +Now, this will work: + +.. code:: python + + >>> list1 = [1, 2, 3] + >>> assert test(list1) == 6 # ok + + >>> list2 = ['a', 'b'] + >>> assert test(list2) == 0 # ok diff --git a/poetry.lock b/poetry.lock index 3ae02c2..c76e0f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -455,7 +455,7 @@ typing-extensions = {version = ">=3.7.4.0", markers = "python_version < \"3.8\"" [[package]] name = "identify" -version = "2.2.10" +version = "2.2.11" description = "File identification library for Python" category = "dev" optional = false @@ -1512,8 +1512,8 @@ gitpython = [ {file = "GitPython-3.1.18.tar.gz", hash = "sha256:b838a895977b45ab6f0cc926a9045c8d1c44e2b653c1fcc39fe91f42c6e8f05b"}, ] identify = [ - {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, - {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, + {file = "identify-2.2.11-py2.py3-none-any.whl", hash = "sha256:7abaecbb414e385752e8ce02d8c494f4fbc780c975074b46172598a28f1ab839"}, + {file = "identify-2.2.11.tar.gz", hash = "sha256:a0e700637abcbd1caae58e0463861250095dfe330a8371733a471af706a4a29a"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, diff --git a/tests/test_typeclass/test_cache.py b/tests/test_typeclass/test_cache.py index 6706776..3b02b45 100644 --- a/tests/test_typeclass/test_cache.py +++ b/tests/test_typeclass/test_cache.py @@ -43,11 +43,11 @@ def test_cache_concrete(clear_cache) -> None: # noqa: WPS218 assert not my_typeclass._dispatch_cache # noqa: WPS437 assert my_typeclass(_MyConcrete()) == 1 - assert not my_typeclass._dispatch_cache # noqa: WPS437 + assert _MyConcrete in my_typeclass._dispatch_cache # noqa: WPS437 _MyABC.register(_MyRegistered) assert my_typeclass(_MyRegistered()) == 2 # type: ignore - assert not my_typeclass._dispatch_cache # noqa: WPS437 + assert _MyRegistered in my_typeclass._dispatch_cache # noqa: WPS437 def test_cached_calls(clear_cache) -> None: diff --git a/typesafety/test_typeclass/test_generics/test_generics_concrete.yml b/typesafety/test_typeclass/test_generics/test_generics_concrete.yml index ff87d2e..f78cc47 100644 --- a/typesafety/test_typeclass/test_generics/test_generics_concrete.yml +++ b/typesafety/test_typeclass/test_generics/test_generics_concrete.yml @@ -20,6 +20,150 @@ some(['a']) # E: List item 0 has incompatible type "str"; expected "int" +- case: typeclass_concrete_generic_annotated_as_delegate + disable_cache: false + main: | + from typing import List + from classes import typeclass + + class SomeDelegate(List[int]): + ... + + @typeclass + def some(instance) -> int: + ... + + @some.instance(List[int], delegate=SomeDelegate) + def _some_list_int(instance: SomeDelegate) -> int: + ... + + some([1, 2, 3]) + some([]) + some(['a']) # E: List item 0 has incompatible type "str"; expected "int" + + +- case: typeclass_delegate_not_subtype_wrong + disable_cache: false + main: | + from typing import List + from classes import typeclass + + class SomeDelegate(object): + ... + + @typeclass + def some(instance) -> int: + ... + + @some.instance(List[int], delegate=SomeDelegate) + def _some_list_int(instance: SomeDelegate) -> int: + ... + out: | + main:11: error: Delegate types used for instance annotation "main.SomeDelegate" must be a direct subtype of runtime type "builtins.list[builtins.int*]" + + +- case: typeclass_delegate_not_subtype_correct + disable_cache: false + main: | + from typing import List + from classes import typeclass + + class SomeDelegate(object): + ... + + @typeclass + def some(instance) -> int: + ... + + @some.instance(List[int], delegate=SomeDelegate) + def _some_list_int(instance: List[int]) -> int: + ... + + +- case: typeclass_concrete_generic_supports_delegate + disable_cache: false + main: | + from classes import typeclass, Supports, AssociatedType + from typing import List + + class ListOfIntMeta(type): + def __instancecheck__(cls, arg) -> bool: + return ( + isinstance(arg, list) and + bool(arg) and + all(isinstance(list_item, int) for list_item in arg) + ) + + class ListOfInt(List[int], metaclass=ListOfIntMeta): + ... + + class A(AssociatedType): + ... + + @typeclass(A) + def sum_all(instance) -> int: + ... + + @sum_all.instance(List[int], delegate=ListOfInt) + def _sum_all_list_int(instance: ListOfInt) -> int: + return sum(instance) + + def test(a: Supports[A]): + ... + + a: ListOfInt + b: List[int] + c: List[str] + test(a) + test(b) + test(c) + out: | + main:33: error: Argument 1 to "test" has incompatible type "List[int]"; expected "Supports[A]" + main:34: error: Argument 1 to "test" has incompatible type "List[str]"; expected "Supports[A]" + + +- case: typeclass_concrete_generic_supports_instance + disable_cache: false + main: | + from classes import typeclass, Supports, AssociatedType + from typing import List + + class ListOfIntMeta(type): + def __instancecheck__(cls, arg) -> bool: + return ( + isinstance(arg, list) and + bool(arg) and + all(isinstance(list_item, int) for list_item in arg) + ) + + class ListOfInt(List[int], metaclass=ListOfIntMeta): + ... + + class A(AssociatedType): + ... + + @typeclass(A) + def sum_all(instance) -> int: + ... + + @sum_all.instance(List[int], delegate=ListOfInt) + def _sum_all_list_int(instance: List[int]) -> int: + return sum(instance) + + def test(a: Supports[A]): + ... + + a: ListOfInt + b: List[int] + c: List[str] + test(a) + test(b) + test(c) + out: | + main:33: error: Argument 1 to "test" has incompatible type "List[int]"; expected "Supports[A]" + main:34: error: Argument 1 to "test" has incompatible type "List[str]"; expected "Supports[A]" + + - case: typeclass_concrete_generic_delegate_and_protocol disable_cache: false main: |