← board

obj(...) does not dispatch a user __call__

class C:
    def __call__(self, x):
        return x * 2
c = C()
print(c(5))

CPython prints 10. pxx returns a garbage integer (pinned does the same).

Found while fixing bug-nilpy-calling-a-non-callable-segfaults, and left out of that fix deliberately: the guard there refuses a tag-7 instance only when its class has no __call__, so a list/dict/tuple now raises TypeError properly while an instance that defines __call__ still falls through to the old path rather than being newly refused. Refusing it would have turned a wrong value into a wrong error — a regression — so the arm stays narrow and the dispatch is this ticket.

Where it goes

pyvar_callv0..3 in pyeval.pas already resolve the class of a tag-7 payload (GetInstanceRTTI), and PyHostCall(vmobj, name, args, res) is the existing machinery for calling a method by name with a TPyList of arguments — PyFindMethCI(cls, '__call__') plus PyHostCall should be most of it.

Note pylib.pas already has PyNotCallableError ("object is not callable (no __call__)") written and never called — it was authored for this arm and left unwired.

Gate

make test-nilpy + self-host byte-identical, CPython-diffed over __call__ with 0/1/2/3 args, an inherited __call__, and a class WITHOUT one still raising TypeError.


Correction to this ticket: the repro in the body was never broken

Measured on pinned before touching anything — the ticket's own program:

class C:
    def __call__(self, x):
        return x * 2
c = C()
print(c(5))          # pinned prints 10. Correct, and always has been.

So "pxx returns a garbage integer (pinned does the same)" is wrong. That spelling takes the STATIC path: the frontend knows c's class and lowers a direct method call, never reaching a dispatcher at all.

The defect is the dynamic receiver — every route that loses the static type:

d = {"c": c};  d["c"](5)      # pinned: SEGFAULT
lst = [c];     lst[0](5)      # pinned: SEGFAULT
def get(): return C()
get()(5)                      # pinned: SEGFAULT
def apply(f, v): return f(v)
apply(c, 5)                   # pinned: SEGFAULT

There the receiver is a VARIANT, pyvar_callv<n> runs, PyNotCallable passes it (it refuses a tag-7 instance only when the class has NO __call__), and the INSTANCE POINTER is then called as a code address. A crash, not a wrong value.

Worth recording because the wrong description is what made this look like a value bug for the whole time it was open, and because it is the second ticket in this family whose stated repro did not reproduce (see the (3 + 4)(x)-is-a-parser-bug note in feature-nilpy-a-callable-value-needs-its-own-variant-tag).

Resolution

PyCallDunder in pyeval.pas — receiver + argument list into the existing PyHostCall by-name trampoline, offered from all four of pyvar_callv0..3 right after PyNotCallable and answering False for anything that is not a tag-7 instance with a __call__, so every other callee falls through unchanged. PyFindMethCI walks the parent chain, so an inherited __call__ works and the confirmed-method precondition is what makes PyHostCall's missing-method Halt unreachable from here.

pylib's PyNotCallableError is left unwired: PyNotCallable already raises a catchable TypeError for a __call__-less instance, and routing through a second raiser would change the message a test matches on for no behavioural gain. Its zero-caller state is now deliberate rather than an oversight.

Verification

test_nilpy_dunder_call.npy (the test that already existed and was gated by an inline Makefile expectation) extended with the dynamic routes: dict, list, call result, unannotated parameter at arities 0/1/3, an inherited __call__ both directly and through a dict, and a class WITHOUT one reached the same dynamic way still raising catchably. Output is byte-identical to CPython's.

Log