Skip to content

v0.10.1: backlog patches across exceptions, slices, ranges#17

Merged
tamnd merged 84 commits into
mainfrom
feat/v0.10.1-backlog
May 7, 2026
Merged

v0.10.1: backlog patches across exceptions, slices, ranges#17
tamnd merged 84 commits into
mainfrom
feat/v0.10.1-backlog

Conversation

@tamnd

@tamnd tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner

First slice of the v0.10.1 backlog. These are small isolated ports that piled up across v0.2 through v0.10 and aren't tied to any one panel.

What's in here

  • Ellipsis singleton, modeled after the NotImplemented one
  • Exception classes that were still missing: warnings (Warning + 10 subclasses), syntax (SyntaxError, IndentationError, TabError, _IncompleteInputError), os (OSError + 15 subclasses with errno mapping), unicode (UnicodeError + 3 variants), group (BaseExceptionGroup, ExceptionGroup), and FloatingPointError
  • types.SimpleNamespace with eq/ne via dict comparison
  • PyCapsule opaque-pointer container
  • PySlice_Unpack / AdjustIndices / GetIndicesEx and slice.indices(length)
  • range_iterator split into its own file with length_hint

Notes

  • errno aliases on darwin (EAGAIN == EWOULDBLOCK) needed function-init dedup so the first entry wins, matching CPython's ADD_ERRNO order
  • range length_hint uses big.Int ceiling division so the hint stays correct for partly consumed iterators
  • .golangci.yml exclusion extended to errors/exc_*.go so the PyExc_ singletons stop tripping revive's var-naming check

Test plan

  • go test ./... -count=1
  • golangci-lint run ./...

tamnd added 20 commits May 6, 2026 17:17
Mirrors CPython's Py_Ellipsis: a single ellipsisObject of type
ellipsis with Repr/Str returning "Ellipsis". Provides Ellipsis()
and IsEllipsis() to match the Py_Ellipsis / Py_IsEllipsis surface.
Adds the panels left out of the v0.3 first cut:

- ArithmeticError gains FloatingPointError so the trio matches
  CPython (ZeroDivision, Overflow, FloatingPoint).
- Warning plus the eleven Warning subclasses (User, Deprecation,
  PendingDeprecation, Syntax, Runtime, Future, Import, Unicode,
  Bytes, Resource, Encoding) so the warnings module can raise them.
- SyntaxError plus IndentationError, TabError, and the private
  _IncompleteInputError that the parser uses for partial input.
- OSError plus its fifteen subclasses, and ErrnoSubclass(errno) for
  the errnomap promotion CPython does inside OSError.__new__.
  Aliasing errnos (EAGAIN/EWOULDBLOCK on Linux) are deduped so the
  first ADD_ERRNO call wins, matching the C order.
- UnicodeError plus the Encode/Decode/Translate variants.
- BaseExceptionGroup and ExceptionGroup for PEP 654 support.

The PyExc_ prefix carries underscores into Go names; the lint
exclusion that already covered errors/builtins.go is widened to
cover errors/exc_*.go for the same reason.
Mirrors namespaceobject.c: a Header-backed object whose attributes
live in a plain Dict, with attribute access wired to GetItem /
SetItem / DelItem on that dict. Repr formats name=value pairs in
sorted-key order. Equality compares the underlying dicts pairwise
through RichCmpBool so SimpleNamespace(a=1) == SimpleNamespace(a=1)
holds.
Wraps an arbitrary Go value behind a name. NewCapsule rejects nil
pointers (CPython raises ValueError); GetPointer requires the
caller to present the same name the capsule was created with.
Context() and SetContext() expose the user-data slot so existing
callers that round-trip extension state through the capsule keep
working.
Routes slice subscripting through Unpack, AdjustIndices, and
GetIndicesEx so list[a:b:c] and friends can resolve to int triples
plus a length. Also adds slice.indices(length) on the Slice type.

CPython: Objects/sliceobject.c
Moves rangeIterator out of range.go and adds LengthHint so callers
like sorted() and reversed() can pre-size buffers from a partly
consumed iterator. Ceil((stop - cur) / step) via big.Int, clamped to
zero in either direction.

CPython: Objects/rangeobject.c:1006 longrangeiter_len
CPython: Objects/rangeobject.c:1128 PyLongRangeIter_Type
Builds the four iterator adapters that builtins layer on top of
tp_iter. SeqIter is the iter(seq) fallback for objects with only
__getitem__; CallIter handles the iter(callable, sentinel) form;
enumerate yields (index, item) pairs with a big.Int index so it
stays correct past int64; reversed walks a sequence backward via
SequenceMethods.GetItem.

CPython: Objects/iterobject.c:21 PySeqIter_Type
CPython: Objects/iterobject.c:171 PyCallIter_Type
CPython: Python/bltinmodule.c:1455 PyEnum_Type
CPython: Python/bltinmodule.c:2724 PyReversed_Type
Both walk the items and compare with PyObject_RichCompareBool /
Py_EQ, the same dispatch CPython uses. Index follows CPython's bound
normalisation: negative start/stop offset by len, clamp to zero,
clamp stop to len, raise ValueError on miss.

CPython: Objects/tupleobject.c:765 tuple_index
CPython: Objects/tupleobject.c:822 tuple_count
The previous formula was a simple XOR-fold with rotation. Replace it
with the actual CPython algorithm: per-element shuffle_bits, XOR
combine, count-mixed multiplier, and avalanche dispersion. The -1
sentinel is mapped to 590923713 the way frozenset_hash does.

CPython: Objects/setobject.c:768 _shuffle_bits
CPython: Objects/setobject.c:793 frozenset_hash
Adds the set-vs-set algebra plus the subset/superset/disjoint
predicates and an in-place Update. Result type follows CPython:
self.Union(...) returns a frozenset when self is a frozenset.

CPython: Objects/setobject.c:1448 set_union
CPython: Objects/setobject.c:1611 set_intersection
CPython: Objects/setobject.c:1750 set_difference_multi
CPython: Objects/setobject.c:1881 set_symmetric_difference
CPython: Objects/setobject.c:1986 set_issubset
CPython: Objects/setobject.c:2185 set_isdisjoint
Adds the explicit allocation function lists use under the hood:
new_allocated = (newsize + (newsize >> 3) + 6) & ~3, with the
shrink-below-half rule and the don't-overallocate-tiny-jump
collapse. listGrowthCurve is the same formula exposed for tests so
the canonical 0/4/8/16/24/32/40/52/64/76 pattern stays pinned.

CPython: Objects/listobject.c:55 list_resize
Wires Type.Repr / Type.Str so Repr(IntType) yields "<class 'int'>"
instead of the generic "<type object at addr>" fallback. Adds a
Module field for types defined in non-builtin modules so the qualified
"<class 'pkg.Name'>" form surfaces when set; an empty or "builtins"
module is hidden, matching CPython.

CPython: Objects/typeobject.c:1268 type_repr
Adds IsTrue/IsFalse/IsBool singleton checks plus the PyObject_*
container free functions (Length, GetItem, SetItem, DelItem, Contains,
Iter, IterNext, TypeOf) so callers stop reaching into type slots
directly.
Adds HashBytes/HashString thin wrappers around hash.Buffer and rewires
strStub.Hash to use them. Drops the inline FNV-1a placeholder; every
buffer-shaped value now goes through the same SipHash-1-3 path that
reads the runtime hash secret.
Adds NewStructSeqType / NewStructSeq for the named-tuple types CPython
uses for sys.flags, sys.version_info, os.stat_result, etc. Instances
behave like tuples for indexing, iteration, hashing, and richcompare;
Getattro maps field names onto positional slots.
Pulls the -5..256 PyLong_SMALL_INTS cache out of int.go and into its
own file with smallIntFromInt64 / smallIntFromBig helpers. NewInt and
NewIntFromBig now route through the helpers so the hit path is one
function call and the cache window only lives in one place.
…cores

IntFromString accepts the same shapes int(s, base) does in CPython:
optional whitespace, optional sign, optional 0x/0o/0b prefix when base
is 0 or matches, PEP 515 underscores between digits, and arbitrary
precision through math/big. Misplaced underscores, dangling prefixes,
and invalid digits all surface as ValueError.
Pulls intAnd / intOr / intXor / intInvert / intLshift / intRshift out
of int.go. Adds a shared shiftOperands helper so lshift and rshift
share the negative-count and overflow checks instead of repeating
them.
Pulls add/sub/mul/true-div/floor-div/mod/divmod/pow out of int.go.
The split keeps int.go limited to type setup, repr/hash/richcmp, and
the unary slots; the binary-arithmetic panel is now line-by-line
parallel to the long_add/long_sub/... ordering in CPython's
longobject.c.
Continues the longobject.c-shaped split. int.go keeps just the
struct, type singleton, slot wiring, and the constructors; everything
slot-shaped lives next to its peers in long_arith / long_bitwise /
long_misc.
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

Finished the longobject.c-shaped split. int.go is back to just the struct, type singleton, slot wiring, and constructors. The slot bodies live next to their peers in long_arith / long_bitwise / long_misc, which lines up with the file layout CPython uses.

…s.go

These three slot-bundle structs lived at the bottom of type.go but
they aren't really part of the Type definition; they're the protocol
panels Type points at. Pulling them into slots.go matches CPython's
layout in Include/cpython/object.h and keeps type.go focused on the
typeobject itself.
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

Pulled NumberMethods, SequenceMethods, and MappingMethods into their own slots.go. type.go was getting noisy with the bundle structs at the bottom and these are really the protocol panels that hang off the type, not part of the typeobject definition itself. Same arrangement as Include/cpython/object.h.

…ar/copy) into list_misc.go

Adds the user-facing list methods that listobject.c exposes through
list_methods. They were missing entirely from gopy; the runtime had
Append and SetSlice and that was it. Also moves listRichCmp out of
list.go to keep the slot dispatch table separate from the method
panel.
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

list had Append/SetSlice and nothing else. Ported the rest of the listobject.c method panel: count, index, insert, remove, pop, reverse, clear, copy. Sort still needs Timsort and lives in its own task. Also moved listRichCmp out of list.go into list_misc since the slot dispatch table doesn't need to share a file with the slot bodies.

The previous repr leaned on strconv.FormatFloat 'g' -1, which gives
the right digits but flips to exponential at a different threshold
than CPython. repr(1e15) was coming out as "1e+15" instead of
"1000000000000000.0", and integral floats lost their trailing ".0".

Ports format_float_short's layout decision: take the shortest digit
string from Go's 'e' -1 form, then route to fixed or exponential
output based on CPython's decpt > 16 / decpt <= -4 rule, padding
zeros and adding ".0" the way Py_DTSF_ADD_DOT_0 asks for.

Test panel pins repr against CPython 3.14 across the boundary
values (0, -0.0, 1e15, 1e16, 0.0001, 0.00001, nan, inf).
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

Ported float repr to match CPython's shortest-roundtrip output. The old code used Go's strconv 'g' -1, which produces the right digits but flips to exponential form at a smaller magnitude than CPython does. Now repr(1e15) gives "1000000000000000.0" instead of "1e+15", and integral floats keep their trailing ".0" via the Py_DTSF_ADD_DOT_0 behaviour. Test panel pins the boundary cases (0, -0.0, 1e15/1e16 hop, 0.0001/0.00001 hop, nan, inf).

…rscores

float() didn't have a string parser at all. Ported PyFloat_FromString
to objects/float_parse.go: trim whitespace, peel + or - sign,
recognise inf / infinity / nan case-insensitively, accept PEP 515
underscores between digits, and hand the remainder to
strconv.ParseFloat for the actual digit-to-double step.

Test panel covers the accept side (signs, exponents, underscores,
inf, nan) and the reject side (empty, lone sign, dangling
underscores, underscores adjacent to dot or exponent marker).
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

float() never had a string parser, so reaching for float("1.5") from any builtin path would fall through. Ported PyFloat_FromString: whitespace trim, optional sign, inf/infinity/nan tokens (case-insensitive), PEP 515 underscores between digits. The actual digit-to-double bit goes through strconv.ParseFloat since Go ships the same shortest-roundtrip behaviour CPython gets out of _Py_dg_strtod.

bytes was missing entirely. Ported the immutable bytes type:
constructors (NewBytes, NewBytesFromString, EmptyBytes singleton),
the b'...' repr with quote selection and \x escapes, hash through
the runtime SipHash dispatcher, lexicographic richcmp, and the
sequence panel for length/getitem/contains. Methods (replace, split,
decode, ...) come in the next task.
Adds TpTraverse to cell, bound method, classmethod, staticmethod,
frame (including the activation-record's globals/builtins/locals/fast/
cell/free locals plus the back-chain), coroutine wrapper, and the two
async generator awaitables. Generators, coroutines, and async
generators themselves carry no Object fields in gopy (the suspended
frame lives on the goroutine), so they remain leaves.

Splits gc_gil.c into gc/gil.go as a documented no-op: gopy has no
per-type freelists for _PyGC_ClearAllFreeLists to clear.
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

Filled in two of the three remaining 1613 gaps.

tp_traverse was only on dict/list/tuple/set/odict; everything else that can host a cycle had no slot, so the GC would treat them as leaves. Added impls for cell, bound method, classmethod, staticmethod, frame, coroutine wrapper, and the two async-generator awaitables. The frame one is the meaty one — it walks globals/builtins/locals plus the fast/cell/free arrays through the InterpreterFrame interface, then follows the back-chain. Generator/Coroutine/AsyncGenerator themselves stay leaves: in gopy the suspended frame lives on the goroutine, not as an Object field on the generator, so there's no edge to traverse from the gen object's POV.

For the gc_gil piece — that file in CPython is one function, _PyGC_ClearAllFreeLists, which drops cached objects from per-type freelists at the end of a top-gen pass. gopy has no per-type freelists (Go's allocator owns it), so the parity is just a documented no-op. Pulled the inline stub out of collector.go and gave it a real file at gc/gil.go so the absence is explicit instead of buried.

Tests at objects/traverse_more_test.go pin every new traverse impl, including the frame back-chain walk via a fakeInterp stand-in. The gc.garbage resurrection work is still open as the third task.

The collector reclaimed every unreachable cycle unconditionally, even
when a finalizer took a fresh ref. Add handleResurrected after
finalize_garbage to redo the deduce pass on the post-finalize list and
pull resurrected objects back to a generation. DEBUG_SAVEALL now
appends the still-dead set to the gc.garbage list, which is the same
*objects.List the gc module exposes, so user code that reads or clears
gc.garbage sees the runtime view directly. Mirrors gc.c:1261
handle_resurrected_objects and the SAVEALL branch of gc.c:1142
delete_garbage.
@tamnd

tamnd commented May 6, 2026

Copy link
Copy Markdown
Owner Author

1613-P done. The collector now does a proper resurrection pass after finalize_garbage instead of trusting Go's GC to keep refloated objects alive: handleResurrected strips the COLLECTING/UNREACHABLE flags off the post-finalize list, re-runs updateRefs / subtractRefs / moveUnreachable, and splits survivors out into a fresh stillUnreachable head. Anything a finalizer Increfs is back in tracked state by the time Collect returns, mirroring gc.c:1261.

DEBUG_SAVEALL is wired through end-to-end. The module's gc.garbage attribute and state.garbage are now the same *objects.List, registered via a SetGarbage hook that mirrors the existing SetCallbacks pattern. When the bit is set, appendGarbage walks the still-dead list and pushes each object onto gc.garbage; without it the list stays empty (PEP-442-only world has no legacy __del__ path to populate it otherwise). uncollectable stays at zero in gopy because the legacy-finalizer panel is empty by construction.

Tests in gc/garbage_test.go cover: empty by default, populated under SaveAll, the module attribute and the runtime view share storage, finalizer-driven resurrection keeps both cycle members tracked, the resurrected finalizer fires exactly once across two collections, and SaveAll-skipped-when-disabled. Existing cycle/finalize tests still pass; resurrection's new pre-pass leaves them unchanged because no finalizer Increfs.

Spec 1613-N/-O/-P now all show [x] on the v0.10.x remaining-work list. Filed a follow-up to walk every scenario in cpython/Lib/test/test_gc.py and pin the matching gopy assertion so we can claim 100% parity rather than just "the obvious cases pass."

Walks the GCTests panel in cpython/Lib/test/test_gc.py and translates
every scenario gopy can run today into a Go counterpart. Each test
carries the matching CPython source citation and an explicit
assertion against the corresponding gc surface (cycle reclaim per
container type, get_referents shape, is_tracked / is_finalized
membership, get_count / collect_generations promotion, get_stats
panel and counter increments, freeze / unfreeze round-trip,
get_objects per-generation visibility, the three resurrection
invariants from PEP 442, and the bug21435 regression). Scenarios
that hinge on user classes, capsules, the trashcan, or collector
reentrancy are pinned as TestParityGapsTracked subtests with a
citation and the gating spec/task ID, so the gap is discoverable
from `go test -v` rather than dropped silently.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

test_gc.py parity sweep landed in commit 8e32f09. Walked the GCTests panel end to end and shipped a 1:1 Go counterpart for every scenario gopy can execute today, each carrying a // CPython: Lib/test/test_gc.py:LINENUM citation:

  • Container-shape cycles: list self-loop, dict self-loop, list+tuple cycle (the one CPython has to close through a list because tuples are immutable).
  • Inspection panel: get_referents over list/tuple/dict plus the atom-returns-empty case, is_tracked membership for both atoms and containers, is_finalized across resurrection (the bit must persist when the object refloats).
  • Generation accounting: get_count after a fresh allocation, collect_generations promotion from gen0 to gen1 after Collect(0), get_stats panel shape and per-generation collections deltas, freeze / unfreeze round-trip, get_objects(-1) and get_objects(0) visibility before and after a full collect.
  • PEP 442 invariants: finalizer fires once even across resurrection, resurrection is transitive (cargo refloats with Lazarus), and a resurrecting cycle must not block reclaim of unrelated cycles in the same Collect. The third one also pins that the resurrected count does not inflate stats[gen].collected.
  • bug21435 regression.

Scenarios that hinge on user classes, capsules, the trashcan, or collector reentrancy are pinned as TestParityGapsTracked subtests with an explicit citation and the gating spec/task ID, so the gap is discoverable from go test -v ./gc/... rather than dropped silently. As __build_class__ (#356), exec() (#344), and friends land, those Skip lines flip into real ports.

Lib/weakref.py and Lib/_weakrefset.py wrap a regular set/dict with a
small _remove closure installed as the weakref callback so dead
referents drop out automatically. The Go port mirrors that shape:
each container is a *Header-embedding type whose internal map is
keyed by *objects.Weakref, and a per-container BuiltinFunction
removes the matching entry when handle_weakrefs fires the callback.

TpTraverse on each container visits the keys/values that should
participate in cycle detection but skips the weakly-referenced side,
preserving weak semantics. Tests exercise the API surface plus the
end-to-end gc.Track -> gc.Collect -> _remove path that actually
proves the collector wiring works.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

WeakSet, WeakValueDictionary, and WeakKeyDictionary now exist as Go types in a new weakref/ package. The shape mirrors Lib/_weakrefset.py and Lib/weakref.py: each container's storage is a map keyed by *objects.Weakref, and a per-container BuiltinFunction acts as the _remove callback that the GC's handle_weakrefs path fires when a referent dies.

TpTraverse on each container visits the side that should keep things alive (strong values for WeakValueDictionary, strong values + weakref keys for WeakKeyDictionary, just the stored weakrefs for WeakSet) but never the weakly-held side, so cycle detection sees the right boundary.

Tests cover the API (add/remove/discard/contains/iter/update/copy/pop on the set side, get/set/del/contains/keys/values/items/update on the dict side) plus the end-to-end gc.Track -> gc.Collect -> _remove path that proves the collector wiring actually fires.

One known leak: each _remove closure captures the container strongly, so an abandoned container won't be reclaimed until every weakref it issued has died. CPython sidesteps this by capturing ref(self) in the closure; matching that needs a TpTraverse-aware closure shape, which I tracked as 1613-S in the spec for a follow-up pass.

Wraps Bytes / ByteArray / MemoryView. Indexing returns an int byte;
slicing returns a new MemoryView (alias for step==1, copy otherwise).
Exposes the standard buffer-protocol attributes (format="B", itemsize,
nbytes, readonly, ndim=1, shape, strides, suboffsets, c_contiguous,
f_contiguous, contiguous, obj). Hashing only on read-only views;
equality compares against any bytes-like.

Multi-format and writable views are deferred to 1689-B once Type
grows a buffer slot.

CPython: Objects/memoryobject.c:3402 PyMemoryView_Type
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Added objects/memoryview.go for the v0.10 surface: 1-D contiguous unsigned-byte views over bytes / bytearray / another memoryview. Integer indexing returns an int (byte value); slicing returns a new view (aliasing the underlying slice for step==1, copying for stepped slices since the slice is no longer contiguous in the source). The buffer-protocol attributes — format, itemsize, nbytes, readonly, ndim, shape, strides, suboffsets, the contiguity flags, obj — all come back from __getattr__. Equality runs against any bytes-like via a small bytesViewOf helper, and hashing is gated on readonly like CPython.

Writable views and the PEP 3118 multi-format path are still deferred to 1689-B; they need a real buffer slot on Type first. Tests cover the lot (15 cases): wrapping, indexing, both slice shapes, iteration, attributes, equality vs bytes, hash, tobytes/tolist, and the rejection path for non-bytes-like inputs.

LOAD_BUILD_CLASS used to error out; now it pushes the builtin, the
inner class body runs with the class namespace as f_locals, and the
result flows through type(name, bases, ns) to NewUserType. Method
descriptors bind through Function's tp_descr_get.

Filling in the path uncovered three smaller bugs that had to be
fixed before the end-to-end tests could pass: actionAstClassDef was
a placeholder that swallowed every class statement at parse time;
argumentsOf did not unwrap the []any{*ast.Arguments} shape so every
def had Args=[] (so methods saw self as a global); LOAD_ATTR's oparg
was missing the <<1 shift, so c.foo looked up Names[0] instead of
Names[1]. The second const-table entry sometimes pointing at a
nested code object also needed a wrapConst case to lift it.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Got class C(...): ... actually working through __build_class__.

The path was longer than I expected. LOAD_BUILD_CLASS now resolves
the builtin from f.Builtins (falling back to f.Globals for the
test case where the builtins dict is the only scope), and the new
vm/build_class.go runs the inner body with the class namespace as
f_locals before handing it to type(name, bases, ns).

To make type(...) actually allocate user instances I added an
IsUser flag (the gopy stand-in for Py_TPFLAGS_HEAPTYPE), an Instance
struct that holds a dict, NewUserType to consume the namespace,
and FunctionType.DescrGet so methods bind through the descriptor
protocol when looked up on an instance.

Three latent bugs in the parser/compiler showed up only once classes
exercised the full path:

  • actionAstClassDef was a placeholder, so every class C: ... parsed
    to an empty Module body. Wired up the real implementation in
    action_helpers_gen.go and added the name to the parser-gen excluded
    list.
  • argumentsOf didn't unwrap the []any{*ast.Arguments} shape the
    grammar passes through params, so every def f(self, ...): ...
    produced FunctionDef with Args=[] and the body treated parameters
    as globals (LOAD_GLOBAL self instead of LOAD_FAST self). Fixing
    this also fixes plain top-level defs that take arguments.
  • LOAD_ATTR's oparg lost the <<1 shift CPython packs in for the
    method bit, so c.foo was looking up Names[0] instead of Names[1].
    Same shift that codegen.c:codegen_addop_name applies.

Also a tiny wrapConst case for *compile.Code so MAKE_FUNCTION sees
an *objects.Code when a const slot points to a nested code object.

Eight end-to-end tests in vm/build_class_test.go cover empty body,
class attributes, instantiation, instance attr set/get, method bind

  • call, __init__ with args, single-base inheritance, and the raw
    function returned by C.foo. All green; full suite and lint clean.

tamnd added 3 commits May 7, 2026 10:17
The vm reads LOAD_GLOBAL's oparg as (idx<<1)|push_null. addOpName
was only shifting LOAD_ATTR, so the second LOAD_GLOBAL inside a
function (idx 1) ended up looking like 'push NULL plus idx 0' to
the interpreter and the wrong global got loaded with a stray NULL
on the stack. Surfaced once super(B, self) gave us a function that
loads two distinct globals.

CPython: Python/codegen.c:L354 codegen_addop_name
CPython 3.14 dropped MAKE_FUNCTION's flags oparg and made
attribute attachment a separate op: MAKE_FUNCTION just wraps the
code object and one SET_FUNCTION_ATTRIBUTE per attribute (closure,
annotations, kwdefaults, defaults) stamps the value pushed in the
outer scope onto the new function. The vm already handled
SET_FUNCTION_ATTRIBUTE but compile was still folding the flags
into MAKE_FUNCTION's oparg, so closure tuples ended up stranded on
the stack and inner functions hit COPY_FREE_VARS with no closure.

CPython: Python/codegen.c:L923 codegen_make_closure
The objects layer gets a Super type that mirrors CPython's
superobject: __getattr__ walks the MRO past the named type, and
the descriptor protocol rebinds super(C) to super(C, owner) for
the implicit-instance form. Type call dispatch routes super(...)
through SuperType.Call; supercheck enforces the type/instance
relationship.

Class bodies now stamp __class__ as an implicit cell when any
method references super (or __class__ directly): the codegen
prologue emits MAKE_CELL for it, the epilogue copies it into
__classcell__, and __build_class__ patches the cell with the
freshly built class so super(C, self) inside C's methods resolves
correctly.

CPython: Objects/typeobject.c:11769 superobject
CPython: Python/codegen.c:L1515 codegen_class_body class-cell
prologue/epilogue
CPython: Objects/typeobject.c:4474 type_new_set_classcell
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

super lands on this branch — super(B, self).method() and super(B, self).__init__(...) work end-to-end through MakeFunction → build_class.

The objects.Super type mirrors superobject: getattr walks the owner's MRO past the named type, the descriptor protocol rebinds super(C) to super(C, owner), and the type call slot dispatches both the one-arg and two-arg forms (zero-arg super() still raises until I wire frame introspection in a follow-up).

Two compile-side bugs surfaced while building the e2e tests and got their own commits up front:

  • LOAD_GLOBAL was emitting the raw name index instead of (idx<<1). Worked fine while every function only used a single global; broke the moment super(B, self) needed two distinct globals (the second was decoded as "push NULL plus index 0").
  • MAKE_FUNCTION still folded the closure / defaults / annotations bits into its oparg, but the 3.14 vm expects them as separate SET_FUNCTION_ATTRIBUTE ops. Closure tuples were getting stranded on the stack and inner functions hit COPY_FREE_VARS: frame has no closure as soon as anything tried to capture a cell.

Class bodies now stamp __class__ as an implicit cell whenever any method references super (or __class__ itself) — the codegen prologue emits MAKE_CELL, the epilogue copies it into __classcell__, and NewUserType patches the cell with the freshly built class.

Coverage:

  • objects/super_test.go — 11 unit tests at the type layer (MRO walk, supercheck, repr, descriptor rebind, kwargs rejection)
  • vm/super_test.go — three e2e Python sources: two-arg chain, grandparent reach via MRO, and the canonical super().__init__ chain

Next up: #367 __slots__.

Plumbs __slots__ through NewUserType so a class that declares it gets a
fixed-index slot array instead of the implicit per-instance __dict__.
Each slot becomes a MemberDescr (data descriptor) on the type; reads
and writes route through DescrGet/DescrSet against Instance.slots, and
attribute writes for any name that isn't a slot or descriptor raise
AttributeError when no dict is present.

Mirrors CPython's type_new_slots / type_new_descriptors split: the slot
list is validated up front (identifiers, no class-variable conflicts,
__dict__/__weakref__ specials), then each entry is registered as a
PyMemberDef-style descriptor at a fixed offset.

Drops the trailing period on a super.go error string while here.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

slots landed in dc9ab25.

NewUserType now reads __slots__ out of the class namespace before walking it for descriptors. Each slot becomes a MemberDescr on the type at a fixed index; the instance carries a parallel slots []Object array and skips the per-instance __dict__ unless __slots__ explicitly lists __dict__ or a base contributes one. Non-slot writes on a __slots__-only class hit the tp_setattro fallthrough and raise AttributeError, matching PyObject_GenericSetAttr when tp_dictoffset == 0.

Validation mirrors type_new_visit_slots/type_new_copy_slots: __slots__ may be a string or a tuple/list of strings, names must be identifiers, and a slot whose name collides with a class-body assignment raises ValueError. __weakref__ is recognised but skipped (no per-instance weakref offset yet). Reading an unset slot raises AttributeError, matching Py_T_OBJECT_EX.

Coverage:

  • objects/slots_test.go exercises the descriptor install path directly.
  • vm/slots_test.go runs the full class C: __slots__ = (...) source through compile + __build_class__.

Wires the parser + compile pipeline behind the compile(source, filename,
mode, ...) builtin. The mode string maps to parser.ModeFile / ModeEval /
ModeSingle; the resulting compile.Code is lifted to objects.Code so the
returned value can flow into vm.EvalCode (or the eval/exec builtins
landing alongside this).

Argument validation matches CPython: ValueError on bad mode / out-of-
range optimize, TypeError on missing required args, wrong types, or
duplicate keyword arg. flags is parsed for signature parity (the AST
bits are rejected because gopy has no Python-level AST), dont_inherit
is accepted but ignored (gopy carries no compiler-flags context to
inherit), and func_type is rejected with the same wording CPython uses
when PyCF_ONLY_AST is missing.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

compile() landed in d043a68.

The builtin now wraps the existing parser + compile pipeline: mode picks the parser entry (exec -> file, eval -> expression, single -> single statement), and the produced compile.Code is lifted to objects.Code so callers can hand the result to EvalCode (and to the upcoming eval/exec builtins).

Validation mirrors CPython:

  • mode must be one of exec/eval/single. func_type carries the same "requires PyCF_ONLY_AST" rejection wording, since gopy still lacks Python-level AST.
  • optimize must be in [-1, 2]; anything else is a ValueError.
  • flags is recognised but the only bits that would change behaviour (PyCF_ONLY_AST / PyCF_OPTIMIZED_AST) are rejected for the same reason as func_type. Other future-statement / dont_imply_dedent bits are silently accepted because they don't alter codegen output here yet.
  • dont_inherit is parsed for signature parity; gopy has no surrounding compiler-flags context to inherit, so the value is a no-op.

Coverage:

  • builtins/compile_test.go covers the three real modes, kwarg routing, missing-arg / wrong-type / unknown-kwarg / out-of-range errors, and the func_type rejection.
  • vm/compile_builtin_test.go runs an end-to-end pipeline: compile() -> EvalCode, and confirms the produced code object actually executes.

Both builtins delegate to the same compile + EvalCode pipeline. The
v0.10.1 cut wires the source coercion (str -> compile, code passed
through), the (globals, locals) defaulting from the running frame via
SetCurrentScope, and the (globals must be a real dict, locals can be
any object) validation CPython enforces.

vm.builtins_hook installs a SetEvaluator hook that grabs the current
state.Thread (allocating a fresh one when called outside any frame, so
library callers work too) and dispatches through EvalCode. eval() returns
the expression value; exec() returns None on success and propagates the
real Python error otherwise.

Closure tuples for exec(code, ..., closure=...) are not yet supported;
the hook rejects unknown kwargs so the call surfaces a TypeError today
rather than silently ignoring closure cells.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

eval() and exec() landed in dabbe1a. They share so much shape (the same source coercion, the same globals/locals defaulting, the same evaluator hook) that splitting them across two commits would have been pure noise.

The dispatch goes:

  • str source -> parser.ParseString (eval mode for eval(), file mode for exec()) -> compile.Compile -> lift to objects.Code.
  • code object source -> passed through.
  • both then call the Evaluator hook installed by vm/builtins_hook.go, which grabs the active state.Thread (or allocates a fresh one when there is no running frame, so library callers work) and routes through EvalCode.

Defaulting matches CPython: globals must be a real dict; when omitted (or None), it pulls from the running frame via the existing SetCurrentScope hook. locals defaults to globals when only globals was given, and to the running frame's locals when both are omitted. eval() returns the expression value, exec() returns None on success.

Closure tuples for exec(code, ..., closure=...) are not wired yet: the panel rejects unknown kwargs so any closure= call surfaces a TypeError today instead of silently dropping the closure cells. That can come back as a follow-up if anything in CPython's testsuite reaches for it.

Coverage:

  • builtins/eval_test.go exercises the arg routing and validation through a captured evaluator hook (no vm dependency).
  • vm/eval_builtin_test.go runs the real pipeline: eval("3*14", g), exec("answer = 6*7\n", g), and eval(compile("21+21","<t>","eval"), g).

Marking #345 and #344 done.

The full _io tree splits raw / buffered / text I/O across PyFileIO,
PyBufferedReader / PyBufferedWriter, and PyTextIOWrapper, with io.open
gluing them together. v0.10.1 collapses that stack into one File type
(objects/file.go) backed by *os.File: read modes wrap a bufio.Reader,
write/append modes wrap a bufio.Writer, the binary flag decides
whether read / write trade in bytes or str. Methods (read, readline,
write, close, flush, __enter__, __exit__, readable, writable) and
attributes (name, mode, closed) come through Getattro since the type
panel registration plumbing isn't wired for built-ins yet.

builtins/open.go ports the validator from Lib/_pyio.py:75 def open: it
checks the mode chars against "axrwb+t", rejects duplicates, t+b, and
multiple direction bits, then maps the validated booleans onto an
os.OpenFile flag combo (O_RDONLY / O_WRONLY|O_CREATE|O_TRUNC /
O_WRONLY|O_CREATE|O_APPEND / O_WRONLY|O_CREATE|O_EXCL, with '+'
flipping to O_RDWR). encoding / errors / newline are accepted but not
yet honored.

The split into RawIOBase / Buffered / Text and the encoding-aware
TextIOWrapper land with the rest of 1689.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

Picked up #365 next: open() and a minimum-viable File type to back it.

Rather than land the full _io tree (PyFileIO, PyBufferedReader/Writer, PyTextIOWrapper, IOBase) in one shot, I collapsed those layers into a single objects.File for now. It wraps *os.File plus a bufio.Reader for read modes and a bufio.Writer for write/append modes; a binary flag toggles whether read/write trade in bytes or str. Methods (read, readline, write, close, flush, enter, exit, readable, writable) and attributes (name, mode, closed) are exposed via Getattro since the type-method registration plumbing for built-ins isn't wired yet.

builtins/open.go is essentially a Go port of the validator at _pyio.py:75 def open — it walks the mode string against "axrwb+t", rejects duplicates, t+b, multiple direction bits, then translates the validated booleans into the right os.OpenFile flag combo. '+' flips whichever base mode you have to O_RDWR. encoding, errors, and newline are accepted but not honored yet (text mode goes straight through Go's UTF-8).

Tests cover the round-trip (write then reopen-and-read), all six error paths in mode validation, append vs. truncate vs. exclusive, and the iterator/enter/exit contract. Splitting this back into separate raw/buffered/text classes — and adding a real codec layer — stays on the 1689 punchlist.

Parser/myreadline.c is the dispatch layer between PyRun_Interactive*
and whichever line reader is installed: PyOS_StdioReadline by default,
the GNU readline / libedit shim once the readline extension module
loads and overwrites PyOS_ReadlineFunctionPointer. The new
myreadline package keeps that shape - a Reader function-pointer hook
plus a stdio fallback - so plugging in peterh/liner or a cgo readline
bridge later is a SetReader call away rather than a refactor.

The stdio default mirrors PyOS_StdioReadline: write the prompt,
ReadString('\n'), return the line keeping its trailing newline (or
shorter on EOF). io.EOF means clean EOF; ErrInterrupt is the Ctrl-C
counterpart of the C version's "return 1 (Interrupt)" path.
re-entrancy is rejected the same way myreadline.c rejects nested
PyOS_Readline calls.

pythonrun.InteractiveLoop now drives input through myreadline.Readline
and prints "KeyboardInterrupt" + keeps prompting on ErrInterrupt
instead of exiting. Multi-line PS2 continuation still needs a parser
"input incomplete" signal and stays out of this cut.
@tamnd

tamnd commented May 7, 2026

Copy link
Copy Markdown
Owner Author

On to #364: myreadline.

Parser/myreadline.c is the dispatch layer that sits between the REPL and whichever actual line-editor is loaded. By default it points at PyOS_StdioReadline (no editing, no history, just fgets-until-newline); when the readline extension module imports, it overwrites PyOS_ReadlineFunctionPointer with a GNU readline / libedit shim. That second piece is Modules/readline.c, ~1700 lines of C tied to a specific dependency, and lands as its own port — but the function-pointer mechanism itself is the prerequisite, and that's what this commit covers.

New package myreadline/ keeps the same shape:

  • Reader func(stdin, stdout, prompt) (string, error) is the hook signature.
  • SetReader(r) swaps in a custom reader (peterh/liner, a cgo bridge, anything else); nil falls back to StdioReadline.
  • Readline is the public entry that picks the right path and refuses re-entry the way _PyOS_ReadlineTState does.
  • ErrInterrupt distinguishes Ctrl-C from EOF so the REPL can print KeyboardInterrupt and keep prompting.

pythonrun.InteractiveLoop now drives input through myreadline.Readline instead of its private bufio.Reader, and handles the interrupt/EOF distinction. Test coverage installs a fake reader and verifies the loop sees every prompt + acts on every signal.

PS2 continuation is still on the punchlist — it needs the parser to surface an "input incomplete" signal so the loop knows when to keep asking for more lines, which the gopy parser doesn't yet do.

tamnd added 2 commits May 7, 2026 12:54
makeReadFile / makeWriteFile leaked an open file handle past the test:
on Linux and macOS t.TempDir's RemoveAll happily reclaims the path,
but Windows refuses to unlink while a handle is still open and the
cleanup hook errors the test out. t.Cleanup(fi.Close) closes the
handle before the parent t.TempDir cleanup runs.

Spotted on the v0.10.1 backlog branch's CI run when the Windows job
flagged TestFile{ReadAllText,ReadAllBinary,ReadSizedShort,
IteratorYieldsLinesUntilEOF,MethodLookup}.
@tamnd tamnd merged commit 88f9251 into main May 7, 2026
6 checks passed
@tamnd tamnd deleted the feat/v0.10.1-backlog branch May 7, 2026 06:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant