← board

self.<class attribute> in an INHERITED method reads the base's value

class Base:
    kind = "base"
    def __init__(self, n):
        self.n = n
    def describe(self):
        return self.kind + ":" + str(self.n)

class Derived(Base):
    kind = "derived"

d = Derived(3)
print(d.describe(), d.kind, Derived.kind)
# pxx      base:3 derived derived
# CPython  derived:3 derived derived

The boundary

read pxx CPython
d.kind (through the instance) derived derived
Derived.kind (through the class name) derived derived
self.kind inside a method DECLARED ON Derived (works) works
self.kind inside a method declared on BASE base derived

Only the inherited-method read is wrong, and it is the one the template-method pattern is built on: a base method that reads a constant each subclass redefines (kind, name, table, sep). Every subclass then behaves like the base, silently — no error, and the value is a perfectly good string.

Where to look

A class attribute is a hidden GLOBAL per (declaring class, name) ([[project_nilpy_class_attributes_are_hidden_globals_not_rtti]]), and inside a method body self.kind resolves through the class-variable-through-an-instance branch — which is keyed on the class that DECLARES the method (CurSelfClass), not on the receiver's runtime class. FindClassVar walks the PARENT chain from there, so from Base.describe it finds Base's slot and stops; the derived class's own slot, allocated by the same mechanism, is never consulted.

The runtime already has what a correct read needs, as of [[bug-nilpy-class-attribute-through-a-class-reference-reads-garbage]] (2026-08-13): every class attribute is now published into a registry keyed on the class RTTI blob with its slot ADDRESS, and PyClsAttrSlotOf walks ParentRTTI from the RECEIVER's class. So the fix is available without new metadata: when the attribute is redeclared anywhere in the chain below the declaring class, the read through self has to go through the receiver's class at RUN time instead of resolving to the declaring class's slot at compile time.

Cheaper alternative worth measuring first: PyClsAttrRedeclaredInChain already detects exactly this shape (it forces the shared-slot lowering when a subclass redeclares an attribute) — so the machinery to spot the case exists, and what is missing is making the self. read dynamic when it fires.

Gate

A .npy diffed against CPython: a base method reading a redeclared attribute through self, a two-level chain (ABC each redefining it), a subclass that does NOT redefine it (must still see the base's), the same attribute read through the instance and the class name as controls, a super().describe() call, and an attribute written on the class after construction.

2026-08-13 — FIXED, using the registry the class-ref work had just landed

FindClassVar walks UPWARD from the class that declares the METHOD, so inside Base.describe it found Base's slot and stopped — the subclass's own slot, allocated by the same mechanism, was never reachable from there. Which slot is right is a fact about the RECEIVER, so the read is now one at run time whenever the attribute is redeclared down the chain:

A bare kind inside a method is deliberately NOT touched: in Python that is a global lookup, not the class attribute.

Gate

test/test_nilpy_inherited_class_attribute_through_self.npy + .expected from CPython, wired into make test-nilpy: a base method reading a redeclared attribute, a THREE-level chain each redefining it, a subclass that does not redefine it (must see the base's), a second attribute redefined only two levels down, super().get(), the sep.join shape this pattern is usually written as, and the instance/class-name reads as controls. make test-nilpy green, gate.sh quick GREEN.

Log