← board

Should exec inject a __builtins__ key?

The observation

d = {}
exec("x = 1 + 2", d, d)
print(sorted(d.keys()))
value
CPython ['__builtins__', 'x']
pxx ['x']

Reading d["x"] — what essentially all real code does — agrees. The difference is only visible to a program that ENUMERATES the namespace.

Why it is a decision and not just work

The governing rule is default to the reference implementation, and the reference injects the key. But it injects the builtins module object, and NilPy has no module objects — so honouring the letter of it means choosing a value that is not what CPython has there. That trade is the fork:

  1. Leave it out (today). d.keys() is short by one. A program that iterates the namespace and does something per entry sees a smaller, cleaner set — and silently differs from CPython.
  2. Inject __builtins__ -> None (or an empty dict). Key listings match. A program that uses the value — d["__builtins__"]["len"] — gets a wrong answer instead of a missing key, which is the worse failure shape.
  3. Inject __builtins__ -> a real dict of the builtin names. Matches CPython in both key and usable content, at the cost of building that dict on every exec and deciding what belongs in it.

My recommendation is 1 (leave it out) plus this ticket as the record, on the grounds that option 2 trades a visible absence for an invisible lie, and option 3 is real work with no measured demand. But the reference-compat default points the other way, which is exactly why this is not mine to settle.

What would change the answer

A corpus file that iterates an exec'd namespace. None is known — the census population is devdocs/dev/python-libraries.md §7. If one turns up, option 3.

Recorded meanwhile

devdocs/dev/nilpy-semantics-divergences.md carries the row, so the difference is documented rather than latent.


MEASURED 2026-08-16 — the premise is wrong, and there is a real bug hiding behind the cosmetic question

1. CPython injects a DICT, not a module object

The ticket's fork rests on: "it injects the builtins module object, and NilPy has no module objects — so honouring the letter of it means choosing a value that is not what CPython has there." Measured, that is not what happens.

d = {}; exec("x = 1 + 2", d, d)
type(d["__builtins__"]).__name__      # 'dict'      <- not 'module'
d["__builtins__"] is builtins.__dict__ # True       <- by IDENTITY
len(d["__builtins__"])                 # 157
d["__builtins__"]["len"]               # <built-in function len>   (subscript WORKS)

Same answer at module level, inside a function, in __main__, and in an imported module. The module form only appears as __main__'s own __builtins__ when run as a script — a different object in a different place, and not what exec puts in a fresh globals dict.

So there is no module-object obstacle, and the ticket's stated reason for preferring option 1 over option 3 does not exist. NilPy also already has first-class builtin values — f = len; f([1,2,3]) compiles and answers 3 — so a name→builtin dict is representable.

2. Two behaviours any implementation must match (neither is in the ticket)

3. The finding that matters: restricted exec is silently ignored

That non-overwrite rule exists to serve an idiom people actually write:

d = {"__builtins__": {}}
exec("n = len([1,2,3])", d, d)
result
CPython NameErrorlen is not resolvable
pxx n = 3 — resolved anyway

This is not a key-enumeration difference. It is working CPython code taking a different path, and in the direction where the author's explicit instruction (resolve names against this mapping) is silently discarded. Under NilPy's upward-compatibility contract that is a defect, not a dialect choice — and per the compat escape rule, a finding that means silent wrong behaviour is promoted to a bug- ticket in the owning lane rather than parked as parity work. Filed: [[bug-n-exec-ignores-a-caller-supplied-builtins-mapping]].

Stated honestly: CPython's restricted exec is not a security boundary (().__class__.__bases__ and friends escape it), so this is not a sandbox-hole claim. The point is narrower and still solid — the name-resolution behaviour is well-defined, observable, and relied on for evaluating config/template expressions in a controlled namespace.

What this does to the fork

The ticket asks one question; the measurement shows it is two, and they separate cleanly at very different costs:

The ticket treats these as one option and prices the cheap half at the expensive half's cost.

Revised recommendation:

Reclassify this ticket accordingly: it stays open only as the record of the cosmetic call, and decide- overstates what is left in it.


DECIDED 2026-08-19 (user) — leave it out, document it, park the ticket

Decision: do not inject __builtins__ at all. Document the difference as a known incompatibility and defer. Option 1 stands; option 2 stays refused; option 3 is deferred rather than rejected.

The user's framing, and it is the right one: we cannot do it right either way, so a documented incompatibility beats silently wrong behaviour.

What settled it: pxx has TWO name-resolution paths, CPython has one

The last open question was the user's — "why not just insert a pointer to the real dict instead of a copy, so it stays mutable?" Measured rather than argued, and the answer is not about the dict:

So a live pointer would be honoured by exec'd source and inert for every compiled call site: half-working, while looking exactly like CPython semantics. That is a worse failure shape than a copy, which at least fails honestly as a snapshot. Making it genuinely live means routing every compiled builtin call through a run-time dict lookup — a whole-language performance change for a feature with no measured consumer.

Where the record lives

devdocs/dev/nilpy-semantics-divergences.md §exec binds, but injects no __builtins__ key carries the decision and the two-paths reason. That is where a searcher lands; this ticket is the audit trail.

Reopen condition

A real corpus consumer that enumerates or mutates an exec'd namespace, AND a third shape that is not "inert copy" or "half-live pointer". Absent both, this stays parked.

Unaffected

[[bug-n-exec-ignores-a-caller-supplied-builtins-mapping]] — the behavioural half — is NOT covered by this decision and remains live Track N work. Honouring a builtins mapping the caller DID supply needs no builtins table and is where the real defect is.

Log