diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ddbb1..a3358c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Individual release notes live in [`changelog/`](changelog/). | Version | Date | Summary | |---------|------|---------| +| [v0.0.342](changelog/0.0.342.md) | 2026-04-29 | explicit `super(C, inst)` + metaclass `__instancecheck__`/`__subclasscheck__` -- `Class.Metaclass`, fixture 342 | | [v0.0.341](changelog/0.0.341.md) | 2026-04-29 | encoding handlers + `bytes.hex(sep, group)` -- `str.encode` honors `errors=`, `namereplace`, proper UnicodeEncodeError/DecodeError, fixture 341 | | [v0.0.340](changelog/0.0.340.md) | 2026-04-29 | exception API -- `add_note`/`__notes__`, `with_traceback`, `__suppress_context__`, BaseExceptionGroup `split`/`subgroup`/`derive`, fixture 340 | | [v0.0.339](changelog/0.0.339.md) | 2026-04-29 | async generators -- `__aiter__`/`__anext__`/`asend`/`athrow`/`aclose`, async for + comprehension, fixture 339 | diff --git a/changelog/0.0.342.md b/changelog/0.0.342.md new file mode 100644 index 0000000..46fb58e --- /dev/null +++ b/changelog/0.0.342.md @@ -0,0 +1,39 @@ +# v0.0.342 — 2026-04-29 + +Explicit `super(C, instance)` + metaclass `__instancecheck__` / +`__subclasscheck__` + +Closes gaps **B7** and **C3** from the v0.1.0 audit +(`notes/Spec/1500/1530_goipy_v01_gap_audit.md`). Spec at +`notes/Spec/1500/1536_goipy_v0042_super_metaclass.md`. + +- `object/super.go` (new): `Super{StartCls, Self}` proxy. +- `object/object.go`: `Class.Metaclass` field — set when a class + body uses `metaclass=` or inherits one from a base. +- `vm/builtins.go`: rewrote the `super` builtin. Two-arg form + `super(C, inst)` returns a `*object.Super`. Zero-arg form + reconstructs `(__class__, self)` from the calling frame's free + cells + `Fast[0]` — needed when CPython's compiler emits + `LOAD_GLOBAL super; CALL 0` instead of the optimized + `LOAD_SUPER_ATTR` (happens when the module also calls + `super(C, inst)` explicitly somewhere). +- `vm/builtins.go` `__build_class__`: honors the `metaclass=` + kwarg and inherits `Metaclass` from the most-derived base. +- `vm/builtins.go` `isinstance` / `issubclass`: prepend a + `metaInstanceCheck` / `metaSubclassCheck` step that, when a + metaclass exposes `__instancecheck__` / `__subclasscheck__`, + invokes it with `(cls, target)` and returns the truthy result. + Existing MRO + ABC paths are unchanged when no metaclass is set. +- `vm/ops.go` `getAttr`: `*object.Super` branch walks + `lookupAfter(typeOf(self), startCls, name)` and binds via + `bindDescriptor`. Mirrors what `LOAD_SUPER_ATTR` does inline. +- New fixture `internal/testdata/342_super_metaclass.py` — + super(B,b).hi() walking past B; three-deep MRO via explicit + super; diamond inheritance with mixed implicit/explicit super; + classmethod super; metaclass `__instancecheck__` and + `__subclasscheck__`; ABCMeta-style virtual subclass via + predicate; metaclass inherited via base. + +Out of scope: full metatype protocol (custom `__call__` / +`__new__` on the metaclass), 1-arg unbound `super()` descriptor +protocol, `type(cls)` returning the metaclass instance. diff --git a/internal/testdata/342_super_metaclass.expected.txt b/internal/testdata/342_super_metaclass.expected.txt new file mode 100644 index 0000000..6ac588d --- /dev/null +++ b/internal/testdata/342_super_metaclass.expected.txt @@ -0,0 +1,35 @@ +# section 1: super(B, b).hi() walks past B to A.hi +B.hi +A.hi +# section 2: three-deep MRO +C.hi +B.hi +A.hi +# section 3: diamond — explicit super matches implicit +G.hi/E.hi/F.hi/D.hi +E.hi/F.hi/D.hi +F.hi/D.hi +# section 4: super on classmethod still binds class +I:I +I +# section 5: metaclass __instancecheck__ overrides isinstance +True +True +True +# section 6: metaclass __subclasscheck__ overrides issubclass +True +True +# section 7: ABCMeta-style virtual subclass via predicate +True +False +False +# section 8: metaclass inherited via base class +True +True +False +# section 9: no metaclass → regular semantics still hold +True +True +False +True +False diff --git a/internal/testdata/342_super_metaclass.py b/internal/testdata/342_super_metaclass.py new file mode 100644 index 0000000..4fb6d98 --- /dev/null +++ b/internal/testdata/342_super_metaclass.py @@ -0,0 +1,179 @@ +# PEP-related: explicit super(C, instance) + metaclass __instancecheck__/__subclasscheck__. +# Section markers help diff against CPython. + +print("# section 1: super(B, b).hi() walks past B to A.hi") + + +class A: + def hi(self): + return "A.hi" + + +class B(A): + def hi(self): + return "B.hi" + + +b = B() +print(b.hi()) +print(super(B, b).hi()) + + +print("# section 2: three-deep MRO") + + +class C(B): + def hi(self): + return "C.hi" + + +c = C() +print(c.hi()) +print(super(C, c).hi()) +print(super(B, c).hi()) + + +print("# section 3: diamond — explicit super matches implicit") + + +class D: + def hi(self): + return "D.hi" + + +class E(D): + def hi(self): + return "E.hi" + "/" + super().hi() + + +class F(D): + def hi(self): + return "F.hi" + "/" + super().hi() + + +class G(E, F): + def hi(self): + return "G.hi" + "/" + super().hi() + + +g = G() +print(g.hi()) +print(super(G, g).hi()) +print(super(E, g).hi()) + + +print("# section 4: super on classmethod still binds class") + + +class H: + @classmethod + def klass(cls): + return cls.__name__ + + +class I(H): + @classmethod + def klass(cls): + return "I:" + super().klass() + + +print(I.klass()) +print(super(I, I).klass()) + + +print("# section 5: metaclass __instancecheck__ overrides isinstance") + + +class Meta(type): + def __instancecheck__(cls, inst): + return True + + +class Q(metaclass=Meta): + pass + + +print(isinstance([], Q)) +print(isinstance(42, Q)) +print(isinstance("x", Q)) + + +print("# section 6: metaclass __subclasscheck__ overrides issubclass") + + +class Meta2(type): + def __subclasscheck__(cls, sub): + return True + + +class R(metaclass=Meta2): + pass + + +print(issubclass(int, R)) +print(issubclass(list, R)) + + +print("# section 7: ABCMeta-style virtual subclass via predicate") + + +class DuckMeta(type): + def __instancecheck__(cls, inst): + return hasattr(inst, "quack") + + +class Duck(metaclass=DuckMeta): + pass + + +class Mallard: + def quack(self): + return "quack" + + +class NotADuck: + pass + + +print(isinstance(Mallard(), Duck)) +print(isinstance(NotADuck(), Duck)) +print(isinstance(42, Duck)) + + +print("# section 8: metaclass inherited via base class") + + +class Meta3(type): + def __instancecheck__(cls, inst): + return inst == "magic" + + +class S(metaclass=Meta3): + pass + + +class T(S): + pass + + +print(isinstance("magic", S)) +print(isinstance("magic", T)) +print(isinstance("nope", T)) + + +print("# section 9: no metaclass → regular semantics still hold") + + +class U: + pass + + +class V(U): + pass + + +print(isinstance(V(), U)) +print(isinstance(V(), V)) +print(isinstance(42, U)) +print(issubclass(V, U)) +print(issubclass(U, V)) diff --git a/object/object.go b/object/object.go index d7081f5..d2c3292 100644 --- a/object/object.go +++ b/object/object.go @@ -496,6 +496,12 @@ type Class struct { // EnumData is non-nil for enum subclasses (Color, Number, etc.). // It holds the member list, value map, and iteration order. EnumData *EnumData + + // Metaclass is the explicit metaclass declared via `class C(metaclass=M):`, + // or nil for the default `type` metaclass. When non-nil, isinstance() and + // issubclass() consult Metaclass.__instancecheck__ / + // __subclasscheck__ before the normal MRO walk. + Metaclass *Class } // MethodCacheEntry stores one cached classLookup result. diff --git a/object/super.go b/object/super.go new file mode 100644 index 0000000..de3a34e --- /dev/null +++ b/object/super.go @@ -0,0 +1,10 @@ +package object + +// Super represents a `super(StartCls, Self)` proxy. Attribute access on a +// Super walks the MRO of `Self`'s class starting strictly after StartCls. +// Methods returned by the lookup are bound to Self so calling looks like a +// regular bound-method call. +type Super struct { + StartCls *Class + Self Object +} diff --git a/vm/builtins.go b/vm/builtins.go index c8ee13a..62ae1cd 100644 --- a/vm/builtins.go +++ b/vm/builtins.go @@ -1082,10 +1082,17 @@ func (i *Interp) initBuiltins() { return object.True, nil }}) b.SetStr("isinstance", &object.BuiltinFunc{Name: "isinstance", Call: func(ii any, a []object.Object, _ *object.Dict) (object.Object, error) { + in := ii.(*Interp) + if r, ok, err := in.metaInstanceCheck(a[0], a[1]); ok { + return r, err + } return object.BoolOf(isinstance(a[0], a[1])), nil }}) b.SetStr("issubclass", &object.BuiltinFunc{Name: "issubclass", Call: func(ii any, a []object.Object, _ *object.Dict) (object.Object, error) { in := ii.(*Interp) + if r, ok, err := in.metaSubclassCheck(a[0], a[1]); ok { + return r, err + } ca, okA := a[0].(*object.Class) // Second arg may be a tuple of classes. if tup, ok := a[1].(*object.Tuple); ok { @@ -1277,10 +1284,27 @@ func (i *Interp) initBuiltins() { b.SetStr("staticmethod", &object.BuiltinFunc{Name: "staticmethod", Call: func(_ any, a []object.Object, _ *object.Dict) (object.Object, error) { return &object.StaticMethod{Fn: a[0]}, nil }}) - // super is a stub — LOAD_SUPER_ATTR handles the real work by receiving - // (super, __class__, self) on the stack. + // super(): zero-arg form reads __class__ + self from the calling frame + // (mirrors what LOAD_SUPER_ATTR does inline when the compiler emits it). + // Two-arg form `super(C, instance)` returns a Super proxy that walks MRO + // past C and binds methods to instance. b.SetStr("super", &object.BuiltinFunc{Name: "super", Call: func(ii any, a []object.Object, _ *object.Dict) (object.Object, error) { - return object.None, nil + in := ii.(*Interp) + if len(a) == 0 { + cls, self, ok := zeroArgSuperFromFrame(in.curFrame) + if !ok { + return nil, object.Errorf(in.runtimeErr, "super(): no arguments") + } + return &object.Super{StartCls: cls, Self: self}, nil + } + cls, ok := a[0].(*object.Class) + if !ok { + return nil, object.Errorf(in.typeErr, "super() argument 1 must be a type, not '%s'", object.TypeName(a[0])) + } + if len(a) < 2 { + return &object.Super{StartCls: cls}, nil + } + return &object.Super{StartCls: cls, Self: a[1]}, nil }}) b.SetStr("callable", &object.BuiltinFunc{Name: "callable", Call: func(_ any, a []object.Object, _ *object.Dict) (object.Object, error) { switch v := a[0].(type) { @@ -1483,6 +1507,25 @@ func (i *Interp) initBuiltins() { return nil, err } cls := &object.Class{Name: name, Bases: bases, Dict: ns} + // Honor `class Foo(..., metaclass=Meta)`: Meta is a class whose + // __instancecheck__/__subclasscheck__ govern isinstance/issubclass + // against Foo. When unset, default `type` semantics apply. + if kwds != nil { + if mv, ok := kwds.GetStr("metaclass"); ok { + if mc, ok := mv.(*object.Class); ok { + cls.Metaclass = mc + } + } + } + // Inherit metaclass from the most-derived base if no explicit one. + if cls.Metaclass == nil { + for _, b := range bases { + if b.Metaclass != nil { + cls.Metaclass = b.Metaclass + break + } + } + } // __slots__: parse from class namespace. If declared and no // base class has a __dict__ and '__dict__' isn't itself in // the slot list, instances of cls reject non-slot attr @@ -1606,6 +1649,89 @@ func reduceMinMax(in *Interp, a []object.Object, isMin bool) (object.Object, err return best, nil } +// zeroArgSuperFromFrame reconstructs the (StartCls, Self) pair that the +// LOAD_SUPER_ATTR opcode reads inline. Used when the compiler emits the +// generic `LOAD_GLOBAL super; CALL 0` pattern instead — happens when the +// optimizer can't prove `super` is the builtin (e.g. because the module also +// rebinds `super` somewhere). Reads __class__ from the frame's free vars and +// the first positional argument from Fast[0]. +func zeroArgSuperFromFrame(f *Frame) (*object.Class, object.Object, bool) { + if f == nil || f.Code == nil { + return nil, nil, false + } + // Cells (incl. free cells) live in Fast at indices marked FastCell/FastFree. + var cls *object.Class + for k, name := range f.Code.LocalsPlusNames { + if name != "__class__" { + continue + } + if k >= len(f.Code.LocalsPlusKinds) || k >= len(f.Fast) { + break + } + kind := f.Code.LocalsPlusKinds[k] + if kind&(object.FastCell|object.FastFree) == 0 { + continue + } + c, ok := f.Fast[k].(*object.Cell) + if !ok || c == nil { + break + } + v, set := c.Load() + if !set { + break + } + if cc, ok := v.(*object.Class); ok { + cls = cc + } + break + } + if cls == nil { + return nil, nil, false + } + if len(f.Fast) == 0 || f.Fast[0] == nil { + return nil, nil, false + } + return cls, f.Fast[0], true +} + +// metaInstanceCheck honours `metaclass.__instancecheck__(cls, inst)` for +// `isinstance(inst, cls)`. Returns (result, true, err) when the metaclass +// hook fired, otherwise (nil, false, nil) so callers fall through to the +// regular MRO/ABC path. ABCs like abc.ABCMeta keep going through their +// existing ABCCheck callback; this is purely additive. +func (i *Interp) metaInstanceCheck(o, t object.Object) (object.Object, bool, error) { + cls, ok := t.(*object.Class) + if !ok || cls.Metaclass == nil { + return nil, false, nil + } + hook, ok := classLookup(cls.Metaclass, "__instancecheck__") + if !ok { + return nil, false, nil + } + r, err := i.callObject(hook, []object.Object{cls, o}, nil) + if err != nil { + return nil, true, err + } + return object.BoolOf(object.Truthy(r)), true, nil +} + +// metaSubclassCheck mirrors metaInstanceCheck for `issubclass(sub, cls)`. +func (i *Interp) metaSubclassCheck(sub, t object.Object) (object.Object, bool, error) { + cls, ok := t.(*object.Class) + if !ok || cls.Metaclass == nil { + return nil, false, nil + } + hook, ok := classLookup(cls.Metaclass, "__subclasscheck__") + if !ok { + return nil, false, nil + } + r, err := i.callObject(hook, []object.Object{cls, sub}, nil) + if err != nil { + return nil, true, err + } + return object.BoolOf(object.Truthy(r)), true, nil +} + func isinstance(o, t object.Object) bool { if isUnionType(t) { inst := t.(*object.Instance) diff --git a/vm/ops.go b/vm/ops.go index 64cf435..9bc6360 100644 --- a/vm/ops.go +++ b/vm/ops.go @@ -437,6 +437,35 @@ func (i *Interp) length(v object.Object) (int64, error) { // --- attribute access --- func (i *Interp) getAttr(o object.Object, name string) (object.Object, error) { + // Explicit super(StartCls, Self): walk MRO past StartCls and bind to Self. + // Mirrors what LOAD_SUPER_ATTR does inline for zero-arg super(). + if sup, ok := o.(*object.Super); ok { + if sup.Self == nil { + return nil, object.Errorf(i.attrErr, "'super' object has no attribute '%s'", name) + } + var instCls *object.Class + switch s := sup.Self.(type) { + case *object.Instance: + instCls = s.Class + case *object.Class: + instCls = s + default: + if c, ok := sup.Self.(*object.Class); ok { + instCls = c + } + } + if instCls == nil { + return nil, object.Errorf(i.typeErr, "super(): instance has no class") + } + v, found := lookupAfter(instCls, sup.StartCls, name) + if !found { + return nil, object.Errorf(i.attrErr, "'super' object has no attribute '%s'", name) + } + if inst, ok := sup.Self.(*object.Instance); ok { + return i.bindDescriptor(v, inst, instCls) + } + return i.bindDescriptor(v, nil, instCls) + } // Method lookup for str. if s, ok := o.(*object.Str); ok { if m, ok := strMethod(s, name); ok {