← board

A classmethod cannot reach another one through its own receiver

Working CPython code, refused at run time:

class C:
    def __init__(self, v: int):
        self.v = v
    @classmethod
    def make(cls, v: int):
        return cls(v)
    @classmethod
    def twice(cls, v: int):
        return cls.make(v * 2)      # AttributeError: 'type' object has no attribute 'make'
class D(C):
    pass
print(C.twice(5).v)                 # CPython: 10.   pxx: raises

cls(v)constructing through the receiver — works, including through a subclass. Only reaching a METHOD through it fails.

Where it stops

cls is a VT_CLASSREF variant (tag 11). The tag-11 arm of the attribute resolver answers class ATTRIBUTES and __name__, then raises:

if tg = 11 then
begin
  Result := PyClsAttrRefGet(v, name, declFound);
  if declFound then Exit;
  if name = '__name__' then begin Result := PyClsRefName(v); Exit; end;
  raise AttributeError.Create('type object ''' + PyClsRefName(v) + ...);
end;

Methods are not in that registry, so a method name falls through to the raise. Construction avoids it entirely by riding pyvar_callvN -> PyClassRefNew, which reflects create over the RTTI — a path that exists precisely because cls() was made to work.

The shape of a fix, and why it is not obvious

A compile-time route looks cheapest and is probably right: inside a classmethod the receiver's static class is known to be the enclosing class or a subclass of it, so cls.other(v) could compile as a static-marked method call with slot 0 = the raw $clsptr the frame already holds — which is exactly what Self.M(...) does in a Pascal class method. That keeps the runtime class, needs no new runtime machinery, and reuses the parameter this feature already introduced.

The catch is that it must not swallow the general case: k = SomeClass; k.m() where k is an ordinary variable has the same shape and no enclosing classmethod to borrow a static type from. Either that stays a runtime raise (honest, and what happens today) or the tag-11 arm learns method dispatch — do not do both half-way, which is the failure this ticket's parent spent three revisions avoiding.

Provenance

Found while implementing [[feature-nilpy-staticmethod-and-classmethod]]'s @classmethod half (landed 2026-08-29). Deliberately not folded in: the rest of @classmethod is verified byte-identical to CPython across all four receiver forms, and this is a separable mechanism whose failure is LOUD. test/test_nilpy_classmethod.npy names the gap in a comment rather than pinning it, so nothing records the current behaviour as intended.

Gate

make test-nilpy + self-host byte-identical, plus the program above answering 10 and type(D.twice(6)).__name__ answering D — the runtime class must survive the hop, which is the whole point of the receiver.