← board

A dunder subscript through a dynamically-typed receiver is lost

Three receivers, three different failures

All against one class declaring both dunders (or, for the third row, only __setitem__):

class Store:
    def __getitem__(self, k):  return self.d[k]
    def __setitem__(self, k, v): self.d[k] = v
receiver shape pxx CPython
named (control) s["k"] = 1 / s["k"] += 2 correct correct
attribute (control) h.s["n"] += 10 correct correct
list element stores[0]["k"] = 1 TypeError: object does not support item assignment stores it
call result get_store()["n"] += 50 compiles, stores NOTHING 51
call result, plain get_store()["n"] = 50 error: expected expression 50
construction OnlySet()["x"] += 1 poisons a LATER statement: assignment target is not an lvalue at an unrelated line TypeError

The middle one is the expensive row: it compiles clean, runs, and the value is simply not written. No error, no diagnostic, and the read one line later returns the old value — the shape devdocs/dev/debugging-playbook.md opens by calling the expensive case.

The last one is the strangest: the error is reported at a later, unrelated statement (p["z"] = 3 several lines down), so the line number points away from the cause entirely.

One mechanism, not three

The dunder subscript arm (pyparser.inc ~38087, and its verbatim twin in pasparser_lval.inc ~1290 — see [[bug-n-the-dunder-subscript-arm-is-duplicated-verbatim-in-two-lvalue-parsers]]) is gated on mci, the receiver's static class index. A named local or an attribute has one. A list element, a call result and a fresh construction do not, so the arm is never entered and each shape falls to whatever generic path happens to be next — which is why one failure mode is a raise, one is a silent discard and one is a parse corruption.

That is the same distinction the runtime-vs-compile-time split solves elsewhere in this frontend: the operator path dispatches at COMPILE time on the static class, and a dynamically-typed receiver has none for it to key on. pylib already carries the runtime twin for equality and ordering (PyUserObjEq / PyUserObjGt, reached through PyVarEq / pyvar_gt); the subscript protocol has no such twin, so there is nothing to fall back to.

Shape of the fix

Route a dunder subscript on a variant-typed receiver to a runtime dispatcher in pylib — pyvar_getitem_v / pyvar_setitem_v asking the boxed object for __getitem__/__setitem__ through PyUserArithCall1-style lookup, exactly as PyUserObjEq does for ==. That single addition covers all three receiver shapes at once, because all three arrive as variants.

Do NOT fix it by widening the compile-time arm — a call result's class is genuinely unknown at compile time, and guessing one is how bug-nilpy-local-reassigned-across-classes-keeps-one-static-class happened.

Gate

make compiler/pascal26 fixedpoint + tools/gate.sh quick, plus every row of the table above matching CPython, and a witness row in test-core whose .expected is generated by CPython. compiler/builtin/** will change, so it needs a pin.


Resolved 2026-08-27 — one of the three rows was mine; the other two were a different bug

What the ticket got right

The list-element row is exactly what it said: pyvar_setitem knew only TPyDict and TPyList, so a user class arriving as a bare variant handle raised object does not support item assignment for a class that plainly declares __setitem__. And the ticket's "shape of the fix" was right — a runtime dispatcher modelled on PyUserObjEq, not a widened compile-time arm.

It is also the exact write-side twin of an arm pyvar_getitem has carried since bug-nilpy-a-chained-subscript-does-not-see-getitem: the READ through a variant receiver was fixed then and the WRITE was not, which is the one-arm-of-a-double- case shape devdocs/dev/normalise-dont-special-case.md is about.

Fixed: PyUserSetitemCall in pylib — the arity-3 twin of PyUserArithCall1. Both extra parameters arrive tk=22 (measured: PXXDBG=n.ret says __setitem__ registers tk=1 with unannotated Variant parameters), the result is discarded because Python's __setitem__ answers nothing, and an unrecognised RetKind declines rather than guessing at an ABI. pyvar_setitem also gained the tag guard pyvar_getitem has always had: without it a STRING receiver had its character data dereferenced as an object and the is TPyDict test read a VMT pointer out of string bytes. s[0] = "x" is a TypeError in Python, and it now says so.

What the ticket got wrong, and how the mistake was caught

It listed the call-result and construction rows as the same bug. They are not, and the measurement that separated them took one program:

g = {"n": 1}
def gd(): return g
gd()["n"] = 5      # error: expected expression   — a plain DICT

A dict, a list and a user class all fail identically through a call-result receiver, so it is the receiver grammar, not the store, and no amount of pylib work reaches it. Re-filed as [[bug-n-a-subscript-store-whose-receiver-is-a-call-result-does-not-parse]] (p66) with the full table — including the two failure modes that make it rank above this one: f()[k] += v compiles and silently discards the store, and C()[k] += 1 reports its error at a later, unrelated statement.

That is also the correction to this ticket's own "one mechanism, not three" section: it is two mechanisms, and reading it as one is what nearly sent the whole fix into pylib where two thirds of it could not work.

Gate

make compiler/pascal26 (fixedpoint 31c875946ff9 — the compiler binary is unchanged, pylib is data it compiles into user programs), tools/gate.sh quick GREEN, the container/string/no-__setitem__ regression probe agreeing with CPython, and a witness row test_nilpy_setitem_through_a_variant_receiver in test-core: a list element, a dict value, a nested list, an unannotated parameter, the dict/list controls the new arm must not touch, the string tag guard, and a class without __setitem__ keeping its refusal. At pinned v380 it does not compile.

compiler/builtin/pylib.pas changed, so this needs a PIN.

Log