← board

exec() only publishes a def named __body__, and cannot call host globals

Four one-liners, measured at 0604b414089f

Each is a complete program. cpython is 3.14.4.

# program pxx CPython
1 ns={}; exec("def body():\n return 1\n",{},ns); print(ns["body"]()) KeyError: 'body' 1
2 same, but the def is named __body__ 1 1
3 __body__ body calls hostfn() passed in the globals dict pyeval: unknown call: hostfn() 42
4 exec("def __body__(o):\n return o.v\n",{},ns); ns["__body__"](C()) pyeval: no RTTI for attribute v 7

Row 1 vs row 2 is the whole ticket in two lines: the name is load-bearing.

Why

compiler/builtin/pyeval.pas:5731-5754, and the code says so itself:

{ The uforth exec() idiom is `exec("def __body__(): ...", env, ns)` followed by
  `ns["__body__"]()`. [...] Publish it into the caller's namespace as a callable
  variant so the separate `ns["__body__"]()` reaches it }
if (l <> nil) and (FnFind('__body__') >= 0) then
  l.store(MakeStr('__body__'), pyvar_of_callable(Pointer(@PyBodyTramp)));
{ ...and every other top-level binding, which is the general case the
  `__body__` line above was the one hand-wired instance of. }
if l <> nil then
  for si := 0 to LclN - 1 do
    l.store(MakeStr(LclNames[si]), LclVals[si]);

The comment calls the second loop "the general case", but it publishes top-level bindings (LclNames/LclVals), and a def is not one — ExecDef records a body span in the function table, not a local. So exactly one def reaches the caller's namespace, and only because its name is spelled out in the source.

The hazard the surrounding comment documents at length (pyvar_of_callable vs PyBoxObj, and the segfault that came of getting it wrong) is real and its fix is correct. It is the keying that is the bug, not the boxing.

Suggested work

Publish every def registered by this exec as a callable, keyed by its own name, instead of the one hard-coded name — i.e. iterate the function table the same way the second loop iterates bindings, and drop the special case. Row 2 then stops being privileged and row 1 starts working.

Rows 3 and 4 are separate defects in the same call and may want their own tickets once row 1 is fixed; they are recorded here because they were found in one sitting and a fix for row 1 should not be claimed as a fix for the family.

Why this is an N bug and not a compat item

CPython accepts and runs all four programs; pxx does not. That is the upward-compatibility direction, which CLAUDE.md makes the definition of an N bug: if code works on CPython, it must work on NilPy. No divergences-doc entry applies — nothing here is NilPy being laxer than CPython.

Gate

test-nilpy green + self-host byte-identical. The four rows above belong in the suite as .npy tests; row 1 in particular is two lines and would have caught this the day the hand-wiring landed.

Note for whoever takes it

uforth.py is the one real-world consumer and it happens to spell the name the hard-wired way, so the existing uforth coverage cannot fail on this — it is the program the special case was written from. Do not read a green uforth run as evidence the general path works.