Skip to content
Merged
4 changes: 3 additions & 1 deletion mypy/checkexpr.py
Original file line number Diff line number Diff line change
Expand Up @@ -2814,7 +2814,9 @@ def check_overload_call(
code = None
else:
code = codes.OPERATOR
self.msg.no_variant_matches_arguments(callee, arg_types, context, code=code)
self.msg.no_variant_matches_arguments(
callee, arg_types, context, arg_names=arg_names, arg_kinds=arg_kinds, code=code
)

result = self.check_call(
target,
Expand Down
95 changes: 95 additions & 0 deletions mypy/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,14 +1091,109 @@ def no_variant_matches_arguments(
arg_types: list[Type],
context: Context,
*,
arg_names: Sequence[str | None] | None,
arg_kinds: list[ArgKind] | None = None,
code: ErrorCode | None = None,
) -> None:
code = code or codes.CALL_OVERLOAD
name = callable_name(overload)
if name:
name_str = f" of {name}"
for_func = f" for overloaded function {name}"
else:
name_str = ""
for_func = ""

# For keyword argument errors
unexpected_kwargs: list[tuple[str, Type]] = []
if arg_names is not None and arg_kinds is not None:
all_valid_kwargs: set[str] = set()
for item in overload.items:
for i, arg_name in enumerate(item.arg_names):
if arg_name is not None and item.arg_kinds[i] != ARG_STAR:
all_valid_kwargs.add(arg_name)
if item.is_kw_arg:
all_valid_kwargs.clear()
break

if all_valid_kwargs:
for i, (arg_name, arg_kind) in enumerate(zip(arg_names, arg_kinds)):
if arg_kind == ARG_NAMED and arg_name is not None:
if arg_name not in all_valid_kwargs:
unexpected_kwargs.append((arg_name, arg_types[i]))

if unexpected_kwargs:
all_kwargs_confident = True
kwargs_with_suggestions: list[tuple[str, list[str]]] = []
kwargs_without_suggestions: list[str] = []

# Find suggestions for each unexpected kwarg, prioritizing type-matching args
for kwarg_name, kwarg_type in unexpected_kwargs:
matching_type_args: list[str] = []
not_matching_type_args: list[str] = []
has_matching_variant = False

for item in overload.items:
item_has_type_match = False
for i, formal_type in enumerate(item.arg_types):
formal_name = item.arg_names[i]
if formal_name is not None and item.arg_kinds[i] != ARG_STAR:
if is_subtype(kwarg_type, formal_type):
if formal_name not in matching_type_args:
matching_type_args.append(formal_name)
item_has_type_match = True
elif formal_name not in not_matching_type_args:
not_matching_type_args.append(formal_name)
if item_has_type_match:
has_matching_variant = True

matches = best_matches(kwarg_name, matching_type_args, n=3)
if not matches:
matches = best_matches(kwarg_name, not_matching_type_args, n=3)

if matches:
kwargs_with_suggestions.append((kwarg_name, matches))
else:
kwargs_without_suggestions.append(kwarg_name)

if not has_matching_variant:
all_kwargs_confident = False

for kwarg_name, matches in kwargs_with_suggestions:
self.fail(
f'Unexpected keyword argument "{kwarg_name}"'
f"{for_func}; did you mean {pretty_seq(matches, 'or')}?",
context,
code=code,
)

if kwargs_without_suggestions:
if len(kwargs_without_suggestions) == 1:
self.fail(
f'Unexpected keyword argument "{kwargs_without_suggestions[0]}"{for_func}',
context,
code=code,
)
else:
quoted_names = ", ".join(f'"{n}"' for n in kwargs_without_suggestions)
self.fail(
f"Unexpected keyword arguments {quoted_names}{for_func}",
context,
code=code,
)

if not all_kwargs_confident:
self.note(
f"Possible overload variant{plural_s(len(overload.items))}:",
context,
code=code,
)
for item in overload.items:
self.note(pretty_callable(item, self.options), context, offset=4, code=code)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this could generate duplicate notes, as the same notes (and the header) can be generated on line 1218 below. Mypy has duplicate message removal so it may not be visible, but it's still doing some redundant work and relies on the specifics of deduplication which might change in the future.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I've fixed it!


if all_kwargs_confident and len(unexpected_kwargs) == len(arg_types):
return

arg_types_str = ", ".join(format_type(arg, self.options) for arg in arg_types)
num_args = len(arg_types)
if num_args == 0:
Expand Down
139 changes: 139 additions & 0 deletions test-data/unit/check-expressions.test
Original file line number Diff line number Diff line change
Expand Up @@ -2582,3 +2582,142 @@ def last_known_value() -> None:
x, y, z = xy # E: Unpacking a string is disallowed
reveal_type(z) # N: Revealed type is "builtins.str"
[builtins fixtures/primitives.pyi]

[case testOverloadUnexpectedKeywordArgWithTypoSuggestion]
from typing import overload, Union

@overload
def f(foobar: int) -> None: ...

@overload
def f(foobar: str) -> None: ...

def f(foobar: Union[int, str]) -> None: pass

f(fobar=1) # E: Unexpected keyword argument "fobar" for overloaded function "f"; did you mean "foobar"?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional test case ideas:

  • Test a call where there are multiple positional and keyword arguments, and everything else is valid, but one of the keyword arguments is a misspelling (and not the first one).
  • Test a call where there are multiple keyword arguments, and two of them are misspelling of different target keyword argument names, but otherwise the call is fine.
  • A test case like obj.f(fobar=1), where we have a misspelling in a method call.

[builtins fixtures/list.pyi]

[case testOverloadUnexpectedKeywordArgNoMatch]
from typing import overload, Union

@overload
def f(foobar: int) -> None: ...

@overload
def f(foobar: str) -> None: ...

def f(foobar: Union[int, str]) -> None: pass

f(random=[1,2,3]) # E: Unexpected keyword argument "random" for overloaded function "f" \
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional test ideas:

  • Test multiple invalid keyword arguments
  • Test both invalid keyword argument and incompatible positional argument
  • Test both valid an invalid keyword arguments in the same call

# N: Possible overload variants: \
# N: def f(foobar: int) -> None \
# N: def f(foobar: str) -> None \
# E: No overload variant of "f" matches argument type "list[int]"
[builtins fixtures/list.pyi]

[case testOverloadMultipleUnexpectedKeywordArgs]
from typing import overload, Union

@overload
def f(foobar: int) -> None: ...

@overload
def f(foobar: str) -> None: ...

def f(foobar: Union[int, str]) -> None: pass

f(fobar=1, baz=2) # E: Unexpected keyword argument "fobar" for overloaded function "f"; did you mean "foobar"? \
# E: Unexpected keyword argument "baz" for overloaded function "f"
[builtins fixtures/list.pyi]

[case testOverloadManyUnexpectedKeywordArgs]
from typing import overload, Union

@overload
def f(foobar: int) -> None: ...

@overload
def f(foobar: str) -> None: ...

def f(foobar: Union[int, str]) -> None: pass

f(foobar=1, a=2, b=3, c=4, d=5, e=6) # E: Unexpected keyword arguments "a", "b", "c", "d", "e" for overloaded function "f" \
# E: No overload variant of "f" matches argument types "int", "int", "int", "int", "int", "int" \
# N: Possible overload variants: \
# N: def f(foobar: int) -> None \
# N: def f(foobar: str) -> None
[builtins fixtures/list.pyi]

[case testOverloadUnexpectedKeywordArgsWithTypeMismatch]
from typing import overload, Union

@overload
def f(foobar: int) -> None: ...

@overload
def f(foobar: str) -> None: ...

def f(foobar: Union[int, str]) -> None: pass

f(fobar=1, other=[1,2,3]) # E: Unexpected keyword argument "fobar" for overloaded function "f"; did you mean "foobar"? \
# E: Unexpected keyword argument "other" for overloaded function "f" \
# N: Possible overload variants: \
# N: def f(foobar: int) -> None \
# N: def f(foobar: str) -> None \
# E: No overload variant of "f" matches argument types "int", "list[int]"
[builtins fixtures/list.pyi]

[case testOverloadPositionalArgTypeMismatch]
from typing import overload, Union

@overload
def g(x: int, y: int) -> int: ...

@overload
def g(x: str, y: str) -> str: ...

def g(x: Union[int, str], y: Union[int, str]) -> Union[int, str]:
return x

g([1, 2], 3) # E: No overload variant of "g" matches argument types "list[int]", "int" \
# N: Possible overload variants: \
# N: def g(x: int, y: int) -> int \
# N: def g(x: str, y: str) -> str
[builtins fixtures/list.pyi]

[case testOverloadUnexpectedKeywordWithPositionalMismatch]
from typing import overload, Union

@overload
def g(x: int, y: int) -> int: ...

@overload
def g(x: str, y: str) -> str: ...

def g(x: Union[int, str], y: Union[int, str]) -> Union[int, str]:
return x

g([1, 2], z=3) # E: Unexpected keyword argument "z" for overloaded function "g" \
# E: No overload variant of "g" matches argument types "list[int]", "int" \
# N: Possible overload variants: \
# N: def g(x: int, y: int) -> int \
# N: def g(x: str, y: str) -> str
[builtins fixtures/list.pyi]

[case testOverloadNamedArgTypeMismatch]
from typing import overload, Union

@overload
def g(x: int, y: int) -> int: ...

@overload
def g(x: str, y: str) -> str: ...

def g(x: Union[int, str], y: Union[int, str]) -> Union[int, str]:
return x

g(x="hello", y=1) # E: No overload variant of "g" matches argument types "str", "int" \
# N: Possible overload variants: \
# N: def g(x: int, y: int) -> int \
# N: def g(x: str, y: str) -> str
[builtins fixtures/list.pyi]