Skip to content

Commit bc7cf14

Browse files
[ty] Fix loop-header reachability cycles in conditional unpacking (#24006)
## Summary Closes astral-sh/ty#3057. Closes astral-sh/ty#3104.
1 parent 2e65d71 commit bc7cf14

2 files changed

Lines changed: 66 additions & 7 deletions

File tree

crates/ty_python_semantic/resources/mdtest/loops/while_loop.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,34 @@ while random():
383383
x
384384
```
385385

386+
### Statically unreachable `del` branches don't poison cyclic loopback
387+
388+
This creates a loop-header cycle through `if x`, but the `del x` branch should still disappear once
389+
the loopback type settles:
390+
391+
```py
392+
def random() -> bool:
393+
return False
394+
395+
x = 1
396+
while random():
397+
if x:
398+
x = 1
399+
else:
400+
del x
401+
reveal_type(x) # revealed: Literal[1]
402+
```
403+
404+
Comparison-guarded `del` branches should also disappear once the loopback type settles:
405+
406+
```py
407+
x = 1
408+
while x < 10:
409+
if x == 4:
410+
del x
411+
reveal_type(x) # revealed: Literal[1]
412+
```
413+
386414
### Bindings in a loop are possibly-unbound after the loop
387415

388416
```py
@@ -441,6 +469,43 @@ while random():
441469
reveal_type(y) # revealed: Literal[1, 2]
442470
```
443471

472+
### Monotonic widening can keep stale loopback bindings reachable
473+
474+
```py
475+
def random() -> bool:
476+
return False
477+
478+
x = 0
479+
while random():
480+
# TODO: This should reveal `Literal[0]`.
481+
reveal_type(x) # revealed: Literal[0, 2]
482+
if x == 1:
483+
x = 2
484+
```
485+
486+
### Conditional unpacking and loop exits converge normally
487+
488+
This reduced example from issue #3057 used to panic with "too many cycle iterations":
489+
490+
```py
491+
def fetch(req) -> tuple:
492+
return (True, None)
493+
494+
def paginate():
495+
bookmark = None
496+
while True:
497+
if bookmark is None:
498+
req = None
499+
else:
500+
req = bookmark
501+
ok, next_bookmark = fetch(req)
502+
if not ok:
503+
return
504+
bookmark = next_bookmark
505+
if bookmark is None or bookmark == 0:
506+
break
507+
```
508+
444509
### Loop bodies that are guaranteed to execute at least once
445510

446511
TODO: We should be able to see when a loop body is guaranteed to execute at least once. However,

crates/ty_python_semantic/src/place.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,7 +1181,6 @@ fn loop_header_reachability_impl<'db>(
11811181
let loop_header = get_loop_header(db, loop_header_definition.loop_token());
11821182
let place = loop_header_definition.place();
11831183

1184-
let mut has_defined_bindings = false;
11851184
let mut deleted_reachability = Truthiness::AlwaysFalse;
11861185
let mut reachable_bindings = FxIndexSet::default();
11871186

@@ -1202,7 +1201,6 @@ fn loop_header_reachability_impl<'db>(
12021201
def, definition,
12031202
"loop headers only include bindings from within the loop"
12041203
);
1205-
has_defined_bindings = true;
12061204
reachable_bindings.insert(ReachableLoopBinding {
12071205
definition: def,
12081206
narrowing_constraint: live_binding.narrowing_constraint,
@@ -1221,7 +1219,6 @@ fn loop_header_reachability_impl<'db>(
12211219
}
12221220

12231221
LoopHeaderReachability {
1224-
has_defined_bindings,
12251222
deleted_reachability,
12261223
reachable_bindings,
12271224
}
@@ -1230,8 +1227,6 @@ fn loop_header_reachability_impl<'db>(
12301227
/// Result of [`loop_header_reachability`]: pre-computed reachability info for loop-back bindings.
12311228
#[derive(Debug, Clone, PartialEq, Eq, salsa::Update, get_size2::GetSize)]
12321229
pub(crate) struct LoopHeaderReachability<'db> {
1233-
/// Whether any reachable loop-back binding is a defined binding.
1234-
pub(crate) has_defined_bindings: bool,
12351230
pub(crate) deleted_reachability: Truthiness,
12361231
/// Reachable loop-back bindings that are not `del`s.
12371232
pub(crate) reachable_bindings: FxIndexSet<ReachableLoopBinding<'db>>,
@@ -1247,7 +1242,6 @@ impl<'db> LoopHeaderReachability<'db> {
12471242
reachable_bindings.extend(self.reachable_bindings);
12481243

12491244
LoopHeaderReachability {
1250-
has_defined_bindings: self.has_defined_bindings,
12511245
deleted_reachability: self.deleted_reachability,
12521246
reachable_bindings,
12531247
}
@@ -1392,7 +1386,7 @@ fn place_from_bindings_impl<'db>(
13921386
// that none of them loop-back. In that case short-circuit, so that we don't
13931387
// produce an `Unknown` fallback type, and so that `Place::Undefined` is still a
13941388
// possibility below.
1395-
if !loop_header.has_defined_bindings {
1389+
if loop_header.reachable_bindings.is_empty() {
13961390
return None;
13971391
}
13981392
} else {

0 commit comments

Comments
 (0)