← board

A builtin subclass's subscript override is skipped by the operator form

Repro

class C(dict):
    def __getitem__(self, k):
        print("GET")
        return dict.__getitem__(self, k)
    def __setitem__(self, k, v):
        print("SET")
        dict.__setitem__(self, k, v)

c = C()
c["a"] = 1            # CPython: SET     — NilPy: (nothing)
print(c["a"])         # CPython: GET / 1 — NilPy: 1
c.__setitem__("b", 2) # CPython: SET     — NilPy: SET      <- the method spelling is fine
print(c.__getitem__("b"))
CPython NilPy (HEAD)
c["a"] = 1 SET silent
c["a"] GET, 1 1
c.__setitem__(...) SET SET

So the dispatch machinery works; the OPERATOR does not reach it.

Why it is this shape

Same family as the feature that created it. Before [[feature-nilpy-subclass-a-builtin-type]] a dict subclass was not a container to the frontend at all, so the subscript went down the user-class arm — which DOES look for __getitem__ (parser.inc, the FindUMeth(ci, '__getitem__') sites around the chained-base and subscript paths). Widening "is this a container?" from identity to kind was correct and is what made len/in/slice work, but it also handed these instances to the CONTAINER subscript path, which lowers straight to pylib's fetch/store (dict) and at/put (list) and never asks whether the receiver's class overrides the protocol.

This is the failure mode the ticket family keeps producing: two mechanisms serve one concept (a user-class subscript arm that consults __getitem__, a container arm that does not), so a value that is BOTH gets whichever arm is tested first. devdocs/dev/normalise-dont-special-case.md.

Shape of the fix

The container subscript arm should, when the static class of the receiver is a USER subclass of a pylib container (not the container itself), look for an override with FindUMeth(ci, '__getitem__' / '__setitem__') and call it, falling through to the direct fetch/store otherwise. The base class itself must keep the direct lowering — that is the whole point of dict.__getitem__ being aliased to fetch and is what stops an override from recursing into itself. Grep the sibling before closing: __delitem__, __len__, __contains__ and __iter__ are the same question and probably the same answer.

Priority note

Filed at the same prio as its sibling rather than higher despite being a silent-wrong-value bug, because the shapes that hit it are exactly the shapes that could not COMPILE until today — so no working program has been getting a wrong answer from it. That changes the moment a builtin subclass with an overridden subscript lands in a real corpus (html5lib's MethodDispatcher is precisely one), which is when this should be re-rated.

Gate

make compiler/pascal26 fixedpoint + tools/gate.sh quick, plus the repro above matching CPython line for line, and test/test_nilpy_subclass_a_builtin_type.npy / test/test_nilpy_unbound_builtin_method.npy unchanged.


Resolution (2026-08-18, frank2-7e)

Measured the surface first, rather than fixing the two members the ticket named. A differential probe against CPython 3.12 with all seven protocol members overridden on a dict subclass:

member override called?
__getitem__ NO
__setitem__ NO
__len__ NO
__contains__ NO — and it answered False, a wrong value, not a no-op
__delitem__ NO
__iter__ yes
__bool__ yes

So it was five sites, not the two in the repro, and the coordinator's read was right: the gates historically tested one member of a family and the siblings went unlooked-at. __contains__ was the worst of them — "q" in c answered False without entering the method written for it.

One predicate, applied at every container fast-path, rather than five local patches: PyRecOverridesDunder(rec, dunder) — "does this rec's own class DECLARE this member, i.e. is there an override the fast path is about to step over". Keyed on PyRecIsPylibOwnClass (the DECLARING UNIT) rather than a class name list, the same reasoning that predicate already carries: pylib's own containers declare no dunders at all, so it is False for them by construction and cannot rot as pylib grows a container.

Sites:

A segfault found on the way, and fixed here. list.__len__(self) crashed. The unbound-call path passes the receiver POSITIONALLY, so self is already inside CountCallArgsAhead, and it handed that count to FindUMethOverloadAhead, which adds one for the implicit Self — an arity one too high. Invisible while every name had a single overload (the name-only FindUMeth fallback rescued it) and a silent wrong pick the moment one did not: it selected TPyList.count(const v: Variant) and passed self as the VALUE, with no receiver. Now tries the self-inclusive arity first under PyExprMode, dropping a match that turns out to be static (a static takes no positional self). This also corrects A.m(self, x) against an overload set, which was picking the wrong body for the same reason.

Deliberately NOT closed, both named rather than left to be rediscovered:

Verified: make compiler/pascal26 fixedpoint + tools/gate.sh quick GREEN. test/test_nilpy_builtin_subclass_dunder_dispatch.npy is byte-identical to CPython 3.12's output on the same file, and pins all five members on a dict AND a list subclass, the read-only-override shape, and the plain containers unchanged. Wired into test-nilpy and test-core. test_nilpy_subclass_a_builtin_type.npy and test_nilpy_unbound_builtin_method.npy re-diffed against CPython: unchanged.

Log