← board

A method call in a while condition is evaluated once, before the loop

Measured (before, self-hosted at 7c503da50)

def strip_as(s):
    n = 0
    while s.startswith("a"):
        s = s[1:]
        n += 1
        if n > 9: break
    return n
print(strip_as("aaab"))     # CPython 3     pxx 10 (only the break stopped it)

def scan(s, i):
    j = i
    while j < len(s) and s[j].isdigit():
        j += 1
    return j
print(scan("12 34", 0))     # CPython 2     pxx 5 (ran past the space)

What bounded it: an ordinary comparison as the right operand (while j < len(s) and s[j] != " ") was correct; the same and expression in an ASSIGNMENT (b = j < len(s) and s[j].isdigit()) was correct; a free function as the right operand (isd(s[j])) was correct. Only a hoisting sub-expression — a string METHOD call — inside a while condition was wrong, and and was a red herring: a sole while s.startswith("a") fails on its own.

Cause

PyParseWhile stashed the condition's hoisted setup and left it for the ENCLOSING statement's flush, i.e. emitted it BEFORE the loop. Its own comment records the reasoning and the assumption that made it wrong:

"NOTE a literal in a while CONDITION is built once, before the loop — CPython rebuilds it per test; acceptable divergence, the pattern is while x in ("a","b") membership against constants."

True for a constant literal, which is loop-invariant. A string method call hoists through the same mechanism and is not invariant, so the condition was computed once and every later test read the stale result.

Fix — and the wrong fix it went through first, which is the point

Folding the setup into the whole CONDITION (a comma chain in front of it) makes the value fresh, and breaks short-circuit: the right operand of and then runs unconditionally, so while j < len(s) and s[j].isdigit() evaluated s[j] at j == len(s) and raised IndexError. Measured, not reasoned — the corpus caught it immediately.

The correct place is the operand, not the condition. PyFoldHoistSince folds the setup a sub-expression added into THAT sub-expression, and PyParseBoolAnd / PyParseBoolExpr call it for each right operand. Both properties then hold: the value is recomputed per test, and only on the path that actually reaches it. PyParseWhile keeps the same fold for the condition's own top-level setup, which covers a sole method call with no and/or around it.

and/or were verified to short-circuit correctly first — that is what makes attaching setup to an operand safe.

Verified

test/test_nilpy_while_condition_hoist.npy (new, wired into make test-nilpy): sole method-call condition, method as the right operand of and and of or, method FIRST with a comparison second, the short-circuit guard at j == len(s) (must not evaluate s[j]), a condition-mutating loop body, both container-literal conditions, explicit short-circuit side-effect counting, the plain/else/break/nested while shapes, and the tokenizer that found it. All lines match CPython. tools/gate.sh quick GREEN; the probe corpus shows no regressions.

Follow-up: a constant container in the condition is rebuilt per test

Noted by the user while this was being fixed: for a genuinely CONSTANT list or dict, hoisting it to a variable once is what a person would write by hand, and it avoids the per-iteration build. That is now the only case paying for the fix (while x in ("a","b") rebuilds the tuple each test). It is correct — CPython rebuilds it too — but needlessly so.

Not done here, because the predicate ("is this hoisted chain provably constant?") has to be conservative in the safe direction or it silently reinstates this exact bug. Filed with the design as [[feature-nilpy-hoist-constant-container-literals-out-of-a-loop-condition]].

Log