diff --git a/CHANGELOG.md b/CHANGELOG.md index a59419f..a38ae3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ We follow Semantic Versions since the `0.1.0` release. +## Version 0.4.0 WIP + +### Features + +- Adds support for multiple type arguments in `Supports` type + +### Bugfixes + +- Fixes that types referenced in multiple typeclasses + were not handling `Supports` properly #249 + + ## Version 0.3.0 ### Features diff --git a/classes/_typeclass.py b/classes/_typeclass.py index a2c5012..cff27fa 100644 --- a/classes/_typeclass.py +++ b/classes/_typeclass.py @@ -140,7 +140,7 @@ _NewInstanceType = TypeVar('_NewInstanceType', bound=Type) -_StrictAssociatedType = TypeVar('_StrictAssociatedType', bound='AssociatedType') +_AssociatedTypeDef = TypeVar('_AssociatedTypeDef', contravariant=True) _TypeClassType = TypeVar('_TypeClassType', bound='_TypeClass') _ReturnType = TypeVar('_ReturnType') @@ -240,7 +240,7 @@ def __class_getitem__(cls, type_params) -> type: @final -class Supports(Generic[_StrictAssociatedType]): +class Supports(Generic[_AssociatedTypeDef]): """ Used to specify that some value is a part of a typeclass. diff --git a/classes/contrib/mypy/classes_plugin.py b/classes/contrib/mypy/classes_plugin.py index 39a2b31..20b405b 100644 --- a/classes/contrib/mypy/classes_plugin.py +++ b/classes/contrib/mypy/classes_plugin.py @@ -30,8 +30,9 @@ from mypy.types import Type as MypyType from typing_extensions import Final, final -from classes.contrib.mypy.features import associated_type, typeclass +from classes.contrib.mypy.features import associated_type, supports, typeclass +_ASSOCIATED_TYPE_FULLNAME: Final = 'classes._typeclass.AssociatedType' _TYPECLASS_FULLNAME: Final = 'classes._typeclass._TypeClass' _TYPECLASS_DEF_FULLNAME: Final = 'classes._typeclass._TypeClassDef' _TYPECLASS_INSTANCE_DEF_FULLNAME: Final = ( @@ -58,8 +59,14 @@ def get_type_analyze_hook( fullname: str, ) -> Optional[Callable[[AnalyzeTypeContext], MypyType]]: """Hook that works on type analyzer phase.""" - if fullname == 'classes._typeclass.AssociatedType': + if fullname == _ASSOCIATED_TYPE_FULLNAME: return associated_type.variadic_generic + if fullname == 'classes._typeclass.Supports': + associated_type_node = self.lookup_fully_qualified( + _ASSOCIATED_TYPE_FULLNAME, + ) + assert associated_type_node + return supports.VariadicGeneric(associated_type_node) return None def get_function_hook( @@ -80,7 +87,7 @@ def get_method_hook( ) -> Optional[Callable[[MethodContext], MypyType]]: """Here we adjust the typeclass with new allowed types.""" if fullname == '{0}.__call__'.format(_TYPECLASS_DEF_FULLNAME): - return typeclass.typeclass_def_return_type + return typeclass.TypeClassDefReturnType(_ASSOCIATED_TYPE_FULLNAME) if fullname == '{0}.__call__'.format(_TYPECLASS_INSTANCE_DEF_FULLNAME): return typeclass.InstanceDefReturnType() if fullname == '{0}.instance'.format(_TYPECLASS_FULLNAME): diff --git a/classes/contrib/mypy/features/associated_type.py b/classes/contrib/mypy/features/associated_type.py index 6104c43..b398d73 100644 --- a/classes/contrib/mypy/features/associated_type.py +++ b/classes/contrib/mypy/features/associated_type.py @@ -1,23 +1,11 @@ from mypy.plugin import AnalyzeTypeContext -from mypy.types import Instance from mypy.types import Type as MypyType +from classes.contrib.mypy.semanal.variadic_generic import ( + analize_variadic_generic, +) -def variadic_generic(ctx: AnalyzeTypeContext) -> MypyType: - """ - Variadic generic support. - - What is "variadic generic"? - It is a generic type with any amount of type variables. - Starting with 0 up to infinity. - We primarily use it for our ``AssociatedType`` implementation. - """ - sym = ctx.api.lookup_qualified(ctx.type.name, ctx.context) # type: ignore - if not sym or not sym.node: - # This will happen if `Supports[IsNotDefined]` will be called. - return ctx.type - return Instance( - sym.node, - ctx.api.anal_array(ctx.type.args), # type: ignore - ) +def variadic_generic(ctx: AnalyzeTypeContext) -> MypyType: + """Variadic generic support for ``AssociatedType`` type.""" + return analize_variadic_generic(ctx) diff --git a/classes/contrib/mypy/features/supports.py b/classes/contrib/mypy/features/supports.py new file mode 100644 index 0000000..01d5378 --- /dev/null +++ b/classes/contrib/mypy/features/supports.py @@ -0,0 +1,46 @@ +from mypy.nodes import SymbolTableNode +from mypy.plugin import AnalyzeTypeContext +from mypy.types import Instance +from mypy.types import Type as MypyType +from mypy.types import UnionType +from typing_extensions import final + +from classes.contrib.mypy.semanal.variadic_generic import ( + analize_variadic_generic, +) +from classes.contrib.mypy.validation import validate_supports + + +@final +class VariadicGeneric(object): + """ + Variadic generic support for ``Supports`` type. + + We also need to validate that + all type args of ``Supports`` are subtypes of ``AssociatedType``. + """ + + __slots__ = ('_associated_type_node',) + + def __init__(self, associated_type_node: SymbolTableNode) -> None: + """We need ``AssociatedType`` fullname here.""" + self._associated_type_node = associated_type_node + + def __call__(self, ctx: AnalyzeTypeContext) -> MypyType: + """Main entry point.""" + analyzed_type = analize_variadic_generic( + validate_callback=self._validate, + ctx=ctx, + ) + if isinstance(analyzed_type, Instance): + return analyzed_type.copy_modified( + args=[UnionType.make_union(analyzed_type.args)], + ) + return analyzed_type + + def _validate(self, instance: Instance, ctx: AnalyzeTypeContext) -> bool: + return validate_supports.check_type( + instance, + self._associated_type_node, + ctx, + ) diff --git a/classes/contrib/mypy/features/typeclass.py b/classes/contrib/mypy/features/typeclass.py index 3a64d56..8de7ec8 100644 --- a/classes/contrib/mypy/features/typeclass.py +++ b/classes/contrib/mypy/features/typeclass.py @@ -103,7 +103,8 @@ def _process_typeclass_def_return_type( return typeclass_intermediate_def -def typeclass_def_return_type(ctx: MethodContext) -> MypyType: +@final +class TypeClassDefReturnType(object): """ Callback for cases like ``@typeclass(SomeType)``. @@ -111,27 +112,37 @@ def typeclass_def_return_type(ctx: MethodContext) -> MypyType: It checks that ``SomeType`` is correct, modifies the current typeclass. And returns it back. """ - assert isinstance(ctx.default_return_type, Instance) - assert isinstance(ctx.context, Decorator) - instance_args.mutate_typeclass_def( - typeclass=ctx.default_return_type, - definition_fullname=ctx.context.func.fullname, - ctx=ctx, - ) + __slots__ = ('_associated_type',) - validate_typeclass_def.check_type( - typeclass=ctx.default_return_type, - ctx=ctx, - ) - if isinstance(ctx.default_return_type.args[2], Instance): - validate_associated_type.check_type( - associated_type=ctx.default_return_type.args[2], + def __init__(self, associated_type: str) -> None: + """We need ``AssociatedType`` fullname here.""" + self._associated_type = associated_type + + def __call__(self, ctx: MethodContext) -> MypyType: + """Main entry point.""" + assert isinstance(ctx.default_return_type, Instance) + assert isinstance(ctx.context, Decorator) + + instance_args.mutate_typeclass_def( typeclass=ctx.default_return_type, + definition_fullname=ctx.context.func.fullname, ctx=ctx, ) - return ctx.default_return_type + validate_typeclass_def.check_type( + typeclass=ctx.default_return_type, + ctx=ctx, + ) + if isinstance(ctx.default_return_type.args[2], Instance): + validate_associated_type.check_type( + associated_type=ctx.default_return_type.args[2], + associated_type_fullname=self._associated_type, + typeclass=ctx.default_return_type, + ctx=ctx, + ) + + return ctx.default_return_type def instance_return_type(ctx: MethodContext) -> MypyType: diff --git a/classes/contrib/mypy/semanal/__init__.py b/classes/contrib/mypy/semanal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/classes/contrib/mypy/semanal/variadic_generic.py b/classes/contrib/mypy/semanal/variadic_generic.py new file mode 100644 index 0000000..7ba7034 --- /dev/null +++ b/classes/contrib/mypy/semanal/variadic_generic.py @@ -0,0 +1,35 @@ +from typing import Callable, Optional + +from mypy.plugin import AnalyzeTypeContext +from mypy.types import Instance +from mypy.types import Type as MypyType + +_ValidateCallback = Callable[[Instance, AnalyzeTypeContext], bool] + + +def analize_variadic_generic( + ctx: AnalyzeTypeContext, + validate_callback: Optional[_ValidateCallback] = None, +) -> MypyType: + """ + Variadic generic support. + + What is "variadic generic"? + It is a generic type with any amount of type variables. + Starting with 0 up to infinity. + + We also conditionally validate types of passed arguments. + """ + sym = ctx.api.lookup_qualified(ctx.type.name, ctx.context) # type: ignore + if not sym or not sym.node: + # This will happen if `Supports[IsNotDefined]` will be called. + return ctx.type + + instance = Instance( + sym.node, + ctx.api.anal_array(ctx.type.args), # type: ignore + ) + + if validate_callback is not None: + validate_callback(instance, ctx) + return instance diff --git a/classes/contrib/mypy/typeops/mro.py b/classes/contrib/mypy/typeops/mro.py index b4823ff..03d0db3 100644 --- a/classes/contrib/mypy/typeops/mro.py +++ b/classes/contrib/mypy/typeops/mro.py @@ -1,9 +1,10 @@ -from typing import List +from typing import List, Optional from mypy.plugin import MethodContext +from mypy.subtypes import is_equivalent from mypy.types import Instance from mypy.types import Type as MypyType -from mypy.types import TypeVarType, union_items +from mypy.types import TypeVarType, UnionType, union_items from typing_extensions import final from classes.contrib.mypy.typeops import type_loader @@ -96,10 +97,21 @@ def add_supports_metadata(self) -> None: self._ctx, ) - if supports_spec not in instance_type.type.bases: + index = self._find_supports_index(instance_type, supports_spec) + if index is not None: + # We already have `Supports` base class inserted, + # it means that we need to unify them: + # `Supports[A] + Supports[B] == Supports[Union[A, B]]` + self._add_unified_type(instance_type, supports_spec, index) + else: + # This is the first time this type is referenced in + # a typeclass'es instance defintinion. + # Just inject `Supports` with no extra steps: instance_type.type.bases.append(supports_spec) + if supports_spec.type not in instance_type.type.mro: - instance_type.type.mro.insert(0, supports_spec.type) + # We only need to add `Supports` type to `mro` once: + instance_type.type.mro.append(supports_spec.type) self._added_types.append(supports_spec) @@ -110,11 +122,66 @@ def remove_supports_metadata(self) -> None: for instance_type in self._instance_types: assert isinstance(instance_type, Instance) - - for added_type in self._added_types: - if added_type in instance_type.type.bases: - instance_type.type.bases.remove(added_type) - if added_type.type in instance_type.type.mro: - instance_type.type.mro.remove(added_type.type) - + self._clean_instance_type(instance_type) self._added_types = [] + + def _clean_instance_type(self, instance_type: Instance) -> None: + remove_mro = True + for added_type in self._added_types: + index = self._find_supports_index(instance_type, added_type) + if index is not None: + remove_mro = self._remove_unified_type( + instance_type, + added_type, + index, + ) + + if remove_mro and added_type.type in instance_type.type.mro: + # We remove `Supports` type from `mro` only if + # there are not associated types left. + # For example, `Supports[A, B] - Supports[B] == Supports[A]` + # then `Supports[A]` stays. + # `Supports[A] - Supports[A] == None` + # then `Supports` is removed from `mro` as well. + instance_type.type.mro.remove(added_type.type) + + def _find_supports_index( + self, + instance_type: Instance, + supports_spec: Instance, + ) -> Optional[int]: + for index, base in enumerate(instance_type.type.bases): + if is_equivalent(base, supports_spec, ignore_type_params=True): + return index + return None + + def _add_unified_type( + self, + instance_type: Instance, + supports_spec: Instance, + index: int, + ) -> None: + unified_arg = UnionType.make_union([ + *supports_spec.args, + *instance_type.type.bases[index].args, + ]) + instance_type.type.bases[index] = supports_spec.copy_modified( + args=[unified_arg], + ) + + def _remove_unified_type( + self, + instance_type: Instance, + supports_spec: Instance, + index: int, + ) -> bool: + base = instance_type.type.bases[index] + union_types = [ + type_arg + for type_arg in union_items(base.args[0]) + if type_arg not in supports_spec.args + ] + instance_type.type.bases[index] = supports_spec.copy_modified( + args=[UnionType.make_union(union_types)], + ) + return not bool(union_types) diff --git a/classes/contrib/mypy/validation/validate_associated_type.py b/classes/contrib/mypy/validation/validate_associated_type.py index b16a3fa..1ab4b01 100644 --- a/classes/contrib/mypy/validation/validate_associated_type.py +++ b/classes/contrib/mypy/validation/validate_associated_type.py @@ -3,9 +3,6 @@ from mypy.types import CallableType, Instance from typing_extensions import Final -#: Fullname of the `AssociatedType` class. -_ASSOCIATED_TYPE_FULLNAME: Final = 'classes._typeclass.AssociatedType' - # Messages: _WRONG_SUBCLASS_MSG: Final = ( @@ -26,18 +23,13 @@ def check_type( associated_type: Instance, + associated_type_fullname: str, typeclass: 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. - """ + """Checks passed ``AssociatedType`` instance.""" return all([ - _check_base_class(associated_type, ctx), + _check_base_class(associated_type, associated_type_fullname, ctx), _check_body(associated_type, ctx), _check_type_reuse(associated_type, typeclass, ctx), _check_generics(associated_type, typeclass, ctx), @@ -48,17 +40,18 @@ def check_type( def _check_base_class( associated_type: Instance, + associated_type_fullname: str, ctx: MethodContext, ) -> bool: bases = associated_type.type.bases has_correct_base = ( len(bases) == 1 and - _ASSOCIATED_TYPE_FULLNAME == bases[0].type.fullname + associated_type_fullname == bases[0].type.fullname ) if not has_correct_base: ctx.api.fail( _WRONG_SUBCLASS_MSG.format( - _ASSOCIATED_TYPE_FULLNAME, + associated_type_fullname, associated_type, ), ctx.context, diff --git a/classes/contrib/mypy/validation/validate_supports.py b/classes/contrib/mypy/validation/validate_supports.py new file mode 100644 index 0000000..28645a6 --- /dev/null +++ b/classes/contrib/mypy/validation/validate_supports.py @@ -0,0 +1,45 @@ +from mypy.nodes import SymbolTableNode, TypeInfo +from mypy.plugin import AnalyzeTypeContext +from mypy.subtypes import is_subtype +from mypy.types import Instance +from typing_extensions import Final + +# Messages: + +_ARG_SUBTYPE_MSG: Final = ( + 'Type argument "{0}" of "{1}" must be a subtype of "{2}"' +) + + +def check_type( + instance: Instance, + associated_type_node: SymbolTableNode, + ctx: AnalyzeTypeContext, +) -> bool: + """Checks how ``Supports`` is used.""" + return all([ + _check_instance_args(instance, associated_type_node, ctx), + ]) + + +def _check_instance_args( + instance: Instance, + associated_type_node: SymbolTableNode, + ctx: AnalyzeTypeContext, +) -> bool: + assert isinstance(associated_type_node.node, TypeInfo) + associated_type = Instance(associated_type_node.node, []) + + is_correct = True + for type_arg in instance.args: + if not is_subtype(type_arg, associated_type): + is_correct = False + ctx.api.fail( + _ARG_SUBTYPE_MSG.format( + type_arg, + instance.type.name, + associated_type, + ), + ctx.context, + ) + return is_correct diff --git a/typesafety/test_supports_type/test_generic.yml b/typesafety/test_supports_type/test_generic.yml index 1301c75..8e4a459 100644 --- a/typesafety/test_supports_type/test_generic.yml +++ b/typesafety/test_supports_type/test_generic.yml @@ -110,3 +110,72 @@ main:22: error: Incompatible types in assignment (expression has type "List[int]", variable has type "Supports[Some[int]]") main:23: error: Incompatible types in assignment (expression has type "Set[int]", variable has type "Supports[Some[int]]") main:24: error: Incompatible types in assignment (expression has type "int", variable has type "Supports[Some[int]]") + + +- case: supports_multiple_one_generic_one_regular + disable_cache: false + main: | + from classes import AssociatedType, Supports, typeclass + from typing import TypeVar, Iterable, List + + T = TypeVar('T') + + class Some(AssociatedType[T]): + ... + + class FromJson(AssociatedType): + ... + + @typeclass(Some) + def some(instance: Iterable[T]) -> T: + ... + + @typeclass(FromJson) + def from_json(instance) -> str: + ... + + @some.instance(list) + def _some_str(instance: List[T]) -> T: + ... + + @from_json.instance(str) + def _from_json_str(instance: str) -> str: + ... + + a: Supports[Some[str], FromJson] + reveal_type(some(a)) # N: Revealed type is "builtins.str*" + + + - case: supports_multiple_two_generics + disable_cache: false + main: | + from classes import AssociatedType, Supports, typeclass + from typing import TypeVar, Iterable, List + + T = TypeVar('T') + + class Some(AssociatedType[T]): + ... + + class Other(AssociatedType[T]): + ... + + @typeclass(Some) + def some(instance: Iterable[T]) -> T: + ... + + @typeclass(Other) + def other(instance: Iterable[T]) -> T: + ... + + @some.instance(list) + def _some_list(instance: List[T]) -> T: + ... + + @other.instance(list) + def _other_list(instance: List[T]) -> T: + ... + + a: Supports[Some[str], Other[int]] + reveal_type(some(a)) # N: Revealed type is "builtins.str*" + reveal_type(other(a)) # N: Revealed type is "builtins.int*" diff --git a/typesafety/test_supports_type/test_regular.yml b/typesafety/test_supports_type/test_regular.yml index e7bba43..f307d98 100644 --- a/typesafety/test_supports_type/test_regular.yml +++ b/typesafety/test_supports_type/test_regular.yml @@ -23,9 +23,7 @@ convert_to_json(1) convert_to_json('a') - convert_to_json(None) - out: | - main:23: error: Argument 1 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" + convert_to_json(None) # E: Argument 1 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" - case: typeclass_supports_type_restriction @@ -44,6 +42,8 @@ def _to_json_int(instance: int) -> str: return str(instance) + to_json(1) + - case: typeclass_supports_callback disable_cache: false @@ -74,9 +74,7 @@ convert_to_json(to_json, 1) convert_to_json(to_json, 'a') - convert_to_json(to_json, None) - out: | - main:27: error: Argument 2 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" + convert_to_json(to_json, None) # E: Argument 2 to "convert_to_json" has incompatible type "None"; expected "Supports[ToJson]" - case: typeclass_supports_with_function @@ -144,9 +142,124 @@ b: Supports[ToJson] = 'a' # E: Incompatible types in assignment (expression has type "str", variable has type "Supports[ToJson]") -- case: supports_callable_bound +- case: supports_type_bound + disable_cache: false + main: | + from classes import Supports, AssociatedType + + a: Supports[int] # E: Type argument "builtins.int" of "Supports" must be a subtype of "classes._typeclass.AssociatedType" + + class A(AssociatedType): + ... + + b: Supports[A, int] # E: Type argument "builtins.int" of "Supports" must be a subtype of "classes._typeclass.AssociatedType" + + +- case: supports_multiple_types + disable_cache: false + main: | + from classes import AssociatedType, Supports, typeclass + + class A(AssociatedType): + ... + + class B(AssociatedType): + ... + + @typeclass(A) + def one(instance) -> bool: + ... + + @one.instance(int) + def _one_int(instance: int) -> bool: + ... + + @typeclass(B) + def two(instance) -> bool: + ... + + @two.instance(int) + def _two_int(instance: int) -> bool: + ... + + a: Supports[A] = 1 + b: Supports[B] = 1 + ab: Supports[A, B] = 1 + + +- case: supports_multiple_types_callback + disable_cache: false + main: | + from classes import AssociatedType, Supports, typeclass + + class ToJson(AssociatedType): + ... + + class FromJson(AssociatedType): + ... + + class Other(AssociatedType): + ... + + @typeclass(ToJson) + def to_json(instance) -> str: + ... + + @typeclass(FromJson) + def from_json(instance) -> str: + ... + + @typeclass(Other) + def other(instance) -> str: + ... + + @to_json.instance(str) + def _to_json_str(instance: str) -> str: + ... + + @from_json.instance(str) + def _from_json_str(instance: str) -> str: + ... + + @other.instance(str) + def _other_json_str(instance: str) -> str: + ... + + def func(instance: Supports[ToJson, FromJson]) -> Supports[ToJson, FromJson]: + return from_json(to_json(instance)) + + func('abc') + + +- case: supports_multiple_error_handling_in_mro disable_cache: false main: | - from classes import Supports + from classes import AssociatedType, Supports, typeclass + + class ToJson(AssociatedType): + ... + + class FromJson(AssociatedType): + ... + + @typeclass(ToJson) + def to_json(instance) -> str: + ... - Supports[int] # E: Value of type variable "_StrictAssociatedType" of "Supports" cannot be "int" + @typeclass(FromJson) + def from_json(instance) -> str: + ... + + @to_json.instance(str) + def _to_json_str(instance: str) -> str: + ... + + @from_json.instance(str) + def _from_json_str(instance: str, other) -> str: # error + ... + + a: Supports[ToJson] = 'a' + b: Supports[FromJson] = 'a' # error + out: | + main:21: error: Instance callback is incompatible "def (instance: builtins.str, other: Any) -> builtins.str"; expected "def (instance: builtins.str) -> builtins.str" + main:26: error: Incompatible types in assignment (expression has type "str", variable has type "Supports[FromJson]") diff --git a/typesafety/test_typeclass/test_supports/test_typeguard.yml b/typesafety/test_typeclass/test_supports/test_typeguard.yml index e9e6d91..d941a29 100644 --- a/typesafety/test_typeclass/test_supports/test_typeguard.yml +++ b/typesafety/test_typeclass/test_supports/test_typeguard.yml @@ -51,7 +51,7 @@ reveal_type(item) to_json(item) # ok out: | - main:20: error: Argument 1 to "to_json" has incompatible type "Union[int, str]"; expected "Union[Dict[Any, Any], Supports[ToJson]]" + main:20: error: Argument 1 to "to_json" has incompatible type "Union[int, str]"; expected "Supports[ToJson]" main:23: note: Revealed type is "Union[builtins.dict[Any, Any], builtins.int]" @@ -116,5 +116,5 @@ reveal_type(item) copy(item) # ok out: | - main:22: error: Argument 1 to "copy" has incompatible type "Union[builtins.int, builtins.str]"; expected "Union[builtins.dict[Any, Any], classes._typeclass.Supports[main.Copy]]" + main:22: error: Argument 1 to "copy" has incompatible type "Union[builtins.int, builtins.str]"; expected "classes._typeclass.Supports[main.Copy]" main:25: note: Revealed type is "Union[builtins.dict[Any, Any], builtins.int]"