← board

A qualified module member is taken by a case-folded local class

Repro

# cm5.py
def zz():
    return 7
import cm5


class ZZ:
    VAL = 1


print(cm5.zz())      # CPython 7;  pxx: <__main__.ZZ object at 0x...>

Delete the class and it prints 7. Rename the class to anything that does not case-fold onto zz and it prints 7.

Why it matters

It is silent and it produces a plausible-looking value (an object) where a number belongs, so it lands in the expensive half of devdocs/dev/debugging-playbook.md rather than the cheap one. And the qualifier is explicit at the call site: cm5.zz() names the module in the source, which is the one spelling a reader would trust.

import utils beside class Utils, import parser beside class Parser are ordinary Python spellings, and the fold makes them collide.

Family

Same flat-namespace-folds-case root as the fixed sibling, on the other arm. The sibling's two halves were the ALIAS table (from m import zz registering a module alias for a function) and the COMPILED-UNIT table (Canvas folding onto module canvas); both are lookups of the RECEIVER. This one is the reverse: the receiver resolves correctly and the MEMBER is then taken by a case-folded class.

PyIsExactCtorName is the predicate that should be gating the construction — it is case-sensitive and would answer False for zz against class ZZ — so the first thing to measure is whether the qualified path consults it at all. Do not write a cause into this ticket without measuring: two recent N tickets named the wrong mechanism and this one's sibling named a third.

Gate

The three lines above matching CPython, plus the commented-out row in test/test_nilpy_import_case_folds_onto_a_class.npy re-enabled (and its .expected regenerated by CPython). Per-fix loop; no pin (parser only, unless the fix reaches compiler/builtin/**).


RESOLVED — 2026-08-27, agent-A

Fixed, same session as the sibling that found it.

Measured

m.zz as a VALUE was always right — it printed a function — and only m.zz() was taken. That asymmetry is the whole localisation: qualified RESOLUTION works, so the fault is in whatever decides a qualified call is a CONSTRUCTION.

Root cause — one guard that did not inherit the exactness rule

PyParseFactorCore's qualified-construction intercept (pyparser.inc, the tk.Frame(root, ...) arm from feature-nilpy-qualified-class-construction) asked

(FindUClassNonRecord(GetTokenStr(TokPos + 1)) >= 0)

FindUClassNonRecord is case-INSENSITIVE, Pascal-style. So zz found class ZZ, the arm consumed m. and handed the rest to PyClassCreateExpr, and the call became a construction of a class the program never named at that site.

Its BARE-NAME twin, one screen up in the same function, asks PyIsExactCtorName — the case-sensitive predicate added by [[bug-nilpy-a-lowercase-name-is-hijacked-by-a-case-matching-class]] for exactly this failure. The qualified arm was written afterwards and did not inherit it: the same one-concept-two-sites shape devdocs/dev/normalise-dont-special-case.md is about, and the second site is again the one that stayed broken.

The fix

One predicate swap: FindUClassNonRecord(...) >= 0PyIsExactCtorName(...). It wraps the same lookup and adds the exact-spelling test plus class ALIASES (from m import Box as B registers B against Box's row, so the declared name never equals the spelling used) — a drop-in that keeps aliased qualified construction working.

Measured matrix

shape before after
import m + class ZZm.zz() <ZZ object> 7
same through an alias, import m as cscs.zz() <ZZ object> 7
m.zz as a VALUE, then called correct correct
m.Frame(3) — qualified construction, exact name correct correct
cs.Frame(4) — through an alias correct correct
the local ZZ.VAL / QQ().VAL the fold was stealing from correct correct

test_nilpy_qualified_ctor, test_nilpy_qualified_ctor_does_not_capture_its_args and test_nilpy_shim_from_import_class_attrs — the tests that exist for the arm being narrowed — were re-run by hand and are unchanged.

The commented-out row in test/test_nilpy_import_case_folds_onto_a_class.npy (the sibling's witness) is re-enabled and its .expected regenerated, which is what the Gate asked for.

Gate

make compiler/pascal26self-host fixedpoint: verified — 1 round(s). tools/gate.sh quick → GREEN. Witness test/test_nilpy_qualified_member_vs_case_folded_class.npy + helper module registered in test-core, .expected generated by CPython, red at pinned v381 and green now. No pin — parser only.

Log