An attribute off a call returning a class VALUE does not parse when a subclass exists
class P:
def __init__(self, a):
self.a = a
def mk(self):
cls = P
return cls(self.a) # a classref VALUE call -> variant
class Q(P): # <-- delete these two lines and it compiles
pass
print(P(7).mk().a) # CPython: 7 pxx: pascal26:11: error: unexpected token
Expected: ), but got: (Kind: 81, Line: 11)
pascal26:11: error: unexpected token
near: mk >>> a
PRE-EXISTING
Measured identical on stable_linux_amd64/default/pinned (v384) and on the
fixedpoint carrying [[bug-n-self-class-cannot-be-called-as-a-constructor]],
2026-08-27. Filed from that ticket's sibling sweep, where self.__class__(...)
made the shape easy to reach — but it is not that ticket's doing, and the repro
above deliberately uses cls = P so it needs no __class__ at all.
The boundary, measured
Varying one thing at a time (the failing selector is .a, an ordinary field —
__class__ behaves no differently, which is what rules the dunder out):
| method returns | subclass? | P(7).mk().a |
|---|---|---|
P(self.a) — a static ctor |
yes | ok |
cls(self.a) — a classref value |
no | ok |
cls(self.a) — a classref value |
yes | unexpected token |
self.__class__(self.a) |
no | ok |
self.__class__(self.a) |
yes | unexpected token |
So it needs BOTH a variant-typed (classref-constructed) return AND a same-named method set. Neither alone does it.
Where to look
Not the classref lowering — that is fine in every row above; the value it
produces is correct and x = P(7).mk() then x.a on a separate line works.
It is the SELECTOR arm after a call: . + ident off a call result is claimed
either by the static-class arm (tk = tyClass) or by the variant-receiver arm
(pyparser.inc, the (tk = tyVariant) and (CurTok.Kind = tkDot) test around the
bug-nilpy-attribute-off-a-subscript-of-a-call-result-yields-the-variant-tag
comment). With a subclass present the call's tk is evidently neither, so
nothing claims the . and the expression simply ends.
FindProc returns the representative of a same-named set and its RetType is
what type inference reads — the hazard called out at length in
[[bug-nilpy-redefining-a-def-rebinds-calls-that-came-before-it]]. A subclass is
what turns mk into a set of two, so the representative's RetType is the
first suspect. Measure it — PXXDBG=n.procs and a.ast: — rather than
assuming; this is exactly the block whose own comments record having broken
self-host and sum(range(i)) before, and a wrong root cause recorded here is
worth more than the bug.
Why it matters
A parse error is the good failure mode — nothing silently wrong ships. But the
shape is ordinary: a factory method plus a subclass is the reason to write a
factory method at all, and the diagnostic (unexpected token at the field name)
names neither the class-value return nor the subclass, so it reads as a typo.
Resolution — a call is TWO node kinds and two chain loops knew one
Fix: AN_VIRTUAL_CALL added beside AN_CALL in two loops in
pasparser_expr.inc — the bare-ATTRIBUTE-on-a-call-result loop, and the
.method()-on-a-call-result loop above it. Both are PyExprMode-guarded, so
Pascal is untouched; self-host verified.
A method on a class that participates in a hierarchy is dispatched through the
VMT and built as AN_VIRTUAL_CALL; everything else is AN_CALL
(PyClassInHierarchy, pyparser.inc ~15323). So a subclass anywhere in the file
changed the node KIND of a call in a class two lines above it, the loops did not
recognise it, nothing claimed the ., and the expression ended where it stood.
The subscript guard a few lines further down already tested both kinds. That
is what says these two loops had drifted rather than never known — the same
normalise-dont-special-case shape the file keeps meeting, with the sibling
sitting in the same routine.
Three things this ticket asserted that measurement corrected
Worth recording because the ticket's "Where to look" would have sent the next reader to the wrong file, and it argued for it convincingly.
-
"
FindProcreturns the representative of a same-named set... the representative'sRetTypeis the first suspect." No. An unrelated class declaring its ownmkdoes not trigger the bug; only a SUBCLASS ofPdoes. It is inheritance, not name collision. AndPXXDBG=n.retreportstk=22 rec=0formkwith and without the subclass — the inferred return type is identical, andASTTk[callNode] := Ord(Procs[mpi].RetType)is written the same way on both branches of the virtual/direct choice. -
"It is the SELECTOR arm...
PyParseClassRecordSelectors(pyparser.inc)". A probe at that routine's entry printed nothing at all for this program, in either the failing or the passing case. The NilPy chain forP(7).mk().anever enters it; the loops that handle it live inpasparser_expr.incafter the constructor intercept. A probe on the Pascal twin printed only RTL parses (pyExpr=0), which is how that was settled. -
PyEvalOnce'sAN_CALL-only test looked like the same bug and is not. It sits at the top ofPyParseClassRecordSelectorsand has the identical shape; extending it changed nothing measurable —b.add(2).add(3).nanswers5with a subclass present, on v388 and with the change alike — so it was reverted rather than landed on plausibility. The double-evaluation it guards does not reproduce through the virtual path today.
Measurement note
An early boundary table in this session read "OK" for cases that in fact failed,
because the probe captured errors with 2>&1 >/dev/null and pxx prints its
diagnostics on stdout: the errors were discarded and a leftover binary from
the previous row was run instead. Any pxx probe must test for the OUTPUT FILE's
existence, not for an empty stderr.
Gate
Every shape, with and without the subclass, now agrees — which is the property that was broken:
| expression | with class Q(P) |
without |
|---|---|---|
P(7).mk().a |
7 |
7 |
P(7).mk().d["k"] |
5 |
5 |
P(7).mk().mk().a |
7 |
7 |
P(7).mk().txt().upper() |
HI |
HI |
P(7).txt().upper() (control, str result) |
HI |
HI |
R(4).make().v (control, STATIC construction) |
8 |
8 |
o = P(7).mk(); o.a, o.d["k"] (control, name receiver) |
7 5 |
7 5 |
All match CPython. The last two rows are the two shapes that were right all along and are what made the asymmetry visible.
17 named chain / dispatch / dynattr canaries green
(attr_off_subscript_of_call_result, chaining, deep_statement_chain,
class_name_chain, class_attr_shared_slot_via_call_result, dynattr,
dynattr_class, builtin_subclass_dunder_dispatch,
dunder_on_self_reaches_the_override, self_class_constructs, …). Self-host
fixedpoint verified, converged after 1 round(s).
Test: test/test_nilpy_attribute_off_a_virtual_call_result.npy
(+.expected, registered) — the seven rows above.
Log
- 2026-08-27 — resolved, commit fb94570e2.