From 3b0885c17b5f9a86c8287f5bb3f48bf84a7f84c6 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 3 Jun 2021 13:40:08 +0300 Subject: [PATCH 01/12] Closes #8, closes #84 --- classes/_typeclass.py | 46 ++- classes/contrib/mypy/classes_plugin.py | 350 +----------------- classes/contrib/mypy/features/__init__.py | 0 classes/contrib/mypy/features/typeclass.py | 329 ++++++++++++++++ classes/contrib/mypy/typeops/__init__.py | 0 .../mypy/typeops/instance_signature.py | 77 ++++ classes/contrib/mypy/typeops/type_loader.py | 27 ++ classes/contrib/mypy/typeops/typecheck.py | 98 +++++ docs/conf.py | 1 + poetry.lock | 18 +- .../test_definition_by_class.yml | 4 +- typesafety/test_typeclass/test_generics.yml | 21 ++ typesafety/test_typeclass/test_instance.yml | 36 +- 13 files changed, 637 insertions(+), 370 deletions(-) create mode 100644 classes/contrib/mypy/features/__init__.py create mode 100644 classes/contrib/mypy/features/typeclass.py create mode 100644 classes/contrib/mypy/typeops/__init__.py create mode 100644 classes/contrib/mypy/typeops/instance_signature.py create mode 100644 classes/contrib/mypy/typeops/type_loader.py create mode 100644 classes/contrib/mypy/typeops/typecheck.py create mode 100644 typesafety/test_typeclass/test_generics.yml diff --git a/classes/_typeclass.py b/classes/_typeclass.py index 99fb1f6..7bdcc58 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -12,17 +12,19 @@ from typing_extensions import Literal, final _TypeClassType = TypeVar('_TypeClassType') -_ReturnType = TypeVar('_ReturnType') -_CallbackType = TypeVar('_CallbackType', bound=Callable) +_ReturnType = TypeVar('_ReturnType') # TODO: do we really need it? + +_SignatureType = TypeVar('_SignatureType', bound=Callable) + _InstanceType = TypeVar('_InstanceType') _DefinitionType = TypeVar('_DefinitionType', bound=Type) def typeclass( - signature: _CallbackType, + signature: _SignatureType, # By default `_TypeClassType` and `_ReturnType` are `nothing`, # but we enhance them via mypy plugin later: -) -> '_TypeClass[_TypeClassType, _ReturnType, _CallbackType, _DefinitionType]': +) -> '_TypeClass[_TypeClassType, _SignatureType, _DefinitionType]': """ Function to define typeclasses. @@ -188,7 +190,7 @@ def typeclass( return _TypeClass(signature) -class Supports(Generic[_CallbackType]): +class Supports(Generic[_SignatureType]): """ Used to specify that some value is a part of a typeclass. @@ -238,9 +240,7 @@ class Supports(Generic[_CallbackType]): @final -class _TypeClass( - Generic[_TypeClassType, _ReturnType, _CallbackType, _DefinitionType], -): +class _TypeClass(Generic[_TypeClassType, _SignatureType, _DefinitionType]): """ That's how we represent typeclasses. @@ -275,7 +275,7 @@ class _TypeClass( __slots__ = ('_instances', '_protocols') - def __init__(self, signature: _CallbackType) -> None: + def __init__(self, signature: _SignatureType) -> None: """ Protected constructor of the typeclass. @@ -298,7 +298,7 @@ def __init__(self, signature: _CallbackType) -> None: the same return type for all cases. Only modified once during ``@typeclass`` creation - - ``_CallbackType`` is used to ensure that all parameters + - ``_SignatureType`` is used to ensure that all parameters for all type cases are the same. That's how we enforce consistency in all function signatures. The only exception is the first argument: it is polymorfic. @@ -405,13 +405,6 @@ def instance( ]: """Case for regular typeclasses.""" - @overload - def instance( - self, - type_argument: Callable[[_InstanceType], _ReturnType], - ) -> NoReturn: - """Case for typeclasses that are defined by annotation only.""" - @overload def instance( self, @@ -424,6 +417,25 @@ def instance( ]: """Case for protocol based typeclasses.""" + @overload + def instance( + self, + type_argument: Callable, # See `mypy` plugin for more specific type + ) -> NoReturn: + """ + Case for typeclasses that are defined by annotation only. + + We do not limit what callables can be passed here with type annotations, + because it is too complex to express. + + For example, we require different variance rules + for different function arguments. + The first argument should be strictly covariant (more specific). + Other arguments should be similar or contravariant (less specific). + + See our ``mypy`` plugin for more details. + """ + def instance( self, type_argument, diff --git a/classes/contrib/mypy/classes_plugin.py b/classes/contrib/mypy/classes_plugin.py index 0d4e31d..9cc3e2b 100644 --- a/classes/contrib/mypy/classes_plugin.py +++ b/classes/contrib/mypy/classes_plugin.py @@ -17,350 +17,18 @@ """ -from typing import Callable, Optional, Type, Union +from typing import Callable, Optional, Type -from mypy.nodes import ARG_POS, Decorator, MemberExpr from mypy.plugin import FunctionContext, MethodContext, MethodSigContext, Plugin -from mypy.typeops import bind_self -from mypy.types import AnyType, CallableType, Instance +from mypy.types import CallableType from mypy.types import Type as MypyType -from mypy.types import ( - TypeOfAny, - TypeVarType, - UninhabitedType, - UnionType, - get_proper_type, -) from typing_extensions import final - -@final -class _AdjustArguments(object): - """ - Adjust argument types when we define typeclasses via ``typeclass`` function. - - It has two modes: - 1. As a decorator ``@typeclass`` - 2. As a regular call with a class definition: ``typeclass(SomeProtocol)`` - - It also checks how typeclasses are defined. - """ - - def __call__(self, ctx: FunctionContext) -> MypyType: - defn = ctx.arg_types[0][0] - is_defined_by_class = ( - isinstance(defn, CallableType) and - not defn.arg_types and - isinstance(defn.ret_type, Instance) - ) - - if is_defined_by_class: - return self._adjust_protocol_arguments(ctx) - elif isinstance(defn, CallableType): - return self._adjust_function_arguments(ctx) - return ctx.default_return_type - - def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: - assert isinstance(ctx.arg_types[0][0], CallableType) - assert isinstance(ctx.arg_types[0][0].ret_type, Instance) - - instance = ctx.arg_types[0][0].ret_type - type_info = instance.type - signature = type_info.get_method('__call__') - if not signature: - ctx.api.fail( - 'Typeclass definition must have `__call__` method', - ctx.context, - ) - return AnyType(TypeOfAny.from_error) - - signature_type = get_proper_type(signature.type) - assert isinstance(signature_type, CallableType) - return self._adjust_typeclass( - bind_self(signature_type), - ctx, - class_definition=instance, - ) - - def _adjust_function_arguments(self, ctx: FunctionContext) -> MypyType: - assert isinstance(ctx.default_return_type, Instance) - - typeclass_def = ctx.default_return_type.args[2] - assert isinstance(typeclass_def, CallableType) - return self._adjust_typeclass(typeclass_def, ctx) - - def _adjust_typeclass( - self, - typeclass_def: MypyType, - ctx: FunctionContext, - class_definition: Optional[Instance] = None, - ) -> MypyType: - assert isinstance(typeclass_def, CallableType) - assert isinstance(ctx.default_return_type, Instance) - - typeclass_def.arg_types[0] = UninhabitedType() - - args = [ - typeclass_def.arg_types[0], - typeclass_def.ret_type, - ] - - definition_type = ( - class_definition - if class_definition - else UninhabitedType() - ) - - ctx.default_return_type.args = (*args, typeclass_def, definition_type) - return ctx.default_return_type - - -def _adjust_call_signature(ctx: MethodSigContext) -> CallableType: - """Returns proper ``__call__`` signature of a typeclass.""" - assert isinstance(ctx.type, Instance) - - real_signature = ctx.type.args[2] - if not isinstance(real_signature, CallableType): - return ctx.default_signature - - real_signature.arg_types[0] = ctx.type.args[0] - - if isinstance(ctx.type.args[3], Instance): - supports_spec = _load_supports_type(ctx.type.args[3], ctx) - real_signature.arg_types[0] = UnionType.make_union([ - real_signature.arg_types[0], - supports_spec, - ]) - - return real_signature +from classes.contrib.mypy.features import typeclass @final -class _AdjustInstanceSignature(object): - """ - Adjusts the typing signature after ``.instance(type)`` call. - - We need this to get typing match working: - so the type mentioned in ``.instance()`` call - will be the same as the one in a function later on. - """ - - def __call__(self, ctx: MethodContext) -> MypyType: - if not isinstance(ctx.type, Instance): - return ctx.default_return_type - if not isinstance(ctx.default_return_type, CallableType): - return ctx.default_return_type - - instance_type = self._adjust_typeclass_callable(ctx) - self._adjust_typeclass_type(ctx, instance_type) - if isinstance(instance_type, Instance): - self._add_supports_metadata(ctx, instance_type) - return ctx.default_return_type - - @classmethod - def from_function_decorator(cls, ctx: FunctionContext) -> MypyType: - """ - It is used when ``.instance`` is used without params as a decorator. - - Like: - - .. code:: python - - @some.instance - def _some_str(instance: str) -> str: - ... - - """ - is_decorator = ( - isinstance(ctx.context, Decorator) and - len(ctx.context.decorators) == 1 and - isinstance(ctx.context.decorators[0], MemberExpr) and - ctx.context.decorators[0].name == 'instance' - ) - if not is_decorator: - return ctx.default_return_type - - passed_function = ctx.arg_types[0][0] - assert isinstance(passed_function, CallableType) - - if not passed_function.arg_types: - return ctx.default_return_type - - annotation_type = passed_function.arg_types[0] - if isinstance(annotation_type, Instance): - if annotation_type.type and annotation_type.type.is_protocol: - ctx.api.fail( - 'Protocols must be passed with `is_protocol=True`', - ctx.context, - ) - return ctx.default_return_type - else: - ctx.api.fail( - 'Only simple instance types are allowed, got: {0}'.format( - annotation_type, - ), - ctx.context, - ) - return ctx.default_return_type - - ret_type = CallableType( - arg_types=[passed_function], - arg_kinds=[ARG_POS], - arg_names=[None], - ret_type=AnyType(TypeOfAny.implementation_artifact), - fallback=passed_function.fallback, - ) - instance_type = ctx.api.expr_checker.accept( # type: ignore - ctx.context.decorators[0].expr, # type: ignore - ) - - # We need to change the `ctx` type from `Function` to `Method`: - return cls()(MethodContext( - type=instance_type, - arg_types=ctx.arg_types, - arg_kinds=ctx.arg_kinds, - arg_names=ctx.arg_names, - args=ctx.args, - callee_arg_names=ctx.callee_arg_names, - default_return_type=ret_type, - context=ctx.context, - api=ctx.api, - )) - - def _adjust_typeclass_callable( - self, - ctx: MethodContext, - ) -> MypyType: - assert isinstance(ctx.type, Instance) - assert isinstance(ctx.default_return_type, CallableType) - - real_signature = ctx.type.args[2] - to_adjust = ctx.default_return_type.arg_types[0] - - assert isinstance(real_signature, CallableType) - assert isinstance(to_adjust, CallableType) - - instance_type = to_adjust.arg_types[0] - instance_kind = to_adjust.arg_kinds[0] - instance_name = to_adjust.arg_names[0] - - to_adjust.arg_types = real_signature.arg_types - to_adjust.arg_kinds = real_signature.arg_kinds - to_adjust.arg_names = real_signature.arg_names - to_adjust.variables = real_signature.variables - to_adjust.is_ellipsis_args = real_signature.is_ellipsis_args - - to_adjust.arg_types[0] = instance_type - to_adjust.arg_kinds[0] = instance_kind - to_adjust.arg_names[0] = instance_name - - return instance_type - - def _adjust_typeclass_type( - self, - ctx: MethodContext, - instance_type: MypyType, - ) -> None: - assert isinstance(ctx.type, Instance) - - unified = list(filter( - # It means that function was defined without annotation - # or with explicit `Any`, we prevent our Union from pollution. - # Because `Union[Any, int]` is just `Any`. - # We also clear accidental type vars. - self._filter_out_unified_types, - [instance_type, ctx.type.args[0]], - )) - - ctx.type.args = ( - UnionType.make_union(unified), - *ctx.type.args[1:], - ) - - def _add_supports_metadata( - self, - ctx: MethodContext, - instance_type: Instance, - ) -> None: - """ - Injects fake ``Supports[TypeClass]`` parent classes into ``mro``. - - Ok, this is wild. Why do we need this? - Because, otherwise expressing ``Supports`` is not possible, - here's an example: - - .. code:: python - - >>> from classes import Supports, typeclass - >>> from typing_extensions import Protocol - - >>> class ToStr(Protocol): - ... def __call__(self, instance) -> str: - ... ... - - >>> to_str = typeclass(ToStr) - >>> @to_str.instance(int) - ... def _to_str_int(instance: int) -> str: - ... return 'Number: {0}'.format(instance) - - >>> assert to_str(1) == 'Number: 1' - - Now, let's use ``Supports`` to only pass specific - typeclass instances in a function: - - .. code:: python - - >>> def convert_to_string(arg: Supports[ToStr]) -> str: - ... return to_str(arg) - - This is possible, due to a fact that we insert ``Supports[ToStr]`` - into all classes that are mentioned as ``.instance()`` for ``ToStr`` - typeclass. - - So, we can call: - - .. code:: python - - >>> assert convert_to_string(1) == 'Number: 1' - - But, ``convert_to_string(None)`` will raise a type error. - """ - assert isinstance(ctx.type, Instance) - - supports_spec = _load_supports_type(ctx.type.args[3], ctx) - - if supports_spec not in instance_type.type.bases: - instance_type.type.bases.append(supports_spec) - if supports_spec.type not in instance_type.type.mro: - instance_type.type.mro.insert(0, supports_spec.type) - - def _filter_out_unified_types(self, type_: MypyType) -> bool: - return not isinstance(type_, (TypeVarType, UninhabitedType)) - - -def _load_supports_type( - arg_type: MypyType, - ctx: Union[MethodContext, MethodSigContext], -) -> Instance: - """ - Loads ``Supports[]`` type with proper generic type. - - It uses the short name, - because for some reason full name is not always loaded. - """ - assert isinstance(ctx.type, Instance) - class_definition = ctx.type.args[3] - - supports_spec = ctx.api.named_generic_type( - 'classes.Supports', - [class_definition], - ) - assert supports_spec - supports_spec.type._promote = None # noqa: WPS437 - return supports_spec - - -class _TypedDecoratorPlugin(Plugin): +class _TypeClassPlugin(Plugin): """ Our plugin for typeclasses. @@ -378,10 +46,10 @@ def get_function_hook( ) -> Optional[Callable[[FunctionContext], MypyType]]: """Here we adjust the typeclass constructor.""" if fullname == 'classes._typeclass.typeclass': - return _AdjustArguments() + return typeclass.ConstructorReturnType() if fullname == 'instance of _TypeClass': # `@some.instance` call without params: - return _AdjustInstanceSignature.from_function_decorator + return typeclass.InstanceReturnType.from_function_decorator return None def get_method_hook( @@ -391,7 +59,7 @@ def get_method_hook( """Here we adjust the typeclass with new allowed types.""" if fullname == 'classes._typeclass._TypeClass.instance': # `@some.instance` call with explicit params: - return _AdjustInstanceSignature() + return typeclass.InstanceReturnType() return None def get_method_signature_hook( @@ -400,10 +68,10 @@ def get_method_signature_hook( ) -> Optional[Callable[[MethodSigContext], CallableType]]: """Here we fix the calling method types to accept only valid types.""" if fullname == 'classes._typeclass._TypeClass.__call__': - return _adjust_call_signature + return typeclass.call_signature return None def plugin(version: str) -> Type[Plugin]: """Plugin's public API and entrypoint.""" - return _TypedDecoratorPlugin + return _TypeClassPlugin diff --git a/classes/contrib/mypy/features/__init__.py b/classes/contrib/mypy/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py new file mode 100644 index 0000000..8798490 --- /dev/null +++ b/classes/contrib/mypy/features/typeclass.py @@ -0,0 +1,329 @@ +from typing import Optional + +from mypy.checker import detach_callable +from mypy.nodes import ARG_POS, Decorator, MemberExpr +from mypy.plugin import FunctionContext, MethodContext, MethodSigContext +from mypy.typeops import bind_self +from mypy.types import AnyType, CallableType, Instance +from mypy.types import Type as MypyType +from mypy.types import ( + TypeOfAny, + TypeVarType, + UninhabitedType, + UnionType, + get_proper_type, +) +from typing_extensions import final + +from classes.contrib.mypy.typeops import ( + instance_signature, + type_loader, + typecheck, +) + + +@final +class ConstructorReturnType(object): + """ + Adjust argument types when we define typeclasses via ``typeclass`` function. + + It has two modes: + 1. As a decorator ``@typeclass`` + 2. As a regular call with a class definition: ``typeclass(SomeProtocol)`` + + It also checks how typeclasses are defined. + """ + + def __call__(self, ctx: FunctionContext) -> MypyType: + defn = ctx.arg_types[0][0] + is_defined_by_class = ( + isinstance(defn, CallableType) and + not defn.arg_types and + isinstance(defn.ret_type, Instance) + ) + + if is_defined_by_class: + return self._adjust_protocol_arguments(ctx) + elif isinstance(defn, CallableType): + return self._adjust_typeclass(defn, ctx) + return ctx.default_return_type + + def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: + assert isinstance(ctx.arg_types[0][0], CallableType) + assert isinstance(ctx.arg_types[0][0].ret_type, Instance) + + instance = ctx.arg_types[0][0].ret_type + type_info = instance.type + signature = type_info.get_method('__call__') + if not signature: + ctx.api.fail( + 'Typeclass definition must have `__call__` method', + ctx.context, + ) + return AnyType(TypeOfAny.from_error) + + signature_type = get_proper_type(signature.type) + assert isinstance(signature_type, CallableType) + return self._adjust_typeclass( + bind_self(signature_type), + ctx, + class_definition=instance, + ) + + def _adjust_typeclass( + self, + typeclass_def: MypyType, + ctx: FunctionContext, + class_definition: Optional[Instance] = None, + ) -> MypyType: + assert isinstance(typeclass_def, CallableType) + assert isinstance(ctx.default_return_type, Instance) + + ctx.default_return_type.args = ( + UninhabitedType(), # We start with empty set of instances + typeclass_def, + class_definition if class_definition else UninhabitedType(), + ) + return ctx.default_return_type + + +@final +class InstanceReturnType(object): + """ + Adjusts the typing signature after ``.instance(type)`` call. + + We need this to get typing match working: + so the type mentioned in ``.instance()`` call + will be the same as the one in a function later on. + + We use ``ctx.arg_names[0]`` to determine which mode is used: + 1. If it is empty, than annotation-based dispatch method is used + 2. If it is not empty, that means that decorator with arguments is used, + like ``@some.instance(MyType)`` + + """ + + def __call__(self, ctx: MethodContext) -> MypyType: + """""" + if not isinstance(ctx.type, Instance): + return ctx.default_return_type + if not isinstance(ctx.default_return_type, CallableType): + # We need this line to trigger + # `OverloadedDef` proper branch detection, + # without it would consider this return type as the correct one + # (usually it is `NoReturn` here when wrong overload is used): + ctx.api.fail('Bad return type', ctx.context) # Not shown to user + return ctx.default_return_type + + signature = self._adjust_typeclass_callable(ctx) + if not typecheck.check_typeclass(signature, ctx): + return ctx.default_return_type + + instance_type = self._add_new_instance_type(ctx) + self._add_supports_metadata(ctx, instance_type) + return detach_callable(ctx.default_return_type) + + @classmethod + def from_function_decorator(cls, ctx: FunctionContext) -> MypyType: + """ + It is used when ``.instance`` is used without params as a decorator. + + Like: + + .. code:: python + + @some.instance + def _some_str(instance: str) -> str: + ... + + """ + is_decorator = ( + isinstance(ctx.context, Decorator) and + len(ctx.context.decorators) == 1 and + isinstance(ctx.context.decorators[0], MemberExpr) and + ctx.context.decorators[0].name == 'instance' + ) + if not is_decorator: + return ctx.default_return_type + + passed_function = ctx.arg_types[0][0] + assert isinstance(passed_function, CallableType) + + if not passed_function.arg_types: + return ctx.default_return_type + + annotation_type = passed_function.arg_types[0] + if isinstance(annotation_type, Instance): + if annotation_type.type and annotation_type.type.is_protocol: + ctx.api.fail( + 'Protocols must be passed with `is_protocol=True`', + ctx.context, + ) + return ctx.default_return_type + else: + ctx.api.fail( + 'Only simple instance types are allowed, got: {0}'.format( + annotation_type, + ), + ctx.context, + ) + return ctx.default_return_type + + ret_type = CallableType( + arg_types=[passed_function], + arg_kinds=[ARG_POS], + arg_names=[None], + ret_type=AnyType(TypeOfAny.implementation_artifact), + fallback=passed_function.fallback, + ) + instance_type = ctx.api.expr_checker.accept( # type: ignore + ctx.context.decorators[0].expr, # type: ignore + ) + + # We need to change the `ctx` type from `Function` to `Method`: + return cls()(MethodContext( + type=instance_type, + arg_types=ctx.arg_types, + arg_kinds=ctx.arg_kinds, + arg_names=ctx.arg_names, + args=ctx.args, + callee_arg_names=ctx.callee_arg_names, + default_return_type=ret_type, + context=ctx.context, + api=ctx.api, + )) + + def _adjust_typeclass_callable( + self, + ctx: MethodContext, + ) -> CallableType: + """Prepares callback""" + assert isinstance(ctx.default_return_type, CallableType) + assert isinstance(ctx.type, Instance) + + if not ctx.arg_names[0]: + # We only need to adjust callables + # that are passed via a decorator with params, + # annotations-only are ignored: + return ctx.default_return_type + + real_signature = ctx.type.args[1].copy_modified() + to_adjust = ctx.default_return_type.arg_types[0] + assert isinstance(real_signature, CallableType) + assert isinstance(to_adjust, CallableType) + + ctx.default_return_type.arg_types[0] = instance_signature.prepare( + real_signature, + to_adjust.arg_types[0], + ctx, + ) + return ctx.default_return_type.arg_types[0] + + def _add_new_instance_type( + self, + ctx: MethodContext, + ) -> MypyType: + """Adds new types into type argument 0 by unifing unique types.""" + assert isinstance(ctx.type, Instance) + assert isinstance(ctx.default_return_type, CallableType) + assert isinstance(ctx.default_return_type.arg_types[0], CallableType) + + instance_type = ctx.default_return_type.arg_types[0].arg_types[0] + unified = list(set(filter( + # It means that function was defined without annotation + # or with explicit `Any`, we prevent our Union from pollution. + # Because `Union[Any, int]` is just `Any`. + # We also clear accidental type vars. + lambda type_: not isinstance(type_, (TypeVarType, UninhabitedType)), + [instance_type, ctx.type.args[0]], + ))) + + ctx.type.args = ( + UnionType.make_union(unified), + *ctx.type.args[1:], + ) + return instance_type + + def _add_supports_metadata( + self, + ctx: MethodContext, + instance_type: MypyType, + ) -> None: + """ + Injects fake ``Supports[TypeClass]`` parent classes into ``mro``. + + Ok, this is wild. Why do we need this? + Because, otherwise expressing ``Supports`` is not possible, + here's an example: + + .. code:: python + + >>> from classes import Supports, typeclass + >>> from typing_extensions import Protocol + + >>> class ToStr(Protocol): + ... def __call__(self, instance) -> str: + ... ... + + >>> to_str = typeclass(ToStr) + >>> @to_str.instance(int) + ... def _to_str_int(instance: int) -> str: + ... return 'Number: {0}'.format(instance) + + >>> assert to_str(1) == 'Number: 1' + + Now, let's use ``Supports`` to only pass specific + typeclass instances in a function: + + .. code:: python + + >>> def convert_to_string(arg: Supports[ToStr]) -> str: + ... return to_str(arg) + + This is possible, due to a fact that we insert ``Supports[ToStr]`` + into all classes that are mentioned as ``.instance()`` for ``ToStr`` + typeclass. + + So, we can call: + + .. code:: python + + >>> assert convert_to_string(1) == 'Number: 1' + + But, ``convert_to_string(None)`` will raise a type error. + """ + if not isinstance(instance_type, Instance): + return + + assert isinstance(ctx.type, Instance) + + supports_spec = type_loader.load_supports_type(ctx.type.args[2], ctx) + if supports_spec not in instance_type.type.bases: + instance_type.type.bases.append(supports_spec) + if supports_spec.type not in instance_type.type.mro: + instance_type.type.mro.insert(0, supports_spec.type) + + +def call_signature(ctx: MethodSigContext) -> CallableType: + """Returns proper ``__call__`` signature of a typeclass.""" + assert isinstance(ctx.type, Instance) + + real_signature = ctx.type.args[1] + if not isinstance(real_signature, CallableType): + return ctx.default_signature + + real_signature.arg_types[0] = ctx.type.args[0] + + if isinstance(ctx.type.args[2], Instance): + # Why do we need this check? + # Let's see what will happen without it: + # For example, typeclass `ToJson` with `int` and `str` have will have + # `Union[str, int]` as the first argument type. + # But, we need `Union[str, int, Supports[ToJson]]` + # That's why we are loading this type if the definition is there. + supports_spec = type_loader.load_supports_type(ctx.type.args[2], ctx) + real_signature.arg_types[0] = UnionType.make_union([ + real_signature.arg_types[0], + supports_spec, + ]) + return real_signature diff --git a/classes/contrib/mypy/typeops/__init__.py b/classes/contrib/mypy/typeops/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/contrib/mypy/typeops/instance_signature.py b/classes/contrib/mypy/typeops/instance_signature.py new file mode 100644 index 0000000..92116ab --- /dev/null +++ b/classes/contrib/mypy/typeops/instance_signature.py @@ -0,0 +1,77 @@ +from mypy.argmap import map_actuals_to_formals +from mypy.checker import detach_callable +from mypy.constraints import infer_constraints_for_callable +from mypy.expandtype import expand_type +from mypy.nodes import TempNode +from mypy.plugin import MethodContext +from mypy.stats import is_generic +from mypy.types import CallableType +from mypy.types import Type as MypyType + + +def prepare( + typeclass_signature: CallableType, + instance_type: MypyType, + ctx: MethodContext, +) -> CallableType: + """Creates proper signature from typeclass definition and given instance.""" + instance_definition = typeclass_signature.arg_types[0] + if is_generic(instance_definition): + return _prepare_generic(typeclass_signature, instance_type, ctx) + return _prepare_regular(typeclass_signature, instance_type, ctx) + + +def _prepare_generic( + typeclass_signature: CallableType, + instance_type: MypyType, + ctx: MethodContext, +) -> CallableType: + formal_to_actual = map_actuals_to_formals( + [typeclass_signature.arg_kinds[0]], + [typeclass_signature.arg_names[0]], + typeclass_signature.arg_kinds, + typeclass_signature.arg_names, + lambda index: ctx.api.accept(TempNode( # type: ignore + instance_type, context=ctx.context, + )), + ) + constraints = infer_constraints_for_callable( + typeclass_signature, + [instance_type], + [typeclass_signature.arg_kinds[0]], + formal_to_actual, + ) + return detach_callable(expand_type( + typeclass_signature, + { + constraint.type_var: constraint.target + for constraint in constraints + }, + )) + + +def _prepare_regular( + typeclass_signature: CallableType, + instance_type: MypyType, + ctx: MethodContext, +) -> CallableType: + to_adjust = ctx.default_return_type.arg_types[0] + + assert isinstance(typeclass_signature, CallableType) + assert isinstance(to_adjust, CallableType) + + instance_kind = to_adjust.arg_kinds[0] + instance_name = to_adjust.arg_names[0] + + to_adjust.arg_types = typeclass_signature.arg_types + to_adjust.arg_kinds = typeclass_signature.arg_kinds + to_adjust.arg_names = typeclass_signature.arg_names + to_adjust.variables = typeclass_signature.variables + to_adjust.is_ellipsis_args = typeclass_signature.is_ellipsis_args + to_adjust.ret_type = typeclass_signature.ret_type + + to_adjust.arg_types[0] = instance_type + to_adjust.arg_kinds[0] = instance_kind + to_adjust.arg_names[0] = instance_name + + return detach_callable(to_adjust) diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py new file mode 100644 index 0000000..e5d7671 --- /dev/null +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -0,0 +1,27 @@ +from typing import Union + +from mypy.plugin import MethodContext, MethodSigContext +from mypy.types import Instance +from mypy.types import Type as MypyType +from typing_extensions import Final + +_SUPPORTS_QUALIFIED_NAME: Final = 'classes.Supports' + + +def load_supports_type( + arg_type: MypyType, + ctx: Union[MethodContext, MethodSigContext], +) -> Instance: + """ + Loads ``Supports[]`` type with proper generic type. + + It uses the short name, + because for some reason full name is not always loaded. + """ + supports_spec = ctx.api.named_generic_type( + _SUPPORTS_QUALIFIED_NAME, + [arg_type], + ) + assert supports_spec + supports_spec.type._promote = None # noqa: WPS437 + return supports_spec diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py new file mode 100644 index 0000000..4f1de36 --- /dev/null +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -0,0 +1,98 @@ +from mypy.plugin import MethodContext +from mypy.subtypes import is_subtype +from mypy.types import AnyType, CallableType, Instance, TypeOfAny + + +def check_typeclass( + instance_signature: CallableType, + ctx: MethodContext, +) -> bool: + """ + We need to typecheck passed functions in order to build correct typeclasses. + + What do we do here? + 1. When ``@some.instance(type_)`` is used, we typecheck that ``type_`` + matches original typeclass definition, + like: ``def some(instance: MyType)`` + 2. If ``def _some_ex(instance: type_)`` is used, + we also check the function signature + to be compatible with the typeclass definition + + """ + assert isinstance(ctx.default_return_type, CallableType) + assert isinstance(ctx.type, Instance) + assert isinstance(ctx.type.args[1], CallableType) + + typeclass_signature = ctx.type.args[1] + assert isinstance(typeclass_signature, CallableType) + + signature_check = _check_typeclass_signature( + typeclass_signature, + instance_signature, + ctx, + ) + instance_check = _check_instance_type( + typeclass_signature, + instance_signature, + ctx, + ) + return signature_check and instance_check + + +def _check_typeclass_signature( + typeclass_signature: CallableType, + instance_signature: CallableType, + ctx: MethodContext, +) -> bool: + if ctx.arg_names[0]: + # This check only makes sence when we use annotations directly. + return True + + simplified_typeclass_signature = typeclass_signature.copy_modified( + arg_types=[ + AnyType(TypeOfAny.implementation_artifact), + *typeclass_signature.arg_types[1:], + ] + ) + simplified_instance_signature = instance_signature.copy_modified( + arg_types=[ + AnyType(TypeOfAny.implementation_artifact), + *instance_signature.arg_types[1:], + ] + ) + signature_check = is_subtype( + simplified_typeclass_signature, + simplified_instance_signature, + ) + if not signature_check: + ctx.api.fail( + 'Argument 1 has incompatible type "{0}"; expected "{1}"'.format( + instance_signature, + typeclass_signature.copy_modified(arg_types=[ + instance_signature.arg_types[0], + *typeclass_signature.arg_types[1:], + ]), + ), + ctx.context, + ) + return signature_check + + +def _check_instance_type( + typeclass_signature: CallableType, + instance_signature: CallableType, + ctx: MethodContext, +) -> bool: + instance_check = is_subtype( + instance_signature.arg_types[0], + typeclass_signature.arg_types[0], + ) + if not instance_check: + ctx.api.fail( + 'Instance "{0}" does not match original type "{1}"'.format( + instance_signature.arg_types[0], + typeclass_signature.arg_types[0], + ), + ctx.context, + ) + return instance_check diff --git a/docs/conf.py b/docs/conf.py index d17369f..1897504 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,6 +26,7 @@ # 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 diff --git a/poetry.lock b/poetry.lock index 227feef..eb603e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -96,7 +96,7 @@ msgpack = ["msgpack-python (>=0.5,<0.6)"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2021.5.30" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -538,7 +538,7 @@ test = ["flake8 (>=3.8.4,<3.9.0)", "pycodestyle (>=2.6.0,<2.7.0)"] [[package]] name = "importlib-metadata" -version = "4.3.0" +version = "4.3.1" description = "Read metadata from Python packages" category = "dev" optional = false @@ -987,7 +987,7 @@ docutils = ">=0.11,<1.0" [[package]] name = "ruamel.yaml" -version = "0.17.4" +version = "0.17.6" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" category = "dev" optional = false @@ -1365,8 +1365,8 @@ cachy = [ {file = "cachy-0.3.0.tar.gz", hash = "sha256:186581f4ceb42a0bbe040c407da73c14092379b1e4c0e327fdb72ae4a9b269b1"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, + {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, @@ -1579,8 +1579,8 @@ immutables = [ {file = "immutables-0.15.tar.gz", hash = "sha256:3713ab1ebbb6946b7ce1387bb9d1d7f5e09c45add58c2a2ee65f963c171e746b"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.3.0-py3-none-any.whl", hash = "sha256:c8b9a7c6000baa7adf7abcd2c41db11f172bcb5d6e448c73c2407dbc7e7e2af3"}, - {file = "importlib_metadata-4.3.0.tar.gz", hash = "sha256:c4646abbce80191bb548636f846e353ff1edc46a06bc536ea0a60d53211dc690"}, + {file = "importlib_metadata-4.3.1-py3-none-any.whl", hash = "sha256:c2e27fa8b6c8b34ebfcd4056ae2ca290e36250d1fbeceec85c1c67c711449fac"}, + {file = "importlib_metadata-4.3.1.tar.gz", hash = "sha256:2d932ea08814f745863fd20172fe7de4794ad74567db78f2377343e24520a5b6"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1824,8 +1824,8 @@ restructuredtext-lint = [ {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, ] "ruamel.yaml" = [ - {file = "ruamel.yaml-0.17.4-py3-none-any.whl", hash = "sha256:ac79fb25f5476e8e9ed1c53b8a2286d2c3f5dde49eb37dbcee5c7eb6a8415a22"}, - {file = "ruamel.yaml-0.17.4.tar.gz", hash = "sha256:44bc6b54fddd45e4bc0619059196679f9e8b79c027f4131bb072e6a22f4d5e28"}, + {file = "ruamel.yaml-0.17.6-py3-none-any.whl", hash = "sha256:748bbdddf9e7f6e1aad9481dfdd93a42f9c39c45821a0f09a31309cd0086e803"}, + {file = "ruamel.yaml-0.17.6.tar.gz", hash = "sha256:5605cb8ceeebaeed85ae4e97fc80547eca1b3537c163404ffe83f26adf5c9ce7"}, ] "ruamel.yaml.clib" = [ {file = "ruamel.yaml.clib-0.2.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc"}, diff --git a/typesafety/test_typeclass/test_definition_by_class.yml b/typesafety/test_typeclass/test_definition_by_class.yml index cd0d50c..fd0aa53 100644 --- a/typesafety/test_typeclass/test_definition_by_class.yml +++ b/typesafety/test_typeclass/test_definition_by_class.yml @@ -23,7 +23,7 @@ reveal_type(to_json) out: | main:19: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - main:20: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], builtins.str, def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' + main:20: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' - case: typeclass_class_wrong_sig @@ -70,7 +70,7 @@ reveal_type(to_json) out: | main:20: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - main:21: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], builtins.str, def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' + main:21: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' - case: typeclass_protocol_wrong_sig diff --git a/typesafety/test_typeclass/test_generics.yml b/typesafety/test_typeclass/test_generics.yml new file mode 100644 index 0000000..d29093c --- /dev/null +++ b/typesafety/test_typeclass/test_generics.yml @@ -0,0 +1,21 @@ +- case: typeclass_generic_definition + disable_cache: false + main: | + from typing import Iterable, List, TypeVar + + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance: Iterable[X], b: int) -> X: + ... + + @some.instance + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + + x = ['a', 'b'] + y = [1, 2, 3] + reveal_type(some(x, 0)) # N: Revealed type is 'builtins.str*' + reveal_type(some(y, 0)) # N: Revealed type is 'builtins.int*' diff --git a/typesafety/test_typeclass/test_instance.yml b/typesafety/test_typeclass/test_instance.yml index 44b503c..03fb45a 100644 --- a/typesafety/test_typeclass/test_instance.yml +++ b/typesafety/test_typeclass/test_instance.yml @@ -25,7 +25,7 @@ def some(instance) -> str: ... - @some.instance(str) + @some.instance(int) def _some_str(instance: str) -> str: ... @@ -33,9 +33,43 @@ def _some_int(instance: str) -> str: ... out: | + main:7: error: Argument 1 has incompatible type "Callable[[str], str]"; expected "Callable[[int], str]" main:11: error: Argument 1 has incompatible type "Callable[[str], str]"; expected "Callable[[int], str]" +- case: typeclass_instance_variance + disable_cache: False + main: | + from classes import typeclass + + class A(object): + ... + + class B(A): + ... + + class C(B): + ... + + @typeclass + def some(instance, arg: B) -> str: + ... + + @some.instance(str) + def _some_str(instance: str, arg: A) -> str: + ... + + @some.instance(bool) + def _some_bool(instance: bool, arg: B) -> str: + ... + + @some.instance(int) + def _some_int(instance: int, arg: C) -> str: + ... + out: | + main:20: error: Argument 1 has incompatible type "Callable[[int, C], str]"; expected "Callable[[int, B], str]" + + - case: typeclass_instance_any disable_cache: false main: | From 37c99d85fd3464423fc06b754f7a1b6311cab29a Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 7 Jun 2021 13:22:07 +0300 Subject: [PATCH 02/12] Closes #209 --- classes/_typeclass.py | 112 +++------- classes/contrib/mypy/classes_plugin.py | 5 +- classes/contrib/mypy/features/typeclass.py | 203 +++++------------- classes/contrib/mypy/typeops/inference.py | 65 ++++++ classes/contrib/mypy/typeops/instance_args.py | 36 ++++ .../mypy/typeops/instance_signature.py | 77 ------- classes/contrib/mypy/typeops/type_loader.py | 9 + classes/contrib/mypy/typeops/typecheck.py | 82 +++++-- 8 files changed, 268 insertions(+), 321 deletions(-) create mode 100644 classes/contrib/mypy/typeops/inference.py create mode 100644 classes/contrib/mypy/typeops/instance_args.py delete mode 100644 classes/contrib/mypy/typeops/instance_signature.py diff --git a/classes/_typeclass.py b/classes/_typeclass.py index 7bdcc58..015048b 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -1,30 +1,23 @@ -from typing import ( - Callable, - Dict, - Generic, - NoReturn, - Type, - TypeVar, - Union, - overload, -) - -from typing_extensions import Literal, final - -_TypeClassType = TypeVar('_TypeClassType') -_ReturnType = TypeVar('_ReturnType') # TODO: do we really need it? +from typing import Callable, Dict, Generic, Type, TypeVar, Union -_SignatureType = TypeVar('_SignatureType', bound=Callable) +from typing_extensions import final _InstanceType = TypeVar('_InstanceType') +_SignatureType = TypeVar('_SignatureType', bound=Callable) _DefinitionType = TypeVar('_DefinitionType', bound=Type) +_Fullname = TypeVar('_Fullname', bound=str) # Literal value + +_NewInstanceType = TypeVar('_NewInstanceType', bound=Type) + +_TypeClassType = TypeVar('_TypeClassType', bound='_TypeClass') +_ReturnType = TypeVar('_ReturnType') def typeclass( signature: _SignatureType, - # By default `_TypeClassType` and `_ReturnType` are `nothing`, + # By default almost all variables are `nothing`, # but we enhance them via mypy plugin later: -) -> '_TypeClass[_TypeClassType, _SignatureType, _DefinitionType]': +) -> '_TypeClass[_InstanceType, _SignatureType, _DefinitionType, _Fullname]': """ Function to define typeclasses. @@ -240,7 +233,9 @@ class Supports(Generic[_SignatureType]): @final -class _TypeClass(Generic[_TypeClassType, _SignatureType, _DefinitionType]): +class _TypeClass( + Generic[_InstanceType, _SignatureType, _DefinitionType, _Fullname], +): """ That's how we represent typeclasses. @@ -309,7 +304,7 @@ def __init__(self, signature: _SignatureType) -> None: def __call__( self, - instance: Union[_TypeClassType, Supports[_DefinitionType]], + instance: Union[_InstanceType, Supports[_DefinitionType]], *args, **kwargs, ) -> _ReturnType: @@ -393,86 +388,35 @@ def supports( instance_type in self._protocols ) - @overload - def instance( - self, - type_argument: Type[_InstanceType], - *, - is_protocol: Literal[False] = ..., - ) -> Callable[ - [Callable[[_InstanceType], _ReturnType]], - NoReturn, # We need this type to disallow direct instance calls - ]: - """Case for regular typeclasses.""" - - @overload - def instance( - self, - type_argument, - *, - is_protocol: Literal[True], - ) -> Callable[ - [Callable[[_InstanceType], _ReturnType]], - NoReturn, # We need this type to disallow direct instance calls - ]: - """Case for protocol based typeclasses.""" - - @overload - def instance( - self, - type_argument: Callable, # See `mypy` plugin for more specific type - ) -> NoReturn: - """ - Case for typeclasses that are defined by annotation only. - - We do not limit what callables can be passed here with type annotations, - because it is too complex to express. - - For example, we require different variance rules - for different function arguments. - The first argument should be strictly covariant (more specific). - Other arguments should be similar or contravariant (less specific). - - See our ``mypy`` plugin for more details. - """ - def instance( self, - type_argument, + type_argument: _NewInstanceType, *, is_protocol: bool = False, - ): + ) -> '_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. - That's why we also have this ugly ``@overload`` cases. - Otherwise, ``Protocol`` instances - would not match ``Type[_InstanceType]`` type due to ``mypy`` rules. - + when passing protocols. See our ``mypy`` plugin for that. """ - original_handler = None - if not is_protocol: - # If it is not a protocol, we can try to get an annotation from - annotations = getattr(type_argument, '__annotations__', None) - if annotations: - original_handler = type_argument - type_argument = annotations[ - type_argument.__code__.co_varnames[0] # noqa: WPS609 - ] - # That's how we check for generics, # generics that look like `List[int]` or `set[T]` will fail this check, # because they are `_GenericAlias` instance, # which raises an exception for `__isinstancecheck__` - isinstance(object(), type_argument) + isinstance(object(), type_argument) # TODO: support _GenericAlias def decorator(implementation): container = self._protocols if is_protocol else self._instances container[type_argument] = implementation return implementation - - if original_handler is not None: - return decorator(original_handler) # type: ignore return decorator + + +from typing_extensions import Protocol + + +# TODO: use `if TYPE_CHECK:` +class _TypeClassInstanceDef(Protocol[_InstanceType, _TypeClassType]): + def __call__(self, callback: _SignatureType) -> _SignatureType: + ... diff --git a/classes/contrib/mypy/classes_plugin.py b/classes/contrib/mypy/classes_plugin.py index 9cc3e2b..b5bb283 100644 --- a/classes/contrib/mypy/classes_plugin.py +++ b/classes/contrib/mypy/classes_plugin.py @@ -47,9 +47,6 @@ def get_function_hook( """Here we adjust the typeclass constructor.""" if fullname == 'classes._typeclass.typeclass': return typeclass.ConstructorReturnType() - if fullname == 'instance of _TypeClass': - # `@some.instance` call without params: - return typeclass.InstanceReturnType.from_function_decorator return None def get_method_hook( @@ -57,6 +54,8 @@ def get_method_hook( fullname: str, ) -> Optional[Callable[[MethodContext], MypyType]]: """Here we adjust the typeclass with new allowed types.""" + if fullname == 'classes._typeclass._TypeClassInstanceDef.__call__': + return typeclass.InstanceDefReturnType() if fullname == 'classes._typeclass._TypeClass.instance': # `@some.instance` call with explicit params: return typeclass.InstanceReturnType() diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 8798490..6da3491 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -1,25 +1,13 @@ from typing import Optional -from mypy.checker import detach_callable -from mypy.nodes import ARG_POS, Decorator, MemberExpr from mypy.plugin import FunctionContext, MethodContext, MethodSigContext from mypy.typeops import bind_self -from mypy.types import AnyType, CallableType, Instance +from mypy.types import AnyType, CallableType, Instance, LiteralType, TupleType from mypy.types import Type as MypyType -from mypy.types import ( - TypeOfAny, - TypeVarType, - UninhabitedType, - UnionType, - get_proper_type, -) +from mypy.types import TypeOfAny, UninhabitedType, UnionType, get_proper_type from typing_extensions import final -from classes.contrib.mypy.typeops import ( - instance_signature, - type_loader, - typecheck, -) +from classes.contrib.mypy.typeops import instance_args, type_loader, typecheck @final @@ -45,7 +33,8 @@ def __call__(self, ctx: FunctionContext) -> MypyType: if is_defined_by_class: return self._adjust_protocol_arguments(ctx) elif isinstance(defn, CallableType): - return self._adjust_typeclass(defn, ctx) + assert defn.definition + return self._adjust_typeclass(defn, defn.definition.fullname, ctx) return ctx.default_return_type def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: @@ -66,6 +55,7 @@ def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: assert isinstance(signature_type, CallableType) return self._adjust_typeclass( bind_self(signature_type), + type_info.fullname, ctx, class_definition=instance, ) @@ -73,16 +63,21 @@ def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: def _adjust_typeclass( self, typeclass_def: MypyType, + definition_fullname: str, ctx: FunctionContext, + *, class_definition: Optional[Instance] = None, ) -> MypyType: assert isinstance(typeclass_def, CallableType) assert isinstance(ctx.default_return_type, Instance) + str_fallback = ctx.api.str_type() # type: ignore + ctx.default_return_type.args = ( UninhabitedType(), # We start with empty set of instances typeclass_def, class_definition if class_definition else UninhabitedType(), + LiteralType(definition_fullname, str_fallback), ) return ctx.default_return_type @@ -105,149 +100,69 @@ class InstanceReturnType(object): def __call__(self, ctx: MethodContext) -> MypyType: """""" - if not isinstance(ctx.type, Instance): - return ctx.default_return_type - if not isinstance(ctx.default_return_type, CallableType): - # We need this line to trigger - # `OverloadedDef` proper branch detection, - # without it would consider this return type as the correct one - # (usually it is `NoReturn` here when wrong overload is used): - ctx.api.fail('Bad return type', ctx.context) # Not shown to user - return ctx.default_return_type - - signature = self._adjust_typeclass_callable(ctx) - if not typecheck.check_typeclass(signature, ctx): - return ctx.default_return_type - - instance_type = self._add_new_instance_type(ctx) - self._add_supports_metadata(ctx, instance_type) - return detach_callable(ctx.default_return_type) - - @classmethod - def from_function_decorator(cls, ctx: FunctionContext) -> MypyType: - """ - It is used when ``.instance`` is used without params as a decorator. - - Like: - - .. code:: python - - @some.instance - def _some_str(instance: str) -> str: - ... + assert isinstance(ctx.default_return_type, Instance) + assert isinstance(ctx.type, Instance) - """ - is_decorator = ( - isinstance(ctx.context, Decorator) and - len(ctx.context.decorators) == 1 and - isinstance(ctx.context.decorators[0], MemberExpr) and - ctx.context.decorators[0].name == 'instance' - ) - if not is_decorator: - return ctx.default_return_type - - passed_function = ctx.arg_types[0][0] - assert isinstance(passed_function, CallableType) - - if not passed_function.arg_types: - return ctx.default_return_type - - annotation_type = passed_function.arg_types[0] - if isinstance(annotation_type, Instance): - if annotation_type.type and annotation_type.type.is_protocol: - ctx.api.fail( - 'Protocols must be passed with `is_protocol=True`', - ctx.context, - ) - return ctx.default_return_type - else: - ctx.api.fail( - 'Only simple instance types are allowed, got: {0}'.format( - annotation_type, - ), - ctx.context, - ) - return ctx.default_return_type - - ret_type = CallableType( - arg_types=[passed_function], - arg_kinds=[ARG_POS], - arg_names=[None], - ret_type=AnyType(TypeOfAny.implementation_artifact), - fallback=passed_function.fallback, - ) - instance_type = ctx.api.expr_checker.accept( # type: ignore - ctx.context.decorators[0].expr, # type: ignore + # This is the case for `@some.instance(str)` decorator: + 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 + ], ) + return ctx.default_return_type - # We need to change the `ctx` type from `Function` to `Method`: - return cls()(MethodContext( - type=instance_type, - arg_types=ctx.arg_types, - arg_kinds=ctx.arg_kinds, - arg_names=ctx.arg_names, - args=ctx.args, - callee_arg_names=ctx.callee_arg_names, - default_return_type=ret_type, - context=ctx.context, - api=ctx.api, - )) - - def _adjust_typeclass_callable( - self, - ctx: MethodContext, - ) -> CallableType: - """Prepares callback""" - assert isinstance(ctx.default_return_type, CallableType) + +@final +class InstanceDefReturnType(object): + def __call__(self, ctx: MethodContext) -> MypyType: assert isinstance(ctx.type, Instance) + assert isinstance(ctx.type.args[0], TupleType) + assert isinstance(ctx.type.args[1], Instance) - if not ctx.arg_names[0]: - # We only need to adjust callables - # that are passed via a decorator with params, - # annotations-only are ignored: - return ctx.default_return_type + typeclass_ref = ctx.type.args[1] + assert isinstance(typeclass_ref.args[3], LiteralType) + assert isinstance(typeclass_ref.args[3].value, str) - real_signature = ctx.type.args[1].copy_modified() - to_adjust = ctx.default_return_type.arg_types[0] - assert isinstance(real_signature, CallableType) - assert isinstance(to_adjust, CallableType) + typeclass = type_loader.load_typeclass( + fullname=typeclass_ref.args[3].value, + ctx=ctx, + ) + assert isinstance(typeclass.args[1], CallableType) - ctx.default_return_type.arg_types[0] = instance_signature.prepare( - real_signature, - to_adjust.arg_types[0], - ctx, + instance_signature = ctx.arg_types[0][0] + assert isinstance(instance_signature, CallableType) + instance_type = instance_signature.arg_types[0] + + typecheck.check_typeclass( + typeclass_signature=typeclass.args[1], + instance_signature=instance_signature, + passed_types=ctx.type.args[0], + ctx=ctx, ) - return ctx.default_return_type.arg_types[0] + self._add_new_instance_type(typeclass, instance_type) + self._add_supports_metadata(typeclass, instance_type, ctx) + return ctx.default_return_type def _add_new_instance_type( self, - ctx: MethodContext, - ) -> MypyType: - """Adds new types into type argument 0 by unifing unique types.""" - assert isinstance(ctx.type, Instance) - assert isinstance(ctx.default_return_type, CallableType) - assert isinstance(ctx.default_return_type.arg_types[0], CallableType) - - instance_type = ctx.default_return_type.arg_types[0].arg_types[0] - unified = list(set(filter( - # It means that function was defined without annotation - # or with explicit `Any`, we prevent our Union from pollution. - # Because `Union[Any, int]` is just `Any`. - # We also clear accidental type vars. - lambda type_: not isinstance(type_, (TypeVarType, UninhabitedType)), - [instance_type, ctx.type.args[0]], - ))) - - ctx.type.args = ( - UnionType.make_union(unified), - *ctx.type.args[1:], + typeclass: Instance, + new_type: MypyType, + ) -> None: + typeclass.args = ( + instance_args.add_unique(new_type, typeclass.args[0]), + *typeclass.args[1:], ) - return instance_type def _add_supports_metadata( self, - ctx: MethodContext, + typeclass: Instance, instance_type: MypyType, + ctx: MethodContext, ) -> None: """ Injects fake ``Supports[TypeClass]`` parent classes into ``mro``. @@ -297,7 +212,7 @@ def _add_supports_metadata( assert isinstance(ctx.type, Instance) - supports_spec = type_loader.load_supports_type(ctx.type.args[2], ctx) + supports_spec = type_loader.load_supports_type(typeclass.args[2], ctx) if supports_spec not in instance_type.type.bases: instance_type.type.bases.append(supports_spec) if supports_spec.type not in instance_type.type.mro: diff --git a/classes/contrib/mypy/typeops/inference.py b/classes/contrib/mypy/typeops/inference.py new file mode 100644 index 0000000..2a4a7a6 --- /dev/null +++ b/classes/contrib/mypy/typeops/inference.py @@ -0,0 +1,65 @@ + +from typing import Optional + +from mypy.nodes import Decorator, Expression +from mypy.plugin import MethodContext +from mypy.typeops import make_simplified_union +from mypy.types import FunctionLike, Instance +from mypy.types import Type as MypyType +from typing_extensions import Final + +_TYPECLASS_DEF_FULLNAMES: Final = frozenset(( + 'classes._typeclass._TypeClassInstanceDef', +)) + + +def infer_runtime_type_from_context( + fallback: MypyType, + ctx: MethodContext, +) -> MypyType: + if isinstance(ctx.context, Decorator) and len(ctx.context.decorators) > 1: + # Why do we only care for this case? + # TODO + instance_types = [] + for decorator in ctx.context.decorators: + instance_type = _get_typeclass_instance_type(decorator, ctx) + if instance_type is not None: + instance_types.append(_post_process_type(instance_type)) + return make_simplified_union(instance_types) + return _post_process_type(fallback) + + +def _get_typeclass_instance_type( + expr: Expression, + ctx: MethodContext, +) -> Optional[MypyType]: + expr_type = ctx.api.expr_checker.accept(expr) + is_typeclass_instance_def = ( + isinstance(expr_type, Instance) and + bool(expr_type.type) and + expr_type.type.fullname in _TYPECLASS_DEF_FULLNAMES + ) + if is_typeclass_instance_def: + return expr_type.args[0].items[0] # type: ignore + return None + + +def _post_process_type(type_: MypyType) -> MypyType: + if isinstance(type_, FunctionLike) and type_.is_type_obj(): + # What happens here? + # Let's say you define a function like this: + # + # @some.instance(Sized) + # (instance: Sized, b: int) -> str: ... + # + # So, you will recieve callable type + # `def () -> Sized` as `runtime_type` in this case. + # We need to convert it back to regular `Instance`. + # + # It can also be `Overloaded` type, + # but they are safe to return the same `type_object`, + # however we still use `ret_type`, + # because it is practically the same thing, + # but with proper type arguments. + return type_.items()[0].ret_type + return type_ diff --git a/classes/contrib/mypy/typeops/instance_args.py b/classes/contrib/mypy/typeops/instance_args.py new file mode 100644 index 0000000..c6ef298 --- /dev/null +++ b/classes/contrib/mypy/typeops/instance_args.py @@ -0,0 +1,36 @@ +from typing import List, Union + +from mypy.plugin import FunctionContext, MethodContext +from mypy.typeops import make_simplified_union +from mypy.types import Instance, TupleType +from mypy.types import Type as MypyType +from mypy.types import TypeVarType, UninhabitedType + + +def add_unique( + new_instance_type: MypyType, + existing_instance_type: MypyType, +) -> MypyType: + unified = list(filter( + lambda type_: not isinstance(type_, (TypeVarType, UninhabitedType)), + [new_instance_type, existing_instance_type], + )) + return make_simplified_union(unified) + + +def mutate_typeclass_instance_def( + instance: Instance, + *, + passed_types: List[MypyType], + typeclass: Instance, + ctx: Union[MethodContext, FunctionContext], +) -> None: + tuple_type = TupleType( + passed_types, + fallback=ctx.api.named_type('builtins.tuple'), # type: ignore + ) + + instance.args = ( + tuple_type, # Passed runtime types, like str in `@some.instance(str)` + typeclass, # `_TypeClass` instance itself + ) diff --git a/classes/contrib/mypy/typeops/instance_signature.py b/classes/contrib/mypy/typeops/instance_signature.py deleted file mode 100644 index 92116ab..0000000 --- a/classes/contrib/mypy/typeops/instance_signature.py +++ /dev/null @@ -1,77 +0,0 @@ -from mypy.argmap import map_actuals_to_formals -from mypy.checker import detach_callable -from mypy.constraints import infer_constraints_for_callable -from mypy.expandtype import expand_type -from mypy.nodes import TempNode -from mypy.plugin import MethodContext -from mypy.stats import is_generic -from mypy.types import CallableType -from mypy.types import Type as MypyType - - -def prepare( - typeclass_signature: CallableType, - instance_type: MypyType, - ctx: MethodContext, -) -> CallableType: - """Creates proper signature from typeclass definition and given instance.""" - instance_definition = typeclass_signature.arg_types[0] - if is_generic(instance_definition): - return _prepare_generic(typeclass_signature, instance_type, ctx) - return _prepare_regular(typeclass_signature, instance_type, ctx) - - -def _prepare_generic( - typeclass_signature: CallableType, - instance_type: MypyType, - ctx: MethodContext, -) -> CallableType: - formal_to_actual = map_actuals_to_formals( - [typeclass_signature.arg_kinds[0]], - [typeclass_signature.arg_names[0]], - typeclass_signature.arg_kinds, - typeclass_signature.arg_names, - lambda index: ctx.api.accept(TempNode( # type: ignore - instance_type, context=ctx.context, - )), - ) - constraints = infer_constraints_for_callable( - typeclass_signature, - [instance_type], - [typeclass_signature.arg_kinds[0]], - formal_to_actual, - ) - return detach_callable(expand_type( - typeclass_signature, - { - constraint.type_var: constraint.target - for constraint in constraints - }, - )) - - -def _prepare_regular( - typeclass_signature: CallableType, - instance_type: MypyType, - ctx: MethodContext, -) -> CallableType: - to_adjust = ctx.default_return_type.arg_types[0] - - assert isinstance(typeclass_signature, CallableType) - assert isinstance(to_adjust, CallableType) - - instance_kind = to_adjust.arg_kinds[0] - instance_name = to_adjust.arg_names[0] - - to_adjust.arg_types = typeclass_signature.arg_types - to_adjust.arg_kinds = typeclass_signature.arg_kinds - to_adjust.arg_names = typeclass_signature.arg_names - to_adjust.variables = typeclass_signature.variables - to_adjust.is_ellipsis_args = typeclass_signature.is_ellipsis_args - to_adjust.ret_type = typeclass_signature.ret_type - - to_adjust.arg_types[0] = instance_type - to_adjust.arg_kinds[0] = instance_kind - to_adjust.arg_names[0] = instance_name - - return detach_callable(to_adjust) diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py index e5d7671..c767840 100644 --- a/classes/contrib/mypy/typeops/type_loader.py +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -25,3 +25,12 @@ def load_supports_type( assert supports_spec supports_spec.type._promote = None # noqa: WPS437 return supports_spec + + +def load_typeclass( + fullname: str, + ctx: MethodContext, +) -> Instance: + typeclass_info = ctx.api.lookup_qualified(fullname) # type: ignore + assert isinstance(typeclass_info.type, Instance) + return typeclass_info.type diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index 4f1de36..1d385df 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -1,10 +1,17 @@ from mypy.plugin import MethodContext +from mypy.sametypes import is_same_type from mypy.subtypes import is_subtype -from mypy.types import AnyType, CallableType, Instance, TypeOfAny +from mypy.types import AnyType, CallableType, Instance, LiteralType, TupleType +from mypy.types import Type as MypyType +from mypy.types import TypeOfAny + +from classes.contrib.mypy.typeops import inference def check_typeclass( + typeclass_signature: CallableType, instance_signature: CallableType, + passed_types: TupleType, ctx: MethodContext, ) -> bool: """ @@ -17,15 +24,10 @@ def check_typeclass( 2. If ``def _some_ex(instance: type_)`` is used, we also check the function signature to be compatible with the typeclass definition + 3. TODO + TODO: explain covariance and contravariance """ - assert isinstance(ctx.default_return_type, CallableType) - assert isinstance(ctx.type, Instance) - assert isinstance(ctx.type.args[1], CallableType) - - typeclass_signature = ctx.type.args[1] - assert isinstance(typeclass_signature, CallableType) - signature_check = _check_typeclass_signature( typeclass_signature, instance_signature, @@ -36,7 +38,13 @@ def check_typeclass( instance_signature, ctx, ) - return signature_check and instance_check + # TODO: check cases like `some.instance(1)`, only allow types and calls + runtime_check = _check_runtime_type( + passed_types, + instance_signature, + ctx, + ) + return signature_check and instance_check and runtime_check def _check_typeclass_signature( @@ -44,10 +52,6 @@ def _check_typeclass_signature( instance_signature: CallableType, ctx: MethodContext, ) -> bool: - if ctx.arg_names[0]: - # This check only makes sence when we use annotations directly. - return True - simplified_typeclass_signature = typeclass_signature.copy_modified( arg_types=[ AnyType(TypeOfAny.implementation_artifact), @@ -96,3 +100,55 @@ def _check_instance_type( ctx.context, ) return instance_check + + +def _check_runtime_type( + passed_types: TupleType, + instance_signature: CallableType, + ctx: MethodContext, +) -> bool: + runtime_type = inference.infer_runtime_type_from_context( + passed_types.items[0], + ctx, + ) + + if len(passed_types.items) == 2: + assert isinstance(passed_types.items[1], Instance) + assert isinstance(passed_types.items[1].last_known_value, LiteralType) + is_protocol = passed_types.items[1].last_known_value.value + assert isinstance(is_protocol, bool) + else: + is_protocol = False + + instance_check = is_same_type( + instance_signature.arg_types[0], + runtime_type, + ) + if not instance_check: + ctx.api.fail( + 'Instance "{0}" does not match runtime type "{1}"'.format( + instance_signature.arg_types[0], + runtime_type, + ), + ctx.context, + ) + + return _check_runtime_protocol( + runtime_type, ctx, is_protocol=is_protocol, + ) and instance_check + + +def _check_runtime_protocol( + runtime_type: MypyType, + ctx: MethodContext, + *, + is_protocol: bool, +) -> bool: + if isinstance(runtime_type, Instance) and not is_protocol: + if runtime_type.type and runtime_type.type.is_protocol: + ctx.api.fail( + 'Protocols must be passed with `is_protocol=True`', + ctx.context, + ) + return False + return True From d431d22660bfc70cfb188d840fad33329c0681d8 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 7 Jun 2021 16:07:34 +0300 Subject: [PATCH 03/12] Adds type queries for concrete generics and unbound types, closes #210 --- classes/contrib/mypy/typeops/type_queries.py | 35 +++++++++++++++ classes/contrib/mypy/typeops/typecheck.py | 45 +++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 classes/contrib/mypy/typeops/type_queries.py diff --git a/classes/contrib/mypy/typeops/type_queries.py b/classes/contrib/mypy/typeops/type_queries.py new file mode 100644 index 0000000..82e5ed6 --- /dev/null +++ b/classes/contrib/mypy/typeops/type_queries.py @@ -0,0 +1,35 @@ +from mypy.plugin import MethodContext +from mypy.type_visitor import TypeQuery +from mypy.types import Instance +from mypy.types import Type as MypyType +from mypy.types import TypeVarType, UnboundType, get_proper_type + + +def has_concrete_type(instance_type: MypyType, ctx: MethodContext) -> bool: + instance_type = get_proper_type(instance_type) + if isinstance(instance_type, Instance): + return any( + type_arg.accept(_HasNoConcreteTypes(lambda _: True)) + for type_arg in instance_type.args + ) + return False + + +def has_unbound_type(runtime_type: MypyType, ctx: MethodContext) -> bool: + runtime_type = get_proper_type(runtime_type) + if isinstance(runtime_type, Instance): + return any( + type_arg.accept(_HasUnboundTypes(lambda _: False)) + for type_arg in runtime_type.args + ) + return False + + +class _HasNoConcreteTypes(TypeQuery[bool]): + def visit_type_var(self, t: TypeVarType) -> bool: + return False + + +class _HasUnboundTypes(TypeQuery[bool]): + def visit_unbound_type(self, t: UnboundType) -> bool: + return True diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index 1d385df..e4544a3 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -1,3 +1,4 @@ +from mypy.erasetype import erase_type from mypy.plugin import MethodContext from mypy.sametypes import is_same_type from mypy.subtypes import is_subtype @@ -5,7 +6,7 @@ from mypy.types import Type as MypyType from mypy.types import TypeOfAny -from classes.contrib.mypy.typeops import inference +from classes.contrib.mypy.typeops import inference, type_queries def check_typeclass( @@ -120,22 +121,26 @@ def _check_runtime_type( else: is_protocol = False + + instance_type = instance_signature.arg_types[0] instance_check = is_same_type( - instance_signature.arg_types[0], - runtime_type, + erase_type(instance_type), + erase_type(runtime_type), ) if not instance_check: ctx.api.fail( 'Instance "{0}" does not match runtime type "{1}"'.format( - instance_signature.arg_types[0], + instance_type, runtime_type, ), ctx.context, ) - return _check_runtime_protocol( - runtime_type, ctx, is_protocol=is_protocol, - ) and instance_check + return ( + _check_runtime_protocol(runtime_type, ctx, is_protocol=is_protocol) and + _check_concrete_generics(instance_type, runtime_type, ctx) and + instance_check + ) def _check_runtime_protocol( @@ -152,3 +157,29 @@ def _check_runtime_protocol( ) return False return True + + +def _check_concrete_generics( + instance_type: MypyType, + runtime_type: MypyType, + ctx: MethodContext, +) -> bool: + has_concrete_type = type_queries.has_concrete_type(instance_type, ctx) + if has_concrete_type: + ctx.api.fail( + 'Instance "{0}" has concrete type, use generics instead'.format( + instance_type, + ), + ctx.context, + ) + + has_unbound_type = type_queries.has_unbound_type(runtime_type, ctx) + if has_unbound_type: + print(runtime_type.args[0], type(runtime_type.args[0])) + ctx.api.fail( + 'Runtime type "{0}" has unbound type, use implicit any'.format( + runtime_type, + ), + ctx.context, + ) + return has_concrete_type and has_unbound_type From 419fe8557724091713143ace5805d839ce8d4608 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 7 Jun 2021 18:20:05 +0300 Subject: [PATCH 04/12] Fixes flake8 --- classes/_typeclass.py | 44 ++--- classes/contrib/mypy/classes_plugin.py | 8 +- classes/contrib/mypy/features/typeclass.py | 59 ++++--- classes/contrib/mypy/typeops/inference.py | 35 +++- classes/contrib/mypy/typeops/instance_args.py | 21 ++- classes/contrib/mypy/typeops/type_loader.py | 1 + classes/contrib/mypy/typeops/type_queries.py | 52 +++++- classes/contrib/mypy/typeops/typecheck.py | 153 ++++++++++++++---- docs/pages/concept.rst | 11 -- setup.cfg | 2 +- 10 files changed, 282 insertions(+), 104 deletions(-) diff --git a/classes/_typeclass.py b/classes/_typeclass.py index 015048b..9af6d29 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Generic, Type, TypeVar, Union +from typing import TYPE_CHECKING, Callable, Dict, Generic, Type, TypeVar, Union from typing_extensions import final @@ -87,19 +87,9 @@ def typeclass( then it will be called, otherwise the default implementation will be called instead. - You can also use ``.instance`` with just annotation for better readability: - - .. code:: python - - >>> @example.instance - ... def _example_float(instance: float) -> str: - ... return 0.5 - - >>> assert example(5.1) == 0.5 - .. rubric:: Generics - We also support generic, but the support is limited. + We also support generics, but the support is limited. We cannot rely on type parameters of the generic type, only on the base generic class: @@ -144,6 +134,7 @@ def typeclass( .. code:: python >>> from typing import Sequence + >>> @example.instance(Sequence, is_protocol=True) ... def _sequence_example(instance: Sequence) -> str: ... return ','.join(str(item) for item in instance) @@ -161,6 +152,7 @@ def typeclass( .. code:: python >>> from typing_extensions import Protocol + >>> class CustomProtocol(Protocol): ... field: str @@ -183,6 +175,7 @@ def typeclass( return _TypeClass(signature) +@final class Supports(Generic[_SignatureType]): """ Used to specify that some value is a part of a typeclass. @@ -404,7 +397,7 @@ def instance( # generics that look like `List[int]` or `set[T]` will fail this check, # because they are `_GenericAlias` instance, # which raises an exception for `__isinstancecheck__` - isinstance(object(), type_argument) # TODO: support _GenericAlias + isinstance(object(), type_argument) def decorator(implementation): container = self._protocols if is_protocol else self._instances @@ -413,10 +406,25 @@ def decorator(implementation): return decorator -from typing_extensions import Protocol +if TYPE_CHECKING: + from typing_extensions import Protocol + + class _TypeClassInstanceDef( # type: ignore + Protocol[_InstanceType, _TypeClassType], + ): + """ + Callable protocol to help us with typeclass instance callbacks. + This protocol does not exist in real life, + we just need it because we use it in ``mypy`` plugin. + That's why we define it under ``if TYPE_CHECKING:``. + It should not be used -# TODO: use `if TYPE_CHECK:` -class _TypeClassInstanceDef(Protocol[_InstanceType, _TypeClassType]): - def __call__(self, callback: _SignatureType) -> _SignatureType: - ... + See ``InstanceDefReturnType`` for more information. + + One more important thing here: we fill its type vars inside our plugin, + so, don't even care about its definition. + """ + + def __call__(self, callback: _SignatureType) -> _SignatureType: + """It can be called, because in real life it is a function.""" diff --git a/classes/contrib/mypy/classes_plugin.py b/classes/contrib/mypy/classes_plugin.py index b5bb283..841dffe 100644 --- a/classes/contrib/mypy/classes_plugin.py +++ b/classes/contrib/mypy/classes_plugin.py @@ -32,9 +32,10 @@ class _TypeClassPlugin(Plugin): """ Our plugin for typeclasses. - It has three steps: + It has four steps: - Creating typeclasses via ``typeclass`` function - - Adding cases for typeclasses via ``.instance()`` calls + - Adding cases for typeclasses via ``.instance()`` calls with explicit types + - Adding callbacks functions after the ``.instance()`` decorator - Converting typeclasses to simple callable via ``__call__`` method Hooks are in the logical order. @@ -57,8 +58,7 @@ def get_method_hook( if fullname == 'classes._typeclass._TypeClassInstanceDef.__call__': return typeclass.InstanceDefReturnType() if fullname == 'classes._typeclass._TypeClass.instance': - # `@some.instance` call with explicit params: - return typeclass.InstanceReturnType() + return typeclass.instance_return_type return None def get_method_signature_hook( diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 6da3491..62dd79d 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -23,6 +23,7 @@ class ConstructorReturnType(object): """ def __call__(self, ctx: FunctionContext) -> MypyType: + """Main entry point.""" defn = ctx.arg_types[0][0] is_defined_by_class = ( isinstance(defn, CallableType) and @@ -82,44 +83,41 @@ def _adjust_typeclass( return ctx.default_return_type -@final -class InstanceReturnType(object): - """ - Adjusts the typing signature after ``.instance(type)`` call. +def instance_return_type(ctx: MethodContext) -> MypyType: + """Adjusts the typing signature on ``.instance(type)`` call.""" + assert isinstance(ctx.default_return_type, Instance) + assert isinstance(ctx.type, Instance) - We need this to get typing match working: - so the type mentioned in ``.instance()`` call - will be the same as the one in a function later on. + 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 + ], + ) + return ctx.default_return_type - We use ``ctx.arg_names[0]`` to determine which mode is used: - 1. If it is empty, than annotation-based dispatch method is used - 2. If it is not empty, that means that decorator with arguments is used, - like ``@some.instance(MyType)`` +@final +class InstanceDefReturnType(object): """ + Class to check how instance definition is created. - def __call__(self, ctx: MethodContext) -> MypyType: - """""" - assert isinstance(ctx.default_return_type, Instance) - assert isinstance(ctx.type, Instance) + When it is called? + It is called on the second call of ``.instance(str)(callback)``. - # This is the case for `@some.instance(str)` decorator: - 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 - ], - ) - return ctx.default_return_type + We do a lot of stuff here: + 1. Typecheck usage correctness + 2. Adding new instance types to typeclass definition + 3. Adding ``Supports[]`` metadata + """ -@final -class InstanceDefReturnType(object): - def __call__(self, ctx: MethodContext) -> MypyType: + def __call__(self, ctx: MethodContext) -> MypyType: # noqa: WPS218 + """Main entry point.""" assert isinstance(ctx.type, Instance) assert isinstance(ctx.type.args[0], TupleType) assert isinstance(ctx.type.args[1], Instance) @@ -181,6 +179,7 @@ def _add_supports_metadata( ... ... >>> to_str = typeclass(ToStr) + >>> @to_str.instance(int) ... def _to_str_int(instance: int) -> str: ... return 'Number: {0}'.format(instance) diff --git a/classes/contrib/mypy/typeops/inference.py b/classes/contrib/mypy/typeops/inference.py index 2a4a7a6..8afb265 100644 --- a/classes/contrib/mypy/typeops/inference.py +++ b/classes/contrib/mypy/typeops/inference.py @@ -17,14 +17,43 @@ def infer_runtime_type_from_context( fallback: MypyType, ctx: MethodContext, ) -> MypyType: + """ + Infers instance type from several ``@some.instance()`` decorators. + + We have a problem: when user has two ``.instance()`` decorators + on a single function, inference will work only + for a single one of them at the time. + + So, let's say you have this: + + .. code:: python + + @some.instance(str) + @some.instance(int) + def _some_int_str(instance: Union[str, int]): ... + + Your instance has ``Union[str, int]`` annotation as it should have. + But, our ``fallback`` type would be just ``int`` on the first call + and just ``str`` on the second call. + + And this will break our ``is_same_type`` check, + because ``Union[str, int]`` is not the same as ``int`` or ``str``. + + In this case we need to fetch all typeclass decorators and infer + the resulting type manually. + """ if isinstance(ctx.context, Decorator) and len(ctx.context.decorators) > 1: # Why do we only care for this case? - # TODO + # Because if it is a call / or just a single decorator, + # then we are fine with regular type inference. + # Infered type from `mypy` is good enough, just return `fallback`. instance_types = [] for decorator in ctx.context.decorators: instance_type = _get_typeclass_instance_type(decorator, ctx) if instance_type is not None: instance_types.append(_post_process_type(instance_type)) + + # Infered resulting type: return make_simplified_union(instance_types) return _post_process_type(fallback) @@ -33,14 +62,14 @@ def _get_typeclass_instance_type( expr: Expression, ctx: MethodContext, ) -> Optional[MypyType]: - expr_type = ctx.api.expr_checker.accept(expr) + expr_type = ctx.api.expr_checker.accept(expr) # type: ignore is_typeclass_instance_def = ( isinstance(expr_type, Instance) and bool(expr_type.type) and expr_type.type.fullname in _TYPECLASS_DEF_FULLNAMES ) if is_typeclass_instance_def: - return expr_type.args[0].items[0] # type: ignore + return expr_type.args[0].items[0] return None diff --git a/classes/contrib/mypy/typeops/instance_args.py b/classes/contrib/mypy/typeops/instance_args.py index c6ef298..ad66058 100644 --- a/classes/contrib/mypy/typeops/instance_args.py +++ b/classes/contrib/mypy/typeops/instance_args.py @@ -4,15 +4,24 @@ from mypy.typeops import make_simplified_union from mypy.types import Instance, TupleType from mypy.types import Type as MypyType -from mypy.types import TypeVarType, UninhabitedType +from mypy.types import TypeVarType, UnboundType, UninhabitedType +from typing_extensions import Final + +_TYPES_TO_FILTER_OUT: Final = (TypeVarType, UninhabitedType, UnboundType) def add_unique( new_instance_type: MypyType, existing_instance_type: MypyType, ) -> MypyType: + """ + Adds new instance type to existing ones. + + It is smart: filters our junk and uses unique and flat ``Union`` types. + """ unified = list(filter( - lambda type_: not isinstance(type_, (TypeVarType, UninhabitedType)), + # We filter our `NoReturn` and + lambda type_: not isinstance(type_, _TYPES_TO_FILTER_OUT), [new_instance_type, existing_instance_type], )) return make_simplified_union(unified) @@ -25,7 +34,15 @@ def mutate_typeclass_instance_def( typeclass: Instance, ctx: Union[MethodContext, FunctionContext], ) -> None: + """ + Mutates ``TypeClassInstanceDef`` args. + + That's where we fill their values. + Why? Because we need all types from ``some.instance()`` call. + Including ``is_protocol`` for later checks. + """ tuple_type = TupleType( + # We now store passed arg types in a single tuple: passed_types, fallback=ctx.api.named_type('builtins.tuple'), # type: ignore ) diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py index c767840..1b8edae 100644 --- a/classes/contrib/mypy/typeops/type_loader.py +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -31,6 +31,7 @@ def load_typeclass( fullname: str, ctx: MethodContext, ) -> Instance: + """Loads given typeclass from a symboltable by a fullname.""" typeclass_info = ctx.api.lookup_qualified(fullname) # type: ignore assert isinstance(typeclass_info.type, Instance) return typeclass_info.type diff --git a/classes/contrib/mypy/typeops/type_queries.py b/classes/contrib/mypy/typeops/type_queries.py index 82e5ed6..ed78d87 100644 --- a/classes/contrib/mypy/typeops/type_queries.py +++ b/classes/contrib/mypy/typeops/type_queries.py @@ -6,6 +6,30 @@ def has_concrete_type(instance_type: MypyType, ctx: MethodContext) -> bool: + """ + Queries if your instance has any concrete types. + + What do we call "concrete types"? Some examples: + + ``List[X]`` is generic, ``List[int]`` is concrete. + ``List[Union[int, str]]`` is also concrete. + ``Dict[str, X]`` is also concrete. + + So, this helps to write code like this: + + .. code:: python + + @some.instance(list) + def _some_list(instance: List[X]): ... + + And not like: + + .. code:: python + + @some.instance(list) + def _some_list(instance: List[int]): ... + + """ instance_type = get_proper_type(instance_type) if isinstance(instance_type, Instance): return any( @@ -16,6 +40,28 @@ def has_concrete_type(instance_type: MypyType, ctx: MethodContext) -> bool: def has_unbound_type(runtime_type: MypyType, ctx: MethodContext) -> bool: + """ + Queries if your instance has any unbound types. + + Note, that you need to understand + how semantic and type analyzers work in ``mypy`` + to understand what "unbound type" is. + + Long story short, this helps to write code like this: + + .. code:: python + + @some.instance(list) + def _some_list(instance: List[X]): ... + + And not like: + + .. code:: python + + @some.instance(List[X]) + def _some_list(instance: List[X]): ... + + """ runtime_type = get_proper_type(runtime_type) if isinstance(runtime_type, Instance): return any( @@ -25,11 +71,11 @@ def has_unbound_type(runtime_type: MypyType, ctx: MethodContext) -> bool: return False -class _HasNoConcreteTypes(TypeQuery[bool]): - def visit_type_var(self, t: TypeVarType) -> bool: +class _HasNoConcreteTypes(TypeQuery[bool]): # TODO: support explicit `any` + def visit_type_var(self, type_: TypeVarType) -> bool: return False class _HasUnboundTypes(TypeQuery[bool]): - def visit_unbound_type(self, t: UnboundType) -> bool: + def visit_unbound_type(self, type_: UnboundType) -> bool: return True diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index e4544a3..fdb2b13 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -1,3 +1,5 @@ +from typing import Tuple + from mypy.erasetype import erase_type from mypy.plugin import MethodContext from mypy.sametypes import is_same_type @@ -18,16 +20,7 @@ def check_typeclass( """ We need to typecheck passed functions in order to build correct typeclasses. - What do we do here? - 1. When ``@some.instance(type_)`` is used, we typecheck that ``type_`` - matches original typeclass definition, - like: ``def some(instance: MyType)`` - 2. If ``def _some_ex(instance: type_)`` is used, - we also check the function signature - to be compatible with the typeclass definition - 3. TODO - - TODO: explain covariance and contravariance + Please, see docs on each step. """ signature_check = _check_typeclass_signature( typeclass_signature, @@ -39,7 +32,6 @@ def check_typeclass( instance_signature, ctx, ) - # TODO: check cases like `some.instance(1)`, only allow types and calls runtime_check = _check_runtime_type( passed_types, instance_signature, @@ -53,17 +45,49 @@ def _check_typeclass_signature( instance_signature: CallableType, ctx: MethodContext, ) -> bool: + """ + Checks that instance signature is compatible with. + + We use contravariant on arguments and covariant on return type logic here. + What does this mean? + + Let's say that you have this typeclass signature: + + .. code:: python + + class A: ... + class B(A): ... + class C(B): ... + + @typeclass + def some(instance, arg: B) -> B: ... + + What instance signatures will be compatible? + + .. code:: python + + (instance: ..., arg: B) -> B: ... + (instance: ..., arg: A) -> C: ... + + But, any other cases will raise an error. + + .. note:: + We don't check instance types here at all, + we replace it with ``Any``. + See special function, where we check instance type. + + """ simplified_typeclass_signature = typeclass_signature.copy_modified( arg_types=[ AnyType(TypeOfAny.implementation_artifact), *typeclass_signature.arg_types[1:], - ] + ], ) simplified_instance_signature = instance_signature.copy_modified( arg_types=[ AnyType(TypeOfAny.implementation_artifact), *instance_signature.arg_types[1:], - ] + ], ) signature_check = is_subtype( simplified_typeclass_signature, @@ -71,10 +95,10 @@ def _check_typeclass_signature( ) if not signature_check: ctx.api.fail( - 'Argument 1 has incompatible type "{0}"; expected "{1}"'.format( + 'Instance callback is incompatible "{0}"; expected "{1}"'.format( instance_signature, typeclass_signature.copy_modified(arg_types=[ - instance_signature.arg_types[0], + instance_signature.arg_types[0], # Better error message *typeclass_signature.arg_types[1:], ]), ), @@ -88,6 +112,30 @@ def _check_instance_type( instance_signature: CallableType, ctx: MethodContext, ) -> bool: + """ + Checks instance type, helpful when typeclass has type restrictions. + + We use covariant logic on instance type. + What does this mean? + + .. code:: python + + class A: ... + class B(A): ... + class C(B): ... + + @typeclass + def some(instance: B): ... + + What can we use on instance callbacks? + + .. code:: python + + (instance: B) + (instance: C) + + Any other cases will raise an error. + """ instance_check = is_subtype( instance_signature.arg_types[0], typeclass_signature.arg_types[0], @@ -108,19 +156,29 @@ def _check_runtime_type( instance_signature: CallableType, ctx: MethodContext, ) -> bool: + """ + Checks runtime type. + + We call "runtime types" things that we use at runtime to dispatch our calls. + For example: + + 1. We check that type passed in ``some.instance(...)`` matches + one defined in a type annotation + 2. We check that types don't have any concrete types + 3. We check that types dont' have any unbound type variables + 4. We check that ``is_protocol`` is passed correctly + + """ runtime_type = inference.infer_runtime_type_from_context( passed_types.items[0], ctx, ) if len(passed_types.items) == 2: - assert isinstance(passed_types.items[1], Instance) - assert isinstance(passed_types.items[1].last_known_value, LiteralType) - is_protocol = passed_types.items[1].last_known_value.value - assert isinstance(is_protocol, bool) + is_protocol, protocol_arg_check = _check_protocol_arg(passed_types, ctx) else: is_protocol = False - + protocol_arg_check = True instance_type = instance_signature.arg_types[0] instance_check = is_same_type( @@ -139,20 +197,49 @@ def _check_runtime_type( return ( _check_runtime_protocol(runtime_type, ctx, is_protocol=is_protocol) and _check_concrete_generics(instance_type, runtime_type, ctx) and + protocol_arg_check and instance_check ) +def _check_protocol_arg( + passed_types: TupleType, + ctx: MethodContext, +) -> Tuple[bool, bool]: + passed_arg = passed_types.items[1] + is_literal_bool = ( + isinstance(passed_arg, Instance) and + isinstance(passed_arg.last_known_value, LiteralType) and + isinstance(passed_arg.last_known_value.value, bool) + ) + if is_literal_bool: + return passed_arg.last_known_value.value, True + + ctx.api.fail( + 'Use literal bool for "is_protocol" argument, got: "{0}"'.format( + passed_types.items[1], + ), + ctx.context, + ) + return False, False + + def _check_runtime_protocol( runtime_type: MypyType, ctx: MethodContext, *, is_protocol: bool, ) -> bool: - if isinstance(runtime_type, Instance) and not is_protocol: - if runtime_type.type and runtime_type.type.is_protocol: + if isinstance(runtime_type, Instance) and runtime_type.type: + if not is_protocol and runtime_type.type.is_protocol: ctx.api.fail( - 'Protocols must be passed with `is_protocol=True`', + 'Protocols must be passed with "is_protocol=True"', + ctx.context, + ) + return False + elif is_protocol and not runtime_type.type.is_protocol: + ctx.api.fail( + 'Regular types must be passed with "is_protocol=False"', ctx.context, ) return False @@ -164,18 +251,20 @@ def _check_concrete_generics( runtime_type: MypyType, ctx: MethodContext, ) -> bool: - has_concrete_type = type_queries.has_concrete_type(instance_type, ctx) - if has_concrete_type: - ctx.api.fail( - 'Instance "{0}" has concrete type, use generics instead'.format( - instance_type, - ), - ctx.context, - ) + has_concrete_type = False + for type_ in (instance_type, runtime_type): + local_check = type_queries.has_concrete_type(type_, ctx) + if local_check: + ctx.api.fail( + 'Instance "{0}" has concrete type, use typevars or any'.format( + type_, + ), + ctx.context, + ) + has_concrete_type = has_concrete_type or local_check has_unbound_type = type_queries.has_unbound_type(runtime_type, ctx) if has_unbound_type: - print(runtime_type.args[0], type(runtime_type.args[0])) ctx.api.fail( 'Runtime type "{0}" has unbound type, use implicit any'.format( runtime_type, diff --git a/docs/pages/concept.rst b/docs/pages/concept.rst index ebc557c..ead3978 100644 --- a/docs/pages/concept.rst +++ b/docs/pages/concept.rst @@ -99,17 +99,6 @@ Let's define some instances: That's how we define instances for our typeclass. These instances will be executed when the corresponding type will be supplied. -.. note:: - ``.instance`` can use explicit type or just an existing annotation. - It is recommended to use the explicit type, because annotations can be tricky. - For example, sometimes you have to use ``ForwardRef`` - or so called string-based-annotations. It is not supported. - Complex type from annotations are also not supported - like: ``Union[str, int]``. - - So, use annotations for the simplest cases only - and use explicit types in all other cases. - And the last step is to call our typeclass with different value of different types: diff --git a/setup.cfg b/setup.cfg index 2a2e8f0..639fd66 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ per-file-ignores = classes/__init__.py: F401, WPS113, WPS436 classes/_typeclass.py: WPS320 # We need `assert`s to please mypy: - classes/contrib/mypy/classes_plugin.py: S101 + classes/contrib/mypy/*.py: S101 # There are multiple assert's in tests: tests/*.py: S101, WPS226, WPS432, WPS436 From d821900e0ef580dfdfbf4ebd8efde21316cb22cb Mon Sep 17 00:00:00 2001 From: sobolevn Date: Mon, 7 Jun 2021 22:42:32 +0300 Subject: [PATCH 05/12] Fixes lots of tests --- CHANGELOG.md | 6 + classes/_typeclass.py | 23 +- classes/contrib/mypy/features/typeclass.py | 66 +++++- classes/contrib/mypy/typeops/inference.py | 17 +- classes/contrib/mypy/typeops/instance_args.py | 10 +- classes/contrib/mypy/typeops/type_loader.py | 16 +- classes/contrib/mypy/typeops/type_queries.py | 33 ++- classes/contrib/mypy/typeops/typecheck.py | 92 ++++---- .../test_definition_by_class.yml | 42 +++- typesafety/test_typeclass/test_generics.yml | 151 +++++++++++++- typesafety/test_typeclass/test_instance.yml | 197 ++++++++++-------- .../test_typeclass/test_instance_variance.yml | 131 ++++++++++++ typesafety/test_typeclass/test_protocols.yml | 96 ++++++++- typesafety/test_typeclass/test_supports.yml | 22 ++ typesafety/test_typeclass/test_typeclass.yml | 10 + 15 files changed, 758 insertions(+), 154 deletions(-) create mode 100644 typesafety/test_typeclass/test_instance_variance.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8998b2f..eecdf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ We follow Semantic Versions since the `0.1.0` release. +## Version 0.3.0 WIP + +### Features + + + ## Version 0.2.0 ### Features diff --git a/classes/_typeclass.py b/classes/_typeclass.py index 9af6d29..c0adc06 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -1,4 +1,14 @@ -from typing import TYPE_CHECKING, Callable, Dict, Generic, Type, TypeVar, Union +from typing import ( # noqa: WPS235 + TYPE_CHECKING, + Callable, + ClassVar, + Dict, + Generic, + Set, + Type, + TypeVar, + Union, +) from typing_extensions import final @@ -172,6 +182,16 @@ def typeclass( Remember, that generic protocols have the same limitation as generic types. """ + if signature in _TypeClass._known_signatures: # type: ignore # noqa: WPS437 + raise TypeError( + 'Typeclass definition "{0}" cannot be reused'.format( + signature, + ), + ) + if isinstance(signature, type): + _TypeClass._known_signatures.add( # type: ignore # noqa: WPS437 + signature, + ) return _TypeClass(signature) @@ -262,6 +282,7 @@ class _TypeClass( """ __slots__ = ('_instances', '_protocols') + _known_signatures: ClassVar[Set[_SignatureType]] = set() def __init__(self, signature: _SignatureType) -> None: """ diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 62dd79d..1d0c684 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -1,5 +1,6 @@ from typing import Optional +from mypy.nodes import Decorator, TypeInfo from mypy.plugin import FunctionContext, MethodContext, MethodSigContext from mypy.typeops import bind_self from mypy.types import AnyType, CallableType, Instance, LiteralType, TupleType @@ -7,7 +8,12 @@ from mypy.types import TypeOfAny, UninhabitedType, UnionType, get_proper_type from typing_extensions import final -from classes.contrib.mypy.typeops import instance_args, type_loader, typecheck +from classes.contrib.mypy.typeops import ( + inference, + instance_args, + type_loader, + typecheck, +) @final @@ -33,10 +39,14 @@ def __call__(self, ctx: FunctionContext) -> MypyType: if is_defined_by_class: return self._adjust_protocol_arguments(ctx) - elif isinstance(defn, CallableType): - assert defn.definition + elif isinstance(defn, CallableType) and defn.definition: return self._adjust_typeclass(defn, defn.definition.fullname, ctx) - return ctx.default_return_type + + ctx.api.fail( + 'Invalid typeclass definition: "{0}"'.format(defn), + ctx.context, + ) + return UninhabitedType() def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: assert isinstance(ctx.arg_types[0][0], CallableType) @@ -54,12 +64,14 @@ def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: signature_type = get_proper_type(signature.type) assert isinstance(signature_type, CallableType) - return self._adjust_typeclass( + typeclass = self._adjust_typeclass( bind_self(signature_type), type_info.fullname, ctx, class_definition=instance, ) + self._process_typeclass_metadata(type_info, typeclass, ctx) + return typeclass def _adjust_typeclass( self, @@ -82,6 +94,23 @@ def _adjust_typeclass( ) return ctx.default_return_type + def _process_typeclass_metadata( + self, + type_info: TypeInfo, + typeclass: MypyType, + ctx: FunctionContext, + ) -> None: + namespace = type_info.metadata.setdefault('classes', {}) + if namespace.get('typeclass'): # TODO: the same for functions + ctx.api.fail( + 'Typeclass definition "{0}" cannot be reused'.format( + type_info.fullname, + ), + ctx.context, + ) + return + namespace['typeclass'] = typeclass + def instance_return_type(ctx: MethodContext) -> MypyType: """Adjusts the typing signature on ``.instance(type)`` call.""" @@ -142,7 +171,13 @@ def __call__(self, ctx: MethodContext) -> MypyType: # noqa: WPS218 passed_types=ctx.type.args[0], ctx=ctx, ) - self._add_new_instance_type(typeclass, instance_type) + self._add_new_instance_type( + typeclass=typeclass, + new_type=instance_type, + fullname=typeclass_ref.args[3].value, + ctx=ctx, + ) + ctx.type.args[1].args = typeclass.args # Without this line self._add_supports_metadata(typeclass, instance_type, ctx) return ctx.default_return_type @@ -150,7 +185,21 @@ def _add_new_instance_type( self, typeclass: Instance, new_type: MypyType, + fullname: str, + ctx: MethodContext, ) -> None: + has_multiple_decorators = ( + isinstance(ctx.context, Decorator) and + len(ctx.context.decorators) > 1 + ) + if has_multiple_decorators: + # TODO: what happens here? + new_type = inference.infer_runtime_type_from_context( + fallback=new_type, + fullname=fullname, + ctx=ctx, + ) + typeclass.args = ( instance_args.add_unique(new_type, typeclass.args[0]), *typeclass.args[1:], @@ -208,9 +257,14 @@ def _add_supports_metadata( """ if not isinstance(instance_type, Instance): return + if not isinstance(typeclass.args[2], Instance): + return assert isinstance(ctx.type, Instance) + # We also need to modify the metadata for a typeclass typeinfo: + typeclass.args[2].type.metadata['classes']['typeclass'] = typeclass + supports_spec = type_loader.load_supports_type(typeclass.args[2], ctx) if supports_spec not in instance_type.type.bases: instance_type.type.bases.append(supports_spec) diff --git a/classes/contrib/mypy/typeops/inference.py b/classes/contrib/mypy/typeops/inference.py index 8afb265..7c8dfbb 100644 --- a/classes/contrib/mypy/typeops/inference.py +++ b/classes/contrib/mypy/typeops/inference.py @@ -4,7 +4,7 @@ from mypy.nodes import Decorator, Expression from mypy.plugin import MethodContext from mypy.typeops import make_simplified_union -from mypy.types import FunctionLike, Instance +from mypy.types import FunctionLike, Instance, LiteralType from mypy.types import Type as MypyType from typing_extensions import Final @@ -16,6 +16,7 @@ def infer_runtime_type_from_context( fallback: MypyType, ctx: MethodContext, + fullname: Optional[str] = None, ) -> MypyType: """ Infers instance type from several ``@some.instance()`` decorators. @@ -49,7 +50,7 @@ def _some_int_str(instance: Union[str, int]): ... # Infered type from `mypy` is good enough, just return `fallback`. instance_types = [] for decorator in ctx.context.decorators: - instance_type = _get_typeclass_instance_type(decorator, ctx) + instance_type = _get_typeclass_instance_type(decorator, fullname, ctx) if instance_type is not None: instance_types.append(_post_process_type(instance_type)) @@ -60,16 +61,24 @@ def _some_int_str(instance: Union[str, int]): ... def _get_typeclass_instance_type( expr: Expression, + fullname: Optional[str], ctx: MethodContext, ) -> Optional[MypyType]: expr_type = ctx.api.expr_checker.accept(expr) # type: ignore is_typeclass_instance_def = ( isinstance(expr_type, Instance) and bool(expr_type.type) and - expr_type.type.fullname in _TYPECLASS_DEF_FULLNAMES + expr_type.type.fullname in _TYPECLASS_DEF_FULLNAMES and + isinstance(expr_type.args[1], Instance) ) if is_typeclass_instance_def: - return expr_type.args[0].items[0] + is_same_typeclass = ( + isinstance(expr_type.args[1].args[3], LiteralType) and + expr_type.args[1].args[3].value == fullname or + fullname is None + ) + if is_same_typeclass: + return expr_type.args[0].items[0] return None diff --git a/classes/contrib/mypy/typeops/instance_args.py b/classes/contrib/mypy/typeops/instance_args.py index ad66058..641545c 100644 --- a/classes/contrib/mypy/typeops/instance_args.py +++ b/classes/contrib/mypy/typeops/instance_args.py @@ -2,12 +2,18 @@ from mypy.plugin import FunctionContext, MethodContext from mypy.typeops import make_simplified_union -from mypy.types import Instance, TupleType +from mypy.types import AnyType, Instance, TupleType from mypy.types import Type as MypyType from mypy.types import TypeVarType, UnboundType, UninhabitedType from typing_extensions import Final -_TYPES_TO_FILTER_OUT: Final = (TypeVarType, UninhabitedType, UnboundType) +#: Types that polute instance args. +_TYPES_TO_FILTER_OUT: Final = ( + TypeVarType, + UninhabitedType, + UnboundType, + AnyType, +) def add_unique( diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py index 1b8edae..1898a97 100644 --- a/classes/contrib/mypy/typeops/type_loader.py +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -31,7 +31,17 @@ def load_typeclass( fullname: str, ctx: MethodContext, ) -> Instance: - """Loads given typeclass from a symboltable by a fullname.""" + """ + Loads given typeclass from a symboltable by a fullname. + + There are two ways to load a typeclass by name: + # TODO + """ typeclass_info = ctx.api.lookup_qualified(fullname) # type: ignore - assert isinstance(typeclass_info.type, Instance) - return typeclass_info.type + if isinstance(typeclass_info.type, Instance): + return typeclass_info.type + + assert typeclass_info.node + metadata = typeclass_info.node.metadata['classes']['typeclass'] + assert isinstance(metadata, Instance) + return metadata diff --git a/classes/contrib/mypy/typeops/type_queries.py b/classes/contrib/mypy/typeops/type_queries.py index ed78d87..8324ff4 100644 --- a/classes/contrib/mypy/typeops/type_queries.py +++ b/classes/contrib/mypy/typeops/type_queries.py @@ -1,11 +1,18 @@ +from typing import Callable, Iterable + from mypy.plugin import MethodContext from mypy.type_visitor import TypeQuery -from mypy.types import Instance +from mypy.types import AnyType, Instance from mypy.types import Type as MypyType from mypy.types import TypeVarType, UnboundType, get_proper_type -def has_concrete_type(instance_type: MypyType, ctx: MethodContext) -> bool: +def has_concrete_type( + instance_type: MypyType, + ctx: MethodContext, + *, + forbid_explicit_any: bool, +) -> bool: """ Queries if your instance has any concrete types. @@ -33,7 +40,10 @@ def _some_list(instance: List[int]): ... instance_type = get_proper_type(instance_type) if isinstance(instance_type, Instance): return any( - type_arg.accept(_HasNoConcreteTypes(lambda _: True)) + type_arg.accept(_HasNoConcreteTypes( + lambda _: True, + forbid_explicit_any=forbid_explicit_any, + )) for type_arg in instance_type.args ) return False @@ -71,10 +81,25 @@ def _some_list(instance: List[X]): ... return False -class _HasNoConcreteTypes(TypeQuery[bool]): # TODO: support explicit `any` +class _HasNoConcreteTypes(TypeQuery[bool]): + def __init__( + self, + strategy: Callable[[Iterable[bool]], bool], + *, + forbid_explicit_any: bool, + ) -> None: + super().__init__(strategy) + self._forbid_explicit_any = forbid_explicit_any + def visit_type_var(self, type_: TypeVarType) -> bool: return False + def visit_unbound_type(self, type_: UnboundType) -> bool: + return False + + def visit_any(self, type_: AnyType) -> bool: + return self._forbid_explicit_any + class _HasUnboundTypes(TypeQuery[bool]): def visit_unbound_type(self, type_: UnboundType) -> bool: diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index fdb2b13..058f9a6 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -7,9 +7,41 @@ from mypy.types import AnyType, CallableType, Instance, LiteralType, TupleType from mypy.types import Type as MypyType from mypy.types import TypeOfAny +from typing_extensions import Final from classes.contrib.mypy.typeops import inference, type_queries +_INCOMPATIBLE_INSTANCE_MSG: Final = ( + 'Instance callback is incompatible "{0}"; expected "{1}"' +) + +_INSTANCE_RESTRICTION_MSG: Final = ( + 'Instance "{0}" does not match original type "{1}"' +) + +_INSTANCE_RUNTIME_MISMATCH: Final = ( + 'Instance "{0}" does not match runtime type "{1}"' +) + +_IS_PROTOCOL_LITERAL_BOOL: Final = ( + 'Use literal bool for "is_protocol" argument, got: "{0}"' +) + +_IS_PROTOCOL_MISSING: Final = 'Protocols must be passed with "is_protocol=True"' + +_IS_PROTOCOL_UNWANTED: Final = ( + 'Regular types must be passed with "is_protocol=False"' +) + +_CONCRETE_GENERIC_MSG: Final = ( + 'Instance "{0}" has concrete generic type, ' + + 'it is not supported during runtime' +) + +_UNBOUND_TYPE_MSG: Final = ( + 'Runtime type "{0}" has unbound type, use implicit any' +) + def check_typeclass( typeclass_signature: CallableType, @@ -90,12 +122,12 @@ def some(instance, arg: B) -> B: ... ], ) signature_check = is_subtype( - simplified_typeclass_signature, simplified_instance_signature, + simplified_typeclass_signature, ) if not signature_check: ctx.api.fail( - 'Instance callback is incompatible "{0}"; expected "{1}"'.format( + _INCOMPATIBLE_INSTANCE_MSG.format( instance_signature, typeclass_signature.copy_modified(arg_types=[ instance_signature.arg_types[0], # Better error message @@ -142,7 +174,7 @@ def some(instance: B): ... ) if not instance_check: ctx.api.fail( - 'Instance "{0}" does not match original type "{1}"'.format( + _INSTANCE_RESTRICTION_MSG.format( instance_signature.arg_types[0], typeclass_signature.arg_types[0], ), @@ -187,10 +219,7 @@ def _check_runtime_type( ) if not instance_check: ctx.api.fail( - 'Instance "{0}" does not match runtime type "{1}"'.format( - instance_type, - runtime_type, - ), + _INSTANCE_RUNTIME_MISMATCH.format(instance_type, runtime_type), ctx.context, ) @@ -213,12 +242,10 @@ def _check_protocol_arg( isinstance(passed_arg.last_known_value.value, bool) ) if is_literal_bool: - return passed_arg.last_known_value.value, True + return passed_arg.last_known_value.value, True # type: ignore ctx.api.fail( - 'Use literal bool for "is_protocol" argument, got: "{0}"'.format( - passed_types.items[1], - ), + _IS_PROTOCOL_LITERAL_BOOL.format(passed_types.items[1]), ctx.context, ) return False, False @@ -232,16 +259,10 @@ def _check_runtime_protocol( ) -> bool: if isinstance(runtime_type, Instance) and runtime_type.type: if not is_protocol and runtime_type.type.is_protocol: - ctx.api.fail( - 'Protocols must be passed with "is_protocol=True"', - ctx.context, - ) + ctx.api.fail(_IS_PROTOCOL_MISSING, ctx.context) return False elif is_protocol and not runtime_type.type.is_protocol: - ctx.api.fail( - 'Regular types must be passed with "is_protocol=False"', - ctx.context, - ) + ctx.api.fail(_IS_PROTOCOL_UNWANTED, ctx.context) return False return True @@ -252,23 +273,22 @@ def _check_concrete_generics( ctx: MethodContext, ) -> bool: has_concrete_type = False - for type_ in (instance_type, runtime_type): - local_check = type_queries.has_concrete_type(type_, ctx) + type_settings = ( # Not a dict, because of `hash` problems + (instance_type, False), + (runtime_type, True), + ) + + for type_, forbid_explicit_any in type_settings: + local_check = type_queries.has_concrete_type( + type_, + ctx, + forbid_explicit_any=forbid_explicit_any, + ) if local_check: - ctx.api.fail( - 'Instance "{0}" has concrete type, use typevars or any'.format( - type_, - ), - ctx.context, - ) + ctx.api.fail(_CONCRETE_GENERIC_MSG.format(type_), ctx.context) has_concrete_type = has_concrete_type or local_check - has_unbound_type = type_queries.has_unbound_type(runtime_type, ctx) - if has_unbound_type: - ctx.api.fail( - 'Runtime type "{0}" has unbound type, use implicit any'.format( - runtime_type, - ), - ctx.context, - ) - return has_concrete_type and has_unbound_type + if type_queries.has_unbound_type(runtime_type, ctx): + ctx.api.fail(_UNBOUND_TYPE_MSG.format(runtime_type), ctx.context) + return False + return has_concrete_type diff --git a/typesafety/test_typeclass/test_definition_by_class.yml b/typesafety/test_typeclass/test_definition_by_class.yml index fd0aa53..4da44e7 100644 --- a/typesafety/test_typeclass/test_definition_by_class.yml +++ b/typesafety/test_typeclass/test_definition_by_class.yml @@ -20,10 +20,8 @@ to_json(1) to_json('a') to_json(None) - reveal_type(to_json) out: | main:19: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - main:20: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' - case: typeclass_class_wrong_sig @@ -41,7 +39,8 @@ def _to_json_int(instance: str) -> int: ... out: | - main:9: error: Argument 1 has incompatible type "Callable[[str], int]"; expected "Callable[[int, bool], str]" + main:9: error: Instance "builtins.str" does not match runtime type "builtins.int*" + main:9: error: Instance callback is incompatible "def (instance: builtins.str) -> builtins.int"; expected "def (instance: builtins.str, verbose: builtins.bool =) -> builtins.str" - case: typeclass_definied_by_protocol @@ -67,10 +66,8 @@ to_json(1) to_json('a') to_json(None) - reveal_type(to_json) out: | main:20: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - main:21: note: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], def (Union[builtins.str*, builtins.int*, classes._typeclass.Supports[main.ToJson]], verbose: builtins.bool =) -> builtins.str, main.ToJson]' - case: typeclass_protocol_wrong_sig @@ -89,7 +86,8 @@ def _to_json_int(instance: str) -> int: ... out: | - main:10: error: Argument 1 has incompatible type "Callable[[str], int]"; expected "Callable[[int, bool], str]" + main:10: error: Instance "builtins.str" does not match runtime type "builtins.int*" + main:10: error: Instance callback is incompatible "def (instance: builtins.str) -> builtins.int"; expected "def (instance: builtins.str, verbose: builtins.bool =) -> builtins.str" - case: typeclass_protocol_wrong_method @@ -102,3 +100,35 @@ ... to_json = typeclass(ToJson) + + + +- case: typeclass_object_reuse + disable_cache: false + main: | + from classes import typeclass + + class ToJson(object): + def __call__(self, instance) -> str: + ... + + to_json = typeclass(ToJson) + other = typeclass(ToJson) + out: | + main:8: error: Typeclass definition "main.ToJson" cannot be reused + + +- case: typeclass_protocol_reuse + disable_cache: false + main: | + from classes import typeclass + from typing_extensions import Protocol + + class ToJson(Protocol): + def __call__(self, instance) -> str: + ... + + to_json = typeclass(ToJson) + other = typeclass(ToJson) + out: | + main:9: error: Typeclass definition "main.ToJson" cannot be reused diff --git a/typesafety/test_typeclass/test_generics.yml b/typesafety/test_typeclass/test_generics.yml index d29093c..8ce9b52 100644 --- a/typesafety/test_typeclass/test_generics.yml +++ b/typesafety/test_typeclass/test_generics.yml @@ -1,8 +1,27 @@ - case: typeclass_generic_definition disable_cache: false main: | - from typing import Iterable, List, TypeVar + from typing import List, TypeVar + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> X: + ... + + @some.instance(list) + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + + reveal_type(some(['a', 'b'], 0)) # N: Revealed type is 'builtins.str*' + reveal_type(some([1, 2, 3], 0)) # N: Revealed type is 'builtins.int*' + +- case: typeclass_generic_definition_restriction_correct + disable_cache: false + main: | + from typing import Iterable, List, TypeVar from classes import typeclass X = TypeVar('X') @@ -11,11 +30,131 @@ def some(instance: Iterable[X], b: int) -> X: ... - @some.instance + @some.instance(list) + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + + reveal_type(some(['a', 'b'], 0)) # N: Revealed type is 'builtins.str*' + reveal_type(some([1, 2, 3], 0)) # N: Revealed type is 'builtins.int*' + + +- case: typeclass_generic_definition_restriction_wrong + disable_cache: false + main: | + from typing import Generic, List, TypeVar + from classes import typeclass + + X = TypeVar('X') + + class Some(Generic[X]): + ... + + @typeclass + def some(instance: Some[X], b: int) -> X: + ... + + @some.instance(list) def _some_ex(instance: List[X], b: int) -> X: return instance[b] # We need this line to test inner inference + out: | + main:13: error: Instance "builtins.list[X`-1]" does not match original type "main.Some[X`-1]" + + +- case: typeclass_generic_definition_any1 + disable_cache: false + mypy_config: | + disallow_any_explicit = false + disallow_any_generics = false + main: | + from typing import Iterable, List, TypeVar, Any + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> int: + ... + + @some.instance(list) + def _some_ex(instance: List[Any], b: int) -> int: + ... + + +- case: typeclass_generic_definition_any2 + disable_cache: false + mypy_config: | + disallow_any_explicit = false + disallow_any_generics = false + main: | + from typing import Iterable, List, TypeVar, Any + from classes import typeclass - x = ['a', 'b'] - y = [1, 2, 3] - reveal_type(some(x, 0)) # N: Revealed type is 'builtins.str*' - reveal_type(some(y, 0)) # N: Revealed type is 'builtins.int*' + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> int: + ... + + @some.instance(List[Any]) + def _some_ex(instance: list, b: int) -> int: + ... + out: | + main:10: error: Instance "builtins.list[Any]" has concrete generic type, it is not supported during runtime + + +- case: typeclass_generic_definition_unbound + disable_cache: false + main: | + from typing import Iterable, List, TypeVar + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> X: + ... + + @some.instance(List[X]) + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + out: | + main:10: error: Runtime type "builtins.list[X?]" has unbound type, use implicit any + + +- case: typeclass_generic_definition_concrete1 + disable_cache: false + main: | + from typing import Iterable, List, TypeVar + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> X: + ... + + @some.instance(list) + def _some_ex(instance: List[int], b: int) -> X: + return instance[b] # We need this line to test inner inference + out: | + main:10: error: Instance "builtins.list[builtins.int]" has concrete generic type, it is not supported during runtime + main:12: error: Incompatible return value type (got "int", expected "X") + + +- case: typeclass_generic_definition_concrete2 + disable_cache: false + main: | + from typing import Iterable, List, TypeVar + from classes import typeclass + + X = TypeVar('X') + + @typeclass + def some(instance, b: int) -> X: + ... + + @some.instance(List[int]) + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + out: | + main:10: error: Instance "builtins.list[builtins.int*]" has concrete generic type, it is not supported during runtime diff --git a/typesafety/test_typeclass/test_instance.yml b/typesafety/test_typeclass/test_instance.yml index 03fb45a..ffbbd2d 100644 --- a/typesafety/test_typeclass/test_instance.yml +++ b/typesafety/test_typeclass/test_instance.yml @@ -1,4 +1,4 @@ -- case: typeclass_instances_union +- case: typeclass_two_typeclasses_two_instances disable_cache: false main: | from typing import Union @@ -8,169 +8,198 @@ def a(instance) -> str: ... + @typeclass + def b(instance) -> str: + ... + @a.instance(str) - @a.instance(int) - def _a_int_str(instance: Union[str, int]) -> str: - return str(instance) + def _a_str(instance: str) -> str: + ... - reveal_type(a) # N: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*], builtins.str, def (builtins.str*) -> builtins.str, ]' + @b.instance(int) + def _b_int(instance: int) -> str: + ... + a('a') + b(2) -- case: typeclass_instance_mixed_order - disable_cache: False + a(1) + b('b') + out: | + main:23: error: Argument 1 to "a" has incompatible type "int"; expected "str" + main:24: error: Argument 1 to "b" has incompatible type "str"; expected "int" + + +- case: typeclass_two_typeclasses_one_instance + disable_cache: false main: | + from typing import Union from classes import typeclass @typeclass - def some(instance) -> str: + def a(instance) -> str: ... - @some.instance(int) - def _some_str(instance: str) -> str: - ... + @typeclass + def b(instance) -> str: + ... - @some.instance(int) - def _some_int(instance: str) -> str: - ... + @a.instance(str) + @b.instance(int) + def _a_int_str(instance: Union[str, int]) -> str: + return str(instance) + + a('a') + b(2) + + a(1) + b('b') out: | - main:7: error: Argument 1 has incompatible type "Callable[[str], str]"; expected "Callable[[int], str]" - main:11: error: Argument 1 has incompatible type "Callable[[str], str]"; expected "Callable[[int], str]" + main:20: error: Argument 1 to "a" has incompatible type "int"; expected "str" + main:21: error: Argument 1 to "b" has incompatible type "str"; expected "int" -- case: typeclass_instance_variance - disable_cache: False +- case: typeclass_instances_union1 + disable_cache: false main: | + from typing import Union from classes import typeclass - class A(object): + @typeclass + def a(instance) -> str: ... - class B(A): - ... + @a.instance(str) + @a.instance(int) + def _a_int_str(instance: Union[str, int]) -> str: + return str(instance) - class C(B): - ... + a(1) + a('a') + a(None) # E: Argument 1 to "a" has incompatible type "None"; expected "Union[str, int]" - @typeclass - def some(instance, arg: B) -> str: - ... - @some.instance(str) - def _some_str(instance: str, arg: A) -> str: - ... +- case: typeclass_instances_union2 + disable_cache: false + main: | + from classes import typeclass - @some.instance(bool) - def _some_bool(instance: bool, arg: B) -> str: + @typeclass + def a(instance) -> str: ... - @some.instance(int) - def _some_int(instance: int, arg: C) -> str: + @a.instance(str) + @a.instance(int) + def _a_int_str(instance: int) -> str: ... out: | - main:20: error: Argument 1 has incompatible type "Callable[[int, C], str]"; expected "Callable[[int, B], str]" + main:7: error: Instance "builtins.int" does not match runtime type "Union[builtins.str*, builtins.int*]" -- case: typeclass_instance_any +- case: typeclass_instances_union3 disable_cache: false main: | from classes import typeclass @typeclass - def a(instance): + def a(instance) -> str: ... @a.instance(str) + @a.instance(int) def _a_int_str(instance: str) -> str: - return str(instance) - - reveal_type(a) # N: Revealed type is 'classes._typeclass._TypeClass[builtins.str*, Any, def (builtins.str*) -> Any, ]' + ... + out: | + main:7: error: Instance "builtins.str" does not match runtime type "Union[builtins.str*, builtins.int*]" -- case: typeclass_instance_missing_first_arg +- case: typeclass_instances_union4 disable_cache: false main: | + from typing import Union from classes import typeclass @typeclass - def a(instance): + def a(instance) -> str: ... - @a.instance - def some(): + @a.instance(str) + @a.instance(int) + def _a_int_str(instance: Union[str, int, None]) -> str: ... - out: | - main:7: error: Argument 1 to "instance" of "_TypeClass" has incompatible type "Callable[[], Any]"; expected "Callable[[], Any]" + main:8: error: Instance "Union[builtins.str, builtins.int, None]" does not match runtime type "Union[builtins.str*, builtins.int*]" -- case: typeclass_instance_wrong_param - disable_cache: false +- case: typeclass_instance_mixed_order + disable_cache: False main: | from classes import typeclass @typeclass - def a(instance): + def some(instance) -> str: ... - a.instance(1) + @some.instance(int) + def _some_str(instance: str) -> str: + ... + @some.instance(int) + def _some_int(instance: str) -> str: + ... out: | - main:7: error: No overload variant of "instance" of "_TypeClass" matches argument type "int" - main:7: note: <1 more non-matching overload not shown> - main:7: note: def [_InstanceType] instance(self, type_argument: Callable[[_InstanceType], Any]) -> NoReturn - main:7: note: def [_InstanceType] instance(self, type_argument: Type[_InstanceType], *, is_protocol: Literal[False] = ...) -> Callable[[Callable[[_InstanceType], Any]], NoReturn] - main:7: note: Possible overload variants: + main:7: error: Instance "builtins.str" does not match runtime type "builtins.int*" + main:11: error: Instance "builtins.str" does not match runtime type "builtins.int*" -- case: typeclass_instance_annotation_only +- case: typeclass_instance_any1 disable_cache: false + mypy_config: | + disallow_any_explicit = false + disallow_any_generics = false main: | + from typing import Any from classes import typeclass @typeclass - def some(instance) -> str: + def a(instance) -> str: ... - @some.instance - def _some_str(instance: str) -> str: + @a.instance(int) + def _a_int(instance: Any) -> str: ... - - some('abc') + out: | + main:8: error: Instance "Any" does not match runtime type "builtins.int*" -- case: typeclass_instance_annotation_only_complex_types +- case: typeclass_instance_any2 disable_cache: false + mypy_config: | + disallow_any_explicit = false + disallow_any_generics = false main: | + from typing import Any from classes import typeclass - from typing import Union, Sized, Type, Any @typeclass - def some(instance) -> str: + def a(instance) -> str: ... - @some.instance - def _some_union(instance: Union[str, int]) -> str: + @a.instance(Any) + def _a_any(instance: Any) -> str: ... + out: | + main:8: error: Instance "Any" does not match runtime type "builtins.object" + main:8: error: Value of type variable "_NewInstanceType" of "instance" of "_TypeClass" cannot be "object" - @some.instance - def _some_type_type(instance: Type[str]) -> str: - ... - @some.instance - def _some_protocol(instance: Sized) -> str: - ... +- case: typeclass_instance_wrong_param + disable_cache: false + main: | + from classes import typeclass - @some.instance - def _some_annotated(instance) -> str: - ... + @typeclass + def a(instance) -> str: + ... - @some.instance - def _some_explicit_any(instance: Any) -> str: - ... - out: | - main:8: error: Only simple instance types are allowed, got: Union[builtins.str, builtins.int] - main:12: error: Only simple instance types are allowed, got: Type[builtins.str] - main:16: error: Protocols must be passed with `is_protocol=True` - main:20: error: Only simple instance types are allowed, got: Any - main:24: error: Only simple instance types are allowed, got: Any - main:25: error: Explicit "Any" is not allowed + a.instance(1) # E: Value of type variable "_NewInstanceType" of "instance" of "_TypeClass" cannot be "int" diff --git a/typesafety/test_typeclass/test_instance_variance.yml b/typesafety/test_typeclass/test_instance_variance.yml new file mode 100644 index 0000000..4d00cb9 --- /dev/null +++ b/typesafety/test_typeclass/test_instance_variance.yml @@ -0,0 +1,131 @@ +- case: typeclass_instance_arg_variance + disable_cache: False + main: | + from classes import typeclass + + class A(object): + ... + + class B(A): + ... + + class C(B): + ... + + @typeclass + def some(instance, arg: B) -> str: + ... + + @some.instance(str) + def _some_str(instance: str, arg: A) -> str: + ... + + @some.instance(bool) + def _some_bool(instance: bool, arg: B) -> str: + ... + + @some.instance(int) + def _some_int(instance: int, arg: C) -> str: + ... + out: | + main:24: error: Instance callback is incompatible "def (instance: builtins.int, arg: main.C) -> builtins.str"; expected "def (instance: builtins.int, arg: main.B) -> builtins.str" + + +- case: typeclass_instance_ret_type_variance + disable_cache: False + main: | + from classes import typeclass + + class A(object): + ... + + class B(A): + ... + + class C(B): + ... + + @typeclass + def some(instance) -> B: + ... + + @some.instance(str) + def _some_str(instance: str) -> A: + ... + + @some.instance(bool) + def _some_bool(instance: bool) -> B: + ... + + @some.instance(int) + def _some_int(instance: int) -> C: + ... + out: | + main:16: error: Instance callback is incompatible "def (instance: builtins.str) -> main.A"; expected "def (instance: builtins.str) -> main.B" + + +- case: typeclass_instance_self_variance + disable_cache: False + main: | + from classes import typeclass + + class A(object): + ... + + class B(A): + ... + + class C(B): + ... + + @typeclass + def some(instance: B): + ... + + @some.instance(A) + def _some_a(instance: A): + ... + + @some.instance(B) + def _some_b(instance: B): + ... + + @some.instance(C) + def _some_c(instance: C): + ... + out: | + main:16: error: Instance "main.A" does not match original type "main.B" + + +- case: typeclass_instance_runtime_variance + disable_cache: False + main: | + from classes import typeclass + + class A(object): + ... + + class B(A): + ... + + class C(B): + ... + + @typeclass + def some(instance) -> str: + ... + + @some.instance(A) + def _some_a(instance: B) -> str: + ... + + @some.instance(B) + def _some_b(instance: B) -> str: + ... + + @some.instance(C) + def _some_c(instance: B) -> str: + ... + out: | + main:16: error: Instance "main.B" does not match runtime type "main.A" + main:24: error: Instance "main.B" does not match runtime type "main.C" diff --git a/typesafety/test_typeclass/test_protocols.yml b/typesafety/test_typeclass/test_protocols.yml index 315fdc9..6a2cad1 100644 --- a/typesafety/test_typeclass/test_protocols.yml +++ b/typesafety/test_typeclass/test_protocols.yml @@ -17,7 +17,7 @@ protocols(None, 'xyz') # E: Argument 1 to "protocols" has incompatible type "None"; expected "Sized" -- case: typeclass_protocol_wrong_usage +- case: typeclass_protocol_wrong_usage0 disable_cache: false main: | from typing import Sized @@ -27,6 +27,98 @@ def protocols(instance, other: str) -> str: ... - @protocols.instance(Sized) # E: Only concrete class can be given where "Type[Sized]" is expected + @protocols.instance(Sized) def _sized_protocols(instance: Sized, other: str) -> str: ... + out: | + main:8: error: Protocols must be passed with "is_protocol=True" + + +- case: typeclass_protocol_wrong_usage1 + disable_cache: false + main: | + from typing import Sized + from classes import typeclass + + @typeclass + def protocols(instance, other: str) -> str: + ... + + @protocols.instance(Sized, is_protocol=False) + def _sized_protocols(instance: Sized, other: str) -> str: + ... + out: | + main:8: error: Protocols must be passed with "is_protocol=True" + + +- case: typeclass_protocol_wrong_usage2 + disable_cache: false + main: | + from typing import Sized + from classes import typeclass + + @typeclass + def protocols(instance, other: str) -> str: + ... + + @protocols.instance(int, is_protocol=True) + def _sized_protocols(instance: int, other: str) -> str: + ... + out: | + main:8: error: Regular types must be passed with "is_protocol=False" + + +- case: typeclass_protocol_wrong_usage3 + disable_cache: false + main: | + from typing import Sized + from classes import typeclass + + @typeclass + def protocols(instance, other: str) -> str: + ... + + p: bool + + @protocols.instance(int, is_protocol=p) + def _sized_protocols(instance: int, other: str) -> str: + ... + out: | + main:10: error: Use literal bool for "is_protocol" argument, got: "builtins.bool" + + +- case: typeclass_protocol_wrong_usage4 + disable_cache: false + main: | + from typing import Sized + from classes import typeclass + + @typeclass + def protocols(instance, other: str) -> str: + ... + + @protocols.instance(int, is_protocol='abc') + def _sized_protocols(instance: int, other: str) -> str: + ... + out: | + main:8: error: Argument "is_protocol" to "instance" of "_TypeClass" has incompatible type "str"; expected "bool" + main:8: error: Use literal bool for "is_protocol" argument, got: "Literal['abc']?" + + +- case: typeclass_protocol_wrong_usage5 + disable_cache: false + main: | + from typing import Sized + from classes import typeclass + + @typeclass + def protocols(instance, other: str) -> str: + ... + + @protocols.instance(int, is_protocol=bool) + def _sized_protocols(instance: int, other: str) -> str: + ... + out: | + main:8: error: Argument "is_protocol" to "instance" of "_TypeClass" has incompatible type "Type[bool]"; expected "bool" + main:8: error: Use literal bool for "is_protocol" argument, got: "def (builtins.object =) -> builtins.bool*" + diff --git a/typesafety/test_typeclass/test_supports.yml b/typesafety/test_typeclass/test_supports.yml index 432afcc..53e47a8 100644 --- a/typesafety/test_typeclass/test_supports.yml +++ b/typesafety/test_typeclass/test_supports.yml @@ -1,3 +1,25 @@ +- case: typeclass_object_supports + disable_cache: false + main: | + from classes import typeclass + + class ToJson(object): + def __call__(self, instance) -> str: + ... + + to_json = typeclass(ToJson) + + @to_json.instance(int) + def _to_json_int(instance: int) -> str: + ... + + reveal_type(to_json.supports(str)) + reveal_type(to_json.supports(int)) + out: | + main:13: note: Revealed type is 'builtins.bool' + main:14: note: Revealed type is 'builtins.bool' + + - case: typeclass_protocol_supports disable_cache: false main: | diff --git a/typesafety/test_typeclass/test_typeclass.yml b/typesafety/test_typeclass/test_typeclass.yml index 8e58d44..c6534d3 100644 --- a/typesafety/test_typeclass/test_typeclass.yml +++ b/typesafety/test_typeclass/test_typeclass.yml @@ -10,6 +10,16 @@ reveal_type(example) # N: Revealed type is 'classes._typeclass._TypeClass[, builtins.bool, def (instance: , arg: builtins.str, other: builtins.int, *, attr: builtins.bool) -> builtins.bool, ]' +- case: typeclass_definition_wrong + disable_cache: false + main: | + from classes import typeclass + + @typeclass + def example(instance): + ... + + - case: typeclass_wrong_param disable_cache: false main: | From 6cb21c6a7d2b58f2bf9e28efe9343702692616ca Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 8 Jun 2021 00:50:24 +0300 Subject: [PATCH 06/12] Fixes flake8 --- classes/contrib/mypy/features/typeclass.py | 7 +++++-- classes/contrib/mypy/typeops/inference.py | 11 ++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 1d0c684..bbddd4e 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -177,8 +177,10 @@ def __call__(self, ctx: MethodContext) -> MypyType: # noqa: WPS218 fullname=typeclass_ref.args[3].value, ctx=ctx, ) - ctx.type.args[1].args = typeclass.args # Without this line self._add_supports_metadata(typeclass, instance_type, ctx) + + # Without this line we won't mutate args of a class-defined typeclass: + ctx.type.args[1].args = typeclass.args return ctx.default_return_type def _add_new_instance_type( @@ -263,7 +265,8 @@ def _add_supports_metadata( assert isinstance(ctx.type, Instance) # We also need to modify the metadata for a typeclass typeinfo: - typeclass.args[2].type.metadata['classes']['typeclass'] = typeclass + metadata = typeclass.args[2].type.metadata + metadata['classes']['typeclass'] = typeclass supports_spec = type_loader.load_supports_type(typeclass.args[2], ctx) if supports_spec not in instance_type.type.bases: diff --git a/classes/contrib/mypy/typeops/inference.py b/classes/contrib/mypy/typeops/inference.py index 7c8dfbb..a6c679f 100644 --- a/classes/contrib/mypy/typeops/inference.py +++ b/classes/contrib/mypy/typeops/inference.py @@ -50,7 +50,11 @@ def _some_int_str(instance: Union[str, int]): ... # Infered type from `mypy` is good enough, just return `fallback`. instance_types = [] for decorator in ctx.context.decorators: - instance_type = _get_typeclass_instance_type(decorator, fullname, ctx) + instance_type = _get_typeclass_instance_type( + decorator, + fullname, + ctx, + ) if instance_type is not None: instance_types.append(_post_process_type(instance_type)) @@ -72,9 +76,10 @@ def _get_typeclass_instance_type( isinstance(expr_type.args[1], Instance) ) if is_typeclass_instance_def: + inst = expr_type.args[1] is_same_typeclass = ( - isinstance(expr_type.args[1].args[3], LiteralType) and - expr_type.args[1].args[3].value == fullname or + isinstance(inst.args[3], LiteralType) and + inst.args[3].value == fullname or fullname is None ) if is_same_typeclass: From 363072802d972697b50c77419a3e215610808942 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 8 Jun 2021 00:57:52 +0300 Subject: [PATCH 07/12] Fixes codespell --- classes/contrib/mypy/typeops/inference.py | 6 +++--- classes/contrib/mypy/typeops/instance_args.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/classes/contrib/mypy/typeops/inference.py b/classes/contrib/mypy/typeops/inference.py index a6c679f..1721d29 100644 --- a/classes/contrib/mypy/typeops/inference.py +++ b/classes/contrib/mypy/typeops/inference.py @@ -47,7 +47,7 @@ def _some_int_str(instance: Union[str, int]): ... # Why do we only care for this case? # Because if it is a call / or just a single decorator, # then we are fine with regular type inference. - # Infered type from `mypy` is good enough, just return `fallback`. + # Inferred type from `mypy` is good enough, just return `fallback`. instance_types = [] for decorator in ctx.context.decorators: instance_type = _get_typeclass_instance_type( @@ -58,7 +58,7 @@ def _some_int_str(instance: Union[str, int]): ... if instance_type is not None: instance_types.append(_post_process_type(instance_type)) - # Infered resulting type: + # Inferred resulting type: return make_simplified_union(instance_types) return _post_process_type(fallback) @@ -95,7 +95,7 @@ def _post_process_type(type_: MypyType) -> MypyType: # @some.instance(Sized) # (instance: Sized, b: int) -> str: ... # - # So, you will recieve callable type + # So, you will receive callable type # `def () -> Sized` as `runtime_type` in this case. # We need to convert it back to regular `Instance`. # diff --git a/classes/contrib/mypy/typeops/instance_args.py b/classes/contrib/mypy/typeops/instance_args.py index 641545c..5a02386 100644 --- a/classes/contrib/mypy/typeops/instance_args.py +++ b/classes/contrib/mypy/typeops/instance_args.py @@ -7,7 +7,7 @@ from mypy.types import TypeVarType, UnboundType, UninhabitedType from typing_extensions import Final -#: Types that polute instance args. +#: Types that pollute instance args. _TYPES_TO_FILTER_OUT: Final = ( TypeVarType, UninhabitedType, From 13c26a96782b232dcd5ebdb88a5dea705cc595e5 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 8 Jun 2021 01:07:51 +0300 Subject: [PATCH 08/12] Fixes codespell --- classes/contrib/mypy/typeops/typecheck.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index 058f9a6..e2fc96a 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -197,7 +197,7 @@ def _check_runtime_type( 1. We check that type passed in ``some.instance(...)`` matches one defined in a type annotation 2. We check that types don't have any concrete types - 3. We check that types dont' have any unbound type variables + 3. We check that types don't have any unbound type variables 4. We check that ``is_protocol`` is passed correctly """ From 8bdba4d1d80110722d00affdbbfbb3c2cea56ec4 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 8 Jun 2021 01:25:01 +0300 Subject: [PATCH 09/12] Fixes pytest --- docs/pages/concept.rst | 2 +- setup.cfg | 7 +++++++ tests/test_typeclass/test_reuse.py | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 tests/test_typeclass/test_reuse.py diff --git a/docs/pages/concept.rst b/docs/pages/concept.rst index ead3978..c90a59a 100644 --- a/docs/pages/concept.rst +++ b/docs/pages/concept.rst @@ -87,7 +87,7 @@ Let's define some instances: .. code:: python - >>> @json.instance # You can use just the annotation + >>> @json.instance(str) ... def _json_str(instance: str) -> str: ... return '"{0}"'.format(instance) diff --git a/setup.cfg b/setup.cfg index 639fd66..1307bdf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -80,6 +80,13 @@ omit = # which does not work with coverage: classes/contrib/mypy/* +[coverage:report] +exclude_lines = + # a more strict default pragma + \# pragma: no cover\b + + ^if TYPE_CHECKING: + [mypy] # mypy configurations: http://bit.ly/2zEl9WI diff --git a/tests/test_typeclass/test_reuse.py b/tests/test_typeclass/test_reuse.py new file mode 100644 index 0000000..c002b8b --- /dev/null +++ b/tests/test_typeclass/test_reuse.py @@ -0,0 +1,26 @@ +import pytest + +from classes import typeclass + + +class _Example(object): + def __call__(self, instance) -> str: + """Example class-based typeclass def.""" + + +def _example(instance) -> str: + """Example function-based typeclass def.""" + + +def test_class_reuse() -> None: + """Ensures that it is impossible to reuse classes.""" + typeclass(_Example) + + with pytest.raises(TypeError): + typeclass(_Example) + + +def test_function_reuse() -> None: + """Ensures that it is possible to reuse classes.""" + typeclass(_example) + typeclass(_example) From 44ff3c9373b76cbd676de2762c64b4788f01d166 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 8 Jun 2021 01:29:45 +0300 Subject: [PATCH 10/12] Fixes pytest --- tests/test_typeclass/test_reuse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_typeclass/test_reuse.py b/tests/test_typeclass/test_reuse.py index c002b8b..14d5847 100644 --- a/tests/test_typeclass/test_reuse.py +++ b/tests/test_typeclass/test_reuse.py @@ -17,7 +17,7 @@ def test_class_reuse() -> None: typeclass(_Example) with pytest.raises(TypeError): - typeclass(_Example) + typeclass(_Example) # type: ignore def test_function_reuse() -> None: From f39bc3777be10405f9adcacb77f266ac576f8bd0 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 10 Jun 2021 13:51:29 +0300 Subject: [PATCH 11/12] Adds associated types --- classes/__init__.py | 1 + classes/_typeclass.py | 302 ++++++++++-------- classes/contrib/mypy/classes_plugin.py | 21 +- classes/contrib/mypy/features/typeclass.py | 170 +++++----- .../contrib/mypy/typeops/associated_types.py | 49 +++ classes/contrib/mypy/typeops/instance_args.py | 16 +- classes/contrib/mypy/typeops/type_loader.py | 9 +- classes/contrib/mypy/typeops/typecheck.py | 18 +- docs/pages/concept.rst | 65 ++-- tests/test_typeclass/test_reuse.py | 26 -- typesafety/test_supports_type.yml | 74 +++-- typesafety/test_typeclass/test_callback.yml | 12 +- .../test_definition_by_class.yml | 134 -------- typesafety/test_typeclass/test_generics.yml | 25 ++ typesafety/test_typeclass/test_instance.yml | 29 ++ typesafety/test_typeclass/test_protocols.yml | 1 - typesafety/test_typeclass/test_supports.yml | 32 +- typesafety/test_typeclass/test_typeclass.yml | 118 +++---- 18 files changed, 528 insertions(+), 574 deletions(-) create mode 100644 classes/contrib/mypy/typeops/associated_types.py delete mode 100644 tests/test_typeclass/test_reuse.py delete mode 100644 typesafety/test_typeclass/test_definition_by_class.yml diff --git a/classes/__init__.py b/classes/__init__.py index bb402d0..98bc4a1 100644 --- a/classes/__init__.py +++ b/classes/__init__.py @@ -5,5 +5,6 @@ so mypy's ``implicit_reexport`` rule will be happy. """ +from classes._typeclass import AssociatedType as AssociatedType from classes._typeclass import Supports as Supports from classes._typeclass import typeclass as typeclass diff --git a/classes/_typeclass.py b/classes/_typeclass.py index c0adc06..6e01121 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -1,202 +1,192 @@ -from typing import ( # noqa: WPS235 - TYPE_CHECKING, - Callable, - ClassVar, - Dict, - Generic, - Set, - Type, - TypeVar, - Union, -) +""" +Typeclasses for Python. -from typing_extensions import final +.. rubric:: Basic usage -_InstanceType = TypeVar('_InstanceType') -_SignatureType = TypeVar('_SignatureType', bound=Callable) -_DefinitionType = TypeVar('_DefinitionType', bound=Type) -_Fullname = TypeVar('_Fullname', bound=str) # Literal value +The first and the simplest example of a typeclass is just its definition: -_NewInstanceType = TypeVar('_NewInstanceType', bound=Type) +.. code:: python -_TypeClassType = TypeVar('_TypeClassType', bound='_TypeClass') -_ReturnType = TypeVar('_ReturnType') - - -def typeclass( - signature: _SignatureType, - # By default almost all variables are `nothing`, - # but we enhance them via mypy plugin later: -) -> '_TypeClass[_InstanceType, _SignatureType, _DefinitionType, _Fullname]': - """ - Function to define typeclasses. + >>> from classes import typeclass - .. rubric:: Basic usage + >>> @typeclass + ... def example(instance) -> str: + ... '''Example typeclass.''' - The first and the simplest example of a typeclass is just its definition: + >>> example(1) + Traceback (most recent call last): + ... + NotImplementedError: Missing matched typeclass instance for type: int - .. code:: python +In this example we work with the default implementation of a typeclass. +It raises a ``NotImplementedError`` when no instances match. +And we don't yet have a special case for ``int``, +that why we fallback to the default implementation. - >>> from classes import typeclass +It works almost like a regular function right now. +Let's do the next step and introduce +the ``int`` instance for our typeclass: - >>> @typeclass - ... def example(instance) -> str: - ... '''Example typeclass.''' +.. code:: python - >>> example(1) - Traceback (most recent call last): - ... - NotImplementedError: Missing matched typeclass instance for type: int + >>> @example.instance(int) + ... def _example_int(instance: int) -> str: + ... return 'int case' - In this example we work with the default implementation of a typeclass. - It raise a ``NotImplementedError`` when no instances match. - And we don't yet have a special case for ``int``, - that why we fallback to the default implementation. + >>> assert example(1) == 'int case' - It works like a regular function right now. - Let's do the next step and introduce - the ``int`` instance for the typeclass: +Now we have a specific instance for ``int`` +which does something different from the default implementation. - .. code:: python +What will happen if we pass something new, like ``str``? - >>> @example.instance(int) - ... def _example_int(instance: int) -> str: - ... return 'int case' +.. code:: python - >>> assert example(1) == 'int case' + >>> example('a') + Traceback (most recent call last): + ... + NotImplementedError: Missing matched typeclass instance for type: str - Now we have a specific instance for ``int`` - which does something different from the default implementation. +Because again, we don't yet have +an instance of this typeclass for ``str`` type. +Let's fix that. - What will happen if we pass something new, like ``str``? +.. code:: python - .. code:: python + >>> @example.instance(str) + ... def _example_str(instance: str) -> str: + ... return instance - >>> example('a') - Traceback (most recent call last): - ... - NotImplementedError: Missing matched typeclass instance for type: str + >>> assert example('a') == 'a' - Because again, we don't yet have - an instance of this typeclass for ``str`` type. - Let's fix that. +Now it works with ``str`` as well. But differently. +This allows developer to base the implementation on type information. - .. code:: python +So, the rule is clear: +if we have a typeclass instance for a specific type, +then it will be called, +otherwise the default implementation will be called instead. - >>> @example.instance(str) - ... def _example_str(instance: str) -> str: - ... return instance +.. rubric:: Protocols - >>> assert example('a') == 'a' +We also support protocols. It has the same limitation as ``Generic`` types. +It is also dispatched after all regular instances are checked. - Now it works with ``str`` as well. But differently. - This allows developer to base the implementation on type information. +To work with protocols, one needs to pass ``is_protocol`` flag to instance: - So, the rule is clear: - if we have a typeclass instance for a specific type, - then it will be called, - otherwise the default implementation will be called instead. +.. code:: python - .. rubric:: Generics + >>> from typing import Sequence - We also support generics, but the support is limited. - We cannot rely on type parameters of the generic type, - only on the base generic class: + >>> @example.instance(Sequence, is_protocol=True) + ... def _sequence_example(instance: Sequence) -> str: + ... return ','.join(str(item) for item in instance) - .. code:: python + >>> assert example([1, 2, 3]) == '1,2,3' - >>> from typing import Generic, TypeVar +But, ``str`` will still have higher priority over ``Sequence``: - >>> T = TypeVar('T') +.. code:: python - >>> class MyGeneric(Generic[T]): - ... def __init__(self, arg: T) -> None: - ... self.arg = arg + >>> assert example('abc') == 'abc' - Now, let's define the typeclass instance for this type: +We also support user-defined protocols: - .. code:: python +.. code:: python - >>> @example.instance(MyGeneric) - ... def _my_generic_example(instance: MyGeneric) -> str: - ... return 'generi' + str(instance.arg) + >>> from typing_extensions import Protocol - >>> assert example(MyGeneric('c')) == 'generic' + >>> class CustomProtocol(Protocol): + ... field: str - This case will work for all type parameters of ``MyGeneric``, - or in other words it can be assumed as ``MyGeneric[Any]``: + >>> @example.instance(CustomProtocol, is_protocol=True) + ... def _custom_protocol_example(instance: CustomProtocol) -> str: + ... return instance.field - .. code:: python +Now, let's build a class that match this protocol and test it: - >>> assert example(MyGeneric(1)) == 'generi1' +.. code:: python - In the future, when Python will have new type mechanisms, - we would like to improve our support for specific generic instances - like ``MyGeneric[int]`` only. But, that's the best we can do for now. + >>> class WithField(object): + ... field: str = 'with field' - .. rubric:: Protocols + >>> assert example(WithField()) == 'with field' - We also support protocols. It has the same limitation as ``Generic`` types. - It is also dispatched after all regular instances are checked. +""" - To work with protocols, one needs to pass ``is_protocol`` flag to instance: +from typing import ( # noqa: WPS235 + TYPE_CHECKING, + Callable, + Dict, + Generic, + Type, + TypeVar, + Union, + overload, +) - .. code:: python +from typing_extensions import final - >>> from typing import Sequence +_InstanceType = TypeVar('_InstanceType') +_SignatureType = TypeVar('_SignatureType', bound=Callable) +_AssociatedType = TypeVar('_AssociatedType') +_Fullname = TypeVar('_Fullname', bound=str) # Literal value - >>> @example.instance(Sequence, is_protocol=True) - ... def _sequence_example(instance: Sequence) -> str: - ... return ','.join(str(item) for item in instance) +_NewInstanceType = TypeVar('_NewInstanceType', bound=Type) - >>> assert example([1, 2, 3]) == '1,2,3' +_StrictAssociatedType = TypeVar('_StrictAssociatedType', bound='AssociatedType') +_TypeClassType = TypeVar('_TypeClassType', bound='_TypeClass') +_ReturnType = TypeVar('_ReturnType') - But, ``str`` will still have higher priority over ``Sequence``: - .. code:: python +@overload +def typeclass( + definition: Type[_AssociatedType], +) -> '_TypeClassDef[_AssociatedType]': + """Function to created typeclasses with associated types.""" - >>> assert example('abc') == 'abc' - We also support user-defined protocols: +@overload +def typeclass( + signature: _SignatureType, + # By default almost all variables are `nothing`, + # but we enhance them via mypy plugin later: +) -> '_TypeClass[_InstanceType, _SignatureType, _AssociatedType, _Fullname]': + """Function to define typeclasses with just functions.""" - .. code:: python - >>> from typing_extensions import Protocol +def typeclass(signature): + """General case function to create typeclasses.""" + if isinstance(signature, type): + return _TypeClass # It means, that it has a associated type with it + return _TypeClass(signature) # In this case it is a regular function - >>> class CustomProtocol(Protocol): - ... field: str - >>> @example.instance(CustomProtocol, is_protocol=True) - ... def _custom_protocol_example(instance: CustomProtocol) -> str: - ... return instance.field +class AssociatedType(object): + """ + Base class for all associated types. - Now, let's build a class that match this protocol and test it: + How to use? Just import and subclass it: .. code:: python - >>> class WithField(object): - ... field: str = 'with field' + >>> from classes import AssociatedType, typeclass - >>> assert example(WithField()) == 'with field' + >>> class Example(AssociatedType): + ... ... - Remember, that generic protocols have the same limitation as generic types. + >>> @typeclass(Example) + ... def example(instance) -> str: + ... ... + Right now it does nothing in runtime, but this can change in the future. """ - if signature in _TypeClass._known_signatures: # type: ignore # noqa: WPS437 - raise TypeError( - 'Typeclass definition "{0}" cannot be reused'.format( - signature, - ), - ) - if isinstance(signature, type): - _TypeClass._known_signatures.add( # type: ignore # noqa: WPS437 - signature, - ) - return _TypeClass(signature) + + __slots__ = () @final -class Supports(Generic[_SignatureType]): +class Supports(Generic[_StrictAssociatedType]): """ Used to specify that some value is a part of a typeclass. @@ -207,10 +197,11 @@ class Supports(Generic[_SignatureType]): >>> from classes import typeclass, Supports >>> class ToJson(object): - ... def __call__(self, instance) -> str: - ... ... + ... ... - >>> to_json = typeclass(ToJson) + >>> @typeclass(ToJson) + ... def to_json(instance) -> str: + ... ... >>> @to_json.instance(int) ... def _to_json_int(instance: int) -> str: @@ -240,14 +231,16 @@ class Supports(Generic[_SignatureType]): # (expression has type "str", variable has type "Supports[ToJson]") .. warning:: - ``Supports`` only works with typeclasses defined as types. + ``Supports`` only works with typeclasses defined with associated types. """ + __slots__ = () + @final class _TypeClass( - Generic[_InstanceType, _SignatureType, _DefinitionType, _Fullname], + Generic[_InstanceType, _SignatureType, _AssociatedType, _Fullname], ): """ That's how we represent typeclasses. @@ -282,7 +275,6 @@ class _TypeClass( """ __slots__ = ('_instances', '_protocols') - _known_signatures: ClassVar[Set[_SignatureType]] = set() def __init__(self, signature: _SignatureType) -> None: """ @@ -318,7 +310,10 @@ def __init__(self, signature: _SignatureType) -> None: def __call__( self, - instance: Union[_InstanceType, Supports[_DefinitionType]], + instance: Union[ # type: ignore + _InstanceType, + Supports[_AssociatedType], + ], *args, **kwargs, ) -> _ReturnType: @@ -430,6 +425,29 @@ def decorator(implementation): if TYPE_CHECKING: from typing_extensions import Protocol + class _TypeClassDef(Protocol[_AssociatedType]): + """ + Callable protocol to help us with typeclass definition. + + This protocol does not exist in real life, + we just need it because we use it in ``mypy`` plugin. + That's why we define it under ``if TYPE_CHECKING:``. + It should not be used directly. + + See ``TypeClassDefReturnType`` for more information. + """ + + def __call__( + self, + signature: _SignatureType, + ) -> _TypeClass[ + _InstanceType, + _SignatureType, + _AssociatedType, + _Fullname, + ]: + """It can be called, because in real life it is a function.""" + class _TypeClassInstanceDef( # type: ignore Protocol[_InstanceType, _TypeClassType], ): @@ -439,7 +457,7 @@ class _TypeClassInstanceDef( # type: ignore This protocol does not exist in real life, we just need it because we use it in ``mypy`` plugin. That's why we define it under ``if TYPE_CHECKING:``. - It should not be used + It should not be used directly. See ``InstanceDefReturnType`` for more information. diff --git a/classes/contrib/mypy/classes_plugin.py b/classes/contrib/mypy/classes_plugin.py index 841dffe..f52b161 100644 --- a/classes/contrib/mypy/classes_plugin.py +++ b/classes/contrib/mypy/classes_plugin.py @@ -22,10 +22,16 @@ from mypy.plugin import FunctionContext, MethodContext, MethodSigContext, Plugin from mypy.types import CallableType from mypy.types import Type as MypyType -from typing_extensions import final +from typing_extensions import Final, final from classes.contrib.mypy.features import typeclass +_TYPECLASS_FULLNAME: Final = 'classes._typeclass._TypeClass' +_TYPECLASS_DEF_FULLNAME: Final = 'classes._typeclass._TypeClassDef' +_TYPECLASS_INSTANCE_DEF_FULLNAME: Final = ( + 'classes._typeclass._TypeClassInstanceDef' +) + @final class _TypeClassPlugin(Plugin): @@ -47,7 +53,10 @@ def get_function_hook( ) -> Optional[Callable[[FunctionContext], MypyType]]: """Here we adjust the typeclass constructor.""" if fullname == 'classes._typeclass.typeclass': - return typeclass.ConstructorReturnType() + return typeclass.TypeClassReturnType( + typeclass=_TYPECLASS_FULLNAME, + typeclass_def=_TYPECLASS_DEF_FULLNAME, + ) return None def get_method_hook( @@ -55,9 +64,11 @@ def get_method_hook( fullname: str, ) -> Optional[Callable[[MethodContext], MypyType]]: """Here we adjust the typeclass with new allowed types.""" - if fullname == 'classes._typeclass._TypeClassInstanceDef.__call__': + if fullname == '{0}.__call__'.format(_TYPECLASS_DEF_FULLNAME): + return typeclass.typeclass_def_return_type + if fullname == '{0}.__call__'.format(_TYPECLASS_INSTANCE_DEF_FULLNAME): return typeclass.InstanceDefReturnType() - if fullname == 'classes._typeclass._TypeClass.instance': + if fullname == '{0}.instance'.format(_TYPECLASS_FULLNAME): return typeclass.instance_return_type return None @@ -66,7 +77,7 @@ def get_method_signature_hook( fullname: str, ) -> Optional[Callable[[MethodSigContext], CallableType]]: """Here we fix the calling method types to accept only valid types.""" - if fullname == 'classes._typeclass._TypeClass.__call__': + if fullname == '{0}.__call__'.format(_TYPECLASS_FULLNAME): return typeclass.call_signature return None diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index bbddd4e..e756b70 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -1,14 +1,20 @@ -from typing import Optional -from mypy.nodes import Decorator, TypeInfo +from mypy.nodes import Decorator from mypy.plugin import FunctionContext, MethodContext, MethodSigContext -from mypy.typeops import bind_self -from mypy.types import AnyType, CallableType, Instance, LiteralType, TupleType +from mypy.types import ( + AnyType, + CallableType, + FunctionLike, + Instance, + LiteralType, + TupleType, +) from mypy.types import Type as MypyType -from mypy.types import TypeOfAny, UninhabitedType, UnionType, get_proper_type +from mypy.types import TypeOfAny, UnionType from typing_extensions import final from classes.contrib.mypy.typeops import ( + associated_types, inference, instance_args, type_loader, @@ -17,7 +23,7 @@ @final -class ConstructorReturnType(object): +class TypeClassReturnType(object): """ Adjust argument types when we define typeclasses via ``typeclass`` function. @@ -28,88 +34,85 @@ class ConstructorReturnType(object): It also checks how typeclasses are defined. """ + __slots__ = ('_typeclass', '_typeclass_def') + + def __init__(self, typeclass: str, typeclass_def: str) -> None: + """We pass exact type names as the context.""" + self._typeclass = typeclass + self._typeclass_def = typeclass_def + def __call__(self, ctx: FunctionContext) -> MypyType: """Main entry point.""" defn = ctx.arg_types[0][0] - is_defined_by_class = ( + + is_typeclass_def = ( + isinstance(ctx.default_return_type, Instance) and + ctx.default_return_type.type.fullname == self._typeclass_def and + isinstance(defn, FunctionLike) and + defn.is_type_obj() + ) + is_typeclass = ( + isinstance(ctx.default_return_type, Instance) and + ctx.default_return_type.type.fullname == self._typeclass and isinstance(defn, CallableType) and - not defn.arg_types and - isinstance(defn.ret_type, Instance) + defn.definition ) - if is_defined_by_class: - return self._adjust_protocol_arguments(ctx) - elif isinstance(defn, CallableType) and defn.definition: - return self._adjust_typeclass(defn, defn.definition.fullname, ctx) - - ctx.api.fail( - 'Invalid typeclass definition: "{0}"'.format(defn), - ctx.context, - ) - return UninhabitedType() - - def _adjust_protocol_arguments(self, ctx: FunctionContext) -> MypyType: - assert isinstance(ctx.arg_types[0][0], CallableType) - assert isinstance(ctx.arg_types[0][0].ret_type, Instance) - - instance = ctx.arg_types[0][0].ret_type - type_info = instance.type - signature = type_info.get_method('__call__') - if not signature: - ctx.api.fail( - 'Typeclass definition must have `__call__` method', - ctx.context, + if is_typeclass_def: + assert isinstance(ctx.default_return_type, Instance) + assert isinstance(defn, FunctionLike) + return self._process_typeclass_def_return_type( + ctx.default_return_type, + defn, + ctx, ) - return AnyType(TypeOfAny.from_error) - - signature_type = get_proper_type(signature.type) - assert isinstance(signature_type, CallableType) - typeclass = self._adjust_typeclass( - bind_self(signature_type), - type_info.fullname, - ctx, - class_definition=instance, - ) - self._process_typeclass_metadata(type_info, typeclass, ctx) - return typeclass + elif is_typeclass: + assert isinstance(ctx.default_return_type, Instance) + assert isinstance(defn, CallableType) + assert defn.definition + instance_args.mutate_typeclass_def( + ctx.default_return_type, + defn.definition.fullname, + ctx, + ) + return ctx.default_return_type + return AnyType(TypeOfAny.from_error) - def _adjust_typeclass( + def _process_typeclass_def_return_type( self, - typeclass_def: MypyType, - definition_fullname: str, + typeclass_intermediate_def: Instance, + defn: FunctionLike, ctx: FunctionContext, - *, - class_definition: Optional[Instance] = None, ) -> MypyType: - assert isinstance(typeclass_def, CallableType) - assert isinstance(ctx.default_return_type, Instance) + type_info = defn.type_object() + instance = Instance(type_info, []) + typeclass_intermediate_def.args = (instance,) + return typeclass_intermediate_def - str_fallback = ctx.api.str_type() # type: ignore - ctx.default_return_type.args = ( - UninhabitedType(), # We start with empty set of instances - typeclass_def, - class_definition if class_definition else UninhabitedType(), - LiteralType(definition_fullname, str_fallback), - ) - return ctx.default_return_type +def typeclass_def_return_type(ctx: MethodContext) -> MypyType: + """ + Callback for cases like ``@typeclass(SomeType)``. - def _process_typeclass_metadata( - self, - type_info: TypeInfo, - typeclass: MypyType, - ctx: FunctionContext, - ) -> None: - namespace = type_info.metadata.setdefault('classes', {}) - if namespace.get('typeclass'): # TODO: the same for functions - ctx.api.fail( - 'Typeclass definition "{0}" cannot be reused'.format( - type_info.fullname, - ), - ctx.context, - ) - return - namespace['typeclass'] = typeclass + What it does? It works with the associated types. + It checks that ``SomeType`` is correct, modifies the current typeclass. + And returns it back. + """ + assert isinstance(ctx.default_return_type, Instance) + # TODO: change to condition + # This will allow us to warn users on `x = typeclass(T)(func)`, + # instead of falling with exception. + assert isinstance(ctx.context, Decorator) + + if isinstance(ctx.default_return_type.args[2], Instance): + associated_types.check_type(ctx.default_return_type.args[2], ctx) + + instance_args.mutate_typeclass_def( + ctx.default_return_type, + ctx.context.func.fullname, + ctx, + ) + return ctx.default_return_type def instance_return_type(ctx: MethodContext) -> MypyType: @@ -165,6 +168,10 @@ def __call__(self, ctx: MethodContext) -> MypyType: # noqa: WPS218 assert isinstance(instance_signature, CallableType) instance_type = instance_signature.arg_types[0] + # We need to add `Supports` metadata before typechecking, + # because it will affect type hierarchies. + self._add_supports_metadata(typeclass, instance_type, ctx) + typecheck.check_typeclass( typeclass_signature=typeclass.args[1], instance_signature=instance_signature, @@ -177,7 +184,6 @@ def __call__(self, ctx: MethodContext) -> MypyType: # noqa: WPS218 fullname=typeclass_ref.args[3].value, ctx=ctx, ) - self._add_supports_metadata(typeclass, instance_type, ctx) # Without this line we won't mutate args of a class-defined typeclass: ctx.type.args[1].args = typeclass.args @@ -223,13 +229,13 @@ def _add_supports_metadata( .. code:: python >>> from classes import Supports, typeclass - >>> from typing_extensions import Protocol - >>> class ToStr(Protocol): - ... def __call__(self, instance) -> str: - ... ... + >>> class ToStr(object): + ... ... - >>> to_str = typeclass(ToStr) + >>> @typeclass(ToStr) + ... def to_str(instance) -> str: + ... ... >>> @to_str.instance(int) ... def _to_str_int(instance: int) -> str: @@ -264,10 +270,6 @@ def _add_supports_metadata( assert isinstance(ctx.type, Instance) - # We also need to modify the metadata for a typeclass typeinfo: - metadata = typeclass.args[2].type.metadata - metadata['classes']['typeclass'] = typeclass - supports_spec = type_loader.load_supports_type(typeclass.args[2], ctx) if supports_spec not in instance_type.type.bases: instance_type.type.bases.append(supports_spec) diff --git a/classes/contrib/mypy/typeops/associated_types.py b/classes/contrib/mypy/typeops/associated_types.py new file mode 100644 index 0000000..2c0fb7a --- /dev/null +++ b/classes/contrib/mypy/typeops/associated_types.py @@ -0,0 +1,49 @@ +from mypy.plugin import MethodContext +from mypy.types import Instance +from typing_extensions import Final + +#: Fullname of the `AssociatedType` class. +_ASSOCIATED_TYPE_FULLNAME: Final = 'classes._typeclass.AssociatedType' + +# Messages: +_WRONG_SUBCLASS_MSG: Final = ( + 'Single direct subclass of "{0}" required; got "{1}"' +) + + +def check_type( + associated_type: Instance, + ctx: MethodContext, +) -> bool: + """ + Checks passed ``AssociatedType`` instance. + + Right now, we only check that + it is a subtype of our ``AssociatedType`` instance. + In the future, it will do way more. + """ + return all([ + _check_base_class(associated_type, ctx), + # TODO: check_type_reuse + # TODO: check_body + ]) + + +def _check_base_class( + associated_type: Instance, + ctx: MethodContext, +) -> bool: + bases = associated_type.type.bases + has_correct_base = ( + len(bases) == 1 and + _ASSOCIATED_TYPE_FULLNAME == bases[0].type.fullname + ) + if not has_correct_base: + ctx.api.fail( + _WRONG_SUBCLASS_MSG.format( + _ASSOCIATED_TYPE_FULLNAME, + associated_type, + ), + ctx.context, + ) + return has_correct_base diff --git a/classes/contrib/mypy/typeops/instance_args.py b/classes/contrib/mypy/typeops/instance_args.py index 5a02386..f65ff39 100644 --- a/classes/contrib/mypy/typeops/instance_args.py +++ b/classes/contrib/mypy/typeops/instance_args.py @@ -2,7 +2,7 @@ from mypy.plugin import FunctionContext, MethodContext from mypy.typeops import make_simplified_union -from mypy.types import AnyType, Instance, TupleType +from mypy.types import AnyType, Instance, LiteralType, TupleType from mypy.types import Type as MypyType from mypy.types import TypeVarType, UnboundType, UninhabitedType from typing_extensions import Final @@ -33,6 +33,20 @@ def add_unique( return make_simplified_union(unified) +def mutate_typeclass_def( + typeclass: Instance, + definition_fullname: str, + ctx: Union[FunctionContext, MethodContext], +) -> None: + """Adds definition fullname to the typeclass type.""" + str_fallback = ctx.api.str_type() # type: ignore + + typeclass.args = ( + *typeclass.args[:3], + LiteralType(definition_fullname, str_fallback), + ) + + def mutate_typeclass_instance_def( instance: Instance, *, diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py index 1898a97..9af2493 100644 --- a/classes/contrib/mypy/typeops/type_loader.py +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -38,10 +38,5 @@ def load_typeclass( # TODO """ typeclass_info = ctx.api.lookup_qualified(fullname) # type: ignore - if isinstance(typeclass_info.type, Instance): - return typeclass_info.type - - assert typeclass_info.node - metadata = typeclass_info.node.metadata['classes']['typeclass'] - assert isinstance(metadata, Instance) - return metadata + assert isinstance(typeclass_info.type, Instance) + return typeclass_info.type diff --git a/classes/contrib/mypy/typeops/typecheck.py b/classes/contrib/mypy/typeops/typecheck.py index e2fc96a..630b419 100644 --- a/classes/contrib/mypy/typeops/typecheck.py +++ b/classes/contrib/mypy/typeops/typecheck.py @@ -19,17 +19,19 @@ 'Instance "{0}" does not match original type "{1}"' ) -_INSTANCE_RUNTIME_MISMATCH: Final = ( +_INSTANCE_RUNTIME_MISMATCH_MSG: Final = ( 'Instance "{0}" does not match runtime type "{1}"' ) -_IS_PROTOCOL_LITERAL_BOOL: Final = ( +_IS_PROTOCOL_LITERAL_BOOL_MSG: Final = ( 'Use literal bool for "is_protocol" argument, got: "{0}"' ) -_IS_PROTOCOL_MISSING: Final = 'Protocols must be passed with "is_protocol=True"' +_IS_PROTOCOL_MISSING_MSG: Final = ( + 'Protocols must be passed with "is_protocol=True"' +) -_IS_PROTOCOL_UNWANTED: Final = ( +_IS_PROTOCOL_UNWANTED_MSG: Final = ( 'Regular types must be passed with "is_protocol=False"' ) @@ -219,7 +221,7 @@ def _check_runtime_type( ) if not instance_check: ctx.api.fail( - _INSTANCE_RUNTIME_MISMATCH.format(instance_type, runtime_type), + _INSTANCE_RUNTIME_MISMATCH_MSG.format(instance_type, runtime_type), ctx.context, ) @@ -245,7 +247,7 @@ def _check_protocol_arg( return passed_arg.last_known_value.value, True # type: ignore ctx.api.fail( - _IS_PROTOCOL_LITERAL_BOOL.format(passed_types.items[1]), + _IS_PROTOCOL_LITERAL_BOOL_MSG.format(passed_types.items[1]), ctx.context, ) return False, False @@ -259,10 +261,10 @@ def _check_runtime_protocol( ) -> bool: if isinstance(runtime_type, Instance) and runtime_type.type: if not is_protocol and runtime_type.type.is_protocol: - ctx.api.fail(_IS_PROTOCOL_MISSING, ctx.context) + ctx.api.fail(_IS_PROTOCOL_MISSING_MSG, ctx.context) return False elif is_protocol and not runtime_type.type.is_protocol: - ctx.api.fail(_IS_PROTOCOL_UNWANTED, ctx.context) + ctx.api.fail(_IS_PROTOCOL_UNWANTED_MSG, ctx.context) return False return True diff --git a/docs/pages/concept.rst b/docs/pages/concept.rst index c90a59a..886deb1 100644 --- a/docs/pages/concept.rst +++ b/docs/pages/concept.rst @@ -125,29 +125,24 @@ Example: >>> assert json.supports(int) is True >>> assert json.supports(dict) is False -Class-based definition -~~~~~~~~~~~~~~~~~~~~~~ +Typeclasses with associated types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You can also define typeclasses not as functions, but as classes. -It won't affect anything, except some advanced ``mypy`` usage. -For example, functions in ``mypy`` cannot be used as type arguments. +You can also define typeclasses with associated types. +It will allow you to use ``Supports`` type later on. -Instead of regular functions, you can define classes with ``__call__`` method. The syntax looks like this: .. code:: python - >>> from classes import typeclass - - >>> class CanBeTrimmed(object): - ... def __call__(self, instance, length: int) -> str: - ... ... + >>> from classes import AssociatedType, typeclass - >>> can_be_trimmed = typeclass(CanBeTrimmed) + >>> class CanBeTrimmed(AssociatedType): # Associated type definition + ... ... -.. note:: - Note that you have to use ``typeclass`` as a function call here, - class decorator won't work. Because ``mypy`` does not type-check them yet. + >>> @typeclass(CanBeTrimmed) + ... def can_be_trimmed(instance, length: int) -> str: + ... ... The instance definition syntax is the same: @@ -174,13 +169,14 @@ that are able to be converted to JSON: .. code:: python - >>> from classes import Supports, typeclass + >>> from classes import AssociatedType, Supports, typeclass - >>> class ToJson(object): - ... def __call__(self, instance) -> str: - ... ... + >>> class ToJson(AssociatedType): + ... ... - >>> to_json = typeclass(ToJson) + >>> @typeclass(ToJson) + ... def to_json(instance) -> str: + ... ... >>> @to_json.instance(int) ... def _to_json_int(instance: int) -> str: @@ -209,32 +205,31 @@ And this will fail (both in runtime and during type checking): ... NotImplementedError: Missing matched typeclass instance for type: NoneType - -One more tip: you can use ``Protocol`` as the base class for this. -It might be a good indicator that this is not a real Python class, -but is just an abstraction. Our team would recommend this style: +You can also use ``Supports`` as a type annotation for defining typeclasses: .. code:: python - >>> from typing_extensions import Protocol, final + >>> class MyFeature(AssociatedType): + ... ... - >>> @final # This type cannot have sub-types - ... class MyTypeclass(Protocol): # It cannot have instances - ... def __call__(self, instance) -> str: # Protocols don't have bodies - ... """Tell us, what this typeclass is about.""" + >>> @typeclass(MyFeature) + ... def my_feature(instance: 'Supports[MyFeature]') -> str: + ... ... -You can also use ``Supports`` as a type annotation for defining typeclasses: +It might be helpful, when you have ``no-untyped-def`` rule enabled. + +One more tip: our team would recommend this style: .. code:: python - >>> class MyFeature(object): - ... def __call__(self, instance: 'Supports[MyFeature]') -> str: - ... ... + >>> from typing_extensions import Protocol, final -It might be helpful, when you have ``no-untyped-def`` rule enabled. + >>> @final # This type cannot have sub-types + ... class MyTypeclass(AssociatedType): + ... """Tell us, what this typeclass is about.""" .. warning:: - ``Supports`` only works with typeclasses defined as Python classes. + ``Supports`` only works with typeclasses defined with associated types. Related concepts diff --git a/tests/test_typeclass/test_reuse.py b/tests/test_typeclass/test_reuse.py deleted file mode 100644 index 14d5847..0000000 --- a/tests/test_typeclass/test_reuse.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from classes import typeclass - - -class _Example(object): - def __call__(self, instance) -> str: - """Example class-based typeclass def.""" - - -def _example(instance) -> str: - """Example function-based typeclass def.""" - - -def test_class_reuse() -> None: - """Ensures that it is impossible to reuse classes.""" - typeclass(_Example) - - with pytest.raises(TypeError): - typeclass(_Example) # type: ignore - - -def test_function_reuse() -> None: - """Ensures that it is possible to reuse classes.""" - typeclass(_example) - typeclass(_example) diff --git a/typesafety/test_supports_type.yml b/typesafety/test_supports_type.yml index 94fa07e..809cb38 100644 --- a/typesafety/test_supports_type.yml +++ b/typesafety/test_supports_type.yml @@ -1,13 +1,14 @@ - case: typeclass_supports_type disable_cache: false main: | - from classes import typeclass, Supports + from classes import typeclass, Supports, AssociatedType - class ToJson(object): - def __call__(self, instance) -> str: - ... + class ToJson(AssociatedType): + ... - to_json = typeclass(ToJson) + @typeclass(ToJson) + def to_json(instance) -> str: + ... @to_json.instance(int) def _to_json_int(instance: int) -> str: @@ -24,20 +25,38 @@ convert_to_json('a') convert_to_json(None) out: | - main:22: error: Argument 1 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" + main:23: error: Argument 1 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" + + +- case: typeclass_supports_type_restriction + disable_cache: false + main: | + from classes import typeclass, Supports, AssociatedType + + class ToJson(AssociatedType): + ... + + @typeclass(ToJson) + def to_json(instance: Supports[ToJson]) -> str: + ... + + @to_json.instance(int) + def _to_json_int(instance: int) -> str: + return str(instance) - case: typeclass_supports_callback disable_cache: false main: | - from classes import typeclass, Supports + from classes import typeclass, Supports, AssociatedType from typing import Callable - class ToJson(object): - def __call__(self, instance) -> str: - ... + class ToJson(AssociatedType): + ... - to_json = typeclass(ToJson) + @typeclass(ToJson) + def to_json(instance) -> str: + ... @to_json.instance(int) def _to_json_int(instance: int) -> str: @@ -57,7 +76,7 @@ convert_to_json(to_json, 'a') convert_to_json(to_json, None) out: | - main:26: error: Argument 2 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" + main:27: error: Argument 2 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" - case: typeclass_supports_with_function @@ -87,34 +106,35 @@ - case: typeclass_supports_other disable_cache: false main: | - from classes import typeclass, Supports + from classes import typeclass, Supports, AssociatedType - class ToJson(object): - def __call__(self, instance) -> str: - ... + class ToJson(AssociatedType): + ... - to_json = typeclass(ToJson) + @typeclass(ToJson) + def to_json(instance) -> str: + ... - class Other(object): - def __call__(self, instance) -> str: - ... + class Other(AssociatedType): + ... def convert_to_json(instance: Supports[Other]) -> str: return to_json(instance) out: | - main:14: error: Argument 1 to "__call__" of "ToJson" has incompatible type "Supports[Other]"; expected "Union[, Supports[ToJson]]" + main:14: error: Argument 1 to "to_json" has incompatible type "Supports[Other]"; expected "Union[, Supports[ToJson]]" - case: supports_annotation disable_cache: false main: | - from classes import typeclass, Supports + from classes import typeclass, Supports, AssociatedType - class ToJson(object): - def __call__(self, instance) -> str: - ... + class ToJson(AssociatedType): + ... - to_json = typeclass(ToJson) + @typeclass(ToJson) + def to_json(instance) -> str: + ... @to_json.instance(int) def _to_json_int(instance: int) -> str: @@ -129,4 +149,4 @@ main: | from classes import Supports - Supports[int] # E: Value of type variable "_CallbackType" of "Supports" cannot be "int" + Supports[int] # E: Value of type variable "_StrictAssociatedType" of "Supports" cannot be "int" diff --git a/typesafety/test_typeclass/test_callback.yml b/typesafety/test_typeclass/test_callback.yml index 86f05ce..ec93836 100644 --- a/typesafety/test_typeclass/test_callback.yml +++ b/typesafety/test_typeclass/test_callback.yml @@ -1,7 +1,7 @@ - case: typeclass_callback_correct disable_cache: false main: | - from typing import Callable + from typing import Callable, Union from classes import typeclass @typeclass @@ -10,7 +10,7 @@ @example.instance(int) @example.instance(float) - def _example_int_float(instance, attr: bool) -> bool: + def _example_int_float(instance: Union[int, float], attr: bool) -> bool: ... @@ -23,7 +23,7 @@ - case: typeclass_callback_wrong disable_cache: false main: | - from typing import Callable + from typing import Callable, Union from classes import typeclass @typeclass @@ -32,7 +32,7 @@ @example.instance(int) @example.instance(float) - def _example_int_float(instance, attr: bool) -> bool: + def _example_int_float(instance: Union[int, float], attr: bool) -> bool: ... def accepts_typeclass(callback: Callable[[str, bool], bool]) -> bool: @@ -40,6 +40,6 @@ reveal_type(accepts_typeclass(example)) out: | - main:16: error: Argument 1 to "accepts_typeclass" has incompatible type "_TypeClass[Union[int, float], bool, Callable[[int, bool], bool], ]"; expected "Callable[[str, bool], bool]" - main:16: note: "_TypeClass[Union[int, float], bool, Callable[[int, bool], bool], ].__call__" has type "Callable[[Arg(Supports[], 'instance'), VarArg(Any), KwArg(Any)], bool]" + main:16: error: Argument 1 to "accepts_typeclass" has incompatible type "_TypeClass[float, Callable[[Any, bool], bool], , Literal['main.example']]"; expected "Callable[[str, bool], bool]" + main:16: note: "_TypeClass[float, Callable[[Any, bool], bool], , Literal['main.example']].__call__" has type "Callable[[Arg(Union[float, Supports[]], 'instance'), VarArg(Any), KwArg(Any)], _ReturnType]" main:16: note: Revealed type is 'builtins.bool' diff --git a/typesafety/test_typeclass/test_definition_by_class.yml b/typesafety/test_typeclass/test_definition_by_class.yml deleted file mode 100644 index 4da44e7..0000000 --- a/typesafety/test_typeclass/test_definition_by_class.yml +++ /dev/null @@ -1,134 +0,0 @@ -- case: typeclass_definied_by_type - disable_cache: false - main: | - from classes import typeclass - - class ToJson(object): - def __call__(self, instance, verbose: bool = False) -> str: - ... - - to_json = typeclass(ToJson) - - @to_json.instance(int) - def _to_json_int(instance: int, verbose: bool = False) -> str: - return str(instance) - - @to_json.instance(str) - def _to_json_str(instance: str, verbose: bool = False) -> str: - return instance - - to_json(1) - to_json('a') - to_json(None) - out: | - main:19: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - - -- case: typeclass_class_wrong_sig - disable_cache: false - main: | - from classes import typeclass - - class ToJson(object): - def __call__(self, instance, verbose: bool = False) -> str: - ... - - to_json = typeclass(ToJson) - - @to_json.instance(int) - def _to_json_int(instance: str) -> int: - ... - out: | - main:9: error: Instance "builtins.str" does not match runtime type "builtins.int*" - main:9: error: Instance callback is incompatible "def (instance: builtins.str) -> builtins.int"; expected "def (instance: builtins.str, verbose: builtins.bool =) -> builtins.str" - - -- case: typeclass_definied_by_protocol - disable_cache: false - main: | - from classes import typeclass - from typing_extensions import Protocol - - class ToJson(Protocol): - def __call__(self, instance, verbose: bool = False) -> str: - ... - - to_json = typeclass(ToJson) - - @to_json.instance(int) - def _to_json_int(instance: int, verbose: bool = False) -> str: - return str(instance) - - @to_json.instance(str) - def _to_json_str(instance: str, verbose: bool = False) -> str: - return instance - - to_json(1) - to_json('a') - to_json(None) - out: | - main:20: error: Argument 1 to "__call__" of "ToJson" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" - - -- case: typeclass_protocol_wrong_sig - disable_cache: false - main: | - from classes import typeclass - from typing_extensions import Protocol - - class ToJson(Protocol): - def __call__(self, instance, verbose: bool = False) -> str: - ... - - to_json = typeclass(ToJson) - - @to_json.instance(int) - def _to_json_int(instance: str) -> int: - ... - out: | - main:10: error: Instance "builtins.str" does not match runtime type "builtins.int*" - main:10: error: Instance callback is incompatible "def (instance: builtins.str) -> builtins.int"; expected "def (instance: builtins.str, verbose: builtins.bool =) -> builtins.str" - - -- case: typeclass_protocol_wrong_method - disable_cache: false - main: | - from classes import typeclass - - class ToJson(object): - def __init__(self, instance, verbose: bool = False) -> str: # E: The return type of "__init__" must be None - ... - - to_json = typeclass(ToJson) - - - -- case: typeclass_object_reuse - disable_cache: false - main: | - from classes import typeclass - - class ToJson(object): - def __call__(self, instance) -> str: - ... - - to_json = typeclass(ToJson) - other = typeclass(ToJson) - out: | - main:8: error: Typeclass definition "main.ToJson" cannot be reused - - -- case: typeclass_protocol_reuse - disable_cache: false - main: | - from classes import typeclass - from typing_extensions import Protocol - - class ToJson(Protocol): - def __call__(self, instance) -> str: - ... - - to_json = typeclass(ToJson) - other = typeclass(ToJson) - out: | - main:9: error: Typeclass definition "main.ToJson" cannot be reused diff --git a/typesafety/test_typeclass/test_generics.yml b/typesafety/test_typeclass/test_generics.yml index 8ce9b52..e20608e 100644 --- a/typesafety/test_typeclass/test_generics.yml +++ b/typesafety/test_typeclass/test_generics.yml @@ -18,6 +18,31 @@ reveal_type(some([1, 2, 3], 0)) # N: Revealed type is 'builtins.int*' +# TODO: enable after fixing how `Supports[X]` is inserted into a metadata +- case: typeclass_generic_definition_associated_type + disable_cache: false + skip: true + main: | + from typing import List, TypeVar + from classes import typeclass, AssociatedType + + X = TypeVar('X') + + class Some(AssociatedType): + ... + + @typeclass(Some) + def some(instance, b: int) -> X: + ... + + @some.instance(list) + def _some_ex(instance: List[X], b: int) -> X: + return instance[b] # We need this line to test inner inference + + reveal_type(some(['a', 'b'], 0)) # N: Revealed type is 'builtins.str*' + reveal_type(some([1, 2, 3], 0)) # N: Revealed type is 'builtins.int*' + + - case: typeclass_generic_definition_restriction_correct disable_cache: false main: | diff --git a/typesafety/test_typeclass/test_instance.yml b/typesafety/test_typeclass/test_instance.yml index ffbbd2d..8853fdf 100644 --- a/typesafety/test_typeclass/test_instance.yml +++ b/typesafety/test_typeclass/test_instance.yml @@ -203,3 +203,32 @@ ... a.instance(1) # E: Value of type variable "_NewInstanceType" of "instance" of "_TypeClass" cannot be "int" + + +- case: typeclass_instance_callback_def + disable_cache: false + main: | + from classes import typeclass + + @typeclass + def some(instance, b: int) -> int: + ... + + def _some_str(instance: str, b: int) -> int: + ... + some.instance(str)(_some_str) + + some('a', 1) + some(None, 1) # E: Argument 1 to "some" has incompatible type "None"; expected "str" + + +- case: typeclass_instance_named_alias + disable_cache: false + main: | + from classes import typeclass + + @typeclass + def some(instance, b: int) -> int: + ... + + alias = some.instance(str) # E: Need type annotation for 'alias' diff --git a/typesafety/test_typeclass/test_protocols.yml b/typesafety/test_typeclass/test_protocols.yml index 6a2cad1..fe2af53 100644 --- a/typesafety/test_typeclass/test_protocols.yml +++ b/typesafety/test_typeclass/test_protocols.yml @@ -121,4 +121,3 @@ out: | main:8: error: Argument "is_protocol" to "instance" of "_TypeClass" has incompatible type "Type[bool]"; expected "bool" main:8: error: Use literal bool for "is_protocol" argument, got: "def (builtins.object =) -> builtins.bool*" - diff --git a/typesafety/test_typeclass/test_supports.yml b/typesafety/test_typeclass/test_supports.yml index 53e47a8..9dc3f47 100644 --- a/typesafety/test_typeclass/test_supports.yml +++ b/typesafety/test_typeclass/test_supports.yml @@ -1,36 +1,14 @@ - case: typeclass_object_supports disable_cache: false main: | - from classes import typeclass - - class ToJson(object): - def __call__(self, instance) -> str: - ... - - to_json = typeclass(ToJson) + from classes import typeclass, AssociatedType - @to_json.instance(int) - def _to_json_int(instance: int) -> str: + class ToJson(AssociatedType): ... - reveal_type(to_json.supports(str)) - reveal_type(to_json.supports(int)) - out: | - main:13: note: Revealed type is 'builtins.bool' - main:14: note: Revealed type is 'builtins.bool' - - -- case: typeclass_protocol_supports - disable_cache: false - main: | - from classes import typeclass - from typing_extensions import Protocol - - class ToJson(Protocol): - def __call__(self, instance) -> str: - ... - - to_json = typeclass(ToJson) + @typeclass(ToJson) + def to_json(instance) -> str: + ... @to_json.instance(int) def _to_json_int(instance: int) -> str: diff --git a/typesafety/test_typeclass/test_typeclass.yml b/typesafety/test_typeclass/test_typeclass.yml index c6534d3..1331125 100644 --- a/typesafety/test_typeclass/test_typeclass.yml +++ b/typesafety/test_typeclass/test_typeclass.yml @@ -1,125 +1,101 @@ -- case: typeclass_definition +- case: typeclass_definition_any disable_cache: false main: | from classes import typeclass @typeclass - def example(instance, arg: str, other: int, *, attr: bool) -> bool: + def example(instance): ... - reveal_type(example) # N: Revealed type is 'classes._typeclass._TypeClass[, builtins.bool, def (instance: , arg: builtins.str, other: builtins.int, *, attr: builtins.bool) -> builtins.bool, ]' - -- case: typeclass_definition_wrong +- case: typeclass_definied_by_type disable_cache: false main: | - from classes import typeclass + from classes import typeclass, AssociatedType - @typeclass - def example(instance): + class ToJson(AssociatedType): ... + @typeclass(ToJson) + def to_json(instance, verbose: bool = False) -> str: + ... -- case: typeclass_wrong_param - disable_cache: false - main: | - from classes import typeclass + @to_json.instance(int) + def _to_json_int(instance: int, verbose: bool = False) -> str: + return str(instance) - typeclass(1) + @to_json.instance(str) + def _to_json_str(instance: str, verbose: bool = False) -> str: + return instance + to_json(1, verbose=True) + to_json('a') + to_json(None) out: | - main:3: error: Value of type variable "_CallbackType" of "typeclass" cannot be "int" - - -- case: typeclass_instance - disable_cache: false - main: | - from classes import typeclass - - @typeclass - def example(instance, arg: str) -> bool: - ... - - @example.instance(str) - def _example_str(instance: str, arg: str) -> bool: - ... - - reveal_type(example) # N: Revealed type is 'classes._typeclass._TypeClass[builtins.str*, builtins.bool, def (builtins.str*, arg: builtins.str) -> builtins.bool, ]' + main:20: error: Argument 1 to "to_json" has incompatible type "None"; expected "Union[str, int, Supports[ToJson]]" -- case: typeclass_instance_union +- case: typeclass_class_wrong_sig disable_cache: false main: | - from classes import typeclass + from classes import typeclass, AssociatedType - @typeclass - def example(instance, arg: str) -> bool: + class ToJson(AssociatedType): ... - @example.instance(int) - @example.instance(float) - def _example_int_float(instance, arg: str) -> bool: + @typeclass(ToJson) + def to_json(instance, verbose: bool = False) -> str: ... - @example.instance(str) - def _example_str(instance: str, arg: str) -> bool: + @to_json.instance(int) + def _to_json_int(instance: str) -> int: ... - - reveal_type(example) # N: Revealed type is 'classes._typeclass._TypeClass[Union[builtins.str*, builtins.int*, builtins.float*], builtins.bool, def (builtins.str*, arg: builtins.str) -> builtins.bool, ]' + out: | + main:10: error: Instance "builtins.str" does not match runtime type "builtins.int*" + main:10: error: Instance callback is incompatible "def (instance: builtins.str) -> builtins.int"; expected "def (instance: builtins.str, verbose: builtins.bool =) -> builtins.str" -- case: typeclass_incorrect_instance_callback1 +- case: typeclass_definied_by_wrong_type disable_cache: false main: | from classes import typeclass - @typeclass - def example(instance, arg: str) -> bool: + class ToJson(object): ... - @example.instance(int) - def _example_int(instance: str, arg: str) -> bool: + @typeclass(ToJson) + def to_json(instance, verbose: bool = False) -> str: ... - - reveal_type(example) out: | - main:7: error: Argument 1 has incompatible type "Callable[[str, str], bool]"; expected "Callable[[int, str], bool]" - main:11: note: Revealed type is 'classes._typeclass._TypeClass[builtins.int*, builtins.bool, def (builtins.int*, arg: builtins.str) -> builtins.bool, ]' + main:6: error: Single direct subclass of "classes._typeclass.AssociatedType" required; got "main.ToJson" -- case: typeclass_incorrect_instance_callback2 +- case: typeclass_definied_by_multiple_parents disable_cache: false main: | - from classes import typeclass + from classes import typeclass, AssociatedType - @typeclass - def example(instance, arg: str) -> bool: + class A(object): ... - @example.instance(int) - def _example_int(instance: int) -> bool: + class ToJson(AssociatedType, A): ... - reveal_type(example) + @typeclass(ToJson) + def to_json(instance, verbose: bool = False) -> str: + ... out: | - main:7: error: Argument 1 has incompatible type "Callable[[int], bool]"; expected "Callable[[int, str], bool]" - main:11: note: Revealed type is 'classes._typeclass._TypeClass[builtins.int*, builtins.bool, def (builtins.int*, arg: builtins.str) -> builtins.bool, ]' + main:9: error: Single direct subclass of "classes._typeclass.AssociatedType" required; got "main.ToJson" -- case: typeclass_incorrect_instance_callback3 +- case: typeclass_definied_by_literal disable_cache: false main: | from classes import typeclass - @typeclass - def example(instance, arg: str) -> bool: - ... - - @example.instance(int) - def _example_int(instance, arg: int) -> bool: - ... - - reveal_type(example) + typeclass(1) out: | - main:7: error: Argument 1 has incompatible type "Callable[[Any, int], bool]"; expected "Callable[[int, str], bool]" - main:11: note: Revealed type is 'classes._typeclass._TypeClass[builtins.int*, builtins.bool, def (builtins.int*, arg: builtins.str) -> builtins.bool, ]' + main:3: error: No overload variant of "typeclass" matches argument type "int" + main:3: note: def [_AssociatedType] typeclass(definition: Type[_AssociatedType]) -> _TypeClassDef[_AssociatedType] + main:3: note: def [_SignatureType, _InstanceType, _AssociatedType, _Fullname <: str] typeclass(signature: _SignatureType) -> _TypeClass[_InstanceType, _SignatureType, _AssociatedType, _Fullname] + main:3: note: Possible overload variants: From c340487d2c91eb238e3673cf7964c6a09a1b2bd1 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Thu, 10 Jun 2021 13:55:19 +0300 Subject: [PATCH 12/12] Fixes some TODOs --- classes/contrib/mypy/features/typeclass.py | 13 ++++++++++++- classes/contrib/mypy/typeops/type_loader.py | 7 +------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index e756b70..de68a03 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -201,7 +201,18 @@ def _add_new_instance_type( len(ctx.context.decorators) > 1 ) if has_multiple_decorators: - # TODO: what happens here? + # If we have multiple decorators on a function, + # it is not safe to assume + # that all the regular instance type is fine. Here's an example: + # + # @some.instance(str) + # @other.instance(int) + # (instance: Union[str, int]) -> ... + # + # So, if we just copy copy `instance`, + # both typeclasses will have both `int` and `str` + # as their instance types. This is not what we want. + # We want: `some` to have `str` and `other` to have `int` new_type = inference.infer_runtime_type_from_context( fallback=new_type, fullname=fullname, diff --git a/classes/contrib/mypy/typeops/type_loader.py b/classes/contrib/mypy/typeops/type_loader.py index 9af2493..1b8edae 100644 --- a/classes/contrib/mypy/typeops/type_loader.py +++ b/classes/contrib/mypy/typeops/type_loader.py @@ -31,12 +31,7 @@ def load_typeclass( fullname: str, ctx: MethodContext, ) -> Instance: - """ - Loads given typeclass from a symboltable by a fullname. - - There are two ways to load a typeclass by name: - # TODO - """ + """Loads given typeclass from a symboltable by a fullname.""" typeclass_info = ctx.api.lookup_qualified(fullname) # type: ignore assert isinstance(typeclass_info.type, Instance) return typeclass_info.type