Skip to content

Commit

Permalink
Ignore position if imprecise arguments are matched by name (#16471)
Browse files Browse the repository at this point in the history
Fixes #16405
Fixes #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 <[email protected]>
  • Loading branch information
ilevkivskyi and AlexWaygood authored Nov 13, 2023
1 parent fbb77c3 commit c6cb3c6
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 6 deletions.
24 changes: 18 additions & 6 deletions mypy/subtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions test-data/unit/check-parameter-specification.test
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit c6cb3c6

Please sign in to comment.