← board

A genexpr should be lazy, not materialised-then-walked

Split out of [[bug-nilpy-a-generator-expression-is-not-consumed-once]] when the observable half landed (2026-08-15). Consumption is now correct:

g = (x for x in [1, 2])
print(next(g), list(g))     # 1 [2] — right

What is still eager is the BUILD:

def naturals():
    n = 0
    while True:
        yield n
        n += 1

g = (x * 2 for x in naturals())   # hangs: every element is built first

...and side-effect ordering: (print(x) for x in xs) prints everything at construction, where CPython prints nothing until the first next.

The shape a fix probably takes

map/filter/zip/enumerate are already lazy TPyIter cursors, so a genexpr wants to BE one rather than to gain a second lazy mechanism: build a pyiter_map over the source with the element expression as the mapping, and a pyiter_filter for an if clause.

The element expression is a token SPAN, and turning a token span plus its free names into a callable with its captures bound is exactly what the lambda lifter does (PyLambdaBodyIsLiftable, PyCompileLambdaBody, pyboundfn_*). So the plausible route is to reuse it rather than write a third mechanism — and its existing fallback (the shape it declines to lift keeps the eager path) is a ready-made safety net for whatever the lifter cannot take.

A multi-for genexpr ((x for a in aa for x in a)) needs a nested cursor; check whether that is worth the first slice or should keep the eager path.

Gate

.npy diffed against CPython: an infinite source with next and itertools.islice-shaped consumption; side-effect ORDER (print inside the element expression); a filter clause; a genexpr over a genexpr; one capturing an enclosing local that CHANGES between construction and consumption (CPython binds the outermost iterable eagerly and everything else late — that asymmetry is the subtle part); plus every shape test_nilpy_genexpr_is_consumed_once.npy already covers, unchanged.