← board

o.ClassName on a TObject local silently becomes a dynamic-attr fetch

The two spellings

Both inside compiler/builtin/pylib.pas, both on the same object:

var obj: Pointer; o: TObject;
...
o := TObject(obj);
Result := o.ClassName;            { WRONG — becomes a dynamic attribute fetch }
Result := TObject(obj).ClassName; { RIGHT — the RTTI ClassName call }

The first form does not read RTTI at all. It lowers to NilPy's dynamic attribute path, so at run time a user NilPy class instance dies with:

Unhandled exception: AttributeError: 'Dog' object has no attribute 'ClassName'

The second form, which is what the two neighbouring AttributeError sites in pydynattr_get / pydynattr_get_v already use, compiles to the RTTI call and works. The fix that hit this simply adopted the working spelling.

Why this matters more than the one call site

This is the shape-blindspot pattern again ([[project_string_conversion_shape_blindspot_pattern]]): a lowering decision keyed on the syntactic SHAPE of the receiver (a bare local vs an explicit cast) rather than on its resolved TYPE. o is declared TObject; there is nothing ambiguous about it. Any Pascal code in pylib.pas that reaches for a TObject method through a plain local is exposed, and the failure is a run-time AttributeError far from the source line — not a compile error.

It is loud when it fires (an unhandled AttributeError), which is why this is prio 65 and not higher. But it is loud only if that branch is reached: the fallback arm in pytype_name_v is taken solely for user classes, so it survived the container cases and only surfaced when a test covered type(Dog()).__name__.

Repro

The compact repro is a Pascal one inside pylib, but it is observable from NilPy: add to pytype_name_v's fallback arm Result := o.ClassName; (with o: TObject assigned from the pointer), rebuild, then:

class Dog:
    pass
print(type(Dog()).__name__)     # AttributeError: 'Dog' object has no attribute 'ClassName'

What to investigate

Which lowering decides that a .<ident> on a class-typed receiver goes to the NilPy dynamic-attr path. The guard is presumably "is this a NilPy compilation and did the member lookup miss?" — but ClassName is not a declared field, it is a TObject method, so the member lookup has to consult the RTTI/method side before concluding "miss → dynamic attr". The cast form evidently takes a different route that does consult it.

Worth checking whether the same divergence affects other TObject members (ClassType, InheritsFrom, Free) through a plain local, and whether it is NilPy-only or also reachable from Pascal (isNilPy gates a lot of this).

Gate

A .npy that calls type(<user class instance>).__name__ already covers the regression (test/test_nilpy_type_name.npy). A direct test wants a Pascal unit in the NilPy builtin set calling o.ClassName on a plain TObject local.

Resolved 2026-08-02 — commit e455ff322

The ticket's "What to investigate" was right about where to look and the answer was one predicate.

The dynamic-attribute fallback (parser.inc, the FindUField/FindUMeth/ FindUProp all-miss branch) was gated on isNilPy, which is true for the WHOLE compilation — every Pascal unit loaded into a NilPy program included. So a Python-only rule was deciding member resolution inside pylib.pas and friends: a TObject method reached through a plain local is not a declared member of the receiver's UClass, all three lookups miss, and the branch concluded "undeclared -> dynamic attribute". The cast form takes a different route that consults RTTI, which is why one spelling worked and the difference looked arbitrary.

Changed to NilPyUserCode, the predicate that already gates every other NilPy-only rule ("the main .npy/.py the user wrote, not the Pascal units compiled alongside it").

Answers to the ticket's open questions

Test

test/test_nilpy_tobject_member_via_local.npy + test/nilpy_units/tobjprobe.pas (wired into make test-nilpy). Both spellings are kept side by side on purpose: a regression that re-breaks only the local form should read as the one-line divergence it is rather than as a whole-file failure.

Noted in passing, NOT chased

InheritsFrom(TObject) on a NilPy user-class instance returns False. That is consistent with pxx's model — a NilPy class is registered as a UClass with no parent, so it is not a TObject descendant even though TObject(p) casts to one — but it is worth knowing before anyone writes InheritsFrom into pylib expecting Delphi semantics. Not filed: it is a modelling question, not a wrong value, and nothing depends on it today.

Gate: gate.sh quick GREEN, self-host fixedpoint byte-identical.

Log