Skip to content

Commit efecd59

Browse files
authored
Support user defined variadic tuple types (#15961)
Fixes #15946 Note this actually adds support also for variadic NamedTuples and variadic TypedDicts. Not that anyone requested this, but since generic NamedTuples and generic TypedDicts are supported using the same mechanism (special aliases) as generic tuple types (like `class A(Tuple[T, S]): ...` in the issue), it looked more risky and arbitrary to _not_support them. Btw the implementation is simple, but while I was working on this, I accidentally found a problem with my general idea of doing certain type normlaizations in `semanal_typeargs.py`. The problem is that sometimes we can call `get_proper_type()` during semantic analysis, so all the code that gets triggered by this (mostly `expand_type()`) can't really rely on types being normalized. Fortunately, with just few tweaks I manged to make the code mostly robust to such scenarios (TBH there are few possible holes left, but this is getting really complex, I think it is better to release this, and see if people will ever hit such scenarios, then fix accordingly).
1 parent 29abf39 commit efecd59

9 files changed

+149
-21
lines changed

mypy/expandtype.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ def visit_unpack_type(self, t: UnpackType) -> Type:
269269
# instead.
270270
# However, if the item is a variadic tuple, we can simply carry it over.
271271
# In particular, if we expand A[*tuple[T, ...]] with substitutions {T: str},
272-
# it is hard to assert this without getting proper type.
272+
# it is hard to assert this without getting proper type. Another important
273+
# example is non-normalized types when called from semanal.py.
273274
return UnpackType(t.type.accept(self))
274275

275276
def expand_unpack(self, t: UnpackType) -> list[Type] | AnyType | UninhabitedType:
@@ -414,6 +415,10 @@ def visit_tuple_type(self, t: TupleType) -> Type:
414415
unpacked = get_proper_type(item.type)
415416
if isinstance(unpacked, Instance):
416417
assert unpacked.type.fullname == "builtins.tuple"
418+
if t.partial_fallback.type.fullname != "builtins.tuple":
419+
# If it is a subtype (like named tuple) we need to preserve it,
420+
# this essentially mimics the logic in tuple_fallback().
421+
return t.partial_fallback.accept(self)
417422
return unpacked
418423
fallback = t.partial_fallback.accept(self)
419424
assert isinstance(fallback, ProperType) and isinstance(fallback, Instance)

mypy/maptype.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,5 @@ def instance_to_type_environment(instance: Instance) -> dict[TypeVarId, Type]:
113113
required number of type arguments. So this environment consists
114114
of the class's type variables mapped to the Instance's actual
115115
arguments. The type variables are mapped by their `id`.
116-
117116
"""
118117
return {binder.id: arg for binder, arg in zip(instance.type.defn.type_vars, instance.args)}

mypy/nodes.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3546,7 +3546,12 @@ def from_tuple_type(cls, info: TypeInfo) -> TypeAlias:
35463546
assert info.tuple_type
35473547
# TODO: is it possible to refactor this to set the correct type vars here?
35483548
return TypeAlias(
3549-
info.tuple_type.copy_modified(fallback=mypy.types.Instance(info, info.defn.type_vars)),
3549+
info.tuple_type.copy_modified(
3550+
# Create an Instance similar to fill_typevars().
3551+
fallback=mypy.types.Instance(
3552+
info, mypy.types.type_vars_as_args(info.defn.type_vars)
3553+
)
3554+
),
35503555
info.fullname,
35513556
info.line,
35523557
info.column,
@@ -3563,7 +3568,10 @@ def from_typeddict_type(cls, info: TypeInfo) -> TypeAlias:
35633568
# TODO: is it possible to refactor this to set the correct type vars here?
35643569
return TypeAlias(
35653570
info.typeddict_type.copy_modified(
3566-
fallback=mypy.types.Instance(info, info.defn.type_vars)
3571+
# Create an Instance similar to fill_typevars().
3572+
fallback=mypy.types.Instance(
3573+
info, mypy.types.type_vars_as_args(info.defn.type_vars)
3574+
)
35673575
),
35683576
info.fullname,
35693577
info.line,

mypy/semanal.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@
277277
get_proper_types,
278278
is_named_instance,
279279
remove_dups,
280+
type_vars_as_args,
280281
)
281282
from mypy.types_utils import is_invalid_recursive_alias, store_argument_type
282283
from mypy.typevars import fill_typevars
@@ -1702,12 +1703,17 @@ def setup_type_vars(self, defn: ClassDef, tvar_defs: list[TypeVarLikeType]) -> N
17021703
def setup_alias_type_vars(self, defn: ClassDef) -> None:
17031704
assert defn.info.special_alias is not None
17041705
defn.info.special_alias.alias_tvars = list(defn.type_vars)
1706+
# It is a bit unfortunate that we need to inline some logic from TypeAlias constructor,
1707+
# but it is required, since type variables may change during semantic analyzer passes.
1708+
for i, t in enumerate(defn.type_vars):
1709+
if isinstance(t, TypeVarTupleType):
1710+
defn.info.special_alias.tvar_tuple_index = i
17051711
target = defn.info.special_alias.target
17061712
assert isinstance(target, ProperType)
17071713
if isinstance(target, TypedDictType):
1708-
target.fallback.args = tuple(defn.type_vars)
1714+
target.fallback.args = type_vars_as_args(defn.type_vars)
17091715
elif isinstance(target, TupleType):
1710-
target.partial_fallback.args = tuple(defn.type_vars)
1716+
target.partial_fallback.args = type_vars_as_args(defn.type_vars)
17111717
else:
17121718
assert False, f"Unexpected special alias type: {type(target)}"
17131719

mypy/semanal_typeargs.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,31 +86,31 @@ def visit_type_alias_type(self, t: TypeAliasType) -> None:
8686
# correct aliases. Also, variadic aliases are better to check when fully analyzed,
8787
# so we do this here.
8888
assert t.alias is not None, f"Unfixed type alias {t.type_ref}"
89-
args = flatten_nested_tuples(t.args)
89+
# TODO: consider moving this validation to typeanal.py, expanding invalid aliases
90+
# during semantic analysis may cause crashes.
9091
if t.alias.tvar_tuple_index is not None:
91-
correct = len(args) >= len(t.alias.alias_tvars) - 1
92+
correct = len(t.args) >= len(t.alias.alias_tvars) - 1
9293
if any(
9394
isinstance(a, UnpackType) and isinstance(get_proper_type(a.type), Instance)
94-
for a in args
95+
for a in t.args
9596
):
9697
correct = True
9798
else:
98-
correct = len(args) == len(t.alias.alias_tvars)
99+
correct = len(t.args) == len(t.alias.alias_tvars)
99100
if not correct:
100101
if t.alias.tvar_tuple_index is not None:
101102
exp_len = f"at least {len(t.alias.alias_tvars) - 1}"
102103
else:
103104
exp_len = f"{len(t.alias.alias_tvars)}"
104105
self.fail(
105-
f"Bad number of arguments for type alias, expected: {exp_len}, given: {len(args)}",
106+
"Bad number of arguments for type alias,"
107+
f" expected: {exp_len}, given: {len(t.args)}",
106108
t,
107109
code=codes.TYPE_ARG,
108110
)
109111
t.args = set_any_tvars(
110112
t.alias, t.line, t.column, self.options, from_error=True, fail=self.fail
111113
).args
112-
else:
113-
t.args = args
114114
is_error = self.validate_args(t.alias.name, t.args, t.alias.alias_tvars, t)
115115
if not is_error:
116116
# If there was already an error for the alias itself, there is no point in checking

mypy/semanal_typeddict.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def map_items_to_base(
252252
if not tvars:
253253
mapped_items[key] = type_in_base
254254
continue
255+
# TODO: simple zip can't be used for variadic types.
255256
mapped_items[key] = expand_type(
256257
type_in_base, {t.id: a for (t, a) in zip(tvars, base_args)}
257258
)

mypy/typeanal.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
UnionType,
8383
UnpackType,
8484
callable_with_ellipsis,
85+
flatten_nested_tuples,
8586
flatten_nested_unions,
8687
get_proper_type,
8788
has_type_vars,
@@ -763,8 +764,8 @@ def analyze_type_with_type_info(
763764
if info.special_alias:
764765
return instantiate_type_alias(
765766
info.special_alias,
766-
# TODO: should we allow NamedTuples generic in ParamSpec and TypeVarTuple?
767-
self.anal_array(args),
767+
# TODO: should we allow NamedTuples generic in ParamSpec?
768+
self.anal_array(args, allow_unpack=True),
768769
self.fail,
769770
False,
770771
ctx,
@@ -782,7 +783,7 @@ def analyze_type_with_type_info(
782783
return instantiate_type_alias(
783784
info.special_alias,
784785
# TODO: should we allow TypedDicts generic in ParamSpec?
785-
self.anal_array(args),
786+
self.anal_array(args, allow_unpack=True),
786787
self.fail,
787788
False,
788789
ctx,
@@ -1948,7 +1949,10 @@ def instantiate_type_alias(
19481949
# TODO: we need to check args validity w.r.t alias.alias_tvars.
19491950
# Otherwise invalid instantiations will be allowed in runtime context.
19501951
# Note: in type context, these will be still caught by semanal_typeargs.
1951-
typ = TypeAliasType(node, args, ctx.line, ctx.column)
1952+
# Type aliases are special, since they can be expanded during semantic analysis,
1953+
# so we need to normalize them as soon as possible.
1954+
# TODO: can this cause an infinite recursion?
1955+
typ = TypeAliasType(node, flatten_nested_tuples(args), ctx.line, ctx.column)
19521956
assert typ.alias is not None
19531957
# HACK: Implement FlexibleAlias[T, typ] by expanding it to typ here.
19541958
if (

mypy/types.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,9 +1045,12 @@ class UnpackType(ProperType):
10451045
"""Type operator Unpack from PEP646. Can be either with Unpack[]
10461046
or unpacking * syntax.
10471047
1048-
The inner type should be either a TypeVarTuple, a constant size
1049-
tuple, or a variable length tuple. Type aliases to these are not allowed,
1050-
except during semantic analysis.
1048+
The inner type should be either a TypeVarTuple, or a variable length tuple.
1049+
In an exceptional case of callable star argument it can be a fixed length tuple.
1050+
1051+
Note: the above restrictions are only guaranteed by normalizations after semantic
1052+
analysis, if your code needs to handle UnpackType *during* semantic analysis, it is
1053+
wild west, technically anything can be present in the wrapped type.
10511054
"""
10521055

10531056
__slots__ = ["type"]
@@ -2143,7 +2146,11 @@ def with_normalized_var_args(self) -> Self:
21432146
assert nested_unpacked.type.fullname == "builtins.tuple"
21442147
new_unpack = nested_unpacked.args[0]
21452148
else:
2146-
assert isinstance(nested_unpacked, TypeVarTupleType)
2149+
if not isinstance(nested_unpacked, TypeVarTupleType):
2150+
# We found a non-nomralized tuple type, this means this method
2151+
# is called during semantic analysis (e.g. from get_proper_type())
2152+
# there is no point in normalizing callables at this stage.
2153+
return self
21472154
new_unpack = nested_unpack
21482155
else:
21492156
new_unpack = UnpackType(
@@ -3587,6 +3594,17 @@ def remove_dups(types: list[T]) -> list[T]:
35873594
return new_types
35883595

35893596

3597+
def type_vars_as_args(type_vars: Sequence[TypeVarLikeType]) -> tuple[Type, ...]:
3598+
"""Represent type variables as they would appear in a type argument list."""
3599+
args: list[Type] = []
3600+
for tv in type_vars:
3601+
if isinstance(tv, TypeVarTupleType):
3602+
args.append(UnpackType(tv))
3603+
else:
3604+
args.append(tv)
3605+
return tuple(args)
3606+
3607+
35903608
# This cyclic import is unfortunate, but to avoid it we would need to move away all uses
35913609
# of get_proper_type() from types.py. Majority of them have been removed, but few remaining
35923610
# are quite tricky to get rid of, but ultimately we want to do it at some point.

test-data/unit/check-typevar-tuple.test

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,3 +1032,90 @@ Second = Tuple[C, D]
10321032
x: G[Unpack[First], Unpack[Second]] # E: Type argument "A" of "G" must be a subtype of "int" \
10331033
# E: Type argument "D" of "G" must be a subtype of "str"
10341034
[builtins fixtures/tuple.pyi]
1035+
1036+
[case testVariadicTupleType]
1037+
from typing import Tuple, Callable
1038+
from typing_extensions import TypeVarTuple, Unpack
1039+
1040+
Ts = TypeVarTuple("Ts")
1041+
class A(Tuple[Unpack[Ts]]):
1042+
fn: Callable[[Unpack[Ts]], None]
1043+
1044+
x: A[int]
1045+
reveal_type(x) # N: Revealed type is "Tuple[builtins.int, fallback=__main__.A[builtins.int]]"
1046+
reveal_type(x[0]) # N: Revealed type is "builtins.int"
1047+
reveal_type(x.fn) # N: Revealed type is "def (builtins.int)"
1048+
1049+
y: A[int, str]
1050+
reveal_type(y) # N: Revealed type is "Tuple[builtins.int, builtins.str, fallback=__main__.A[builtins.int, builtins.str]]"
1051+
reveal_type(y[0]) # N: Revealed type is "builtins.int"
1052+
reveal_type(y.fn) # N: Revealed type is "def (builtins.int, builtins.str)"
1053+
1054+
z: A[Unpack[Tuple[int, ...]]]
1055+
reveal_type(z) # N: Revealed type is "__main__.A[Unpack[builtins.tuple[builtins.int, ...]]]"
1056+
# TODO: this requires fixing map_instance_to_supertype().
1057+
# reveal_type(z[0])
1058+
reveal_type(z.fn) # N: Revealed type is "def (*builtins.int)"
1059+
1060+
t: A[int, Unpack[Tuple[int, str]], str]
1061+
reveal_type(t) # N: Revealed type is "Tuple[builtins.int, builtins.int, builtins.str, builtins.str, fallback=__main__.A[builtins.int, builtins.int, builtins.str, builtins.str]]"
1062+
reveal_type(t[0]) # N: Revealed type is "builtins.int"
1063+
reveal_type(t.fn) # N: Revealed type is "def (builtins.int, builtins.int, builtins.str, builtins.str)"
1064+
[builtins fixtures/tuple.pyi]
1065+
1066+
[case testVariadicNamedTuple]
1067+
from typing import Tuple, Callable, NamedTuple, Generic
1068+
from typing_extensions import TypeVarTuple, Unpack
1069+
1070+
Ts = TypeVarTuple("Ts")
1071+
class A(NamedTuple, Generic[Unpack[Ts], T]):
1072+
fn: Callable[[Unpack[Ts]], None]
1073+
val: T
1074+
1075+
y: A[int, str]
1076+
reveal_type(y) # N: Revealed type is "Tuple[def (builtins.int), builtins.str, fallback=__main__.A[builtins.int, builtins.str]]"
1077+
reveal_type(y[0]) # N: Revealed type is "def (builtins.int)"
1078+
reveal_type(y.fn) # N: Revealed type is "def (builtins.int)"
1079+
1080+
z: A[Unpack[Tuple[int, ...]]]
1081+
reveal_type(z) # N: Revealed type is "Tuple[def (*builtins.int), builtins.int, fallback=__main__.A[Unpack[builtins.tuple[builtins.int, ...]], builtins.int]]"
1082+
reveal_type(z.fn) # N: Revealed type is "def (*builtins.int)"
1083+
1084+
t: A[int, Unpack[Tuple[int, str]], str]
1085+
reveal_type(t) # N: Revealed type is "Tuple[def (builtins.int, builtins.int, builtins.str), builtins.str, fallback=__main__.A[builtins.int, builtins.int, builtins.str, builtins.str]]"
1086+
1087+
def test(x: int, y: str) -> None: ...
1088+
nt = A(fn=test, val=42)
1089+
reveal_type(nt) # N: Revealed type is "Tuple[def (builtins.int, builtins.str), builtins.int, fallback=__main__.A[builtins.int, builtins.str, builtins.int]]"
1090+
1091+
def bad() -> int: ...
1092+
nt2 = A(fn=bad, val=42) # E: Argument "fn" to "A" has incompatible type "Callable[[], int]"; expected "Callable[[], None]"
1093+
[builtins fixtures/tuple.pyi]
1094+
1095+
[case testVariadicTypedDict]
1096+
from typing import Tuple, Callable, Generic
1097+
from typing_extensions import TypeVarTuple, Unpack, TypedDict
1098+
1099+
Ts = TypeVarTuple("Ts")
1100+
class A(TypedDict, Generic[Unpack[Ts], T]):
1101+
fn: Callable[[Unpack[Ts]], None]
1102+
val: T
1103+
1104+
y: A[int, str]
1105+
reveal_type(y) # N: Revealed type is "TypedDict('__main__.A', {'fn': def (builtins.int), 'val': builtins.str})"
1106+
reveal_type(y["fn"]) # N: Revealed type is "def (builtins.int)"
1107+
1108+
z: A[Unpack[Tuple[int, ...]]]
1109+
reveal_type(z) # N: Revealed type is "TypedDict('__main__.A', {'fn': def (*builtins.int), 'val': builtins.int})"
1110+
reveal_type(z["fn"]) # N: Revealed type is "def (*builtins.int)"
1111+
1112+
t: A[int, Unpack[Tuple[int, str]], str]
1113+
reveal_type(t) # N: Revealed type is "TypedDict('__main__.A', {'fn': def (builtins.int, builtins.int, builtins.str), 'val': builtins.str})"
1114+
1115+
def test(x: int, y: str) -> None: ...
1116+
td = A({"fn": test, "val": 42})
1117+
reveal_type(td) # N: Revealed type is "TypedDict('__main__.A', {'fn': def (builtins.int, builtins.str), 'val': builtins.int})"
1118+
1119+
def bad() -> int: ...
1120+
td2 = A({"fn": bad, "val": 42}) # E: Incompatible types (expression has type "Callable[[], int]", TypedDict item "fn" has type "Callable[[], None]")
1121+
[builtins fixtures/tuple.pyi]

0 commit comments

Comments
 (0)