From dfad788c9ad2f01755f565f54b57cf10d76ae35b Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Mon, 15 Dec 2025 19:55:47 +0100 Subject: [PATCH 1/3] added utility for mapping types to instances --- mypy/checkexpr.py | 63 ++++++++++++++++++++------------ mypy/maptype.py | 51 +++++++++++++++++++++++++- test-data/unit/check-kwargs.test | 16 ++++---- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 9990caaeb7a1..b26f486a98de 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, SUPERTYPE_OF, Constraint, infer_constraints 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 map_instance_to_supertype, map_type_to_instance from mypy.meet import is_overlapping_types, narrow_declared_type from mypy.message_registry import ErrorMessage from mypy.messages import MessageBuilder, format_type @@ -118,6 +119,7 @@ Plugin, ) from mypy.semanal_enum import ENUM_BASES +from mypy.solve import solve_constraints from mypy.state import state from mypy.subtypes import ( covers_at_runtime, @@ -6143,28 +6145,43 @@ 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 map_type_to_instance(typ, template) is not None + + # infer constraints and solve + constraints: list[Constraint] = [ + # solve_constraints seems to completely ignore upper bounds. + # So we need to include it manually. + Constraint(T, SUBTYPE_OF, T.upper_bound), + *infer_constraints(template, typ, SUPERTYPE_OF), + ] + solution, _ = solve_constraints([T], constraints) + assert len(solution) == 1 + + return solution[0] is not None and is_subtype( + typ, + self.chk.named_generic_type( + "_typeshed.SupportsKeysAndGetItem", [solution[0], AnyType(TypeOfAny.special_form)] + ), ) def not_ready_callback(self, name: str, context: Context) -> None: diff --git a/mypy/maptype.py b/mypy/maptype.py index 59ecb2bc9993..7471923e8e02 100644 --- a/mypy/maptype.py +++ b/mypy/maptype.py @@ -1,8 +1,55 @@ from __future__ import annotations -from mypy.expandtype import expand_type_by_instance +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 map_type_to_instance(typ: Type, target: Instance) -> Instance | None: + """Attempt to map `typ` to an Instance of the same class as `target` + + 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 + + Args: + typ: The type to map from. + target: The target instance type to map to. + + Returns: + None: if the mapping is not possible. + Instance: the mapped instance type if the mapping is possible. + """ + 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, + if not tvars: + return target if is_subtype(typ, target) else None + + from mypy.constraints import SUBTYPE_OF, SUPERTYPE_OF, Constraint, infer_constraints + from mypy.solve import solve_constraints + + # 2. determine constraints + constraints: list[Constraint] = infer_constraints(target, typ, SUPERTYPE_OF) + for tvar in tvars: + 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 Instance by substituting typevars with solution + env = {tvar.id: sol for tvar, sol in zip(tvars, 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..55a80d426c7e 100644 --- a/test-data/unit/check-kwargs.test +++ b/test-data/unit/check-kwargs.test @@ -572,11 +572,13 @@ 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 +def func(a: int, b: int) -> None: ... + +def test( + good_kw: dict[Literal["a", "b"], int], + bad_kw: dict[Literal["one", 1], int], +) -> None: + func(**good_kw) + func(**bad_kw) # E: Argument after ** must have string keys [builtins fixtures/dict.pyi] From c1cee0bfa85892c3697038e0cad9f46f2d5363a2 Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Mon, 15 Dec 2025 20:10:52 +0100 Subject: [PATCH 2/3] introduced helper function to map type to protocol --- mypy/checkexpr.py | 24 +++--------------------- mypy/maptype.py | 34 ++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index b26f486a98de..afb02e804113 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -19,7 +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, SUPERTYPE_OF, Constraint, infer_constraints +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 ( @@ -31,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, map_type_to_instance +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 @@ -119,7 +119,6 @@ Plugin, ) from mypy.semanal_enum import ENUM_BASES -from mypy.solve import solve_constraints from mypy.state import state from mypy.subtypes import ( covers_at_runtime, @@ -6165,24 +6164,7 @@ def is_valid_keyword_var_arg(self, typ: Type) -> bool: "_typeshed.SupportsKeysAndGetItem", [T, AnyType(TypeOfAny.special_form)] ) - return map_type_to_instance(typ, template) is not None - - # infer constraints and solve - constraints: list[Constraint] = [ - # solve_constraints seems to completely ignore upper bounds. - # So we need to include it manually. - Constraint(T, SUBTYPE_OF, T.upper_bound), - *infer_constraints(template, typ, SUPERTYPE_OF), - ] - solution, _ = solve_constraints([T], constraints) - assert len(solution) == 1 - - return solution[0] is not None and is_subtype( - typ, - self.chk.named_generic_type( - "_typeshed.SupportsKeysAndGetItem", [solution[0], 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 7471923e8e02..f595a478a240 100644 --- a/mypy/maptype.py +++ b/mypy/maptype.py @@ -1,26 +1,30 @@ from __future__ import annotations +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, Type, TypeOfAny, has_type_vars -def map_type_to_instance(typ: Type, target: Instance) -> Instance | None: - """Attempt to map `typ` to an Instance of the same class as `target` - - 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 +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. - Instance: the mapped instance type if the mapping is 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 @@ -28,16 +32,18 @@ def map_type_to_instance(typ: Type, target: Instance) -> Instance | None: # 1. get type vars of target tvars = get_all_type_vars(target) - # fast path: if no type vars, + # 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, SUPERTYPE_OF, Constraint, infer_constraints + 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, SUPERTYPE_OF) + 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 @@ -46,8 +52,8 @@ def map_type_to_instance(typ: Type, target: Instance) -> Instance | None: if None in solution: return None - # 4. build resulting Instance by substituting typevars with solution - env = {tvar.id: sol for tvar, sol in zip(tvars, solution)} + # 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 From 05e0a4168e7fe49b5e6d373fbe9a24ad9ef2e92a Mon Sep 17 00:00:00 2001 From: Randolf Scholz Date: Tue, 16 Dec 2025 10:03:52 +0100 Subject: [PATCH 3/3] test also custom key+getitem class --- test-data/unit/check-kwargs.test | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/test-data/unit/check-kwargs.test b/test-data/unit/check-kwargs.test index 55a80d426c7e..bbc189628f60 100644 --- a/test-data/unit/check-kwargs.test +++ b/test-data/unit/check-kwargs.test @@ -572,13 +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 Literal +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: dict[Literal["a", "b"], int], - bad_kw: dict[Literal["one", 1], int], + 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]