← board

A nested def shadowed by an outer name binds to None

def make3(base):
    def g(x, off=base):
        return x + off
    return g

g = make3(10)          # the OUTER name is also `g`
print(g(1))            # CPython 11
                       # pxx: TypeError: object is not callable — the name is None

Renaming either side fixed it. Found 2026-08-15 while testing [[bug-nilpy-a-nested-defs-default-parameter-ignores-the-callers-value]], where the first draft of the test hit it by accident.

Loud, and the message blamed the wrong party: "the name is None (an import that did not resolve, or a value never assigned)" when the value WAS assigned, from a call whose return went missing.

Cause — measured, and it is not where the ticket guessed

The filed ticket pointed at PyQualifyNested picking the wrong row between the qualified make3.g and the module g. It is simpler and one level up.

Varying the shape found the boundary: g = 5 after the same factory was fine, h = make3(10) was fine, and t = make3(10); g = t failed — and it failed on t's line, BEFORE the colliding assignment. So the defect is not in the assignment at all. It is in return g inside make3: the module-level g had already been allocated (the assignment being parsed), FindSym('g') found it, and all three gates around the nested-def lookup read

(FindSym(name) < 0) and (FindProc(PyQualifyNested(name)) >= 0)

so return g handed back the module global — None, since its own assignment had not run yet.

In Python a def BINDS A LOCAL of the enclosing scope; it shadows a global unconditionally. "Does a symbol of this name exist anywhere" was never the right question. PyNestedDefOutranksSym asks the right one — the found symbol is an skGlobal and a qualified nested proc of that name exists — and the three gates (the func-value arm and the qualify-the-call arm in parser.inc, plus PyMakeFuncValue's early exit) now go through it. All three, because this is a one-concept-three-sites shape and fixing one arm leaves the value path or the call path still reading the global.

A genuine LOCAL of the same name still wins: that is a REBINDING in the same scope (def g(): ... then g = 5), which the redefinition machinery settles.

Not handled, and noted in the code: an enclosing body that declares global g, where Python does give the module binding back. No corpus needs it; PyBodyDeclaresNonlocal is the shape a fix would follow.

Gate

test/test_nilpy_nested_def_outer_name_collision.npy (+.expected, in the Makefile), byte-identical to CPython: the straight collision, the name rebound from a second factory call, a defaulted parameter on the shadowed def, the inner def called INSIDE its own scope while the outer name exists, a two-level nest where the middle name collides too, the collision through an intermediate variable, a module-level def of the same name (a different question, still right), and controls. gate.sh quick GREEN.

Log