Skip to content

[mypyc] Don't assign the range loop variable if the range is empty - #22132

Open
rheard wants to merge 2 commits into
python:masterfrom
rheard:fix-mypyc-1230
Open

rheard wants to merge 2 commits into
python:masterfrom
rheard:fix-mypyc-1230

Conversation

@rheard

@rheard rheard commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Fixes mypyc/mypyc#1230.

ForRange.init() assigned the start value to the loop variable before the first condition check. A for loop over an empty range() therefore still set the variable, and for obj.attr in range(3) called the property setter four times. CPython leaves the variable untouched when the range is empty, so a value assigned before the loop should survive, or the variable should stay unbound. The assignment was left over from when the loop variable was also the counter. Since #21098, begin_body() assigns the variable at the start of each iteration, so the initial assignment isn't needed. Without it, reading a variable that may be unbound after the loop raises UnboundLocalError, as it does for other loops.

ForRange and ForInfiniteCounter (the index of enumerate()) also evaluated the loop target only once, before the loop. They now call get_assignment_target() in begin_body(), like the other loop generators. A target such as a[f()] is now evaluated on every iteration, and not at all for an empty loop.

The IR test changes drop the assignment before the loop, and with it a short int to i64 conversion in testVecI64ConstructFromRange. The loop variable's register is now first assigned in the loop body, so it's declared later.

New run tests cover:

  • An empty range with the variable assigned before the loop, and with it unbound (int and i64).
  • A negative step.
  • A range() inside zip() and enumerate().
  • A generator.
  • Module level.
  • for self.x in range(n) in __init__, which leaves x undefined when n is 0.
  • Attribute and index targets for range() and enumerate() loops.

@p-sawicki

Copy link
Copy Markdown
Collaborator

now that we evaluate the loop target correctly, there's an issue when the loop target affects the iterable that we iterate over:

def index(items: list[int], calls: list[int]) -> int:
    calls.append(1)
    if len(calls) == 2:
        items.clear()
    return 0


def test_enumerate() -> None:
    items = [10, 20]
    target = [-1]
    calls: list[int] = []
    result: list[int] = []
    for target[index(items, calls)], value in enumerate(items):
        result.append(value)

    assert items == []
    assert result == [10, 20]


def test_zip() -> None:
    items = [10, 20]
    target = [-1]
    calls: list[int] = []
    result: list[int] = []
    for target[index(items, calls)], value in zip(range(2), items):
        result.append(value)

    assert items == []
    assert result == [10, 20]

we fetch the value from items on every loop iteration so once it's cleared we get a segfault. i think to match cpython we'd have to fetch all the values ahead of time.

@rheard

rheard commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

@p-sawicki Sure, though I will say that seems like a related but different bug that appears on both master and this PR.

I've gone ahead and created a commit for that though. I can drop it from here and add it to another PR if you'd like.

When nested in zip() or enumerate(), ForSequence reads the item in gen_condition(), right after the length check, in a new block. This matches CPython's list iterator, which reads the item in __next__ right after its bounds check. begin_body() then uses that value. Every other generator already has its item by the end of gen_condition() (ForIterable, ForNativeGenerator and the dict loops fetch it there, and ForRange and
ForInfiniteCounter keep it in a register), so all items are fetched before any target is evaluated.

Unrelated crashes found while testing

Pre-existing on master, I'll create issues and/or PRs for these when I have a bit more free time:

  1. zip() with a native generator that doesn't end the loop. for a, b in zip(range(2), gen()), where gen is a compiled generator function that yields 3 values, segfaults.
  2. __next__ returning an unboxed type. In a native class with def __next__(self) -> int, next(obj), for x in obj and list(obj) segfault. Calling obj.__next__() directly works, and so does -> object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

for loop over an empty range() assigns the start value to the loop variable

2 participants