← board

A bare attribute on a call result is refused

Split from bug-nilpy-a-method-call-on-a-callable-values-result-is-refused, which asked for .attr and [ to be checked alongside .method(). Two of the three landed; this is the third, and it is NOT just a parser gap.

class A:
    def __init__(self, v): self.v = v
    def show(self): return "A" + str(self.v)

def mk(v): return A(v)
g = mk
g(3).show()   # works now
g()[1]        # works now (list case)
g(3).v        # pascal26: error: unexpected token   —  CPython: 3

o = g(3); o.v works, so the field read itself is fine; only the chained, non-ident receiver is refused.

Why the parser fix alone is NOT the fix — measured

Every .-loop in ParseFactor's suffix cluster requires .name(, so a bare attribute on a non-ident receiver is claimed by nobody. Adding a loop that routes it to PyMakeDynAttrGet (the same builder an ident receiver uses) makes it PARSE — and then it raises at run time:

Unhandled exception: AttributeError: 'A' object has no attribute 'v'

pydynattr_get_v (pylib.pas) looks only in PyDynAttrStore, the side table for attributes created by assignment/setattr. A field DECLARED by the class is invisible to it, so the getter answers "no attribute" for a field that plainly exists. The ident-receiver path does not go through this helper at all — the frontend knows the receiver's class and emits a static field read.

That change was written, measured, and reverted: turning a loud compile error into a plausible-but-false AttributeError moves the failure away from the cause, which is the expensive failure class here. The parse gap stays visible until the runtime half exists.

What the runtime half needs

The RTTI blob already carries the fields — lib/rtl/typinfo.pas:

TClassRTTI = record ... FieldCount: Int64; FieldsPtr: PFieldInfo; end;
TFieldInfo = record NamePtr: PString; Offset, TypeKind, RecId, Flags: Int64; end;

So the fallback is: walk FieldsPtr up the ParentRTTI chain (as PyFindMethCI does for methods), match the name case-insensitively, then box inst + Offset by TypeKind. pylib does not mirror PFieldInfo yet, so that record and a kind switch (int/int64/double/bool/char/ansistring/class/variant) are the work — roughly the same shape PyFindMethCI + PyHostCall have for methods, and it would serve getattr() on a declared field too.

Gate

make test-nilpy + self-host byte-identical, with .npy cases for g(3).v, a chain g(3).v + 1, an INHERITED field, a field of each scalar kind, and a genuinely missing attribute still raising AttributeError — diffed against CPython. Extend test_nilpy_postfix_after_parens, which already carries the .method() and [i] halves.

Resolution (2026-08-11) — the runtime half first, then the parse

Done in the order the ticket insisted on, because the reverse turns a loud compile error into a plausible-but-false AttributeError.

Runtime half. PyDeclaredAttrGet (pylib) walks the class's RTTI field table up the parent chain — the same shape PyFindMethCI already has for methods — and boxes inst + Offset by TypeKind: AnsiString, Double, Single, Boolean, Char, Int64/QWord, Integer/LongInt, Cardinal, SmallInt, Word, ShortInt, Byte, NativeInt/UInt, Variant (copied) and a class instance. A kind with no Python value shape yet (record, set, frozen string, static array) reports NOT-FOUND rather than inventing a value, so the failure stays loud. pylib already uses typinfo, so PFieldInfo needed no new mirror — the ticket's "pylib does not mirror PFieldInfo yet" was stale.

Both getters consult it: pydynattr_get (statically class-typed receiver) and pydynattr_get_v (variant receiver), each AFTER the setattr side-store, which is CPython's order — an instance __dict__ entry shadows the declared field.

Parse half. One more loop in the Python suffix cluster: a bare .name (no ( after it) on an AN_CALL receiver that is not statically a class routes to PyMakeDynAttrGet. The class-selector loop above it needs a known class and every method loop needs a (, which is why this shape was claimed by nobody.

Diffed against CPython, all matching: mk(3).v, g(3).v through a callable value, g(3).v + 1, a field of each scalar kind, an INHERITED field, a chain through the attribute, o = mk(3); o.v still fine, and a genuinely missing attribute still raising AttributeError. hasattr/getattr with a default also answer correctly for a declared field.

Gate: make test-nilpy GREEN, gate.sh quick GREEN (self-host byte-identical). test/test_nilpy_postfix_after_parens extended with the bare-attribute rows — it already carried the .method() and [i] halves of this family.

Needs a pin before other lanes see it (compiler/builtin).

Log