← board

The old-style iteration protocol reaches only the for loop

Filed 2026-08-19 from [[feature-nilpy-for-loop-getitem-protocol-fallback]], which fixed the for lowering. The repo's own rule says to grep for the sibling arms before closing a double case, so this is those siblings.

All rows measured at HEAD and on pinned — every one is pre-existing, none is a regression from the for-loop fix.

class Box:
    def __init__(self, items): self.items = items
    def __getitem__(self, idx): return self.items[idx]
    def __len__(self): return len(self.items)
b = Box([1, 2, 3])
shape CPython pxx
for x in b 1 2 3 fixed — 1 2 3
[x*2 for x in b] [2, 4, 6] fixed — shares the lowering
list(b) [1, 2, 3] [] — SILENT WRONG VALUE
sum(b) 6 TypeError: expected a str, a list or a dict, got object
2 in b True TypeError: argument is not a container (no __contains__)
p, q, r = b 1 2 3 compile error

The one that matters most

list(b) answers []. Not an error — an empty list, which flows onward and produces a wrong result far from its cause. That is this repo's expensive failure shape, and it is the reason this is filed as a bug rather than a feature request. The other rows fail loudly and are merely missing.

Shape of the fix

One concept — "this object is iterable by index" — is served by several independent paths: the for lowering (now fixed), list()/sum()'s container conversion, the in operator's __contains__ check, and tuple unpacking. Each tests for a container in its own way, which is why fixing one moved none of the others.

The right fix is almost certainly NOT four more special cases but one shared "materialise this receiver as a sequence" helper the four consumers call — normalise-dont-special-case. pyiter_of_userobj already exists for the __iter__ protocol and is the natural place for the __getitem__ fallback to live, so that every consumer that already goes through a cursor gets it at once.

Also still missing: __getitem__ WITHOUT __len__

CPython walks 0.. and stops on IndexError, so a class with only __getitem__ is iterable. pxx requires both and now says so explicitly rather than dying with "pylib (count) not loaded". Loud and honest, but still a gap.

Root cause (2026-08-27) — the divergence is in pylib, not in the frontend

Every consumer in the table selects the variant overload of its builtin (list(const v: Variant), sum(const v: Variant) …), measured by printing the resolved proc at the call site — so PyNodeIsUserIterable and the frontend's argument-normalising machinery never decide any of these. The runtime chain does, and it had PyUserObjHasDunder(o, '__iter__') at each of its gates. A __getitem__ class failed that test, fell to each gate's default, and list()'s default is an EMPTY LIST — which is why the row that mattered was silent.

The frontend was not innocent, but only in one place: the for lowering refused __getitem__ WITHOUT __len__ at compile time, because its index loop snapshots a length before the walk and cannot express "stop on IndexError".

Fix — one predicate per side, plus the cursor that was missing

Runtime (compiler/builtin/pylib.pas).

Frontend (compiler/pyparser.inc).

Also fixed, found by the witness test

reversed() over ANY user object answered [] — the __iter__ protocol included, so this was never about __getitem__. reversed(const v: Variant) had its own chain that fell to an empty cursor. It now materialises through pyseq_of_obj like every other consumer. Note reversed() over an __iter__-only class is a TypeError in CPython and answers [3, 2, 1] here; that is the laxness the routine's own comment already declares for a cursor operand, and CLAUDE.md's "we accept a form CPython rejects" row.

Verified against the CPython oracle

test/test_nilpy_getitem_iteration_protocol.npy (+ .expected generated by CPython), registered in test-core: for, comprehension, list, tuple, sum, max/min, in, sorted, reversed, zip, enumerate, map, any/all, tuple unpack and join — every one of them over a __getitem__ class, plus the __getitem__-without-__len__ shape and the modern __iter__ protocol as the non-regression arm. GREEN at HEAD; at pinned the program did not even compile.

make compiler/pascal26 fixedpoint + tools/gate.sh quick GREEN, then make stabilize-fast && make pin (v378) — the pin is required because this changes compiler/builtin/**, which other lanes build from the frozen copy.

Log