diff --git a/pyrefly/lib/alt/expr.rs b/pyrefly/lib/alt/expr.rs index 3c9dafc4de..4e4c9d9346 100644 --- a/pyrefly/lib/alt/expr.rs +++ b/pyrefly/lib/alt/expr.rs @@ -4236,7 +4236,9 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { ), Type::Type(f) if matches!(&*f, Type::Quantified(q) if q.is_type_var()) => { // Repeated match because pattern guards cannot move out of bindings. - let Type::Quantified(quantified) = *f else { unreachable!("guarded by matches! above") }; + let Type::Quantified(quantified) = *f else { + unreachable!("guarded by matches! above") + }; let quantified = *quantified; let base_display_ty = self.heap.mk_type_of(self.heap.mk_quantified(quantified.clone())); diff --git a/pyrefly/lib/alt/solve.rs b/pyrefly/lib/alt/solve.rs index d3b5c857f0..6531a5248e 100644 --- a/pyrefly/lib/alt/solve.rs +++ b/pyrefly/lib/alt/solve.rs @@ -2956,18 +2956,30 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { if iterable_ty.ty().is_never() { return EmptyAnswer; } - // String and bytes literals have known length, so generate a fixed-length iterable - let iterables = match iterable_ty.ty() { + let mut iterables = Vec::new(); + self.map_over_union(iterable_ty.ty(), |ty| match ty { + // String and bytes literals have known length. Type::Literal(lit) if let Lit::Str(s) = &lit.value => { let char_ty = self.heap.mk_literal_string(LitStyle::Implicit); - vec![Iterable::FixedLen(vec![char_ty; s.chars().count()])] + iterables.push(Iterable::FixedLen(vec![char_ty; s.chars().count()])); } Type::Literal(lit) if let Lit::Bytes(b) = &lit.value => { let elem_ty = self.heap.mk_class_type(self.stdlib.int().clone()); - vec![Iterable::FixedLen(vec![elem_ty; b.len()])] + iterables.push(Iterable::FixedLen(vec![elem_ty; b.len()])); } - _ => self.iterate(iterable_ty.ty(), *range, errors, None), - }; + _ if !matches!(expect, SizeExpectation::Ge(0)) + && (matches!(ty, Type::ClassType(cls) if cls == self.stdlib.str()) + || matches!(ty, Type::LiteralString(_))) => + { + self.error( + errors, + *range, + ErrorKind::BadUnpacking, + format!("Cannot unpack {} into {}", ty, expect.message()), + ); + } + _ => iterables.extend(self.iterate(ty, *range, errors, None)), + }); for iterable in iterables { match iterable { Iterable::OfType(_) => {} diff --git a/pyrefly/lib/alt/unwrap.rs b/pyrefly/lib/alt/unwrap.rs index ae38e42941..bf626cfe7f 100644 --- a/pyrefly/lib/alt/unwrap.rs +++ b/pyrefly/lib/alt/unwrap.rs @@ -20,6 +20,7 @@ use crate::solver::solver::SubsetError; use crate::types::callable::Param; use crate::types::callable::Required; use crate::types::class::ClassType; +use crate::types::literal::Lit; use crate::types::tuple::Tuple; use crate::types::types::Type; use crate::types::types::Var; @@ -254,6 +255,7 @@ impl<'ctx, 'answer, Ans: LookupAnswer> AnswersSolver<'ctx, 'answer, Ans> { { return false; } + Type::Literal(lit) if matches!(lit.value, Lit::Str(_) | Lit::Bytes(_)) => return false, Type::LiteralString(_) => return false, // Tuples are always sequences for pattern matching Type::Tuple(_) => return true, diff --git a/pyrefly/lib/test/assign.rs b/pyrefly/lib/test/assign.rs index 810db0ef21..eb4fa03f92 100644 --- a/pyrefly/lib/test/assign.rs +++ b/pyrefly/lib/test/assign.rs @@ -1001,6 +1001,7 @@ testcase!( test_unpack_string_exact, r#" a, b, c = "abc" +x, y = "12" "#, ); @@ -1009,6 +1010,10 @@ testcase!( r#" a, *b = "abc" x, y, *z = "w" # E: Cannot unpack Literal['w'] (of size 1) into 2+ values +x, *y = "123" +x, *y = "12" +x, *y = "1" +x, *y = "" # E: Cannot unpack Literal[''] (of size 0) into 1+ values "#, ); @@ -1034,6 +1039,69 @@ bad = items[::0] # E: Slice step cannot be zero "#, ); +testcase!( + test_for_loop_unpack_string, + r#" +for k, v in {"x": 1}: # E: Cannot unpack + pass + +xs: list[str] = [] +for a, b in xs: # E: Cannot unpack + pass +"#, +); + +testcase!( + test_for_loop_unpack_tuple_annotation, + r#" +from __future__ import annotations + +x: list[tuple[str, str]] = [] +for a, b in x: + pass +"#, +); + +testcase!( + test_unpack_string_star_only, + r#" +from typing import LiteralString, assert_type + +def f(s: str, literal: LiteralString) -> None: + *chars, = s + assert_type(chars, list[str]) + *literal_chars, = literal + assert_type(literal_chars, list[LiteralString]) + +*empty, = "" +*nonempty, = "123" +"#, +); + +testcase!( + test_unpack_string_union, + r#" +from typing import Literal, LiteralString + +def f(xs: list[str | tuple[int, int]], literals: list[LiteralString | tuple[int, int]]) -> None: + for a, b in xs: # E: Cannot unpack + pass + for a, *rest in xs: # E: Cannot unpack + pass + for *chars, in xs: + pass + for a, b in literals: # E: Cannot unpack + pass + for *literal_chars, in literals: + pass + +def g(s: Literal["12", "34"], short: Literal["", "1"], b: Literal[b"12", b"34"]) -> None: + a, c = s + a, *rest = short # E: Cannot unpack + x, y = b +"#, +); + testcase!( test_union_gradual_narrow_list_any_or_none, r#" diff --git a/pyrefly/lib/test/generic_basic.rs b/pyrefly/lib/test/generic_basic.rs index 29db7dd3a5..5d4a2b441c 100644 --- a/pyrefly/lib/test/generic_basic.rs +++ b/pyrefly/lib/test/generic_basic.rs @@ -10,6 +10,39 @@ use pyrefly_python::sys_info::PythonVersion; use crate::test::util::TestEnv; use crate::testcase; +testcase!( + test_parenthesized_generic_arguments, + r#" +from __future__ import annotations +from typing import TypeVar, assert_type + +class Box[T]: + def __init__(self, value: T) -> None: ... + +type Mapping[K, V] = dict[K, V] +type Wrapped[T] = Box[T] + +K = TypeVar("K") +V = TypeVar("V") +T = TypeVar("T") +LegacyMapping = dict[K, V] +LegacyWrapped = Box[T] + +def f(d: dict[(str, int)], b: Box[(int,)], m: Mapping[(str, int)], w: Wrapped[(int,)]): + assert_type(d, dict[str, int]) + assert_type(b, Box[int]) + assert_type(m, dict[str, int]) + assert_type(w, Box[int]) + +assert_type(dict[(str, int)](), dict[str, int]) +assert_type(Box[(int,)](1), Box[int]) +assert_type(LegacyMapping[(str, int)](), dict[str, int]) +assert_type(LegacyWrapped[(int,)](1), Box[int]) + +x: list[(str, str)] = [] # E: Expected 1 type argument for `list`, got 2 +"#, +); + testcase!( test_tyvar_constructor, r#" diff --git a/pyrefly/lib/test/pattern_match.rs b/pyrefly/lib/test/pattern_match.rs index ff4f5a2433..b2a76db732 100644 --- a/pyrefly/lib/test/pattern_match.rs +++ b/pyrefly/lib/test/pattern_match.rs @@ -884,6 +884,32 @@ def test_str_not_sequence(x: str | Sequence[int]) -> None: "#, ); +testcase!( + test_sequence_pattern_string_and_bytes_literals_excluded, + r#" +from typing import Literal, assert_type + +type Text = Literal["", "1", "12", "closed", b"", b"1", b"12", b"closed"] + +def fixed(x: Text | tuple[int, int]) -> None: + match x: + case [first, second]: + assert_type(x, tuple[int, int]) + assert_type(first, int) + assert_type(second, int) + case _: + assert_type(x, Text) + +def starred(x: Text | tuple[int, int]) -> None: + match x: + case [*values]: + assert_type(x, tuple[int, int]) + assert_type(values, list[int]) + case _: + assert_type(x, Text) +"#, +); + testcase!( test_sequence_pattern_list, r#"