Open-world method dispatch on a dynamically-typed receiver
The repro
class A:
def __init__(self):
self.v = 1
def use(o):
return o.ping(2) # no class declares a method or callable field .ping()
print(A().v)
Seven lines. use is never called; CPython compiles and runs this. Reproduces
on the pin and at the tip.
Where it bites
lekkerzeilen wind.py:141, if not canopy.contains(sx, sz). contains is
declared by three classes in world.py, and no module in the package imports
world — the canopy is built by the importer tool and handed in. The tell is
the next line: canopy.at(...) resolves only because wind.py happens to
declare an unrelated at of its own. Identical call site, and the answer
depends on a coincidence elsewhere in the file. chart.py:102
(tile.read_grid("bed")) is the same cause.
Why the direction is settled, and it is not a taste argument
The written rule decides it. CLAUDE.md's N lane: "NilPy is UPWARD compatible with CPython, one direction. Accepting what CPython rejects is a feature." The contrapositive is the whole of this ticket — refusing what CPython accepts is a defect, and duck typing across a module boundary is not an edge case of CPython's model, it is the model.
And the closed world is not a policy we hold — it is a default we have
already conditionally abandoned. feature-nilpy-fallback-import drops closed
-world dispatch WHOLESALE for any program containing an optional import that
did not resolve. So a program that says try: import X / except ImportError:
gets CPython semantics, and one that does not gets a compile error, for the
identical call site. A rule that applies except when an unrelated import
fails is not protecting anyone, and that inconsistency — not anybody's
preference — is the argument.
Direction settled by frankuser 2026-09-09 on that reasoning, ratifying a measurement made here. It was filed as a fork; it is not one, because it is a semantics decision about our own frontend rather than permission machinery or anything outward-facing.
The typo protection MOVES; it does not disappear
That objection is real and it is the reason the closed world was built. The answer is that a closed-world WARNING is compatible with runtime lookup; a closed-world REFUSAL is not. Keep the scan, keep the diagnostic, emit it as a warning when no declared class matches, and let the call dispatch at run time. Note the aperture was always narrow: this path runs ONLY for a receiver whose static type is unknown, so a typo on a statically-typed receiver is caught elsewhere and is untouched either way.
The cost, which is why this is ranked as a feature
Half the machinery exists: PyMakeOptionalMissingCall already emits a call
that raises, and the fallback-import path already routes through it. What is
missing is a runtime method-lookup-by-name — dispatch today is a
class-pointer compare against candidates enumerated at compile time, so with
zero candidates there is nothing to emit. getattr does not substitute for it
and is separately broken: it sees FIELDS only, and segfaults through a dynamic
receiver (bug-n-getattr-cannot-see-a-method-and-segfaults-through-a-dynamic-receiver).
Log
- 2026-09-10 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 592a5e573.