← board

with A() as a, B() as b: — only one context manager per with

with Ctx(3) as a, Ctx(4) as b:      # error: Expected: :, but got: (Kind: 80)
    print(a.n, b.n)

A compile error. Single-manager with is fully correct — a CPython-diffed sweep of __enter__/__exit__, the exception path and as binding all matched exactly, so this comma form is the only gap in the statement.

It is pure sugar: with A() as a, B() as b: is defined as nesting, so the lowering is a loop over the comma-separated managers producing nested blocks. The __exit__ ORDER matters and is what a test must pin — inner exits first, and an exception in the body must still exit both, innermost first.

Common in file-handling code (with open(a) as f, open(b) as g:), which is why it is filed rather than left as a curiosity.

Gate

make test-nilpy + self-host byte-identical, CPython-diffed over two and three managers, as-less managers mixed with bound ones, exit ORDER, and an exception raised in the body (both managers must exit, innermost first).

2026-08-09 — IMPLEMENTED

PyParseWith was split into PyParseWithTail (one manager and everything to its right), which recurses on the comma. Nesting is not an approximation of the comma form — Python DEFINES it that way — so __exit__ order and the exception path come out right by construction rather than by hand.

The hour this cost, and it is a recorded landmine: bodyNode := PyParseWithTail; — a BARE own-function name READS Result in this dialect instead of recursing. It compiled, recursed not at all, handed back an uninitialised node, and left the remaining managers in the token stream — so the error surfaced as undefined variable (as) on the NEXT manager, several tokens away from the cause. Probes showed the comma branch firing and the recursion's entry probe never printing, which is what named it. PyParseWithTail() with parens is the fix. See project_bare_funcname_result_partial; the note says it had already bitten twice, and this is the third.

Found while verifying, filed separately: return from inside a with does NOT run __exit__ — pre-existing, not specific to multiple managers, and silent (the returned value is right; only the release is lost). The source comment at that lowering claims try/finally covers "the exception path and break/return too"; the exception path is genuinely correct, return is not, and break / continue were never measured. Filed as bug-nilpy-return-inside-a-with-skips-exit. The test deliberately does not pin today's answer for it.

Verified against CPython: one, two and three managers; as-less mixed with bound; the exception path (both exit, innermost first, exception still propagates); hand-written nesting compared against the comma form; an __enter__ returning something other than self; and a loop inside a multi-manager body. gate.sh quick GREEN.