Chaining a method call on the RESULT of a method call does not parse
Found 2026-07-28 while testing tuple dict keys ([[bug-nilpy-tuple-dict-key-never-matches]]).
class B:
def __init__(self):
self.items = []
def add(self, x):
self.items.append(x)
return self
b = B().add(1).add(2).add(3)
print(len(b.items), b.items)
CPython prints 3 [1, 2, 3]. pxx:
pascal26:8: error: expected expression
near: add >>> add
One .add(1) on a constructor result is fine; it is the SECOND .method(...)
in the chain that stops the parse — the postfix loop does not accept another
call suffix after a call. return self builders are ordinary Python and this is
the standard shape for them.
The silent half
Inside a comprehension the same chain COMPILES and quietly does the wrong thing:
class P:
def __init__(self, n):
self.n = n
self.tags = {}
def tag(self, k, v):
self.tags[(k, v)] = self.n
return self
ps = [P(i).tag("a", i).tag("b", i * 2) for i in range(30)]
print(ps[5].tags[("a", 5)]) # 5 — the first call's store landed
print(ps[5].tags[("b", 10)]) # KeyError — the second call's did not
So the two contexts disagree: statement position rejects the chain, expression position accepts it and drops all but the first call. Whatever fix lands should make both agree — the comprehension path is the dangerous one, since a builder that silently applies half its calls looks like it worked.
Related postfix-suffix work: [[project_nilpy_parsefactor_suffix_extension_point]] records that the suffix hooks live at FOUR routes, which is consistent with one of them handling a call suffix and another not.
Gate
make test-nilpy plus a .npy with a return self builder chained three deep,
in BOTH statement and comprehension position, diffed against CPython.
Resolved 2026-07-29 (commit c896cb882) — the parse error hid a WRONG ANSWER
Measuring before fixing found the worse half: the chain shape that DID parse,
b.add(2).add(3) on a named receiver, compiled and called add(2) TWICE — 7
where CPython prints 5. A value node referenced twice in the AST is re-emitted,
and a virtual dispatch reads its receiver twice (VMT, then Self). So the ticket's
"does not parse" and a silent miscomputation were the same bug seen from two
sides.
Fixed by binding the receiver to a hidden temp (PyEvalOnce), typing return self
as the method's own class, and routing a variant link (make()[0].upper()) to
the runtime-dispatch path.
Log
- 2026-07-29 — resolved, commit c896cb882.