diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9990caaeb7a1..afb02e804113 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -19,6 +19,7 @@ from mypy.checkmember import analyze_member_access, has_operator from mypy.checkstrformat import StringFormatterChecker from mypy.constant_fold import constant_fold_expr +from mypy.constraints import SUBTYPE_OF from mypy.erasetype import erase_type, remove_instance_last_known_values, replace_meta_vars from mypy.errors import ErrorInfo, ErrorWatcher, report_internal_error from mypy.expandtype import ( @@ -30,7 +31,7 @@ from mypy.exprtotype import TypeTranslationError, expr_to_unanalyzed_type from mypy.infer import ArgumentInferContext, infer_function_type_arguments, infer_type_arguments from mypy.literals import literal -from mypy.maptype import map_instance_to_supertype +from mypy.maptype import as_type, map_instance_to_supertype from mypy.meet import is_overlapping_types, narrow_declared_type from mypy.message_registry import ErrorMessage from mypy.messages import MessageBuilder, format_type @@ -6143,30 +6144,28 @@ def is_valid_var_arg(self, typ: Type) -> bool: def is_valid_keyword_var_arg(self, typ: Type) -> bool: """Is a type valid as a **kwargs argument?""" typ = get_proper_type(typ) - return ( - ( - # This is a little ad hoc, ideally we would have a map_instance_to_supertype - # that worked for protocols - isinstance(typ, Instance) - and typ.type.fullname == "builtins.dict" - and is_subtype(typ.args[0], self.named_type("builtins.str")) - ) - or isinstance(typ, ParamSpecType) - or is_subtype( - typ, - self.chk.named_generic_type( - "_typeshed.SupportsKeysAndGetItem", - [self.named_type("builtins.str"), AnyType(TypeOfAny.special_form)], - ), - ) - or is_subtype( - typ, - self.chk.named_generic_type( - "_typeshed.SupportsKeysAndGetItem", [UninhabitedType(), UninhabitedType()] - ), - ) + if isinstance(typ, ParamSpecType | AnyType): + return True + + # Check if 'typ' is a SupportsKeysAndGetItem[T, Any] for some T <: str + # Note: is_subtype(typ, SupportsKeysAndGetItem[str, Any])` is too harsh + # since SupportsKeysAndGetItem is invariant in the key type parameter. + + # create a TypeVar and template type + T = TypeVarType( + "T", + "T", + id=TypeVarId(-1, namespace=""), + values=[], + upper_bound=self.named_type("builtins.str"), + default=AnyType(TypeOfAny.from_omitted_generics), + ) + template = self.chk.named_generic_type( + "_typeshed.SupportsKeysAndGetItem", [T, AnyType(TypeOfAny.special_form)] ) + return as_type(typ, SUBTYPE_OF, template) is not None + def not_ready_callback(self, name: str, context: Context) -> None: """Called when we can't infer the type of a variable because it's not ready yet. diff --git a/mypy/maptype.py b/mypy/maptype.py index 59ecb2bc9993..f595a478a240 100644 --- a/mypy/maptype.py +++ b/mypy/maptype.py @@ -1,8 +1,61 @@ from __future__ import annotations -from mypy.expandtype import expand_type_by_instance +from typing import cast + +from mypy.expandtype import expand_type, expand_type_by_instance from mypy.nodes import TypeInfo -from mypy.types import AnyType, Instance, TupleType, TypeOfAny, has_type_vars +from mypy.types import AnyType, Instance, TupleType, Type, TypeOfAny, has_type_vars + + +def as_type(typ: Type, direction: int, target: Type) -> Type | None: + """Attempts to solve type variables in `target` so that `typ` is a subtype/supertype of + the resulting type. + + Args: + typ: The type to map from. + direction: One of SUBTYPE_OF or SUPERTYPE_OF + target: The target instance type to map to. + + Returns: + None: if the mapping is not possible. + Type: the mapped type if the mapping is possible. + + Examples: + (list[int], Iterable[T]) -> Iterable[int] + (list[list[int]], Iterable[list[T]]) -> Iterable[list[int]] + (dict[str, int], Mapping[K, int]) -> Mapping[str, int] + (list[int], Mapping[K, V]) -> None + """ + from mypy.subtypes import is_subtype + from mypy.typeops import get_all_type_vars + + # 1. get type vars of target + tvars = get_all_type_vars(target) + + # fast path: if no type vars, just check subtype + if not tvars: + return target if is_subtype(typ, target) else None + + from mypy.constraints import SUBTYPE_OF, Constraint, infer_constraints + from mypy.solve import solve_constraints + + # 2. determine constraints + constraints: list[Constraint] = infer_constraints(target, typ, not direction) + for tvar in tvars: + # need to manually include these because solve_constraints ignores them + # apparently + constraints.append(Constraint(tvar, SUBTYPE_OF, tvar.upper_bound)) + + # 3. solve constraints + solution, _ = solve_constraints(tvars, constraints) + + if None in solution: + return None + + # 4. build resulting Type by substituting type vars with solution + env = {tvar.id: s for tvar, s in zip(tvars, cast("list[Type]", solution))} + target = expand_type(target, env) + return target if is_subtype(typ, target) else None def map_instance_to_supertype(instance: Instance, superclass: TypeInfo) -> Instance: diff --git a/test-data/unit/check-kwargs.test b/test-data/unit/check-kwargs.test index 4099716bcf6b..bbc189628f60 100644 --- a/test-data/unit/check-kwargs.test +++ b/test-data/unit/check-kwargs.test @@ -572,11 +572,29 @@ main:41: error: Argument 1 to "foo" has incompatible type "**dict[str, str]"; ex [builtins fixtures/dict.pyi] [case testLiteralKwargs] -from typing import Any, Literal -kw: dict[Literal["a", "b"], Any] -def func(a, b): ... -func(**kw) - -badkw: dict[Literal["one", 1], Any] -func(**badkw) # E: Argument after ** must have string keys +from typing import Literal, Mapping, Iterable +def func(a: int, b: int) -> None: ... + +class GOOD_KW: + def keys(self) -> Iterable[Literal["a", "b"]]: ... + def __getitem__(self, key: str) -> int: ... + +class BAD_KW: + def keys(self) -> Iterable[Literal["one", 1]]: ... + def __getitem__(self, key: str) -> int: ... + +def test( + good_kw: GOOD_KW, + bad_kw: BAD_KW, + good_dict: dict[Literal["a", "b"], int], + bad_dict: dict[Literal["one", 1], int], + good_mapping: Mapping[Literal["a", "b"], int], + bad_mapping: Mapping[Literal["one", 1], int], +) -> None: + func(**good_kw) + func(**bad_kw) # E: Argument after ** must have string keys + func(**good_dict) + func(**bad_dict) # E: Argument after ** must have string keys + func(**good_mapping) + func(**bad_mapping) # E: Argument after ** must have string keys [builtins fixtures/dict.pyi]