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
- 2026-08-11 — resolved, commit 897749d9a.