← board

A nested def cannot capture a name the enclosing function assigns LATER

Python binds a closure at CALL time, so this is ordinary:

def outer():
    def inner():
        if flag:            # captured, bound when inner() runs
            return "yes"
        return "no"
    flag = True
    return inner()

print(outer())              # CPython: yes

pxx: error: undefined variable (flag). Moving flag = True ABOVE the nested def compiles and runs correctly, which is the whole difference.

Found in songformatter's convertrawtext.py:1168 ([[feature-demo-songformatter-pxx-target]]): printHeaders() reads firstpage, which format_song_text_as_pdf assigns further down.

Why the obvious fixes do not work

The capture list is built from a token scan when the def is met, and a name is captured only when FindSym resolves it to a local or parameter of the enclosing scope (pyparser.inc, the nCaps loop). Two attempts, both reverted because neither changed the error:

That both had no effect says the failure is not in the capture list at all: a nested def's body is parsed in a DEFERRED pass (PyPendNest*, drained in ParsePyUnit / the def-body queue), and by then the enclosing function's symbols have been rolled back, so the body's own name resolution is what fails. The fix therefore belongs where the deferred body is parsed — the captured names must be in scope as the trailing parameters they were registered as, and a name the enclosing scope will assign has to be added to the capture list BEFORE the body is queued.

Third attempt: compiles, and produces GARBAGE

Skipping the DEDENT(s) that close the nested def before scanning forward for a later name = (the earlier scan started ON them, so its depth went negative immediately and it found nothing) does make the program compile — and it then prints a wild integer instead of yes. The capture is passed as a trailing argument at the CALL site, so materialising the local is not enough on its own: the pre-allocated variant is not the symbol the later assignment writes, or it is never initialised.

That is worse than the compile error and was reverted. Whatever the fix is, it has to make the enclosing assignment and the capture agree on ONE symbol — which is the same conclusion the deferred-body analysis above reaches, from the other end.

Gate

make test-nilpy plus a .npy with a capture assigned after the nested def, and one assigned before (which must keep working), diffed against CPython.

Fixed 2026-07-28

Both halves, and the ORDER mattered: every earlier attempt at the capture "compiled and produced garbage", and that garbage was [[bug-nilpy-nested-def-nonint-result-garbage]] underneath — the enclosing function's result type, not the capture. With that fixed, materialising the later-assigned local at capture time is enough, and capture_after/capture_before both match CPython.

The lesson worth keeping: when a fix "works but returns a wrong value", suspect a second bug below it rather than the fix.

Log