← board

A class base that is an expression does not compile

Repro — three cells, one variable

class P(object):        # OK
    def hi(self): return "hi"
print(P().hi())
class A:                # OK
    def hi(self): return "hi"
class P(A):
    pass
print(P().hi())
B = object               # FAILS
class P(B):
    def hi(self): return "hi"
print(P().hi())
pascal26: error, near: B  object >>>   P

CPython runs all three. So a base which is a class name resolves, and a base which is a name bound to a type does not — the same distinction that bug-n-a-type-name-is-not-a-first-class-value fixed for ordinary value positions, not yet extended to the base-class position.

A base which is a call fails the same way, which is the shape that matters in practice:

def wm(meta):
    return object
class P(wm(type)):      # FAILS
    pass

Why it is worth more than it looks

It is the last wall on six.with_metaclass, and therefore on html5lib's parser. html5parser.py:426 reads:

class Phase(with_metaclass(getMetaclass(debug, log))):

and getMetaclass (line 419) returns plain type unless the debug flag is set. So the real, default path asks for no metaclass at all — semantically just class Phase(object), which this dialect can already express. The only thing stopping it is that the base is written as an expression.

That is worth stating plainly because it changes the cost: supporting with_metaclass here does not require metaclasses. It requires evaluating the base expression. Metaclass support is only needed for html5lib's debug mode, which the corpus scan does not exercise.

lib/rtl/mimic_six.py therefore refuses with_metaclass with a message naming this ticket, rather than returning object — because returning object would be semantically correct for the meta is type case and still would not compile at the call site. The wall is the base expression, not the shim's answer.

Gate

The third and fourth cells above compile and print hi. Then mimic_six's with_metaclass can return object for meta is type (and keep refusing anything else, which genuinely does need metaclasses), and html5parser.py advances past line 426.


RE-MEASURED at HEAD 2026-08-19 — not fruit, and the ticket's own repro misdiagnoses it

Re-measured because the ticket said "Measured against pinned v345, not re-checked at HEAD". Still reproduces, but it is three separate defects, and the headline repro is not an instance of the bug the title names.

shape result what it actually is
B = object then class P(B) undefined variable (object) at line 1 fails in the ASSIGNMENT — object is not a first-class value. Nothing to do with the base position.
B = A then class P(B) unknown base class B the real base-position bug
class P(pick()) unknown base class pick base is an arbitrary call
class P(A) OK control

Two controls that decide the sizing:

B = A ; print(B().hi())                       # WORKS
B = A if len(sys.argv) > 99 else C            # a RUNTIME-chosen alias
print(B().hi())                               # WORKS -- prints C, like CPython

A class held in a variable is a genuine run-time value (a metaclass blob), resolved when it is called. There is therefore no compile-time record that B means class A — and a base class is needed at compile time, because it determines the layout and the vtable.

So the fix is not an extra arm in the base-name chain (pyparser.inc:32370-32403, which is pure name lookup: Exception, qualified, FindUClassNonRecord, PyBuiltinBaseCi, error). It is either:

Both are mechanisms. Parking rather than starting one.

The corpus argument in the summary does not hold

The frontmatter says this "blocks six.with_metaclass, which html5lib's parser spells as class Phase(with_metaclass(...)) — the single remaining wall on html5parser.py". Checked:

# html5lib/html5parser.py:426
class Phase(with_metaclass(getMetaclass(debug, log))):

That is the call shape, and its argument depends on a run-time flag, so it needs (b) — the harder half. Option (a), the tractable half, would not move html5parser.py at all. lib/rtl/mimic_six.py:97 already reaches the same conclusion and deliberately raises rather than pretending.

Also worth recording so it is not re-derived: html5parser.py's current first wall is not this at all. At HEAD it stops on decode has no parameter named 'final' ([[bug-nilpy-a-callable-in-a-variable-loses-to-a-def-of-the-same-name]]), which is upstream of the base-class question. So even (b) would not make html5parser.py compile today, and the "single remaining wall" claim is stale.

Leaving prio at 45. Suggest splitting out the object-as-a-value defect, which is unrelated to this title and may be much cheaper.


RESOLVED 2026-08-26 — option (a), and it needed no new mechanism

Re-measured at dev HEAD first (endpoint measurement before anything else): still broken, exactly the three-way split the 2026-08-19 pass recorded. Not already-fixed fruit.

The mechanism was already there, with one arm missing

The 2026-08-19 note concluded there is "no compile-time record that B means class A" and sized the work as a new constant-alias analysis. That is the one part of the diagnosis that was wrong, and it is why the ticket sat: the record exists. UClsAlias* (symtab.inc) is a compile-time name→class-row table, and FindUClassNonRecord — the very function the base position already calls — resolves through it.

So the base-name chain at PyParseClass needed no new arm at all. The whole fix is a pre-pass that puts a module-level B = A into the table that four other spellings of "this name means that class" already use:

spelling registered before?
from m import C as D yes
import m as mm (qualified base) yes
Pascal T = TList alias yes
a builtin base (dict, list) yes, own path
B = A no — the one that stayed broken

Five mechanisms served one concept and one was missing, which is the normalise-dont-special-case shape exactly. Registering it means every alias-aware consumer starts answering at once — PyIsExactCtorName, PyIsClassTypeExact, IsClassType, the construction intercept in pasparser_lval.inc — rather than the base position alone. Verified: isinstance through the alias, B is A, B(...) construction, and a chained alias (C = B after B = A) all work, and none of them were touched directly.

Rebinding is the whole difficulty, and it is why this is narrow

The table is flat and global with no notion of a program point, so a name bound more than once cannot be represented in it:

B = A ; B = C ; class P(B)      # CPython: C.  "First binding wins": A.

A wrong base is a wrong layout and a wrong vtable, and nothing downstream would say so — the exact silent-wrong-value failure the playbook is built around. So the pass registers a name only when the module binds it exactly once, counting every binding form (assignment, augmented assignment, as, a for target, a def/class of that name) at every depth. Anything else is left as it was: still a compile error, which is a refusal rather than a wrong answer.

Boundary — what varying the shape showed

shape before after note
class P(A) OK OK control
B = Aclass P(B) FAIL OK the ticket
B = AC = Bclass P(C) FAIL OK chain, free
B = AB = Cclass P(B) FAIL refused CPython says C; refusing beats guessing
B = A at module level, B = 5 in a def FAIL refused conservative: name bound twice
B = A if … else CB() OK OK control, must not become compile-time
B = AB(), isinstance(x, B), B is A OK OK controls
class P(pick()) FAIL FAIL a call — genuinely needs run-time class creation
B = object FAIL FAIL fails in the ASSIGNMENT — split out, see below

Filed rather than fixed

Regression test — a witness, in test-core

test/test_nilpy_class_base_is_an_alias.npy + .expected (generated by CPython, not written by hand), and test/test_nilpy_class_base_alias_rebound_refused.npy for the refusal half. Wired into test-core, not test-nilpy: test-core runs in the NATIVE tier (the fast watcher verdict) while test-nilpy is limited/full only.

Witness, not smoke: at the broken sha the first file does not compile at all (unknown base class Base at line 30), so no row in it could have passed by accident. The rebound file guards the dangerous direction — it must never start compiling to the first binding.

Gate

make compiler/pascal26 self-host fixedpoint byte-identical (converged in 1 round); both new test-core recipe lines pass; tools/gate.sh quick GREEN. No Track A file was modified — RegisterUClassAlias / FindUClassNonRecord are called, not changed.

Log