From 6c8e0cc47c014894ea41621a10f3d1b465322362 Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Mon, 13 Nov 2023 16:59:50 +0000 Subject: [PATCH] Ignore position if imprecise arguments are matched by name (#16471) Fixes https://github.com/python/mypy/issues/16405 Fixes https://github.com/python/mypy/issues/16412 Imprecise argument kinds inference was added a while ago to support various edge cases with `ParamSpec`. This feature required mapping actual kinds to formal kinds, which is in general undecidable. At that time we decided to not add much special-casing, and wait for some real use-cases. So far there are two relevant issues, and it looks like both of them can be fixed with simple special-casing: ignore argument positions in subtyping if arguments can be matched by name. This adds minor unsafety, and generally doesn't look bad, so I think we should go ahead with it. --------- Co-authored-by: Alex Waygood --- mypy/subtypes.py | 24 ++++++-- .../unit/check-parameter-specification.test | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/mypy/subtypes.py b/mypy/subtypes.py index 7e37751b1c15..4fd3f8ff98ca 100644 --- a/mypy/subtypes.py +++ b/mypy/subtypes.py @@ -1651,7 +1651,12 @@ def _incompatible(left_arg: FormalArgument | None, right_arg: FormalArgument | N continue return False if not are_args_compatible( - left_arg, right_arg, ignore_pos_arg_names, allow_partial_overlap, is_compat + left_arg, + right_arg, + is_compat, + ignore_pos_arg_names=ignore_pos_arg_names, + allow_partial_overlap=allow_partial_overlap, + allow_imprecise_kinds=right.imprecise_arg_kinds, ): return False @@ -1676,9 +1681,9 @@ def _incompatible(left_arg: FormalArgument | None, right_arg: FormalArgument | N if not are_args_compatible( left_by_position, right_by_position, - ignore_pos_arg_names, - allow_partial_overlap, is_compat, + ignore_pos_arg_names=ignore_pos_arg_names, + allow_partial_overlap=allow_partial_overlap, ): return False i += 1 @@ -1711,7 +1716,11 @@ def _incompatible(left_arg: FormalArgument | None, right_arg: FormalArgument | N continue if not are_args_compatible( - left_by_name, right_by_name, ignore_pos_arg_names, allow_partial_overlap, is_compat + left_by_name, + right_by_name, + is_compat, + ignore_pos_arg_names=ignore_pos_arg_names, + allow_partial_overlap=allow_partial_overlap, ): return False @@ -1735,6 +1744,7 @@ def _incompatible(left_arg: FormalArgument | None, right_arg: FormalArgument | N and right_by_name != right_by_pos and (right_by_pos.required or right_by_name.required) and strict_concatenate_check + and not right.imprecise_arg_kinds ): return False @@ -1749,9 +1759,11 @@ def _incompatible(left_arg: FormalArgument | None, right_arg: FormalArgument | N def are_args_compatible( left: FormalArgument, right: FormalArgument, + is_compat: Callable[[Type, Type], bool], + *, ignore_pos_arg_names: bool, allow_partial_overlap: bool, - is_compat: Callable[[Type, Type], bool], + allow_imprecise_kinds: bool = False, ) -> bool: if left.required and right.required: # If both arguments are required allow_partial_overlap has no effect. @@ -1779,7 +1791,7 @@ def is_different(left_item: object | None, right_item: object | None) -> bool: return False # If right is at a specific position, left must have the same: - if is_different(left.pos, right.pos): + if is_different(left.pos, right.pos) and not allow_imprecise_kinds: return False # If right's argument is optional, left's must also be diff --git a/test-data/unit/check-parameter-specification.test b/test-data/unit/check-parameter-specification.test index eb6fbf07f045..d65a0214b599 100644 --- a/test-data/unit/check-parameter-specification.test +++ b/test-data/unit/check-parameter-specification.test @@ -1687,9 +1687,18 @@ P = ParamSpec("P") T = TypeVar("T") def apply(fn: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> None: ... + def test(x: int) -> int: ... apply(apply, test, x=42) # OK apply(apply, test, 42) # Also OK (but requires some special casing) +apply(apply, test, "bad") # E: Argument 1 to "apply" has incompatible type "Callable[[Callable[P, T], **P], None]"; expected "Callable[[Callable[[int], int], str], None]" + +def test2(x: int, y: str) -> None: ... +apply(apply, test2, 42, "yes") +apply(apply, test2, "no", 42) # E: Argument 1 to "apply" has incompatible type "Callable[[Callable[P, T], **P], None]"; expected "Callable[[Callable[[int, str], None], str, int], None]" +apply(apply, test2, x=42, y="yes") +apply(apply, test2, y="yes", x=42) +apply(apply, test2, y=42, x="no") # E: Argument 1 to "apply" has incompatible type "Callable[[Callable[P, T], **P], None]"; expected "Callable[[Callable[[int, str], None], int, str], None]" [builtins fixtures/paramspec.pyi] [case testParamSpecApplyPosVsNamedOptional] @@ -2087,6 +2096,52 @@ reveal_type(d(b, f1)) # E: Cannot infer type argument 1 of "d" \ reveal_type(d(b, f2)) # N: Revealed type is "def (builtins.int)" [builtins fixtures/paramspec.pyi] +[case testParamSpecGenericWithNamedArg1] +from typing import Callable, TypeVar +from typing_extensions import ParamSpec + +R = TypeVar("R") +P = ParamSpec("P") + +def run(func: Callable[[], R], *args: object, backend: str = "asyncio") -> R: ... +class Result: ... +def run_portal() -> Result: ... +def submit(func: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> R: ... + +reveal_type(submit( # N: Revealed type is "__main__.Result" + run, + run_portal, + backend="asyncio", +)) +submit( + run, # E: Argument 1 to "submit" has incompatible type "Callable[[Callable[[], R], VarArg(object), DefaultNamedArg(str, 'backend')], R]"; expected "Callable[[Callable[[], Result], int], Result]" + run_portal, + backend=int(), +) +[builtins fixtures/paramspec.pyi] + +[case testParamSpecGenericWithNamedArg2] +from typing import Callable, TypeVar, Type +from typing_extensions import ParamSpec + +P= ParamSpec("P") +T = TypeVar("T") + +def smoke_testable(*args: P.args, **kwargs: P.kwargs) -> Callable[[Callable[P, T]], Type[T]]: + ... + +@smoke_testable(name="bob", size=512, flt=0.5) +class SomeClass: + def __init__(self, size: int, name: str, flt: float) -> None: + pass + +# Error message is confusing, but this is a known issue, see #4530. +@smoke_testable(name=42, size="bad", flt=0.5) # E: Argument 1 has incompatible type "Type[OtherClass]"; expected "Callable[[int, str, float], OtherClass]" +class OtherClass: + def __init__(self, size: int, name: str, flt: float) -> None: + pass +[builtins fixtures/paramspec.pyi] + [case testInferenceAgainstGenericCallableUnionParamSpec] from typing import Callable, TypeVar, List, Union from typing_extensions import ParamSpec