← board

A subscript store whose receiver is a call result does not parse

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

  1. Silent discard. f()[k] += v compiles clean, runs, and the value is never written; the read one line later returns the old value. No error, no diagnostic — the shape devdocs/dev/debugging-playbook.md opens by calling the expensive case.
  2. A diagnostic pointing at the wrong line. C()[k] += 1 reports assignment target is not an lvalue at an unrelated statement several lines below, so the line number leads away from the cause. Reproduced with the receiver in a try: block; the reported line was p["z"] = 3.
  3. A refusal on valid Python. f()[k] = v at 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:

  1. A print at the tail of PyParseStatement showed the augmented statement arriving there with CurTok = tkNewline and CurASTNode = AN_ASSIGN — the += had already been consumed by the expression parser.
  2. Instrumenting every AllocNode(AN_ASSIGN) in pyparser.inc — all 24 — showed none of them firing for that statement.
  3. 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

  1. compiler/pasparser_expr.inc — the shared tail DECLINES a target that is a pyvar_getitem call. 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 by NilPyUserCode, so Pascal and C are untouched.
  2. compiler/pyparser.inc, PyParseStatement tail — the new arm for a VARIANT receiver: <expr>[k] = v and <expr>[k] op= v rewrite to pyvar_setitem(recv, k, pyvar_getitem(recv, k) OP rhs). One rewrite covers a dict, a list AND a user class, because pyvar_setitem dispatches all three at run time — which is the direct answer to the ticket's "not a fourth per-container arm" requirement. Helpers PyIsVarGetitemCall, PyMakeVarSubscriptRead, PyRewriteVarSubscriptAssign, modelled on the PyIsBytesSliceCall / PyRewriteSliceAssign pair one construct over.
  3. 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 [...] for op= / **= 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 refusesget_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

Gate

make compiler/pascal26self-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