[ty] Type inference for comprehensions (#20962)

## Summary

Adds type inference for list/dict/set comprehensions, including
bidirectional inference:

```py
reveal_type({k: v for k, v in [("a", 1), ("b", 2)]})  # dict[Unknown | str, Unknown | int]

squares: list[int | None] = [x for x in range(10)]
reveal_type(squares)  # list[int | None]
```

## Ecosystem impact

I did spot check the changes and most of them seem like known
limitations or true positives. Without proper bidirectional inference,
we saw a lot of false positives.

## Test Plan

New Markdown tests
This commit is contained in:
David Peter 2025-11-02 14:35:33 +01:00 committed by GitHub
parent de1a6fb8ad
commit 73107a083c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 266 additions and 28 deletions

View file

@ -103,3 +103,92 @@ async def _():
# revealed: Unknown
[reveal_type(x) async for x in range(3)]
```
## Comprehension expression types
The type of the comprehension expression itself should reflect the inferred element type:
```py
from typing import TypedDict, Literal
# revealed: list[int | Unknown]
reveal_type([x for x in range(10)])
# revealed: set[int | Unknown]
reveal_type({x for x in range(10)})
# revealed: dict[int | Unknown, str | Unknown]
reveal_type({x: str(x) for x in range(10)})
# revealed: list[tuple[int, Unknown | str] | Unknown]
reveal_type([(x, y) for x in range(5) for y in ["a", "b", "c"]])
squares: list[int | None] = [x**2 for x in range(10)]
reveal_type(squares) # revealed: list[int | None]
```
Inference for comprehensions takes the type context into account:
```py
# Without type context:
reveal_type([x for x in [1, 2, 3]]) # revealed: list[Unknown | int]
reveal_type({x: "a" for x in [1, 2, 3]}) # revealed: dict[Unknown | int, str | Unknown]
reveal_type({str(x): x for x in [1, 2, 3]}) # revealed: dict[str | Unknown, Unknown | int]
reveal_type({x for x in [1, 2, 3]}) # revealed: set[Unknown | int]
# With type context:
xs: list[int] = [x for x in [1, 2, 3]]
reveal_type(xs) # revealed: list[int]
ys: dict[int, str] = {x: str(x) for x in [1, 2, 3]}
reveal_type(ys) # revealed: dict[int, str]
zs: set[int] = {x for x in [1, 2, 3]}
```
This also works for nested comprehensions:
```py
table = [[(x, y) for x in range(3)] for y in range(3)]
reveal_type(table) # revealed: list[list[tuple[int, int] | Unknown] | Unknown]
table_with_content: list[list[tuple[int, int, str | None]]] = [[(x, y, None) for x in range(3)] for y in range(3)]
reveal_type(table_with_content) # revealed: list[list[tuple[int, int, str | None]]]
```
The type context is propagated down into the comprehension:
```py
class Person(TypedDict):
name: str
persons: list[Person] = [{"name": n} for n in ["Alice", "Bob"]]
reveal_type(persons) # revealed: list[Person]
# TODO: This should be an error
invalid: list[Person] = [{"misspelled": n} for n in ["Alice", "Bob"]]
```
We promote literals to avoid overly-precise types in invariant positions:
```py
reveal_type([x for x in ("a", "b", "c")]) # revealed: list[str | Unknown]
reveal_type({x for x in (1, 2, 3)}) # revealed: set[int | Unknown]
reveal_type({k: 0 for k in ("a", "b", "c")}) # revealed: dict[str | Unknown, int | Unknown]
```
Type context can prevent this promotion from happening:
```py
list_of_literals: list[Literal["a", "b", "c"]] = [x for x in ("a", "b", "c")]
reveal_type(list_of_literals) # revealed: list[Literal["a", "b", "c"]]
dict_with_literal_keys: dict[Literal["a", "b", "c"], int] = {k: 0 for k in ("a", "b", "c")}
reveal_type(dict_with_literal_keys) # revealed: dict[Literal["a", "b", "c"], int]
dict_with_literal_values: dict[str, Literal[1, 2, 3]] = {str(k): k for k in (1, 2, 3)}
reveal_type(dict_with_literal_values) # revealed: dict[str, Literal[1, 2, 3]]
set_with_literals: set[Literal[1, 2, 3]] = {k for k in (1, 2, 3)}
reveal_type(set_with_literals) # revealed: set[Literal[1, 2, 3]]
```

View file

@ -51,6 +51,6 @@ reveal_type({"a": 1, "b": (1, 2), "c": (1, 2, 3)})
## Dict comprehensions
```py
# revealed: dict[@Todo(dict comprehension key type), @Todo(dict comprehension value type)]
# revealed: dict[int | Unknown, int | Unknown]
reveal_type({x: y for x, y in enumerate(range(42))})
```

View file

@ -41,5 +41,5 @@ reveal_type([1, (1, 2), (1, 2, 3)])
## List comprehensions
```py
reveal_type([x for x in range(42)]) # revealed: list[@Todo(list comprehension element type)]
reveal_type([x for x in range(42)]) # revealed: list[int | Unknown]
```

View file

@ -35,5 +35,5 @@ reveal_type({1, (1, 2), (1, 2, 3)})
## Set comprehensions
```py
reveal_type({x for x in range(42)}) # revealed: set[@Todo(set comprehension element type)]
reveal_type({x for x in range(42)}) # revealed: set[int | Unknown]
```

View file

@ -0,0 +1,50 @@
# Documentation of two fuzzer panics involving comprehensions
Type inference for comprehensions was added in <https://github.com/astral-sh/ruff/pull/20962>. It
added two new fuzzer panics that are documented here for regression testing.
## Too many cycle iterations in `place_by_id`
<!-- expect-panic: too many cycle iterations -->
```py
name_5(name_3)
[0 for unique_name_0 in unique_name_1 for unique_name_2 in name_3]
@{name_3 for unique_name_3 in unique_name_4}
class name_4[**name_3](0, name_2=name_5):
pass
try:
name_0 = name_4
except* 0:
pass
else:
match unique_name_12:
case 0:
from name_2 import name_3
case name_0():
@name_4
def name_3():
pass
(name_3 := 0)
@name_3
async def name_5():
pass
```
## Too many cycle iterations in `infer_definition_types`
<!-- expect-panic: too many cycle iterations -->
```py
for name_1 in {
{{0: name_4 for unique_name_0 in unique_name_1}: 0 for unique_name_2 in unique_name_3 if name_4}: 0
for unique_name_4 in name_1
for name_4 in name_1
}:
pass
```