diff --git a/CHANGELOG.md b/CHANGELOG.md index bce6521b6..f1d251042 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/docs/conf.py b/docs/conf.py old mode 100644 new mode 100755 index bb98b56a8..7dfc83855 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a diff --git a/pyproject.toml b/pyproject.toml index fa045daf0..e3f055bb4 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/hypothesis/laws.py b/returns/contrib/hypothesis/laws.py index 48c9ea499..9e237de94 100644 --- a/returns/contrib/hypothesis/laws.py +++ b/returns/contrib/hypothesis/laws.py @@ -1,5 +1,6 @@ import dataclasses import inspect +import sys from collections.abc import Callable, Iterator from contextlib import ExitStack, contextmanager from typing import Any, TypeVar, final, overload @@ -242,7 +243,21 @@ def _create_law_test_case( ) called_from = inspect.stack()[2] - module = inspect.getmodule(called_from[0]) + # `inspect.getmodule(frame)` is surprisingly fragile under some import + # modes (notably `pytest` collection with assertion rewriting) and can + # return `None`. Use the module name from the caller's globals instead. + module_name = called_from.frame.f_globals.get('__name__') + if module_name is None: + module = None + else: + module = sys.modules.get(module_name) + if module is None: + module = inspect.getmodule(called_from.frame) + if module is None: + raise RuntimeError( + 'Cannot determine a module to attach generated law tests to. ' + 'Please call `check_all_laws` from an imported module scope.', + ) template = 'test_{container}_{interface}_{name}' test_function.__name__ = template.format( # noqa: WPS125 diff --git a/returns/contrib/mypy/_features/partial.py b/returns/contrib/mypy/_features/partial.py index 9d24489a3..d291801f2 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,30 +53,55 @@ 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): + is_valid, applied_args = func_args.build_from_context() + if not is_valid: 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() + if not applied_args: + return function_def # this means, that `partial(func)` is called - is_valid, applied_args = func_args.build_from_context() - if not isinstance(function_def, CallableType | Overloaded) or not is_valid: + callable_def = _coerce_to_callable(function_def, func_args) + if callable_def is None: return default_return return _PartialFunctionReducer( default_return, - function_def, + callable_def, applied_args, ctx, ).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: + from_context = func_args.get_callable_from_context() + return ( + from_context + if isinstance(from_context, CallableType | Overloaded) + else None + ) + return function_def + + @final class _PartialFunctionReducer: """ @@ -219,16 +246,10 @@ def __init__(self, function_ctx: FunctionContext) -> None: """ We need the function default context. - The first arguments of ``partial`` is skipped: + The first argument of ``partial`` is skipped: it is the applied function itself. """ self._function_ctx = function_ctx - self._parts = zip( - self._function_ctx.arg_names[1:], - self._function_ctx.arg_types[1:], - self._function_ctx.arg_kinds[1:], - strict=False, - ) def get_callable_from_context(self) -> ProperType: """Returns callable type from the context.""" @@ -254,17 +275,29 @@ def build_from_context(self) -> tuple[bool, list[FuncArg]]: Here ``*args`` and ``**kwargs`` can be literally anything! In these cases we fallback to the default return type. """ - applied_args = [] - for names, types, kinds in self._parts: + applied_args: list[FuncArg] = [] + for arg in self._iter_applied_args(): + if arg.kind in {ARG_STAR, ARG_STAR2}: + # We cannot really work with `*args`, `**kwargs`. + return False, [] + applied_args.append(arg) + return True, applied_args + + def _iter_applied_args(self) -> Iterator[FuncArg]: + skipped_applied_function = False + for names, types, kinds in zip( + self._function_ctx.arg_names, + self._function_ctx.arg_types, + self._function_ctx.arg_kinds, + strict=False, + ): for arg in self._generate_applied_args( - zip(names, types, kinds, strict=False) + zip(names, types, kinds, strict=False), ): - if arg.kind in {ARG_STAR, ARG_STAR2}: - # We cannot really work with `*args`, `**kwargs`. - return False, [] - - applied_args.append(arg) - return True, applied_args + if not skipped_applied_function: + skipped_applied_function = True + continue + yield arg def _generate_applied_args(self, arg_parts) -> Iterator[FuncArg]: yield from starmap(FuncArg, arg_parts) diff --git a/returns/contrib/mypy/_typeops/inference.py b/returns/contrib/mypy/_typeops/inference.py index bc713ebe2..645e0fad1 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 0aec48d8b..93f9be1f7 100644 --- a/returns/curry.py +++ b/returns/curry.py @@ -2,16 +2,44 @@ from functools import partial as _partial from functools import wraps from inspect import BoundArguments, Signature -from typing import Any, TypeAlias, TypeVar +from typing import Any, Generic, TypeAlias, TypeVar, overload _ReturnType = TypeVar('_ReturnType') +_Decorator: TypeAlias = Callable[ + [Callable[..., _ReturnType]], + Callable[..., _ReturnType], +] +class _PartialDecorator(Generic[_ReturnType]): + """Wraps ``functools.partial`` into a decorator without nesting.""" + + __slots__ = ('_args', '_kwargs') + + def __init__(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None: + self._args = args + self._kwargs = kwargs + + def __call__( + self, inner: Callable[..., _ReturnType] + ) -> Callable[..., _ReturnType]: + return _partial(inner, *self._args, **self._kwargs) + + +@overload def partial( func: Callable[..., _ReturnType], + /, *args: Any, **kwargs: Any, -) -> Callable[..., _ReturnType]: +) -> Callable[..., _ReturnType]: ... + + +@overload +def partial(*args: Any, **kwargs: Any) -> _Decorator: ... + + +def partial(*args: Any, **kwargs: Any) -> Any: """ Typed partial application. @@ -35,7 +63,11 @@ def partial( - https://docs.python.org/3/library/functools.html#functools.partial """ - return _partial(func, *args, **kwargs) + if args and callable(args[0]): + return _partial(args[0], *args[1:], **kwargs) + if args and args[0] is None: + args = args[1:] + return _PartialDecorator(args, kwargs) def curry(function: Callable[..., _ReturnType]) -> Callable[..., _ReturnType]: diff --git a/setup.cfg b/setup.cfg index 3f54ae791..bdec68a9f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,6 +17,7 @@ select = WPS, E999 extend-exclude = .venv + .cache build # Bad code that I write to test things: ex.py diff --git a/tests/test_curry/test_partial.py b/tests/test_curry/test_partial.py new file mode 100644 index 000000000..5291d60d8 --- /dev/null +++ b/tests/test_curry/test_partial.py @@ -0,0 +1,31 @@ +from collections.abc import Callable +from typing import TypeAlias, TypeVar, cast + +from returns.curry import partial + +_ReturnType = TypeVar('_ReturnType') +_Decorator: TypeAlias = Callable[ + [Callable[..., _ReturnType]], + Callable[..., _ReturnType], +] + + +def add(first: int, second: int) -> int: + return first + second + + +def test_partial_direct_call() -> None: + add_one = partial(add, 1) + assert add_one(2) == 3 + + +def test_partial_as_decorator_factory() -> None: + decorator = cast(_Decorator[int], partial()) + add_with_decorator = decorator(add) + assert add_with_decorator(1, 2) == 3 + + +def test_partial_with_none_placeholder() -> None: + decorator = cast(_Decorator[int], partial(None, 1)) + add_with_none_decorator = decorator(add) + assert add_with_none_decorator(2) == 3 diff --git a/typesafety/test_curry/test_partial/mypy.ini b/typesafety/test_curry/test_partial/mypy.ini new file mode 100644 index 000000000..b73e9ef96 --- /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 34cd4aa8b..62a88bedd 100644 --- a/typesafety/test_curry/test_partial/test_partial.yml +++ b/typesafety/test_curry/test_partial/test_partial.yml @@ -113,7 +113,7 @@ ) -> str: ... - partial(multiple, 1, 2.0, 3) + x = partial(multiple, 1, 2.0, 3) out: | main:10: error: Too many arguments for "multiple" [call-arg] @@ -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"