From d0cd57ad5082cd3d5f4dd8df37e7005c3b81d7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikko=20Lepp=C3=A4nen?= Date: Thu, 18 Jun 2026 11:57:19 +0300 Subject: [PATCH] Iterate a TypeVar through its upper bound when unpacking Unpacking `u, v = x` where `x: Z` and `Z` is a TypeVar bounded by `tuple[str, int]` revealed `int | str` for both targets instead of the positional `str` and `int`. The direct `x: tuple[str, int]` case was already correct, so the two were not equivalent, even though pyright, mypy, and ty all agree on the positional types. `AnswersSolver::iterate` had no arm for `Type::Quantified`, so a bounded TypeVar fell through to the iterable-protocol fallback, which views the value as `Iterable[T]` and collapses a fixed-shape tuple to the join of its element types, losing each position. Iterate a TypeVar through its upper bound instead, so the bound's tuple shape is preserved. Reusing `upper_bound` lets the existing union and tuple arms handle the rest: constrained TypeVars iterate per constraint, and an unbounded TypeVar resolves to `object` and is correctly reported as not iterable. This is the same resolve-through-the-bound rule already used for attribute access on a bounded TypeVar. Fixes facebook/pyrefly#3841 --- pyrefly/lib/alt/solve.rs | 11 ++++++ pyrefly/lib/test/tuple.rs | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index bb519fd536..72a5e96217 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -867,6 +867,17 @@ impl<'a, Ans: LookupAnswer> AnswersSolver<'a, Ans> { Type::Var(v) if let Some(_guard) = self.recurse(*v) => { self.iterate(&self.solver().force_var(*v), range, errors, orig_context) } + Type::Quantified(q) if q.is_type_var() => { + // A TypeVar iterates like its upper bound: `Z: tuple[str, int]` must + // unpack positionally rather than collapse to the element join via the + // iterable protocol. Mirrors attribute access on a bounded TypeVar. + self.iterate( + &q.upper_bound(self.stdlib, self.heap), + range, + errors, + orig_context, + ) + } Type::Union(f) => f .members .iter() diff --git a/pyrefly/lib/test/tuple.rs b/pyrefly/lib/test/tuple.rs index d5f87e41ac..ef3aefdc2a 100644 --- a/pyrefly/lib/test/tuple.rs +++ b/pyrefly/lib/test/tuple.rs @@ -370,6 +370,79 @@ def test[*Ts](x1: tuple[int, *tuple[str, ...]], x2: tuple[*Ts]) -> None: "#, ); +testcase!( + test_unpack_typevar_bound_to_tuple, + r#" +from typing import reveal_type +def f[Z: tuple[str, int]](x: Z): + u, v = x + reveal_type(u) # E: revealed type: str + reveal_type(v) # E: revealed type: int +"#, +); + +testcase!( + test_unpack_typevar_bound_to_tuple_three_elements, + r#" +from typing import reveal_type +def f[Z: tuple[str, int, bytes]](x: Z): + a, b, c = x + reveal_type(a) # E: revealed type: str + reveal_type(b) # E: revealed type: int + reveal_type(c) # E: revealed type: bytes +"#, +); + +testcase!( + test_unpack_typevar_bound_to_unbounded_tuple, + r#" +from typing import reveal_type +def f[Z: tuple[int, ...]](x: Z): + a, b = x + reveal_type(a) # E: revealed type: int + reveal_type(b) # E: revealed type: int +"#, +); + +testcase!( + test_unpack_typevar_bound_to_tuple_starred, + r#" +from typing import reveal_type +def f[Z: tuple[str, int, bytes]](x: Z): + a, *b = x + reveal_type(a) # E: revealed type: str + reveal_type(b) # E: revealed type: list[bytes | int] +"#, +); + +testcase!( + test_unpack_constrained_typevar_tuple, + r#" +from typing import TypeVar, reveal_type +Z = TypeVar("Z", tuple[str, int], tuple[bool, bytes]) +def f(x: Z): + a, b = x + reveal_type(a) # E: revealed type: bool | str + reveal_type(b) # E: revealed type: bytes | int +"#, +); + +testcase!( + test_unpack_typevar_unbounded_not_iterable, + r#" +def f[Z](x: Z): + a, b = x # E: Type `object` is not iterable +"#, +); + +testcase!( + test_unpack_typevar_bound_not_iterable, + r#" +def f[Z: int](x: Z): + a, b = x # E: Type `int` is not iterable +"#, +); + testcase!( test_tuple_slice_non_literal, r#"