← board

The measurement

Pin v408, and HEAD before the fix. .expected is CPython's own output for the same file (test/test_nilpy_a_method_default_that_is_not_a_literal.npy).

class C:
    def m_name(self, n=N):       return n        # N = 30 at module level
    def ask_name(self):          return self.m_name()

print(c.ask_name())   # CPython 30, pxx None   <- the call is INSIDE the class
print(c.m_name())     # CPython 30, pxx 30     <- the same call OUTSIDE it

Nine rows move: a module global, a string, a float, a tuple, an expression, a negation, a callee declared BELOW its caller, and two class attributes. Rows with a literal default, an explicit argument, the call from outside, and a method of ANOTHER class called from inside this one already passed and are regression guards.

Declaration order inside the class made no difference, which is the tell that it was the PASS and not the lookup.

The mechanism

PyEvalMethodDefaults ran from the STATEMENT LOOP, after PyParseClass returned. Every method body in the class had therefore already been parsed, and a call site in one of them read what the member pre-pass leaves -- ProcParamDefaultSym = -1, ProcParamDefaultIsNone = True, because the pre-pass parses headers with evaluation OFF -- so DefaultArgValueNode took its NilPy tyVariant + IsNone arm and baked PyMakeNone.

The fix, and why it is split in two

PyEvalMethodDefaults(ci, lo, hi, declareOnly):

Nothing is parsed twice and nothing is evaluated twice. The name is built by one helper, PyParamDefaultGlobalName, used by both -- two spellings of it would drift and a declared-but-never-stored global reads as None, which is this bug again with a different cause.

The declared symbol is tyVariant, not the value's own type, because the value is not known yet and a later retype would leave the already-built ident node at the in-class call site disagreeing with the symbol. A variant is what an omitted NilPy parameter is anyway.

A lone STRING default is deliberately not declared early: the call site already builds the literal from sOff/sLen, so declaring one would reroute working code through a global for no gain.

What the first attempt got wrong, and it is the reusable part

The first version moved the WHOLE evaluation before the body loop. Gate quick green, the new fixture green, a clean pin control -- and the full tier found test_nilpy_a_method_default_is_evaluated_in_the_class_body answering computed attr None against computed attr 14.

class D:
    LIMIT = 7 * 2                  # COMPUTED
    def __init__(self, v=LIMIT): ...

The member pre-pass can only guess a class attribute's type from its tokens, so it types a computed one's $clsattr.<C>.<name> slot tyVariant; PyEmitClassAttrExpr RETYPES it to what the initialiser actually produced, and that runs in the class BODY loop. Read before the body loop, the attribute is still a variant and the default global is allocated variant-shaped over a slot that ends up an Int64 -- a type mismatch whose value reads as None. Measured with a probe on the lookup: $clsattr.D.LIMIT tk=22 against $clsattr.E.MARGIN tk=13 in one program.

A LITERAL attribute is typed correctly by the pre-pass and so needs no retype. The fixture's own positive control for the ordering (MARGIN = 14) was a literal, which is the easier population: it passed on the broken version and certified it. The fixture now carries a COMPUTED one beside it (SPAN = 3 * 5, rows N and O), and under the rejected version those two rows are the ONLY ones that fail -- every other row of the same fixture passes. That is measured, not predicted.

Resolution

Fixed, commit b48c40d28 on origin/master (both bugs landed together; the close is the same commit).

Verified