A subscript store whose receiver is a call result does not parse
- Type: bug — Track N. Found: 2026-08-27 by agent-A while closing
[[bug-n-a-dunder-subscript-through-a-dynamically-typed-receiver-is-lost]],
which originally claimed these rows. It turned out they are not
dunder-specific, which is why they are re-filed here rather than fixed
there — measuring a plain
dictthrough the same receiver is what separated them. - Pre-existing: identical on HEAD and at pinned v380.
The boundary — the container does not matter, the RECEIVER does
g = {"n": 1}
def gd(): return g
gd()["n"] = 5 # error: expected expression CPython: fine
gd()["n"] += 2 # compiles, stores NOTHING CPython: 7
| receiver | c[k] = v |
c[k] += v |
|---|---|---|
| a NAME (control) | correct | correct |
an attribute, h.s[k] (control) |
correct | correct |
a list element, xs[0][k] (control) |
correct | correct |
a call result, f()[k] |
expected expression |
compiles, silently discards |
a construction, C()[k] |
correct | corrupts a LATER statement's parse |
Same table for a dict, a list and a user class with __setitem__ — so this
is the receiver GRAMMAR, not the store.
The three failure modes, worst first
- Silent discard.
f()[k] += vcompiles clean, runs, and the value is never written; the read one line later returns the old value. No error, no diagnostic — the shapedevdocs/dev/debugging-playbook.mdopens by calling the expensive case. - A diagnostic pointing at the wrong line.
C()[k] += 1reportsassignment target is not an lvalueat an unrelated statement several lines below, so the line number leads away from the cause. Reproduced with the receiver in atry:block; the reported line wasp["z"] = 3. - A refusal on valid Python.
f()[k] = vat least fails loudly.
Why it matters
self.cache()[k] = v, get_config()["debug"] = True and
defaultdict_factory()[k] += 1 are all ordinary Python. The workaround is a
temp (c = f(); c[k] = v) and it is real, but nothing points at it — least of
all rows 1 and 2.
Not verified
The cause is not measured. Do not write one into this ticket without
measuring: the statement-level assignment path decides the LHS shape before the
subscript arms run, and both PyParseLValueAST and its verbatim twin in
pasparser_lval.inc are candidates
([[bug-n-the-dunder-subscript-arm-is-duplicated-verbatim-in-two-lvalue-parsers]]).
Two recent N tickets named the wrong mechanism.
Gate
make compiler/pascal26 fixedpoint + tools/gate.sh quick, plus every row of
the table matching CPython for a dict, a list AND a user class — the three
together are what proves the fix is in the receiver grammar and not a fourth
per-container arm. .expected generated by CPython.
RESOLVED — 2026-08-27, agent-A
Fixed. Every row of the table matches CPython for a dict, a list and a user
class, plus the shapes the table did not have: a METHOD-call receiver
(h.data()[k] += v), a chain of calls (outer()()[k] *= 2), a nested subscript
whose OUTER receiver is a call (get_grid()["row"][1] += 1), a slice through a
call result, and statically annotated returns (def f() -> dict).
Measured, not reasoned — and the ticket's own guess was wrong
The "Not verified" section named PyParseLValueAST and its pasparser_lval.inc
twin as the candidates. Neither is involved: PyParseLValueAST takes a symbol
index, so a receiver that is not a name never reaches it. The trace that
settled it took four minutes and three rebuilds:
- A print at the tail of
PyParseStatementshowed the augmented statement arriving there withCurTok = tkNewlineandCurASTNode = AN_ASSIGN— the+=had already been consumed by the expression parser. - Instrumenting every
AllocNode(AN_ASSIGN)inpyparser.inc— all 24 — showed none of them firing for that statement. - Widening the instrumentation found it outside NilPy's own file entirely:
the shared compound-assignment tail in
pasparser_expr.inc, the one whose header says it is "inert for non-C code".
That third step is the finding. That tail already carries three NilPy fixes in its comments, each added because "a DOTTED target reaches this shared expression tail first, so pyparser's site never sees it" — and this is a fourth instance of the same sentence, with a receiver instead of a dot.
Root cause — one concept, THREE parsers, and only the '=' half taught to two
A subscript store needs the receiver, the key and the value in one place. Three different arms build it, one per receiver shape, and all three had the same hole:
| arm | receiver | had = |
had op= |
|---|---|---|---|
PyParseLValueAST (pyparser.inc) |
a NAME | yes | yes |
PyParseClassRecordSelectors — dunder arm |
a chained CLASS-typed value | yes | no |
PyParseClassRecordSelectors — default-property arm |
a chained pylib container | yes | no |
| — none — | a chained VARIANT value | no | no |
The augmented operator that no arm claimed fell out to the shared
pasparser_expr.inc tail, which handles exactly four tokens
(+= -= *= /=, the last being NilPy's //=). It took CurASTNode as its
lvalue — and CurASTNode was the __getitem__ / getter / pyvar_getitem
CALL. There is no address behind a call result, so the AN_ASSIGN it built
stored into the returned value and the update evaporated. Silently: the lvalue
check lives at IR lowering and does not fire on this shape.
The other eight augmented operators (%= &= |= ^= <<= >>= /= **=) are not in
that tail's token set, so they reached the statement level with nothing to
handle them — the expected expression of row 3, and the reason **= and +=
behaved differently on the same line.
The fix — three changes, one shape
compiler/pasparser_expr.inc— the shared tail DECLINES a target that is apyvar_getitemcall. It is not an lvalue and never was; leaving the token in place hands the statement to the arm that can express the store. Guarded byNilPyUserCode, so Pascal and C are untouched.compiler/pyparser.inc,PyParseStatementtail — the new arm for a VARIANT receiver:<expr>[k] = vand<expr>[k] op= vrewrite topyvar_setitem(recv, k, pyvar_getitem(recv, k) OP rhs). One rewrite covers a dict, a list AND a user class, becausepyvar_setitemdispatches all three at run time — which is the direct answer to the ticket's "not a fourth per-container arm" requirement. HelpersPyIsVarGetitemCall,PyMakeVarSubscriptRead,PyRewriteVarSubscriptAssign, modelled on thePyIsBytesSliceCall/PyRewriteSliceAssignpair one construct over.compiler/pyparser.inc,PyParseClassRecordSelectors— both chained arms gained the augmented desugar the named path has had since [[bug-n-an-augmented-subscript-on-a-dunder-class-is-refused]]: peek past the balanced[...]forop=/**=as well as=, bind base and key to hidden temps, read, combine, write.
Receiver and key are evaluated once on every path — the witness has a
counted() receiver and a one_key() index precisely because an AST node
referenced twice is EMITTED twice, and here the receiver is a CALL by
construction, so that would be observable rather than merely possible. The
default-property arm reads through a clone of the index chain, because the
setter appends its value to the original and sharing it makes the value an
argument of the read (a cycle the compiler walks forever).
Half-a-protocol classes keep raising CPython's own TypeError at RUN time, with
the class name, on the chained path as on the named one — verified side by side.
Note on grep-for-the-sibling
The first cut fixed only the variant receiver and looked complete: the whole
receiver matrix went green. The class-typed receivers were found by adding a
row that CPython refuses — get_ro()["n"] += 1 on a __getitem__-only class —
which printed NOT REACHED where CPython raises. That row was in the witness by
accident of wanting a negative control, and it is the only reason the other two
arms were found in this session. The refusal row earns its place.
Filed while here, not fixed
- A VARIANT receiver with no static class raises the right
TypeErrorbut cannot name the class in the message (object does not support item assignmentvs CPython's'RO' object …). Message-only, and the class name is not available at that point without RTTI — error-reporting parity, low prio by CLAUDE.md's ruling. - [[bug-n-the-dunder-subscript-arm-is-duplicated-verbatim-in-two-lvalue-parsers]] still stands; this fix did not touch either copy.
Gate
make compiler/pascal26 → self-host fixedpoint: verified — 1 round(s).
tools/gate.sh quick → GREEN. Witness
test/test_nilpy_subscript_store_on_a_call_result.npy registered in
test-core, .expected generated by CPython, RED at pinned v381 (it does not
compile there) and green now.
Log
- 2026-08-27 — resolved, commit 49d47338e.