A dunder subscript through a dynamically-typed receiver is lost
- Type: bug — Track N. Found: 2026-08-27 by agent-A, while closing
[[bug-n-an-augmented-subscript-on-a-dunder-class-is-refused]] — the
grep-for-the-sibling pass that ticket's fix required
(
devdocs/dev/normalise-dont-special-case.md). - Not caused by that fix. Every row below behaves identically on HEAD and on pinned v380, which is what makes it a separate ticket rather than a regression note.
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
- 2026-08-27 — resolved, commit c60cb62bc.