From 4c3b61104ef4cf341848796ecf5109074152495d Mon Sep 17 00:00:00 2001 From: Ivan Levkivskyi Date: Sun, 17 May 2026 23:21:09 +0100 Subject: [PATCH] Special-case constructor for tuple types --- mypy/checkexpr.py | 22 ++++++++++++++++++ mypy/constraints.py | 16 ++++++++----- mypy/nodes.py | 6 +++++ mypy/typeops.py | 6 +++++ mypy/types.py | 2 +- mypyc/test-data/run-tuples.test | 2 +- test-data/unit/check-typevar-tuple.test | 30 +++++++++++++++++++++++++ 7 files changed, 76 insertions(+), 8 deletions(-) diff --git a/mypy/checkexpr.py b/mypy/checkexpr.py index 48ea7ab51f61b..92a6a7e7dd00b 100644 --- a/mypy/checkexpr.py +++ b/mypy/checkexpr.py @@ -1747,6 +1747,28 @@ def check_callable_call( lambda i: self.accept(args[i]), ) + if callee.special_sig == "tuple" and len(args) == 1: + with self.msg.filter_errors(): + arg_type = get_proper_type(self.accept(args[0])) + # Give precise constructor signature for situations like this: + # class Shape[*Ts](tuple[*Ts]): ... + # Shape((1, 2)) + # The argument type is the same as return type, but with builtins.tuple fallback. + if isinstance(arg_type, TupleType): + assert isinstance(callee.ret_type, ProperType) + if isinstance(callee.ret_type, TupleType): + # Actual type argument is ignored by tuple_fallback() in this case. + any_type = AnyType(TypeOfAny.special_form) + new_arg_type = callee.ret_type.copy_modified( + fallback=self.chk.named_generic_type("builtins.tuple", [any_type]) + ) + callee = callee.copy_modified(arg_types=[new_arg_type]) + elif isinstance(callee.ret_type, Instance): + new_arg_type = map_instance_to_supertype( + callee.ret_type, self.chk.lookup_typeinfo("builtins.tuple") + ) + callee = callee.copy_modified(arg_types=[new_arg_type]) + if callee.is_generic(): need_refresh = any( isinstance(v, (ParamSpecType, TypeVarTupleType)) for v in callee.variables diff --git a/mypy/constraints.py b/mypy/constraints.py index df79fdae5456c..a58af222b1ea8 100644 --- a/mypy/constraints.py +++ b/mypy/constraints.py @@ -1224,8 +1224,9 @@ def infer_against_overloaded( def visit_tuple_type(self, template: TupleType) -> list[Constraint]: actual = self.actual unpack_index = find_unpack_in_list(template.items) - is_varlength_tuple = ( - isinstance(actual, Instance) and actual.type.fullname == "builtins.tuple" + # TODO: we may need to be careful with direction to avoid spurious constraints. + is_varlength_tuple = isinstance(actual, Instance) and actual.type.has_base( + "builtins.tuple" ) if isinstance(actual, TupleType) or is_varlength_tuple: @@ -1237,23 +1238,26 @@ def visit_tuple_type(self, template: TupleType) -> list[Constraint]: unpack_type = template.items[unpack_index] assert isinstance(unpack_type, UnpackType) unpacked_type = get_proper_type(unpack_type.type) + assert isinstance(actual, Instance) # ensured by is_varlength_tuple == True + mapped = map_instance_to_supertype( + actual, actual.type.get_base("builtins.tuple") + ) if isinstance(unpacked_type, TypeVarTupleType): res = [ - Constraint(type_var=unpacked_type, op=self.direction, target=actual) + Constraint(type_var=unpacked_type, op=self.direction, target=mapped) ] else: assert ( isinstance(unpacked_type, Instance) and unpacked_type.type.fullname == "builtins.tuple" ) - res = infer_constraints(unpacked_type, actual, self.direction) - assert isinstance(actual, Instance) # ensured by is_varlength_tuple == True + res = infer_constraints(unpacked_type, mapped, self.direction) for i, ti in enumerate(template.items): if i == unpack_index: # This one we just handled above. continue # For Tuple[T, *Ts, S] <: tuple[X, ...] infer also T <: X and S <: X. - res.extend(infer_constraints(ti, actual.args[0], self.direction)) + res.extend(infer_constraints(ti, mapped.args[0], self.direction)) return res else: assert isinstance(actual, TupleType) diff --git a/mypy/nodes.py b/mypy/nodes.py index 32a694560b24b..bcaf7f7b239cf 100644 --- a/mypy/nodes.py +++ b/mypy/nodes.py @@ -4103,6 +4103,12 @@ def has_base(self, fullname: str) -> bool: return True return False + def get_base(self, fullname: str) -> TypeInfo: + for cls in self.mro: + if cls.fullname == fullname: + return cls + assert False, f"Missing base {fullname} for {self.fullname}" + def direct_base_classes(self) -> list[TypeInfo]: """Return a direct base classes. diff --git a/mypy/typeops.py b/mypy/typeops.py index e13d6dd0b1732..e777bec191f91 100644 --- a/mypy/typeops.py +++ b/mypy/typeops.py @@ -240,6 +240,12 @@ def type_object_type( assert isinstance(method.type, FunctionLike) # is_valid_constructor() ensures this t = method.type result = type_object_type_from_function(t, info, method.info, fallback, is_new) + # Tuple constructor in typeshed is imprecise (and precise one is impossible to express), + # so we special-case constructors for tuple types. Note we skip the tuple class itself + # as a micro-optimization, since it is unlikely one would write tuple((1, 2)). + if method.info.fullname == "builtins.tuple" and info.fullname != "builtins.tuple": + assert isinstance(result, CallableType) + result = result.copy_modified(special_sig="tuple") # Only write cached result is strict_optional=True, otherwise we may get # inconsistent behaviour because of union simplification. if allow_cache and state.strict_optional: diff --git a/mypy/types.py b/mypy/types.py index 40c3839e2efca..b3e090af23d3e 100644 --- a/mypy/types.py +++ b/mypy/types.py @@ -2149,7 +2149,7 @@ class CallableType(FunctionLike): # specified by the user? "special_sig", # Non-None for signatures that require special handling # (currently only values are 'dict' for a signature similar to - # 'dict' and 'partial' for a `functools.partial` evaluation) + # 'dict' and 'partial' for a `functools.partial` evaluation, and 'tuple') "from_type_type", # Was this callable generated by analyzing Type[...] # instantiation? "is_bound", # Is this a bound method? diff --git a/mypyc/test-data/run-tuples.test b/mypyc/test-data/run-tuples.test index abbba46386040..58220a1af7efd 100644 --- a/mypyc/test-data/run-tuples.test +++ b/mypyc/test-data/run-tuples.test @@ -432,7 +432,7 @@ def test_user_defined() -> None: [file copysubclass.py] from typing import Any -class subc(tuple[Any]): +class subc(tuple[Any, ...]): pass [file userdefinedtuple.py] diff --git a/test-data/unit/check-typevar-tuple.test b/test-data/unit/check-typevar-tuple.test index ff5cdc8719bc6..45bb58e1a5972 100644 --- a/test-data/unit/check-typevar-tuple.test +++ b/test-data/unit/check-typevar-tuple.test @@ -2773,3 +2773,33 @@ from typing import Protocol, Unpack class C(Protocol[Unpack]): # E: Free type variable expected in Protocol[...] pass [builtins fixtures/tuple.pyi] + +[case testGenericTupleSubclassConstructor] +from typing import TypeVar, TypeVarTuple, Unpack + +T = TypeVar("T") +S = TypeVar("S") +Ts = TypeVarTuple("Ts") + +class S1(tuple[T, S]): ... +class S2(tuple[Unpack[Ts]]): ... + +reveal_type(S1((1, ""))) # N: Revealed type is "tuple[builtins.int, builtins.str, fallback=__main__.S1[builtins.int, builtins.str]]" +reveal_type(S1((1, "", 2))) # N: Revealed type is "tuple[Never, Never, fallback=__main__.S1[Never, Never]]" \ + # E: Argument 1 to "S1" has incompatible type "tuple[int, str, int]"; expected "tuple[Never, Never]" + +a1: S1[int, str] = S1((1, "")) +b1: S1[int, str] = S1((1, 1)) # E: Argument 1 to "S1" has incompatible type "tuple[int, int]"; expected "tuple[int, str]" +c1: S1[int, str] = S1((1, "", 2)) # E: Argument 1 to "S1" has incompatible type "tuple[int, str, int]"; expected "tuple[int, str]" + +reveal_type(S2(())) # N: Revealed type is "tuple[(), fallback=__main__.S2[()]]" +reveal_type(S2((1, ""))) # N: Revealed type is "tuple[Literal[1]?, Literal['']?, fallback=__main__.S2[Literal[1]?, Literal['']?]]" +reveal_type(S2((1, "", 2))) # N: Revealed type is "tuple[Literal[1]?, Literal['']?, Literal[2]?, fallback=__main__.S2[Literal[1]?, Literal['']?, Literal[2]?]]" + +a2: S2[int, str] = S2((1, "")) +b2: S2[int, str] = S2((1, 1)) # E: Argument 1 to "S2" has incompatible type "tuple[int, int]"; expected "tuple[int, str]" +c2: S2[int, str] = S2((1, "", 2)) # E: Argument 1 to "S2" has incompatible type "tuple[int, str, int]"; expected "tuple[int, str]" + +t: S2[Unpack[tuple[int, ...]]] = S2((1, 2)) +t_bad: S2[Unpack[tuple[int, ...]]] = S2((1, "")) # E: Argument 1 to "S2" has incompatible type "tuple[int, str]"; expected "tuple[int, ...]" +[builtins fixtures/tuple.pyi]