← board

How it was found

Writing the fixture for bug-n-a-callable-attribute-dispatched-at-run-time-takes-at-most-4-arguments. A bound method is one of the four carrier families that fixture has to cover, and its row used Tagged(tag).m5 to keep the tag visible in the output. That row failed with object is not callable while the other three carriers passed.

It was masked, not caused, by that ticket's cap. With the four-argument cap in place the same call reported takes at most 4 arguments instead, so the real failure only becomes visible once five arguments are allowed through. At one argument it is identical on the pinned and the fixed compiler.

Repro

# k.py
class Tagged:
    def __init__(self, tag):
        self.tag = tag

    def m1(self, a):
        return "m1:%s:%s" % (self.tag, a)


class Box:
    def __init__(self, tag):
        self.tag = tag
        self.cb = Tagged(tag).m1        # LITERAL "B" here and it works


def boxes(tag):
    return [Box(tag)]
import k
xs = k.boxes("B")
print(xs[0].cb(1))

CPython prints m1:B:1. pxx warns no class declares a method or callable field .cb() and raises TypeError: object is not callable.

The receiver must be reached through a CONTAINER, as above -- o = k.Box("B") is typed statically and dispatches without consulting the field table.

Measured, all at one argument, pinned compiler

self.cb = ... pxx
Tagged("B").m1 — literal m1:B:1
Tagged(G).m1 — module global object is not callable
Tagged(tag).m1 — parameter object is not callable
Tagged(self.tag).m1 — own attribute object is not callable

Where to look

The field-inference pass that decides what FindUField will see for a class: it evidently records a field for Tagged(<literal>).m and records nothing for Tagged(<expr>).m. The warning is emitted by the candidate scan in pyparser.inc (no class declares a method or callable field), which is CORRECT given an empty field table -- the defect is upstream of it, in what the table was given. The run-time arm then does pydynattr_get and gets something that is not a callable, so the second question is what that slot actually holds.

Resolution (2026-09-18)

One arm, in PyCtorSelectorType: when FindUField misses, ask FindUMeth before giving up, and answer tyVariant. cur becomes -1 because a callable value has no class identity to chase a further selector through, so Tagged(tag).m1.something is still refused by the loop's own cur < 0 guard.

Why the literal spelling worked, and why that was the misleading part. Tagged("B").m1 is a different route through the CALLER, not a different answer in this chase. The argument's value was never the subject -- what decided it was only whether the chase resolved -- and an int literal worked too, which is what ruled out any theory about strings.

Measured, all at ONE argument, pinned versus fixed: Tagged("B").m1 and Tagged(7).m1 worked before and after; Tagged(G).m1, Tagged(tag).m1 and Tagged(self.tag).m1 all raised object is not callable before and print CPython's answer after. Binding off a local first (t = Tagged(tag); self.cb = t.m1) is a different arm and always worked -- it is the fixture's control.

Fixture test/test_nilpy_bound_method_field_from_expression.npy (+ boundmethodfield_mod.py), every row at one argument so it pins THIS defect and not the four-argument callable-field cap that was masking it. Refused by the pinned compiler with the exact message above; byte-identical to CPython on the fixed one. callablefield_mod.py's bound-method row was switched back to the parameterised shape it originally wanted, which now needs both fixes.

Log

For the next seat in this file: "a callable value travels as a variant" now has THREE owners

Not acted on here -- flagged so it is visible rather than rediscovered. PyInferExprType states this rule in its own words in its LAMBDA arm ("A LAMBDA is a CALLABLE VALUE, and a callable value travels as a variant"), the return-type scan carries it as its own arm (PyInferDefRetTypeScan, "return lambda ... is a CALLABLE VALUE"), and this fix is the third place to need it.

devdocs/dev/root-cause-over-microfix.md's own count applies: two is a smell, three is a design flaw. The question worth asking before the fourth arm is written is whether these three want to be one predicate -- "does this expression denote a callable value" -- asked once, rather than three arms that each rediscover the answer for one construct. That is a refactor with a test corpus behind it, not a wind-down job.

Also worth carrying: the fixture is at ONE argument on purpose. The natural, thorough-looking wide-arity fixture measures this defect together with the four-argument callable-field cap, which refuses FIRST, so it would have pinned neither. The pinned compiler's object is not callable is what discriminates the two.