← board

A plain-class Callable field is unreachable through a dynamic receiver

from typing import Optional, Callable

class PC:
    native: Optional[Callable[[int], None]] = None
    def __init__(self, name, native=None):
        self.name = name
        self.native = native

def plain(k):
    print("plain", k)

def run(o):          # o is dynamically typed
    o.native(3)      # error: no class declares a method or callable field .native()

run(PC("x", plain))  # CPython: plain 3

The identical program with PC as a @dataclass compiles and runs.

Cause

pyparser.inc's dynamic-receiver scan (the hitCi < 0 fallback in the member path, ~line 9427) recognises a callable field by UFldProcSig[mmi] >= 0. Only the @dataclass field registration records a signature; a plain class's field is typed either from the __init__ parameter it is assigned from or from a class-level annotation, and neither writes UFldProcSig. So the scan finds nothing and errors.

Moving the annotation ABOVE __init__ does not help — measured.

PARTLY PRE-EXISTING — corrected 2026-08-08

Reproduced identically under stable_linux_amd64/default/pinned — but ONLY for the shape written above, where the class carries a class-level native: Optional[Callable[...]] = None annotation ALONGSIDE the ctor parameter.

CORRECTION: the commoner shape — a field typed purely from an annotated __init__ parameter, with no class-level annotation — runs FINE under pinned. For that one the compile failure was a regression from [[bug-nilpy-closure-stored-in-a-callable-field-jumps-through-the-variant-tag]] (mine, same day), not a pre-existing gap. Both are fixed by the second scan pass described in [[regression-test-nilpy-test-nilpy-function-values]]; this ticket is closed with it. That ticket's 16-cell matrix passes because it calls through a STATICALLY typed local; this is the dynamic-receiver arm.

Shape of the fix

UFldProcSig is doing double duty — ABI (now dead for NilPy, since the slot is a variant) and "is this field callable". The second job wants its own answer. Either record the signature for a plain class's Callable-annotated field too, or let the scan accept a tyVariant field — the call now dispatches through pyvar_callv<n>, which tells all four callable shapes apart at runtime, so it does not need the signature to lower correctly.

Accepting ANY variant field would weaken the typo diagnostic (o.typo(3) would resolve to any class with a variant field of that name), so weigh that; and see [[bug-nilpy-dynamic-receiver-callable-field-casts-to-the-wrong-class]], which the same scan already has and which more candidates would make worse.

Gate

The repro above oracle-diffed with tools/pydiff.py, the existing test_nilpy_callable_field_all_shapes.npy still green, plus the per-fix loop.

Log