crates/ty_python_semantic/resources/mdtest/loops/while_loop.md
while loopdef _(flag: bool):
x = 1
while flag:
x = 2
reveal_type(x) # revealed: Literal[1, 2]
while with else (no break)def _(flag: bool):
x = 1
while flag:
x = 2
else:
reveal_type(x) # revealed: Literal[1, 2]
x = 3
reveal_type(x) # revealed: Literal[3]
while with else (may break)def _(flag: bool, flag2: bool):
x = 1
y = 0
while flag:
x = 2
if flag2:
y = 4
break
else:
y = x
x = 3
reveal_type(x) # revealed: Literal[2, 3]
reveal_type(y) # revealed: Literal[4, 1, 2]
while loopsdef flag() -> bool:
return True
x = 1
while flag():
x = 2
while flag():
x = 3
if flag():
break
else:
x = 4
if flag():
break
else:
x = 5
reveal_type(x) # revealed: Literal[3, 4, 5]
Make sure that the boundness information is correctly tracked in while loop control flow.
while loopdef _(flag: bool):
while flag:
x = 1
# error: [possibly-unresolved-reference]
x
while with else (no break)def _(flag: bool):
while flag:
y = 1
else:
x = 1
# no error, `x` is always bound
x
# error: [possibly-unresolved-reference]
y
while with else (may break)def _(flag: bool, flag2: bool):
while flag:
x = 1
if flag2:
break
else:
y = 1
# error: [possibly-unresolved-reference]
x
# error: [possibly-unresolved-reference]
y
__bool__ incorrectlyclass NotBoolable:
__bool__: int = 3
# error: [unsupported-bool-conversion] "Boolean conversion is not supported for type `NotBoolable`"
while NotBoolable():
pass
while x := False:
pass
reveal_type(x) # revealed: Literal[False]
def random() -> bool:
return False
i = 0
reveal_type(i) # revealed: Literal[0]
while random():
i += 1
reveal_type(i) # revealed: int
reveal_type(i) # revealed: int
i = 0
while i < 1_000_000:
if i > 0:
loop_only += 1 # error: [possibly-unresolved-reference]
if i == 0:
loop_only = 0
i += 1
# error: [possibly-unresolved-reference]
reveal_type(loop_only) # revealed: int
Here the loop condition narrows both the loop-back value and the end-of-loop value:
def random() -> bool:
return False
x = "A"
while x != "C":
reveal_type(x) # revealed: Literal["A", "B"]
if random():
x = "B"
else:
x = "C"
reveal_type(x) # revealed: Literal["B", "C"]
reveal_type(x) # revealed: Literal["C"]
def random() -> bool:
return False
x = "A"
while x != "E":
reveal_type(x) # revealed: Literal["A", "C", "D"]
while x != "C":
reveal_type(x) # revealed: Literal["A", "D", "B"]
if random():
x = "B"
else:
x = "C"
reveal_type(x) # revealed: Literal["B", "C"]
reveal_type(x) # revealed: Literal["C"]
if random():
x = "D"
if random():
x = "E"
reveal_type(x) # revealed: Literal["C", "D", "E"]
reveal_type(x) # revealed: Literal["E"]
break and continuedef random() -> bool:
return False
x = "A"
while True:
reveal_type(x) # revealed: Literal["A", "C", "D"]
while True:
reveal_type(x) # revealed: Literal["A", "C", "D", "B"]
if random():
x = "B"
continue
else:
x = "C"
break
reveal_type(x) # revealed: Never
reveal_type(x) # revealed: Literal["C"]
if random():
x = "D"
continue
if random():
x = "E"
break
reveal_type(x) # revealed: Literal["C"]
reveal_type(x) # revealed: Literal["E"]
break and a narrowing conditionHere the loop condition forces x to be False at loop exit, because there is no break:
def random() -> bool:
return True
x = random()
reveal_type(x) # revealed: bool
while x:
pass
reveal_type(x) # revealed: Literal[False]
However, we can't narrow x like this when there's a break in the loop:
x = random()
while x:
if random():
break
reveal_type(x) # revealed: bool
def random() -> bool:
return False
x = "A"
while random():
reveal_type(x) # revealed: Literal["A", "B", "C", "D"]
x = "B"
if random():
x = "C"
if x == "C":
continue
reveal_type(x) # revealed: Literal["B"]
while random():
reveal_type(x) # revealed: Literal["B", "D"]
if random():
x = "D"
continue
x = "E"
break
reveal_type(x) # revealed: Literal["B", "D", "E"]
if x == "E":
break
reveal_type(x) # revealed: Literal["B", "D"]
reveal_type(x) # revealed: Literal["A", "B", "C", "D", "E"]
def random() -> bool:
return False
foo = None
Bar = None
while random():
reveal_type(foo) # revealed: None | (def foo() -> None)
reveal_type(Bar) # revealed: None | <class 'Bar'>
def foo() -> None: ...
class Bar: ...
def random() -> bool:
return False
while random():
# error: [possibly-unresolved-reference]
reveal_type(y) # revealed: Literal[1]
x = (y := 1)
i = 0
while (i := i + 1) < 1_000_000:
reveal_type(i) # revealed: int
def random() -> bool:
return False
my_dict = {}
my_dict["x"] = 0
reveal_type(my_dict["x"]) # revealed: Literal[0]
while random():
my_dict["x"] += 1
reveal_type(my_dict["x"]) # revealed: int
del prevents bindings from reaching the loopbackThis x cannot reach the use at the top of the loop:
def random() -> bool:
return False
while random():
x # error: [unresolved-reference]
x = 42
del x
On the other hand, if x is defined before the loop, the del makes it a
[possibly-unresolved-reference]:
x = 0
while random():
x # error: [possibly-unresolved-reference]
x = 42
del x
del in a loop makes a variable possibly-unbound after the loopdef random() -> bool:
return False
x = 0
while random():
# error: [possibly-unresolved-reference]
del x
# error: [possibly-unresolved-reference]
x
del branches don't poison cyclic loopbackThis creates a loop-header cycle through if x, but the del x branch should still disappear once
the loopback type settles:
def random() -> bool:
return False
x = 1
while random():
if x:
x = 1
else:
del x
reveal_type(x) # revealed: Literal[1]
Comparison-guarded del branches should also disappear once the loopback type settles:
x = 1
while x < 10:
if x == 4:
del x
reveal_type(x) # revealed: Literal[1]
An inner loop can delete a variable on one iteration, then exit through break on a later
iteration. The variable can therefore be unbound both after the inner loop and at the start of the
next outer iteration.
def stop() -> bool:
raise NotImplementedError
def f(repeat: bool):
x = 0
while repeat:
x # error: [possibly-unresolved-reference]
while True:
if stop():
break
x = 0
del x
x # error: [possibly-unresolved-reference]
An inner loop's deletion does not make a variable possibly unbound on later outer iterations if the variable is reassigned before reaching the next iteration.
def stop() -> bool:
raise NotImplementedError
def f(repeat: bool):
x = 0
while repeat:
reveal_type(x) # revealed: Literal[0]
while True:
if stop():
break
x = 0
del x
x = 0
A deletion guarded by an impossible comparison does not make the variable possibly unbound, even through several nested loops. Evaluating the loop conditions and comparison depends on bindings from the enclosing loops.
def stop() -> bool:
raise NotImplementedError
def f(repeat: bool):
x = 1
while repeat:
reveal_type(x) # revealed: Literal[1]
while x:
if stop():
break
while x:
if stop():
break
if x == 4:
del x
reveal_type(x) # revealed: Literal[1]
def random() -> bool:
return False
while random():
x = 42
# error: [possibly-unresolved-reference]
x
def random() -> bool:
return False
x = 1
y = 2
while random():
x, y = y, x
reveal_type(x) # revealed: Literal[2, 1]
reveal_type(y) # revealed: Literal[1, 2]
def random() -> bool:
return False
x = 0
while random():
x, y = x + 1, None
reveal_type(x) # revealed: int
We need to avoid oscillating cycles in cases like the following, where the type of one of these loop
variables also influences the static reachability of its bindings. This case was minimized from a
real crash that came up during development checking these lines of sympy:
https://github.com/sympy/sympy/blob/c2bfd65accf956576b58f0ae57bf5821a0c4ff49/sympy/core/numbers.py#L158-L166
def random() -> bool:
return False
x = 1
y = 2
while random():
if x:
x, y = y, x
reveal_type(x) # revealed: Literal[2, 1]
reveal_type(y) # revealed: Literal[1, 2]
A negated comparison chain validates an increment before the loop updates its offset. Inference converges even though the guard depends on the value added to the loop variable.
def advance(data: bytes, offset: int) -> None:
while offset < len(data):
byte = data[offset]
if byte == 0:
return
step = byte & 15
if not 1 <= step <= 8:
raise ValueError
offset += step
# TODO: The offset should retain its `int` type.
reveal_type(offset) # revealed: int | Unknown
Type checks and a negated comparison chain validate a record's size before advancing the offset.
Combining these checks with or preserves the integer type of the updated offset.
def read_record(offset: int) -> tuple[int | None, int | None]:
return 1, 1
def read_records(offset: int, end: int) -> None:
while offset < end:
value, size = read_record(offset)
if not isinstance(value, int) or not isinstance(size, int) or not 0 <= size <= end - offset:
raise ValueError
offset += size
reveal_type(offset) # revealed: int
A chained comparison guards both extending a worklist and inserting into a set. The set's inferred
element type remains str as entries are added and queued for later loop iterations.
def visit(start: str, height: int) -> None:
columns = "abc"
column = columns.index(start[0])
row = int(start[1:]) - 1
visited = {start}
pending = [(column, row)]
while pending:
current_column, current_row = pending.pop()
for x, y in ((current_column, current_row - 1),):
if not 0 <= y < height:
continue
visited.add(f"{columns[x]}{y + 1}")
pending.append((x, y))
reveal_type(visited) # revealed: set[str]
Each batch depends on an instance attribute that is updated from the last item in the batch. The
condition and the attribute's type depend on each other across loop iterations. Inference converges,
and the condition narrows the assigned value to a non-empty str.
class Inventory:
after: str | None
def next_batch(self, after: object) -> "list[Inventory]":
return []
def iterate(self):
while True:
item = None
batch = self.next_batch(self.after)
assert batch
for item in batch:
pass
if item and item.after:
self.after = item.after
reveal_type(self.after) # revealed: str & ~AlwaysFalsy
def random() -> bool:
return False
x = 0
while random():
reveal_type(x) # revealed: Literal[0]
if x == 1:
x = 2
This reduced example from issue #3057 used to panic with "too many cycle iterations":
def fetch(req) -> tuple: # error: [missing-type-argument]
return (True, None)
def paginate():
bookmark = None
while True:
if bookmark is None:
req = None
else:
req = bookmark
ok, next_bookmark = fetch(req)
if not ok:
return
bookmark = next_bookmark
if bookmark is None or bookmark == 0:
break
TODO: We should be able to see when a loop body is guaranteed to execute at least once. However, Pyright and other checkers don't currently handle this case either.
x = "foo"
while x != "bar":
definitely_bound = 42
x = "bar"
# TODO: We should see that `definitely_bound` is definitely bound.
# error: [possibly-unresolved-reference]
reveal_type(definitely_bound) # revealed: Literal[42]
VAL = 1
x = 1
while True:
reveal_type(x) # revealed: Literal[1]
if VAL - 1:
x = 2
Divergent in narrowing conditions doesn't run afoul of "monotonic widening" in cycle recoveryThe following is a deceptively-simple-looking case of narrowing that was difficult to get right in the initial implementation of cyclic control flow. We start with a non-empty linked list, and we advance it in a loop until there's exactly one node left:
class Node:
def __init__(self, next: "Node | None" = None):
self.next: "Node | None" = next
node = Node(Node(Node()))
while node.next is not None:
node = node.next
reveal_type(node) # revealed: Node
reveal_type(node.next) # revealed: None
There's nothing wrong with this code, and it was minimized from real cases in the ecosystem. But
it's prone to false-positive [possibly-missing-attribute] warnings on the node.next accesses if
we lose track of the fact that the node variable is never None. Note that the loop condition
narrows node.next, not node itself, so that constraint needs to flow through the assignment in
the loop body, and through the loop header definition that sees that assignment, to the prior uses
of node in the loop condition and in the RHS of the assignment. We expect that to become a Salsa
cycle that we resolve through fixpoint iteration. That runs into two of our cycle recovery
behaviors:
while loop
condition), the cycle_initial value (expression_cycle_initial) is an empty map with a
"fallback type" that reports Divergent for every sub-expression. That even includes literal
expressions like 42 and (in this case) None.Type::cycle_normalized), we union together the type
inferred in the previous iteration with the type inferred in the current one, as long as
neither of them contains Divergent. In other words, we do "monotonic widening".The interaction we have to worry about is getting stuck with a type that's too wide. When we try to
do narrowing in the first cycle iteration, is not None behaves like is not Divergent. If the
consequence is that we don't do any narrowing at all, then for that iteration we'll end up inferring
Node | None for node. (For completeness, we actually infer Node | None | Divergent because of
a nested cycle, but we strip out that Divergent in another part of cycle recovery. The
full chain of events here is quite long.) In the second cycle iteration we'll
get the narrowing right and infer that node is of type Node, but then our monotonic widening
step will union Node with Node | None from the previous iteration, reproduce the same wrong
answer, and declare that to be the fixpoint. Finally we get false-positive warnings from the fact
that None doesn't have a .next field.
So, because we do monotonic widening in cycle recovery, we need to make sure that temporarily
Divergent expressions in narrowing constraints don't lead to too-wide-but-not-visibly-Divergent
types. Instead, Divergent should "poison" any value we try to narrow against it, so that our cycle
recovery logic doesn't carry that result forward.
Addendum: #23563 fixed the implementation of Type::cycle_normalized, so that such "tainted
previous values" are no longer unioned.
global and nonlocal keywords in a loopWe need to make sure that the loop header definition doesn't count as a "use" prior to the
global/nonlocal declaration, or else we'll emit a false-positive semantic syntax error:
x = 0
def _():
y = 0
def _():
while True:
global x
nonlocal y
x = 42
y = 99
On the other hand, we don't want to shadow true positives:
x = 0
def _():
y = 0
def _():
x = 1
y = 1
while True:
global x # error: [invalid-syntax] "name `x` is used prior to global declaration"
nonlocal y # error: [invalid-syntax] "name `y` is used prior to nonlocal declaration"
UNBOUND definitely visibleIn place_from_bindings_impl we usually assert that if at least one (non-UNBOUND) binding is
visible, then UNBOUND should not be definitely-visible. That makes intuitive sense: either a
binding should shadow UNBOUND entirely, or if it was made in a branch then it should attach the
negated branch condition to UNBOUND. However, loop header bindings are an exception to this rule,
because they don't shadow prior bindings. In this example UNBOUND is definitely-visible, and we
need to avoid panicking:
while True:
x # error: [possibly-unresolved-reference]
x = 1
breakRebinding an object followed by an unconditional break does not affect its members at the start of
the loop, because the new object never reaches another iteration.
class C:
x = None
c = C()
c.x = 0
while True:
reveal_type(c.x) # revealed: Literal[0]
c = C()
break
d = [0]
d[0] = 1
while True:
reveal_type(d[0]) # revealed: Literal[1]
d = []
break
The same applies to narrowing from a guard before the loop. The condition is always true on the only
iteration, but the replacement object's attribute is not narrowed after the break.
class Box:
value: bool
def f(box: Box):
if box.value:
return
while reveal_type(not box.value): # revealed: Literal[True]
box = Box()
break
reveal_type(box.value) # revealed: bool
The first iteration sees the initial object's int value; later iterations see the replacement's
str value. The type at the start of the body is therefore int | str. Rebinding restores the full
declared attribute type, including None, until the replacement is narrowed again.
class Box:
value: int | str | None
def example(box: Box):
assert isinstance(box.value, int)
reveal_type(box.value) # revealed: int
while True:
reveal_type(box.value) # revealed: int | str
box = Box()
reveal_type(box.value) # revealed: int | str | None
assert isinstance(box.value, str)
A guard before the loop only constrains the initial object. Rebinding box can make box.value
true on a later iteration, so the loop condition is not always true.
class Box:
value: bool
def condition(box: Box, replacement: Box):
if box.value:
return
reveal_type(box.value) # revealed: Literal[False]
while reveal_type(not box.value): # revealed: bool
box = replacement
When the loop exits normally, box.value is True.
def normal_exit(box: Box, replacement: Box):
if box.value:
return
reveal_type(box.value) # revealed: Literal[False]
while not box.value:
reveal_type(box.value) # revealed: Literal[False]
box = replacement
reveal_type(box.value) # revealed: bool
reveal_type(box.value) # revealed: Literal[True]
continueRebinding also invalidates attribute narrowing when the next iteration is reached through
continue. A replacement whose value is True can end the loop.
class Box:
value: bool
def f(box: Box, replacement: Box):
if box.value:
return
while not box.value:
box = replacement
continue
reveal_type(box.value) # revealed: Literal[True]
An inner loop can rebind box on one iteration, then exit through a break before reaching the
assignment again. The replacement is visible both after the inner loop and on later outer
iterations, so the initial guard no longer narrows box.value.
class Box:
value: bool
def stop() -> bool:
raise NotImplementedError
def f(box: Box, replacement: Box, repeat: bool):
if box.value:
return
while repeat:
reveal_type(box.value) # revealed: bool
while True:
if stop():
break
box = replacement
reveal_type(box.value) # revealed: bool
Replacing wrapper.box invalidates narrowing of wrapper.box.value on subsequent iterations, even
though the outer wrapper object is unchanged.
class Box:
value: bool
class Wrapper:
box: Box
def f(wrapper: Wrapper, replacement: Box):
if wrapper.box.value:
return
while not wrapper.box.value:
wrapper.box = replacement
reveal_type(wrapper.box.value) # revealed: Literal[True]
A guard on the initial tuple's element does not constrain the corresponding element of a replacement tuple. The replacement can therefore end the loop.
def f(flags: tuple[bool], replacement: tuple[bool]):
if flags[0]:
return
while not flags[0]:
flags = replacement
reveal_type(flags[0]) # revealed: Literal[True]