diff --git a/CHANGELOG.md b/CHANGELOG.md index bce6521b..f1d25104 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ incremental in minor, bugfixes only are patches. See [0Ver](https://0ver.org/). +## Unreleased + +### Bugfixes + +- Fixes the `curry.partial` compatibility with mypy 1.6.1+ + + ## 0.26.0 ### Features diff --git a/pyproject.toml b/pyproject.toml index fa045daf..e3f055bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,7 @@ lint.per-file-ignores."tests/test_examples/test_result/test_result_pattern_match "D103", ] lint.per-file-ignores."tests/test_pattern_matching.py" = [ "S101" ] +lint.per-file-ignores."typesafety/test_curry/test_partial/test_partial.py" = [ "S101" ] lint.external = [ "WPS" ] lint.flake8-quotes.inline-quotes = "single" lint.mccabe.max-complexity = 6 diff --git a/returns/contrib/mypy/_features/partial.py b/returns/contrib/mypy/_features/partial.py index 9d24489a..703dec6a 100644 --- a/returns/contrib/mypy/_features/partial.py +++ b/returns/contrib/mypy/_features/partial.py @@ -5,11 +5,13 @@ from mypy.nodes import ARG_STAR, ARG_STAR2 from mypy.plugin import FunctionContext from mypy.types import ( + AnyType, CallableType, FunctionLike, Instance, Overloaded, ProperType, + TypeOfAny, TypeType, get_proper_type, ) @@ -51,17 +53,25 @@ def analyze(ctx: FunctionContext) -> ProperType: default_return = get_proper_type(ctx.default_return_type) if not isinstance(default_return, CallableType): return default_return + return _analyze_partial(ctx, default_return) + + +def _analyze_partial( + ctx: FunctionContext, + default_return: CallableType, +) -> ProperType: + if not ctx.arg_types or not ctx.arg_types[0]: + # No function passed: treat as decorator factory and fallback to Any. + return AnyType(TypeOfAny.implementation_artifact) function_def = get_proper_type(ctx.arg_types[0][0]) func_args = _AppliedArgs(ctx) if len(list(filter(len, ctx.arg_types))) == 1: return function_def # this means, that `partial(func)` is called - if not isinstance(function_def, _SUPPORTED_TYPES): + function_def = _coerce_to_callable(function_def, func_args) + if function_def is None: return default_return - if isinstance(function_def, Instance | TypeType): - # We force `Instance` and similar types to coercse to callable: - function_def = func_args.get_callable_from_context() is_valid, applied_args = func_args.build_from_context() if not isinstance(function_def, CallableType | Overloaded) or not is_valid: @@ -75,6 +85,18 @@ def analyze(ctx: FunctionContext) -> ProperType: ).new_partial() +def _coerce_to_callable( + function_def: ProperType, + func_args: '_AppliedArgs', +) -> CallableType | Overloaded | None: + if not isinstance(function_def, _SUPPORTED_TYPES): + return None + if isinstance(function_def, Instance | TypeType): + # We force `Instance` and similar types to coerce to callable: + return func_args.get_callable_from_context() + return function_def + + @final class _PartialFunctionReducer: """ diff --git a/returns/contrib/mypy/_typeops/inference.py b/returns/contrib/mypy/_typeops/inference.py index bc713ebe..645e0fad 100644 --- a/returns/contrib/mypy/_typeops/inference.py +++ b/returns/contrib/mypy/_typeops/inference.py @@ -73,25 +73,26 @@ def _infer_constraints( """Creates mapping of ``typevar`` to real type that we already know.""" checker = self._ctx.api.expr_checker # type: ignore kinds = [arg.kind for arg in applied_args] - exprs = [arg.expression(self._ctx.context) for arg in applied_args] - formal_to_actual = map_actuals_to_formals( kinds, [arg.name for arg in applied_args], self._fallback.arg_kinds, self._fallback.arg_names, - lambda index: checker.accept(exprs[index]), - ) - constraints = infer_constraints_for_callable( - self._fallback, - arg_types=[arg.type for arg in applied_args], - arg_kinds=kinds, - arg_names=[arg.name for arg in applied_args], - formal_to_actual=formal_to_actual, - context=checker.argument_infer_context(), + lambda index: checker.accept( + applied_args[index].expression(self._ctx.context), + ), ) + return { - constraint.type_var: constraint.target for constraint in constraints + constraint.type_var: constraint.target + for constraint in infer_constraints_for_callable( + self._fallback, + arg_types=[arg.type for arg in applied_args], + arg_kinds=kinds, + arg_names=[arg.name for arg in applied_args], + formal_to_actual=formal_to_actual, + context=checker.argument_infer_context(), + ) } diff --git a/returns/curry.py b/returns/curry.py index 0aec48d8..8fab4944 100644 --- a/returns/curry.py +++ b/returns/curry.py @@ -8,7 +8,7 @@ def partial( - func: Callable[..., _ReturnType], + func: Callable[..., _ReturnType] | None = None, *args: Any, **kwargs: Any, ) -> Callable[..., _ReturnType]: @@ -35,6 +35,14 @@ def partial( - https://docs.python.org/3/library/functools.html#functools.partial """ + if func is None: + + def _decorator( # type: ignore[return-type] + inner: Callable[..., _ReturnType], + ) -> Callable[..., _ReturnType]: + return _partial(inner, *args, **kwargs) + + return _decorator return _partial(func, *args, **kwargs) diff --git a/typesafety/test_curry/test_partial/mypy.ini b/typesafety/test_curry/test_partial/mypy.ini new file mode 100644 index 00000000..b73e9ef9 --- /dev/null +++ b/typesafety/test_curry/test_partial/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +python_version = 3.11 +plugins = returns.contrib.mypy.returns_plugin diff --git a/typesafety/test_curry/test_partial/test_partial.yml b/typesafety/test_curry/test_partial/test_partial.yml index 34cd4aa8..5a7c6a1f 100644 --- a/typesafety/test_curry/test_partial/test_partial.yml +++ b/typesafety/test_curry/test_partial/test_partial.yml @@ -150,3 +150,97 @@ function: Callable[[_SecondType, _FirstType], _SecondType], ): reveal_type(partial(function, default)) # N: Revealed type is "def (_FirstType`-2) -> _SecondType`-1" + + +- case: partial_regression1711 + disable_cache: false + main: | + from returns.curry import partial + + def foo(x: int, y: int, z: int) -> int: + ... + + def bar(x: int) -> int: + ... + + baz = partial(foo, bar(1)) + reveal_type(baz) # N: Revealed type is "def (y: builtins.int, z: builtins.int) -> builtins.int" + + +- case: partial_optional_arg + disable_cache: false + main: | + from returns.curry import partial + + def test_partial_fn( + first_arg: int, + optional_arg: str | None, + ) -> tuple[int, str | None]: + ... + + bound = partial(test_partial_fn, 1) + reveal_type(bound) # N: Revealed type is "def (optional_arg: builtins.str | None) -> tuple[builtins.int, builtins.str | None]" + + +- case: partial_decorator + disable_cache: false + main: | + from returns.curry import partial + + @partial(first=1) + def _decorated(first: int, second: str) -> float: + ... + + reveal_type(_decorated) # N: Revealed type is "Any" + out: | + main:3: error: Untyped decorator makes function "_decorated" untyped [misc] + + +- case: partial_keyword_arg + disable_cache: false + main: | + from returns.curry import partial + + def test_partial_fn( + first_arg: int, + optional_arg: str | None, + ) -> tuple[int, str | None]: + ... + + bound = partial(test_partial_fn, optional_arg='a') + reveal_type(bound) # N: Revealed type is "def (first_arg: builtins.int) -> tuple[builtins.int, builtins.str | None]" + + +- case: partial_keyword_only + disable_cache: false + main: | + from returns.curry import partial + + def _target(*, arg: int) -> int: + ... + + bound = partial(_target, arg=1) + reveal_type(bound) # N: Revealed type is "def () -> builtins.int" + + +- case: partial_keyword_mixed + disable_cache: false + main: | + from returns.curry import partial + + def _target(arg1: int, *, arg2: int) -> int: + ... + + bound = partial(_target, arg2=1) + reveal_type(bound) # N: Revealed type is "def (arg1: builtins.int) -> builtins.int" + + +- case: partial_wrong_signature_any + disable_cache: false + main: | + from returns.curry import partial + + reveal_type(partial(len, 1)) + out: | + main:3: error: Argument 1 to "len" has incompatible type "int"; expected "Sized" [arg-type] + main:3: note: Revealed type is "def (*Any, **Any) -> builtins.int"