← board

A scalar-expression class attribute declared after a method reads None through self

Measured, 2026-09-13 (frankZ) — at HEAD and under pin v408, identically

class Below:
    def r(self):
        return self.P
    P = (1 + 2)

class Above:
    P = (1 + 2)
    def r(self):
        return self.P

class BelowNoParen:
    def r(self):
        return self.P
    P = 1 + 2
CPython pxx
Below().r() 3 None
Above().r() 3 3 — correct
BelowNoParen().r() 3 None
Below.P, Above.P 3 3 3 3 — correct

The parentheses are not the discriminator: P = 1 + 2 fails identically. What matters is that the initialiser is an EXPRESSION rather than a single literal token, so it misses the pre-pass's constant branch (which requires one literal token followed by end-of-line) and lands in the expression branch, where the field is recorded as a variant and only retyped later by PyEmitClassAttrExpr — after any method above it has already been compiled against the variant.

Why the container fix does not cover this, and must not be widened to

bug-n-a-container-class-attribute-... was fixed by asking PyInferExprType in the member pre-pass and recording tyClass when the answer is tyClass AND the container's rec can be resolved. Both halves of that restriction were measured:

So the fix for this ticket is in the STORE path, not in the typing. Making the pre-pass smarter is the move that looks obvious and is measured to crash.

The instrument, reusable

A field read by TWO methods, one above the declaration and one below, in one program: before the container fix, TwoReaders.early() returned empty and late() returned [5, 6] for the same field C. One field, two answers, by method compile order — that is the proof the typing is per-method and not a property of the field, and it is sharper than any single arrangement.