diff --git a/builtins/breakpoint.go b/builtins/breakpoint.go index fad00e66a..7fcb4e2d5 100644 --- a/builtins/breakpoint.go +++ b/builtins/breakpoint.go @@ -113,6 +113,16 @@ func importBreakpointModule(modulepath string) (objects.Object, error) { // // CPython: Python/sysmodule.c:658 sys_breakpointhook warn label func breakpointWarn(envar string) (objects.Object, error) { + // The import or attribute lookup that brought us here raised a Python + // exception (ModuleNotFoundError / AttributeError) which gopy swallows + // in favor of a warning. CPython's sys_breakpointhook calls PyErr_Clear + // before warning, so clear the lingering thread exception too; otherwise + // a later contextmanager exit or except handler observes the stale error. + // + // CPython: Python/sysmodule.c:658 sys_breakpointhook (PyErr_Clear before warn) + if objects.ClearCurrentExceptionHook != nil { + objects.ClearCurrentExceptionHook() + } message := fmt.Sprintf("Ignoring unimportable $PYTHONBREAKPOINT: %q", envar) if err := emitRuntimeWarning(message); err != nil { return nil, err diff --git a/builtins/ctor.go b/builtins/ctor.go index e3aa64aac..faeb50cf5 100644 --- a/builtins/ctor.go +++ b/builtins/ctor.go @@ -1319,6 +1319,15 @@ func bytesIntContents(arg objects.Object) (buf []byte, handled bool, err error) iv, ierr := objects.NumberIndex(arg) if ierr != nil { if isTypeError(ierr) { + // CPython clears the pending TypeError before falling through + // to the buffer / iterable path; without this the swallowed + // exception lingers on the thread and a later `with` exit or + // except handler observes it. + // + // CPython: Objects/bytesobject.c:2812 bytes_new_impl (PyErr_Clear) + if objects.ClearCurrentExceptionHook != nil { + objects.ClearCurrentExceptionHook() + } return nil, false, nil } return nil, true, ierr diff --git a/imp/extension.go b/imp/extension.go index c4fd20839..99504c61a 100644 --- a/imp/extension.go +++ b/imp/extension.go @@ -243,6 +243,29 @@ type interpState struct { // // CPython: Include/internal/pycore_interp.h imports.modules hiddenExt map[string]objects.Object + // configRestores rolls back the per-interpreter mutable config a + // subinterpreter is allowed to change independently (e.g. + // int_max_str_digits, which CPython keeps in interp->long_state). + // gopy stores those values in package globals, so each registered + // snapshotter captures the parent value on push and PopSubinterp + // restores it, mirroring a real subinterpreter's own copy. + // + // CPython: Include/internal/pycore_interp.h long_state.max_str_digits + configRestores []func() +} + +// subinterpSnapshotters hold the per-interpreter config capture callbacks +// other packages register at init time. Each returns a restore closure +// invoked when the subinterpreter is popped. +var subinterpSnapshotters []func() func() + +// RegisterSubinterpSnapshot registers a callback that captures a piece of +// mutable interpreter-global config when a subinterpreter is pushed and +// returns a closure that restores it on pop. Used by module/sys to give +// int_max_str_digits per-interpreter semantics without threading the value +// through the import package. +func RegisterSubinterpSnapshot(fn func() func()) { + subinterpSnapshotters = append(subinterpSnapshotters, fn) } var ( @@ -274,6 +297,9 @@ func PushSubinterp(ownGil, checkMulti bool) { modByIndex: map[int]*objects.Module{}, hiddenExt: hideExtModules(), } + for _, snap := range subinterpSnapshotters { + s.configRestores = append(s.configRestores, snap()) + } interpMu.Lock() nextInterpID++ s.id = nextInterpID @@ -293,6 +319,11 @@ func PopSubinterp() { interpMu.Unlock() if popped != nil { restoreExtModules(popped.hiddenExt) + // Restore in reverse registration order so nested captures unwind + // symmetrically. + for i := len(popped.configRestores) - 1; i >= 0; i-- { + popped.configRestores[i]() + } } } diff --git a/module/_testcapi/module.go b/module/_testcapi/module.go index eb7128111..f54c84a48 100644 --- a/module/_testcapi/module.go +++ b/module/_testcapi/module.go @@ -241,6 +241,7 @@ func buildModule() (*objects.Module, error) { {"bad_get", badGet}, {"set_nomemory", setNomemory}, {"remove_mem_hooks", removeMemHooks}, + {"getbuffer_with_null_view", getbufferWithNullView}, {"config_get", configGet}, {"config_getint", configGetint}, {"config_names", configNames}, @@ -395,6 +396,30 @@ func removeMemHooks(_ []objects.Object, _ map[string]objects.Object) (objects.Ob return objects.None(), nil } +// getbufferWithNullView ports getbuffer_with_null_view: it calls the +// buffer protocol with a NULL view pointer (PyObject_GetBuffer(obj, NULL, +// PyBUF_SIMPLE)). A compliant getbufferproc rejects the obsolete NULL view; +// bytearray's raises BufferError. Objects that do not export a buffer raise +// the TypeError PyObject_GetBuffer would. +// +// CPython: Modules/_testcapimodule.c:1136 getbuffer_with_null_view +func getbufferWithNullView(args []objects.Object, _ map[string]objects.Object) (objects.Object, error) { + if len(args) != 1 { + return nil, fmt.Errorf("TypeError: getbuffer_with_null_view expected 1 argument, got %d", len(args)) + } + obj := args[0] + if _, ok := obj.(*objects.ByteArray); ok { + // CPython: Objects/bytearrayobject.c:51 bytearray_getbuffer (view==NULL) + return nil, fmt.Errorf("BufferError: bytearray_getbuffer: view==NULL argument is obsolete") + } + if _, ok := objects.AsBytesLike(obj); ok { + // Any other buffer exporter would dereference the NULL view; the + // NULL-view argument is obsolete, so report it the same way. + return nil, fmt.Errorf("BufferError: view==NULL argument is obsolete") + } + return nil, fmt.Errorf("TypeError: a bytes-like object is required, not '%s'", obj.Type().Name) +} + // fastcallArgs unpacks a tuple-or-None argument into a positional slice // and count, mirroring fastcall_args. // diff --git a/module/_testinternalcapi/module.go b/module/_testinternalcapi/module.go index 0b616fa1b..c1fd96a04 100644 --- a/module/_testinternalcapi/module.go +++ b/module/_testinternalcapi/module.go @@ -36,6 +36,7 @@ func buildModule() (*objects.Module, error) { {"get_recursion_depth", getRecursionDepth}, {"run_in_subinterp_with_config", runInSubinterpWithConfig}, {"clear_extension", clearExtension}, + {"dict_getitem_knownhash", dictGetitemKnownhash}, } for _, f := range fns { if err := d.SetItem(objects.NewStr(f.name), objects.NewBuiltinFunction(f.name, f.fn)); err != nil { @@ -119,6 +120,33 @@ func runInSubinterpWithConfig(args []objects.Object, _ map[string]objects.Object return objects.NewInt(int64(builtins.RunInFreshNamespace(code.Value()))), nil } +// dictGetitemKnownhash ports dict_getitem_knownhash(mp, key, hash): it +// looks up key in mp with the caller-supplied hash. A non-dict first +// argument is a SystemError (the C code passes a non-dict to +// _PyDict_GetItem_KnownHash, which trips its assert/bad-internal-call); +// a missing key reports KeyError(key); a __eq__ that raises propagates. +// +// CPython: Modules/_testinternalcapi.c:1562 dict_getitem_knownhash +func dictGetitemKnownhash(args []objects.Object, _ map[string]objects.Object) (objects.Object, error) { + if len(args) != 3 { + return nil, fmt.Errorf("TypeError: dict_getitem_knownhash expected 3 arguments, got %d", len(args)) + } + d, ok := args[0].(*objects.Dict) + if !ok { + return nil, fmt.Errorf("SystemError: bad argument to internal function") + } + h, err := objects.NumberIndex(args[2]) + if err != nil { + return nil, err + } + hi, ok := h.(*objects.Int) + if !ok { + return nil, fmt.Errorf("SystemError: bad argument to internal function") + } + hv, _ := hi.Int64() + return d.GetItemKnownHashOrKeyError(args[1], hv) +} + // clearExtension ports clear_extension(name, filename): it clears all // internally cached data for a single-phase extension module so the test // suite can re-import it fresh. It delegates to _PyImport_ClearExtension. diff --git a/module/sys/helpers.go b/module/sys/helpers.go index 723fdcd26..aedc0dae9 100644 --- a/module/sys/helpers.go +++ b/module/sys/helpers.go @@ -6,6 +6,7 @@ import ( "sync/atomic" "github.com/tamnd/gopy/errors" + "github.com/tamnd/gopy/imp" "github.com/tamnd/gopy/objects" pegen "github.com/tamnd/gopy/parser/pegen" "github.com/tamnd/gopy/state" @@ -51,6 +52,18 @@ func init() { // CPython: Objects/longobject.c:2049 long_to_decimal_string_internal, // Objects/longobject.c:2943 long_from_string_base objects.IntMaxStrDigitsHook = intMaxStrDigits.Load + // CPython keeps int_max_str_digits per interpreter + // (interp->long_state.max_str_digits), so a subinterpreter that calls + // sys.set_int_max_str_digits gets its own copy and the parent's value is + // untouched. gopy parks the ceiling in a package global, so register a + // snapshot/restore pair: capture the parent value when a subinterpreter + // is pushed and roll it back when it is popped. + // + // CPython: Include/internal/pycore_interp.h long_state.max_str_digits + imp.RegisterSubinterpSnapshot(func() func() { + saved := intMaxStrDigits.Load() + return func() { intMaxStrDigits.Store(saved) } + }) } // Bind stamps the runtime helpers onto d: exit, setrecursionlimit, diff --git a/objects/dict.go b/objects/dict.go index fa1f8c585..4ce6b1b1a 100644 --- a/objects/dict.go +++ b/objects/dict.go @@ -544,6 +544,24 @@ func (d *Dict) GetItemKnownHash(key Object, h int64) (Object, error) { return d.slotValue(idx), nil } +// GetItemKnownHashOrKeyError is GetItemKnownHash with the C-API miss +// contract: a key that is absent (and no comparison raised) reports +// KeyError(key) rather than the internal not-found sentinel. A raised +// __eq__ propagates unchanged. This backs _testinternalcapi's +// dict_getitem_knownhash probe. +// +// CPython: Objects/dictobject.c:1965 _PyDict_GetItem_KnownHash +func (d *Dict) GetItemKnownHashOrKeyError(key Object, h int64) (Object, error) { + v, err := d.GetItemKnownHash(key, h) + if err != nil { + if errors.Is(err, errKeyNotFound) { + return nil, raiseKeyError(key) + } + return nil, err + } + return v, nil +} + // ContainsKnownHash is Contains with a caller-supplied hash. // // CPython: Objects/dictobject.c:2530 _PyDict_Contains_KnownHash diff --git a/objects/usertype.go b/objects/usertype.go index ad63955fe..fc0a9ea47 100644 --- a/objects/usertype.go +++ b/objects/usertype.go @@ -291,7 +291,7 @@ func NewUserTypeMetaE(name string, bases []*Type, ns *Dict, kwargs map[string]Ob stampMetaclass(t, meta) installSubclassAttrSlots(t) noSlotsDeclared := hasNoSlotsDeclared(ns) - configureManagedDict(t, bases, noSlotsDeclared) + needDictDescr, needWeakrefDescr := configureManagedDict(t, bases, noSlotsDeclared) // type_new_set_attrs copies the namespace into tp_dict (slots, classcell, // plain attributes) BEFORE type_ready -> mro_internal invokes the // metaclass mro(). A metaclass that overrides mro() can therefore read @@ -302,6 +302,20 @@ func NewUserTypeMetaE(name string, bases []*Type, ns *Dict, kwargs map[string]Ob if err := processClassNamespace(t, ns); err != nil { return nil, err } + // type_new_descriptors stamps the __dict__ and __weakref__ getsets AFTER + // the namespace was copied into tp_dict, so they sort after the user's + // class-body names in dict order (bpo-34320 test_namespace_order). The + // add uses PyDict_SetDefaultRef, so a class body that already binds + // __dict__ or __weakref__ (e.g. `__dict__ = property(...)`) keeps its own + // value rather than being overwritten by the managed-dict getset. + // + // CPython: Objects/typeobject.c:8136 type_add_getset (PyDict_SetDefaultRef) + if needDictDescr && !nsHasName(ns, "__dict__") { + installInstanceDictDescr(t) + } + if needWeakrefDescr && !nsHasName(ns, "__weakref__") { + installInstanceWeakrefDescr(t) + } if err := applyMetaclassMRO(t, meta); err != nil { return nil, err } @@ -593,7 +607,18 @@ func hasNoSlotsDeclared(ns *Dict) bool { // CPython: Objects/typeobject.c:4153 type_new (sets // Py_TPFLAGS_INLINE_VALUES + Py_TPFLAGS_MANAGED_DICT on heap types with // a managed dict) -func configureManagedDict(t *Type, bases []*Type, noSlotsDeclared bool) { +// nsHasName reports whether the class-body namespace bound name, the +// signal type_new_descriptors reads (via PyDict_SetDefaultRef) to leave a +// user-provided __dict__ / __weakref__ untouched. +func nsHasName(ns *Dict, name string) bool { + if ns == nil { + return false + } + has, _ := ns.Contains(NewStr(name)) + return has +} + +func configureManagedDict(t *Type, bases []*Type, noSlotsDeclared bool) (needDictDescr, needWeakrefDescr bool) { inheritedDict := false for _, b := range bases { if b != nil && b.HasDict { @@ -611,9 +636,7 @@ func configureManagedDict(t *Type, bases []*Type, noSlotsDeclared bool) { // `class C: pass` but not for a subclass of C. // // CPython: Objects/typeobject.c type_new_descriptors (add_dict gate) - if t.HasDict && !inheritedDict { - installInstanceDictDescr(t) - } + needDictDescr = t.HasDict && !inheritedDict // HasWeakref tracks tp_weaklistoffset. It inherits from any base that // provides weak-reference support, and the no-__slots__ case adds it // for the new type whenever the solid base is not a variable-size @@ -640,16 +663,15 @@ func configureManagedDict(t *Type, bases []*Type, noSlotsDeclared bool) { // already sees it through the MRO. // // CPython: Objects/typeobject.c type_new_descriptors (add_weak gate) - if t.HasWeakref && !inheritedWeakref { - installInstanceWeakrefDescr(t) - } + needWeakrefDescr = t.HasWeakref && !inheritedWeakref if !t.HasDict { - return + return needDictDescr, needWeakrefDescr } t.TpFlags |= TpFlagManagedDict if basesAllowInlineValues(bases, noSlotsDeclared) { t.TpFlags |= TpFlagInlineValues } + return needDictDescr, needWeakrefDescr } // mroHasDict reports whether any class in b's MRO carries a per-instance diff --git a/test/cpython/MANIFEST.txt b/test/cpython/MANIFEST.txt index c3e851ff7..65d6a991c 100644 --- a/test/cpython/MANIFEST.txt +++ b/test/cpython/MANIFEST.txt @@ -125,14 +125,14 @@ test_traceback done v0.12.7 370/370 green test_abstract_numbers ready v0.4.0 7/7 pass test_bool ready v0.2.0 31/31 pass test_buffer out-of-scope - C-level buffer protocol API -test_builtin ready v0.7.0 147/147 pass (skipped=6) -test_bytes ready v0.4.0 317/317 pass (skipped=8) +test_builtin ready v0.13.6 133/133 pass (skipped=7); 1726 bridge: breakpoint PyErr_Clear + type-creation namespace order +test_bytes ready v0.13.6 317/317 pass (skipped=9); 1726 bridge: getbuffer_with_null_view ported test_complex ready v0.4.0 37/37 pass -test_dict ready v0.2.0 120/120 pass +test_dict ready v0.13.6 118/120 pass (skipped=1); 1726 bridge: dict_getitem_knownhash ported. 2 documented impl-detail residuals: test_splittable_popitem (PEP 412 split tables), test_oob_indexing_dictiter_iternextitem (borrow-model iterator, spec 1727) test_dictviews ready v0.2.0 16/16 pass test_float ready v0.4.0 54/54 pass test_funcattrs ready v0.5.0 35/35 pass -test_int ready v0.2.0 52/52 pass +test_int ready v0.13.6 52/52 pass (skipped=7); 1726 bridge: per-interpreter int_max_str_digits snapshot/restore test_list ready v0.2.0 68/68 pass test_long ready v0.2.0 47/47 pass test_memoryview ready v0.4.0 171/171 pass (skipped=18) diff --git a/website/docs/specs/1700/1724_builtins_types_test_panel.md b/website/docs/specs/1700/1724_builtins_types_test_panel.md index b7513dd39..81b028406 100644 --- a/website/docs/specs/1700/1724_builtins_types_test_panel.md +++ b/website/docs/specs/1700/1724_builtins_types_test_panel.md @@ -8,12 +8,30 @@ description: "Full audit and port of the 28 Builtins/types test files from spec ## Status -Active. Branch `feat/v0.13.0-spec-1724-builtins-types-panel`. +Active. Branch `feat/v0.13.6-spec-1724-builtins-types-reaudit`. -Re-audited under the [[1726]] bridge on 2026-06-13 (see the re-audit section -below). Twenty-six of 29 files are fully green; the three with residuals -(`test_int`, `test_bytes`, `test_dict`) carry only implementation-detail or -unported-C-subsystem divergences, each documented with a CPython citation. +Re-audited a second time under the [[1726]] bridge on 2026-06-19 (see the +re-audit section below). Twenty-eight of 29 files are fully green; only +`test_dict` carries residuals, and those are the two implementation-detail +divergences (PEP 412 split-table layout and refcount-exact `__del__` timing) +documented with CPython citations. + +## Checklist + +- [x] P0 panics cleared (`test_funcattrs`, `test_structseq`). +- [x] P1–P8 subsystem ports shipped (range, dict-views, property, memoryview, + strtod, unicodedata/ucn, unicode-file, userdict/userlist). +- [x] First 1726 re-audit (2026-06-13): UCD instantiation refusal, `_pickle` + `load_build` slots+dict, `WITH_DOC_STRINGS` sysconfig. +- [x] Second 1726 re-audit (2026-06-19): per-interpreter `int_max_str_digits` + snapshot/restore (`test_int`), `getbuffer_with_null_view` (`test_bytes`), + breakpoint `PyErr_Clear` before warn + type-creation namespace order + (`test_builtin`), `_testinternalcapi.dict_getitem_knownhash` (`test_dict`). +- [x] Whole panel re-run for ground truth: 28/29 green. +- [ ] `test_dict` two residuals (`test_splittable_popitem`, + `test_oob_indexing_dictiter_iternextitem`) — architectural, documented below, + not planned to port (would reverse the borrow-model iterator / add PEP 412 + split tables for no Python-visible behaviour). ## Goal @@ -27,26 +45,55 @@ that tree before porting. --- -## Zero-skip re-audit under the spec 1726 bridge (audit date: 2026-06-13) +## Zero-skip re-audit under the spec 1726 bridge (audit dates: 2026-06-13, 2026-06-19) The table below was written before the [[1726]] bridge, which makes `check_impl_detail()` report `cpython=True` so every `@cpython_only` test runs on gopy instead of being skipped. Re-running the whole panel under the bridge surfaced a handful of `@cpython_only` and missing-module cases the earlier -all-green pass never executed. Two were genuine gopy bugs and are fixed in this -branch; the rest are behaviour that depends on a CPython implementation detail -gopy does not have, or a C subsystem gopy has never carried. +all-green pass never executed. The genuine gopy bugs are fixed in this branch; +the rest are behaviour that depends on a CPython implementation detail gopy does +not have, or a C subsystem gopy has never carried. Reference interpreter for the skip/run decisions: brew `python@3.14` 3.14.5. -Panel result after the re-audit (run from `test/cpython/`): - -- 26 of 29 files fully green. -- `test_int`: 2 errors, 7 skips. -- `test_bytes`: 1 error, 9 skips. -- `test_dict`: 2 failures, 1 skip. - -### Fixed in this branch +Panel result after the second re-audit (run from `test/cpython/`): + +- 28 of 29 files fully green. +- `test_dict`: 2 failures, 1 skip (both failures are documented + implementation-detail residuals). + +### Fixed in the 2026-06-19 re-audit + +- **`int_max_str_digits` leaked across subinterpreters.** + `test_int_max_str_digits_is_per_interpreter` runs a child interpreter via + `_testcapi.run_in_subinterp` and asserts the parent's limit is unchanged. + CPython keeps the limit per-interpreter (`interp->long_state.max_str_digits`, + `Include/internal/pycore_interp.h`); gopy parked it in a package global. Added + a snapshot/restore hook (`imp.RegisterSubinterpSnapshot`) that captures the + parent value on `PushSubinterp` and restores it on `PopSubinterp`, registered + from `module/sys`. Fixes both `test_int` errors. +- **`getbuffer_with_null_view` was unported.** `test_bytes.test_obsolete_write_lock` + calls a `_testcapi` helper that invokes `PyObject_GetBuffer` with a NULL + `Py_buffer*` to force `BufferError`. Ported the helper. Fixes the `test_bytes` + error. +- **`breakpoint()` left a stale thread exception, and type creation ordered the + `__dict__`/`__weakref__` descriptors before the class namespace.** + `test_builtin.test_envar_unimportable` swallows a `ModuleNotFoundError` in the + default breakpoint hook in favour of a warning; gopy did not clear the thread + exception the way `sys_breakpointhook` calls `PyErr_Clear` + (`Python/sysmodule.c:658`), so a later handler observed the stale error. And + `test_namespace_order` requires the managed-dict descriptors to be installed + after the class namespace is copied in, so a user-provided `__dict__` wins + (`type_new_descriptors` runs `PyDict_SetDefaultRef` after `type_new_set_attrs`, + `Objects/typeobject.c:8136`). Deferred the descriptor install behind an + `nsHasName` guard. Fixes `test_builtin`. +- **`_testinternalcapi.dict_getitem_knownhash` was missing.** + `test_dict`'s CAPI test calls it. Ported `dict_getitem_knownhash` + (`Modules/_testinternalcapi.c:1562`) plus a `GetItemKnownHashOrKeyError` + helper. Clears the CAPI failure. + +### Fixed in the 2026-06-13 re-audit - **`unicodedata.UCD.__new__(UCD)` did not refuse instantiation.** A type that carries `Py_TPFLAGS_DISALLOW_INSTANTIATION` leaves `tp_new` NULL, and @@ -75,10 +122,6 @@ mean either reversing gopy's native implementation or porting a C subsystem the project has never carried. They run under the bridge and surface as errors/failures rather than skips. -- **`test_int` — subinterpreters (2 errors).** - `test_int_max_str_digits_is_per_interpreter` calls - `_testcapi.run_in_subinterp`. gopy has no subinterpreter runtime (PEP 684 / - per-interpreter GIL). Out of scope for a builtins/types panel. - **`test_int` — `_pylong` / C `_decimal` whitebox (7 skips).** These `@cpython_only` tests assert that huge-int `int`↔`str` and `divmod` delegate to the pure-Python `_pylong` module, and several also require the C `_decimal` @@ -88,18 +131,21 @@ errors/failures rather than skips. implementation, and the C `_decimal`/libmpdec subsystem is unported. The observable `int` behaviour (results, error messages, `int_max_str_digits` limit) is already covered and green; only the delegation mechanism differs. -- **`test_bytes` — `getbuffer_with_null_view` (1 error).** - `test_obsolete_write_lock` calls a `_testcapi` helper that invokes - `PyObject_GetBuffer` with a NULL `Py_buffer*` to force `BufferError`. gopy's - buffer protocol is modeled with Go interfaces and has no "NULL view pointer" - C-API contract to exercise. - **`test_dict` — split-table layout + `__del__` timing (2 failures).** `test_splittable_popitem` is `@cpython_only` and inspects the PEP 412 split-table dict layout through `sys.getsizeof`; gopy uses a single combined - dict representation. `test_oob_indexing_dictiter_iternextitem` depends on the - exact refcount drop that fires `__del__` mid-iteration; gopy's iterator holds - the value so the finalizer fires later. Both are interpreter-internal memory - layout / refcount-timing details, not Python-visible behaviour. + dict representation, so a `popitem()` does not grow `sys.getsizeof` the way a + split-to-combined transition does. `test_oob_indexing_dictiter_iternextitem` + depends on `dictiter_iternextitem` recycling one `di_result` tuple and + decreffing the previously yielded value to refcount zero, firing `__del__` + mid-iteration (`Objects/dictobject.c:5697`). gopy's `IterNext` slot returns a + **borrowed** reference (spec [[1727]]) and allocates a fresh tuple per advance, + so the spent value's finalizer fires under the Go GC rather than synchronously; + attempting the in-place reuse instead frees a tuple a generic consumer still + holds (it broke `sorted(d.items())`, see `objects/dict_iter.go`). Both are + interpreter-internal memory layout / refcount-timing details, not + Python-visible behaviour, and matching either would reverse a deliberate gopy + design choice. Left as documented residuals. - **Environment-gated skips that match CPython under the CI locale/feature set:** `test_float.test_float_with_comma` (locale `decimal_point`, skipped under `LC_ALL=C` on both interpreters), `test_memoryview` ctypes cast (no