← board

DECIDED 2026-08-03 — there is no fork. Follow CPython.

User's call, and it is a standing rule rather than a ruling on this ticket: NilPy follows CPython wherever possible — language semantics above all, including the ugly quirks, which do have reasons. A known divergence is not traded for implementation cost, and where something is not implemented yet the compilation HALTS rather than being silently wrong.

So: option A, the real model — reads fall through to the class, a write through an instance creates a per-instance override. Not phased, not approximated. One bug ticket ([[bug-nilpy-class-attribute-unreachable-through-the-class-name]], unblocked by this), fixed once.

The scan survives, as an OPTIMIZATION only — never as semantics

The whole-program scan proposed as "option B" is not a second semantics and must never be written up as one. It is a lowering choice under semantics that are always CPython's, and it is what keeps the correct model cheap. Divergence from a copy-at-construction lowering requires a class-level write AFTER construction; without one, the cheap lowering is provably indistinguishable. That splits attributes three ways:

attribute lowering cost
never class-written at run time copy at construction (what happens today) zero
class-written, never instance-written one shared slot, read directly (a Pascal class var) zero — a global read
class-written and instance-written genuine fall-through + per-instance override the only place the check lands

The counter/registry idiom — C.count += 1 in __init__, the reason the blocked ticket exists at all — is the middle row: exactly correct AND free.

Worth stating for the next reader, because it is the thing that makes the whole model click: self.x = ... in __init__ is not a field declaration, it is an INSTANCE WRITE, the same mechanism as a.x = ... from outside. There is one rule (read falls through, write lands on what you named), not two, and ordinary per-instance fields exist only because __init__ performs those writes.

inst.attr on a class attribute — which read model?

The fork

Today a NilPy class attribute is copied into a real instance field at construction (PyClassAttrInitSeq for a class with a ctor, PyParseNewInstance's hoisted-temp form without one). Reads and writes through an instance are ordinary field access. That is indistinguishable from Python for every program that never writes through the CLASS NAME — which is exactly why ClassName.attr is a compile error today, and why enabling it would turn a loud refusal into a silent wrong value:

class S:
    v = 5
a1 = S(); a2 = S()
S.v = 10
print(a1.v, a2.v, S.v)    # CPython 10 10 10    copy model 5 5 10

So the read model has to change before C.attr can be enabled at all. Two credible models, and they are not close in cost.

Option A — full Python semantics (what the blocked ticket recommends)

Class attributes live only in the class (the hidden $clsattr.<Class>.<name> global). Construction copies nothing. inst.attr reads the global unless the instance has an override; inst.attr = x creates one, reusing the existing pydynattr per-instance machinery.

Option B — whole-program static specialisation

For each class attribute nm of class C, scan the module for an INSTANCE write (<expr>.nm = ... where the receiver is not C itself, plus any setattr). If there is none, no instance can ever override it, so:

That is exactly Python's observable behaviour for such a program, with no runtime cost — a global load instead of a field load — and no override storage. Attributes that DO get an instance write anywhere keep today's copy model unchanged, so nothing regresses; C.nm on those stays refused (or is enabled separately once A exists).

The frontend already does this kind of whole-module token scan and treats it as the normal tool: PyDynAttrEverAssigned (pyparser.inc:7117) is literally "does the module ever write <expr>.nm = ...", and PyMethodUsedAsValue beside it keys the same way. So B is in-idiom, not a new mechanism.

Known sharp edges of B, stated honestly:

Option C — leave it refused

ClassName.attr stays a compile error. The counter/registry idiom (C.count += 1 in __init__) — the main reason to write a class attribute — stays unavailable. This is the status quo and the cost is a permanent, visible hole in a very ordinary corner of Python.

Recommendation

B first, A later if a corpus program needs it. B is a few hundred lines in one file, reuses an established pattern, is conservative by construction (any uncertainty falls back to today), and unblocks the whole ClassName.attr ticket — including its parts 1 and 2, which the blocked ticket already measured as working once the read model is sound. A is the honest long-term model but it rewrites the instance-attribute path to buy behaviour no measured program has needed yet.

If the answer is A, say so and the blocked ticket becomes a much larger piece of work that should be scoped on its own. If it is B, the blocked ticket can be picked up directly.

Note for whoever decides

The blocked ticket's parts 1 and 2 have both been implemented and measured byte-identical to CPython in isolation — the only thing that sent them back was the S.v = 10 case above. So this decision is the entire remaining blocker, not one of several.