diff --git a/crates/weavepy-capi/build.rs b/crates/weavepy-capi/build.rs index 3d90ccc..7e88993 100644 --- a/crates/weavepy-capi/build.rs +++ b/crates/weavepy-capi/build.rs @@ -21,7 +21,7 @@ use std::process::Command; /// build-script flat (no globals). struct ExtensionBuild<'a> { cc: &'a str, - manifest_dir: &'a Path, + include_dir: &'a Path, out_dir: &'a Path, target_os: &'a str, suffix: &'a str, @@ -30,6 +30,33 @@ struct ExtensionBuild<'a> { env_var: &'a str, } +/// Locate the host's stock CPython 3.13 include directory (the one +/// containing `Python.h`) so the wave-1 binary-ABI proof can be compiled +/// against the *real* headers. Returns `None` if no CPython 3.13 is +/// installed or its `Python.h` is missing, in which case the proof +/// fixture is skipped. Honours `WEAVEPY_STOCK_PYTHON` to override the +/// interpreter used for the probe. +fn stock_python_include() -> Option { + println!("cargo:rerun-if-env-changed=WEAVEPY_STOCK_PYTHON"); + let interp = env::var("WEAVEPY_STOCK_PYTHON").unwrap_or_else(|_| "python3.13".to_owned()); + let out = Command::new(&interp) + .arg("-c") + .arg("import sysconfig; print(sysconfig.get_path('include'))") + .output() + .ok()?; + if !out.status.success() { + return None; + } + let inc = String::from_utf8(out.stdout).ok()?.trim().to_owned(); + if inc.is_empty() { + return None; + } + if !Path::new(&inc).join("Python.h").is_file() { + return None; + } + Some(inc) +} + fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let workspace_root = manifest_dir @@ -79,7 +106,7 @@ fn main() { fn build_extension(opts: ExtensionBuild<'_>) { let ExtensionBuild { cc, - manifest_dir, + include_dir, out_dir, target_os, suffix, @@ -98,7 +125,7 @@ fn main() { .arg("-fvisibility=default") .arg("-O0") .arg("-Wno-error") - .arg(format!("-I{}", manifest_dir.join("include").display())) + .arg(format!("-I{}", include_dir.display())) .arg(src) .arg("-o") .arg(&dylib); @@ -120,10 +147,11 @@ fn main() { } } + let weavepy_inc = manifest_dir.join("include"); let smalltest_src = workspace_root.join("tests/capi_ext/_smalltest.c"); build_extension(ExtensionBuild { cc: &cc, - manifest_dir: &manifest_dir, + include_dir: &weavepy_inc, out_dir: &out_dir, target_os: &target_os, suffix, @@ -134,7 +162,7 @@ fn main() { let ndarray_src = workspace_root.join("tests/capi_ext/_ndarray.c"); build_extension(ExtensionBuild { cc: &cc, - manifest_dir: &manifest_dir, + include_dir: &weavepy_inc, out_dir: &out_dir, target_os: &target_os, suffix, @@ -145,7 +173,7 @@ fn main() { let numpylike_src = workspace_root.join("tests/capi_ext/_numpylike.c"); build_extension(ExtensionBuild { cc: &cc, - manifest_dir: &manifest_dir, + include_dir: &weavepy_inc, out_dir: &out_dir, target_os: &target_os, suffix, @@ -154,6 +182,101 @@ fn main() { env_var: "WEAVEPY_CAPI_NUMPYLIKE_EXTENSION", }); + // ---------------------------------------------------------------- + // 2b) RFC 0043 binary-ABI hermetic proofs: compile the proof + // fixtures against the host's *stock* CPython 3.13 headers + // (full, non-limited API → real inlined macros and the genuine + // 416-byte `PyTypeObject`), NOT WeavePy's `include/Python.h`. + // + // * `_stockabi.c` — wave 1: faithful object mirrors, inlined + // head/field macros, refcount poke, `tp_dealloc`. + // * `_stocktype.c` — wave 2 (RFC 0044): classic static + // `PyTypeObject` + `PyType_Ready`, method suites, richcompare, + // call/iter/descriptor protocols, and a `Py_TPFLAGS_HAVE_GC` + // type with `tp_traverse`/`tp_clear`. + // * `_stockarray.c` — wave 3 (RFC 0045): inline `tp_basicsize` + // instance storage (`PyArrayObject` shape), real `tp_members` + // at fixed offsets, the `__array_interface__`/`__array_struct__` + // interchange protocols, and the `import_array()` array-C-API + // capsule pattern. + // + // Skipped (with a note) when CPython 3.13 dev headers aren't + // present, so a bare CI host still builds and the stock proofs + // self-skip. + // ---------------------------------------------------------------- + match stock_python_include() { + Some(inc) => { + println!("cargo:rustc-env=WEAVEPY_STOCK_PYTHON_INCLUDE={inc}"); + let stock_inc = PathBuf::from(&inc); + build_extension(ExtensionBuild { + cc: &cc, + include_dir: &stock_inc, + out_dir: &out_dir, + target_os: &target_os, + suffix, + src: &workspace_root.join("tests/capi_ext/_stockabi.c"), + name: "_stockabi", + env_var: "WEAVEPY_CAPI_STOCKABI_EXTENSION", + }); + build_extension(ExtensionBuild { + cc: &cc, + include_dir: &stock_inc, + out_dir: &out_dir, + target_os: &target_os, + suffix, + src: &workspace_root.join("tests/capi_ext/_stocktype.c"), + name: "_stocktype", + env_var: "WEAVEPY_CAPI_STOCKTYPE_EXTENSION", + }); + build_extension(ExtensionBuild { + cc: &cc, + include_dir: &stock_inc, + out_dir: &out_dir, + target_os: &target_os, + suffix, + src: &workspace_root.join("tests/capi_ext/_stockarray.c"), + name: "_stockarray", + env_var: "WEAVEPY_CAPI_STOCKARRAY_EXTENSION", + }); + // `_stockcython.c` — wave 5 (RFC 0047): a Cython-shaped + // extension that subclasses an extension-defined base and + // reads inherited slots directly off `Py_TYPE(self)` (the + // `inherit_slots` proof), plus the Cython C-API runtime tail. + build_extension(ExtensionBuild { + cc: &cc, + include_dir: &stock_inc, + out_dir: &out_dir, + target_os: &target_os, + suffix, + src: &workspace_root.join("tests/capi_ext/_stockcython.c"), + name: "_stockcython", + env_var: "WEAVEPY_CAPI_STOCKCYTHON_EXTENSION", + }); + // `_stockdatetime.c` — wave 5 (RFC 0029): a datetime consumer + // compiled against the real `datetime.h`, exercising + // `PyDateTime_IMPORT`, the inlined `PyDateTime_GET_*` accessor + // macros, the capsule constructors, and the `tp_basicsize` + // size-check — the exact ABI surface pandas' `tslibs` uses. + build_extension(ExtensionBuild { + cc: &cc, + include_dir: &stock_inc, + out_dir: &out_dir, + target_os: &target_os, + suffix, + src: &workspace_root.join("tests/capi_ext/_stockdatetime.c"), + name: "_stockdatetime", + env_var: "WEAVEPY_CAPI_STOCKDATETIME_EXTENSION", + }); + } + None => { + println!( + "cargo:warning=stock CPython 3.13 headers not found; \ + skipping the _stockabi/_stocktype/_stockarray/_stockcython \ + binary-ABI proof fixtures" + ); + } + } + // Re-export the include directory so dependent crates can see // `Python.h` via `DEP_WEAVEPY_CAPI_INCLUDE`. println!("cargo:include={}", manifest_dir.join("include").display()); diff --git a/crates/weavepy-capi/src/abstract_.rs b/crates/weavepy-capi/src/abstract_.rs index 8f145ee..0e301e7 100644 --- a/crates/weavepy-capi/src/abstract_.rs +++ b/crates/weavepy-capi/src/abstract_.rs @@ -17,6 +17,49 @@ use weavepy_vm::object::{DictKey, Object}; use crate::object::{PyHashT, PyObject, PySsizeT}; +// ---- TEMP recursion diagnostic (remove after fix) ----------------- +thread_local! { + static WP_RCMP_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +struct WpDepthGuard; +impl WpDepthGuard { + fn enter(where_: &str, a: *mut PyObject, b: *mut PyObject) -> Self { + let d = WP_RCMP_DEPTH.with(|c| { + let n = c.get() + 1; + c.set(n); + n + }); + if d > 120 { + let ta = wp_ty_name(a); + let tb = wp_ty_name(b); + panic!("WP recursion guard tripped at {where_} depth={d} a_type={ta} b_type={tb}"); + } + WpDepthGuard + } +} +impl Drop for WpDepthGuard { + fn drop(&mut self) { + WP_RCMP_DEPTH.with(|c| c.set(c.get().saturating_sub(1))); + } +} +fn wp_ty_name(o: *mut PyObject) -> String { + if o.is_null() { + return "".to_string(); + } + let ty = unsafe { (*o).ob_type }; + if ty.is_null() { + return "".to_string(); + } + let name = unsafe { (*(ty as *mut crate::layout::PyTypeObjectFull)).tp_name }; + if name.is_null() { + return "".to_string(); + } + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() +} +// ---- end TEMP ----------------------------------------------------- + // ---------------------------------------------------------------- // PyObject_* helpers. // ---------------------------------------------------------------- @@ -27,6 +70,35 @@ pub unsafe extern "C" fn PyObject_Repr(o: *mut PyObject) -> *mut PyObject { return ptr::null_mut(); } let obj = unsafe { crate::object::clone_object(o) }; + // RFC 0046 (wave 4): a *foreign* object's `repr` must come from its own + // `tp_repr` (numpy's `dtype` prints as `dtype('float64')`); the VM-side + // `repr_for` only sees an opaque `Object::Foreign` and would emit the + // debug `` placeholder. + if matches!(obj, Object::Foreign(_)) { + let r = unsafe { foreign_repr_or_str(o, true) }; + if !r.is_null() { + return r; + } + } + // A VM object with a Python-level `__repr__` (a user/extension class + // instance, or a class with a metaclass `__repr__`) must dispatch that + // dunder — the same way the `repr()` builtin does — so C code calling + // `PyObject_Repr` agrees with the bytecode path. `repr_for` only knows + // the built-in shapes and would emit a `` placeholder for + // everything else (this is how Cython's `repr(...)` on a pure-Python + // instance used to lose its real value). + if matches!(obj, Object::Instance(_) | Object::Type(_)) { + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.repr_object(&obj)) + }) { + Some(Ok(s)) => return crate::object::into_owned(Object::from_str(s)), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + return ptr::null_mut(); + } + None => {} + } + } let s = repr_for(&obj); crate::object::into_owned(Object::from_str(s)) } @@ -37,10 +109,115 @@ pub unsafe extern "C" fn PyObject_Str(o: *mut PyObject) -> *mut PyObject { return ptr::null_mut(); } let obj = unsafe { crate::object::clone_object(o) }; + // RFC 0046 (wave 4): a foreign object's `str` comes from its `tp_str` + // (falling back to `tp_repr`, exactly as CPython's `PyObject_Str`). + if matches!(obj, Object::Foreign(_)) { + let r = unsafe { foreign_repr_or_str(o, false) }; + if !r.is_null() { + return r; + } + } + // Dispatch a Python-level `__str__` (defined *or inherited*) for VM + // instances and metaclass-`__str__` classes, matching the `str()` + // builtin. Without this, Cython code doing `str(obj)` on a pure-Python + // instance — e.g. `pytz.tzinfo.BaseTzInfo.__str__` returning the zone + // name inside pandas' `tz_standardize` — got the `` + // placeholder from `str_for`, corrupting the value. + if matches!(obj, Object::Instance(_) | Object::Type(_)) { + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.str_object(&obj)) + }) { + Some(Ok(s)) => return crate::object::into_owned(Object::from_str(s)), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + return ptr::null_mut(); + } + None => {} + } + } let s = str_for(&obj); crate::object::into_owned(Object::from_str(s)) } +/// CPython-faithful `repr`/`str` for a *foreign* extension object +/// (RFC 0046, wave 4): call `tp_repr` (when `want_repr`) or `tp_str`, +/// `tp_str` falling back to `tp_repr` as CPython does. Returns a new +/// reference, or null when no slot is defined (caller uses the VM +/// placeholder). +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_repr_or_str(o: *mut PyObject, want_repr: bool) -> *mut PyObject { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + // CPython bakes inherited slots into each subtype during `PyType_Ready` + // (`inherit_slots`). WeavePy's `PyType_Ready` does not, so a stock + // subclass such as numpy's `Float64DType` carries a NULL `tp_repr` even + // though its base `np.dtype` defines `arraydescr_repr`. Walk the + // `tp_base` chain to recover the inherited slot, mirroring the effect of + // `inherit_slots` for the repr/str path. + let slot = unsafe { inherited_repr_str_slot(ty, want_repr) }; + if slot.is_null() { + return ptr::null_mut(); + } + let f: unsafe extern "C" fn(*mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(slot) }; + let r = unsafe { f(o) }; + // When the slot raises (returns NULL with a pending exception), the + // caller falls back to the VM placeholder. We must consume the pending + // exception here so it does not leak into the next VM operation — the + // fallback is a best-effort cosmetic repr/str, not a propagated error. + if r.is_null() { + let _ = crate::errors::take_pending(); + } + r +} + +/// Resolve the effective `tp_repr` (when `want_repr`) or `tp_str` for `ty`, +/// walking the `tp_base` chain when the slot is NULL on the subtype. `str` +/// with no `tp_str` anywhere in the chain falls back to `tp_repr`, exactly +/// as CPython's `PyObject_Str`. +/// +/// # Safety +/// `ty` must be a live, non-null `PyTypeObjectFull*` with a readable +/// (possibly NULL-terminated) `tp_base` chain. +unsafe fn inherited_repr_str_slot( + ty: *mut crate::layout::PyTypeObjectFull, + want_repr: bool, +) -> *mut std::os::raw::c_void { + unsafe fn walk( + mut ty: *mut crate::layout::PyTypeObjectFull, + repr: bool, + ) -> *mut std::os::raw::c_void { + // Bound the walk defensively against a cyclic/corrupt base chain. + for _ in 0..256 { + if ty.is_null() { + break; + } + let s = if repr { + unsafe { (*ty).tp_repr } + } else { + unsafe { (*ty).tp_str } + }; + if !s.is_null() { + return s; + } + ty = unsafe { (*ty).tp_base }; + } + ptr::null_mut() + } + let primary = unsafe { walk(ty, want_repr) }; + if !primary.is_null() { + return primary; + } + if !want_repr { + return unsafe { walk(ty, true) }; + } + ptr::null_mut() +} + #[no_mangle] pub unsafe extern "C" fn PyObject_ASCII(o: *mut PyObject) -> *mut PyObject { unsafe { PyObject_Repr(o) } @@ -118,10 +295,69 @@ pub unsafe extern "C" fn PyObject_GetAttrString( do_getattr(o, &key) } +fn trace_resolved(key: &str, v: &Object) { + if std::env::var_os("WEAVEPY_TRACE_GETATTR").is_none() { + return; + } + let detail = match v { + Object::Type(t) => { + let p = crate::types::type_ptr_for_class(t); + format!("Type(name={:?}, ptr={:?})", t.name, p) + } + Object::Foreign(s) => format!("Foreign(ptr={:?})", s.ptr), + other => format!("{}", type_name(other)), + }; + eprintln!("[GETATTR] key={key:?} resolved -> {detail}"); +} + fn do_getattr(o: *mut PyObject, key: &str) -> *mut PyObject { let obj = unsafe { crate::object::clone_object(o) }; - match attr_lookup(&obj, key) { - Some(v) => crate::object::into_owned(v), + if std::env::var_os("WEAVEPY_TRACE_GETATTR").is_some() { + let extra = match &obj { + Object::Type(t) => { + let has = t.lookup(key).is_some(); + format!(" [Type name={:?} lookup_has={}]", t.name, has) + } + _ => String::new(), + }; + eprintln!( + "[GETATTR] key={key:?} on {}{} -> resolving", + type_name(&obj), + extra + ); + } + // RFC 0046 (wave 4): a foreign extension object resolves attributes + // through its own slots, never through the VM's `Foreign` arm (which + // would loop back here via the foreign getattr hook). See + // [`foreign_getattr_dispatch`]. + if matches!(obj, Object::Foreign(_)) { + return foreign_getattr_dispatch(o, &obj, key); + } + // Fast path: the handful of container/instance shapes `attr_lookup` + // resolves without re-entering the interpreter. + if let Some(v) = attr_lookup(&obj, key) { + trace_resolved(key, &v); + return crate::object::into_owned(v); + } + // RFC 0046 (wave 4): everything else — functions, builtins, generators, + // foreign extension objects, and every genuine miss — resolves through + // the VM's full `LOAD_ATTR` machinery, so the C-API agrees with the + // bytecode path on both the value and (on failure) the *exact* + // exception. numpy reads `dispatcher.__qualname__` / `__name__` on a + // plain `function` through here while wrapping `__array_function__` + // implementations; the legacy `_ => None` arm wrongly reported + // "'function' object has no attribute '__qualname__'". + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.load_attr_public(&obj, key)) + }) { + Some(Ok(v)) => { + trace_resolved(key, &v); + crate::object::into_owned(v) + } + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } None => { crate::errors::set_pending( Some( @@ -140,6 +376,76 @@ fn do_getattr(o: *mut PyObject, key: &str) -> *mut PyObject { } } +/// Resolve `name` on a *foreign* extension object (RFC 0046, wave 4), +/// mirroring CPython's `PyObject_GetAttr` dispatch: +/// +/// 1. A **custom** `tp_getattro` (one the extension installed itself, e.g. +/// `ndarray`'s) is the object's own resolution — call it directly. +/// 2. Otherwise (the slot is null or our generic `PyObject_GenericGetAttr`) +/// resolve through the bridged type's harvested descriptors via the VM +/// ([`Interpreter::resolve_foreign_via_type`]). This invokes getset +/// getters / binds methods with the foreign object as `self`, and never +/// re-enters the foreign getattr hook — so there is no recursion. +fn foreign_getattr_dispatch(o: *mut PyObject, obj: &Object, key: &str) -> *mut PyObject { + let tp = unsafe { (*o).ob_type }; + if !tp.is_null() { + let getattro = unsafe { (*tp).tp_getattro }; + let generic = crate::genericalloc::PyObject_GenericGetAttr + as unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject + as usize; + if !getattro.is_null() && getattro as usize != generic { + let name_obj = crate::object::into_owned(Object::from_str(key)); + let f: unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(getattro) }; + let r = unsafe { f(o, name_obj) }; + unsafe { crate::object::Py_DecRef(name_obj) }; + return r; + } + } + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.resolve_foreign_via_type(obj, key)) + }) { + Some(Some(Ok(v))) => crate::object::into_owned(v), + Some(Some(Err(e))) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } + _ => { + crate::errors::set_pending( + Some( + weavepy_vm::builtin_types::builtin_types() + .attribute_error + .clone(), + ), + Object::from_str(format!( + "'{}' object has no attribute '{}'", + type_name(obj), + key + )), + ); + ptr::null_mut() + } + } +} + +/// Apply the descriptor protocol for an attribute `raw` resolved in the +/// MRO of type `t`, as `type.__getattribute__` does (`__get__(None, t)`): +/// +/// * a `classmethod` binds to the class itself (`BoundMethod(t, func)`), +/// * a `staticmethod` unwraps to its plain function, +/// * everything else (plain functions, properties, data) is returned as-is +/// — on a *type* receiver a bare function is the unbound function and a +/// property descriptor returns itself, matching CPython. +fn bind_type_attr(t: &weavepy_vm::sync::Rc, raw: Object) -> Object { + match raw { + Object::ClassMethod(inner) => Object::BoundMethod(weavepy_vm::sync::Rc::new( + weavepy_vm::object::BoundMethod::new(Object::Type(t.clone()), inner.func()), + )), + Object::StaticMethod(inner) => inner.func(), + other => other, + } +} + fn attr_lookup(o: &Object, key: &str) -> Option { match o { Object::Module(m) => { @@ -150,8 +456,46 @@ fn attr_lookup(o: &Object, key: &str) -> Option { let kk = DictKey(Object::from_str(key)); rc.borrow().get(&kk).cloned() } - Object::Type(t) => t.lookup(key), + Object::Type(t) => { + // Mirror `type.__getattribute__`: a class/static method found + // in the type's MRO is bound via its descriptor `__get__(None, + // t)` before being returned. Without this, the C-API getattr + // hands back the raw `classmethod`/`staticmethod` wrapper (not + // callable the way CPython's bound result is), which breaks + // Cython's class-creation path — e.g. `EnumType.__prepare__` + // fetched while building a `class X(Enum)` inside a `.pyx`. + let raw = t.lookup(key)?; + Some(bind_type_attr(t, raw)) + } Object::Instance(inst) => { + // A *bound* super proxy (`super(C, obj)`) has a custom + // `tp_getattro` in CPython (`super_getattro`): attribute access + // walks `__self_class__`'s MRO *after* `__thisclass__`, never the + // proxy's own (`super`) class. This fast path resolves against + // `inst.cls()` — for a super proxy that is the `super` type, whose + // own builtin `__init__` (`super_init_impl`) rejects keyword + // arguments. Real Cython hits this: `pandas.TimeRE.__init__` calls + // `super().__init__(locale_time=...)` through `PyObject_GetAttr`, + // which landed here and wrongly bound `super.__init__` instead of + // `_strptime.TimeRE.__init__`. Defer to the VM's full `LOAD_ATTR` + // (return `None` -> `load_attr_public` in [`do_getattr`]), which + // performs the proper super MRO walk. + { + let d = inst.dict.borrow(); + let is_super_proxy = matches!( + d.get(&DictKey(Object::from_static("__self_class__"))), + Some(Object::Type(_)) + ) && matches!( + d.get(&DictKey(Object::from_static("__thisclass__"))), + Some(Object::Type(_)) + ) && !matches!( + d.get(&DictKey(Object::from_static("__self__"))), + Some(Object::None) | None + ); + if is_super_proxy { + return None; + } + } let kk = DictKey(Object::from_str(key)); if let Some(v) = inst.dict.borrow().get(&kk).cloned() { return Some(v); @@ -187,6 +531,22 @@ fn attr_lookup(o: &Object, key: &str) -> Option { weavepy_vm::object::BoundMethod::new(o.clone(), raw.clone()), ))) } + // A member/getset (`Object::SlotDescriptor`) or a custom + // `__get__` data descriptor must run its descriptor protocol + // — a `__slots__` member in particular stores its value in + // the instance's *slot storage*, not `inst.dict`, so it is + // not resolvable here. Defer to the VM's full `LOAD_ATTR` + // (returning `None` falls through to `load_attr_public` in + // [`do_getattr`]). The previous `_ => Some(raw)` arm returned + // the raw `member_descriptor`, which broke real Cython's + // PEP 489 create slot (`spec.name` -> `PyModule_NewObject` + // got the descriptor, not the name). + Object::SlotDescriptor(_) => None, + Object::Instance(ci) + if ci.cls().lookup("__get__").is_some() => + { + None + } _ => Some(raw), } } @@ -222,6 +582,19 @@ fn type_name(o: &Object) -> &'static str { } } +/// Best-effort human-readable name for a callable, for tracing only. +fn callable_label(o: &Object) -> String { + use Object as O; + match o { + O::Function(f) => f.code().qualname.clone(), + O::Builtin(b) => b.name.to_string(), + O::Type(t) => format!("type:{}", t.name), + O::BoundMethod(bm) => format!("bound:{}", callable_label(&bm.function)), + O::Instance(i) => format!("inst:{}", i.cls().name), + other => type_name(other).to_string(), + } +} + #[no_mangle] pub unsafe extern "C" fn PyObject_SetAttr( o: *mut PyObject, @@ -255,6 +628,33 @@ pub unsafe extern "C" fn PyObject_SetAttrString( fn do_setattr(o: *mut PyObject, key: &str, value: *mut PyObject) -> c_int { let obj = unsafe { crate::object::clone_object(o) }; + // RFC 0029 (wave 5): route through the VM's full `STORE_ATTR`/`DELETE_ATTR` + // dispatch — the same logic bytecode runs — so a metaclass `__setattr__`, + // a data descriptor (`property` setter), and most importantly *class* + // attribute assignment land correctly. pandas' `timestamps.pyx` does + // `Timestamp.min = Timestamp(...)` / `Timestamp.resolution = Timedelta(...)` + // at init via `PyObject_SetAttr` on the *type*; the dict fast-paths below + // only know modules/dicts/instances and rejected a type with "object has + // no settable attributes". The native fallback still applies when no + // interpreter is active (pure C-side construction before any VM frame). + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| { + if value.is_null() { + interp.delete_attr_public(&obj, key) + } else { + let v = unsafe { crate::object::clone_object(value) }; + interp.store_attr_public(&obj, key, v) + } + }) + }) { + return match res { + Ok(()) => 0, + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } match obj { Object::Module(m) => { let v = if value.is_null() { @@ -312,6 +712,30 @@ pub unsafe extern "C" fn PyObject_HasAttr(o: *mut PyObject, attr: *mut PyObject) } } +/// `PyObject_HasAttrWithError(o, attr)` (CPython 3.13) — like +/// [`PyObject_HasAttr`] but *propagates* a non-`AttributeError` failure +/// rather than swallowing it: 1 = present, 0 = absent (the `AttributeError` +/// is cleared), -1 = a different error remains set. Cython's import lookup +/// (`__Pyx__Import_Lookup`) uses this to probe an already-imported module +/// for the names in a `from … import …`. +#[no_mangle] +pub unsafe extern "C" fn PyObject_HasAttrWithError(o: *mut PyObject, attr: *mut PyObject) -> c_int { + let p = unsafe { PyObject_GetAttr(o, attr) }; + if !p.is_null() { + unsafe { crate::object::Py_DecRef(p) }; + return 1; + } + if unsafe { crate::errors::PyErr_Occurred() }.is_null() { + return 0; + } + let attr_err = unsafe { crate::errors::PyExc_AttributeError }; + if attr_err.is_null() || unsafe { crate::errors::PyErr_ExceptionMatches(attr_err) } != 0 { + crate::errors::clear_thread_local(); + return 0; + } + -1 +} + #[no_mangle] pub unsafe extern "C" fn PyObject_HasAttrString(o: *mut PyObject, attr: *const c_char) -> c_int { let p = unsafe { PyObject_GetAttrString(o, attr) }; @@ -329,6 +753,59 @@ pub unsafe extern "C" fn PyObject_DelAttrString(o: *mut PyObject, attr: *const c unsafe { PyObject_SetAttrString(o, attr, ptr::null_mut()) } } +/// After a C-level call returns, refresh the macro-visible size field of +/// any faithful `set`/`dict` mirror passed as a positional argument. +/// +/// RFC 0047 (wave 5): a mutating method reached through its *unbound* type +/// method — Cython's `__Pyx_CallUnboundCMethod` path, e.g. +/// `set.difference_update(s, other)` — hands the container in as `args[0]` +/// and mutates the prefix's native store in place. The inlined +/// `PySet_GET_SIZE` / `PyDict_GET_SIZE` Cython emits next reads the body +/// field directly (there is no C-API call to hook), so the count has to be +/// re-published here. Cheap for non-container args: [`sync_container_size`] +/// gates on the mirror magic before any type comparison. +/// +/// # Safety +/// `args` may be null; if non-null it must have a readable `ob_type`. +unsafe fn sync_arg_container_sizes(args: *mut PyObject) { + if args.is_null() { + return; + } + let trace = std::env::var_os("WEAVEPY_TRACE_SETSEED").is_some(); + match unsafe { crate::object::clone_object(args) } { + Object::Tuple(items) => { + if trace { + eprintln!("[SYNC_ARGS] tuple len={}", items.len()); + } + for i in 0..items.len() { + let e = unsafe { crate::containers::PyTuple_GetItem(args, i as PySsizeT) }; + if trace { + eprintln!( + "[SYNC_ARGS] arg[{}]={:p} mirror={} set={}", + i, + e, + unsafe { crate::mirror::is_mirror(e) }, + unsafe { crate::mirror::is_faithful_set(e) }, + ); + } + unsafe { crate::mirror::sync_container_size(e) }; + } + } + Object::List(rc) => { + let n = rc.borrow().len(); + for i in 0..n { + let e = unsafe { crate::containers::PyList_GetItem(args, i as PySsizeT) }; + unsafe { crate::mirror::sync_container_size(e) }; + } + } + other => { + if trace { + eprintln!("[SYNC_ARGS] non-seq args type={}", other.type_name()); + } + } + } +} + #[no_mangle] pub unsafe extern "C" fn PyObject_Call( callable: *mut PyObject, @@ -362,7 +839,21 @@ pub unsafe extern "C" fn PyObject_Call( } }; - invoke_callable(target, arg_vec, kwarg_pairs) + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + let keys: Vec<&str> = kwarg_pairs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!( + "[TRACE_CALL] target={} name={} nargs={} kwargs={:?} (kwptr_null={})", + type_name(&target), + callable_label(&target), + arg_vec.len(), + keys, + kwargs.is_null() + ); + } + + let result = invoke_callable(target, arg_vec, kwarg_pairs); + unsafe { sync_arg_container_sizes(args) }; + result } #[no_mangle] @@ -392,7 +883,9 @@ pub unsafe extern "C" fn PyObject_CallOneArg( } else { unsafe { crate::object::clone_object(arg) } }; - invoke_callable(target, vec![arg_obj], Vec::new()) + let result = invoke_callable(target, vec![arg_obj], Vec::new()); + unsafe { crate::mirror::sync_container_size(arg) }; + result } /// `PyObject_CallTwoArgs(callable, a, b)` — convenience for the @@ -433,7 +926,14 @@ fn invoke_callable( kwargs: Vec<(String, Object)>, ) -> *mut PyObject { let result: Result = match target { - Object::Builtin(bf) => (bf.call)(&args), + // A WeavePy builtin (incl. a foreign C function bridged through + // `PyModule_Create`/`PyCFunction_NewEx`) carries a separate + // keyword-aware entry point. The C-API call surface + // (`PyObject_Call`/`PyObject_Vectorcall`) MUST route through it + // when keywords are present — Cython emits `np.array(x, dtype=…)` + // / `np.zeros(n, dtype=…)` as vectorcall sites, and dropping the + // keywords here silently defaulted every dtype to float64. + Object::Builtin(bf) => invoke_builtin(&bf, &args, &kwargs), Object::Type(_) | Object::Function(_) | Object::BoundMethod(_) => { // For non-Builtin callables we need the VM to do the // dispatch (locals, frame setup, etc.). @@ -477,7 +977,7 @@ fn invoke_callable_inner( kwargs: Vec<(String, Object)>, ) -> Result { match target { - Object::Builtin(bf) => (bf.call)(&args), + Object::Builtin(bf) => invoke_builtin(&bf, &args, &kwargs), _ => { let r = crate::interp::with_interp_mut(|interp| { interp.call_object(target.clone(), &args, &kwargs) @@ -487,30 +987,345 @@ fn invoke_callable_inner( } } -fn install_runtime_error(err: RuntimeError) { - match err { - RuntimeError::PyException(pe) => { - let cls = match &pe.instance { - Object::Instance(inst) => Some(inst.cls()), - _ => None, - }; - crate::errors::set_pending(cls, Object::from_str(pe.message())); - } - RuntimeError::Internal(msg) => { - crate::errors::set_runtime_error(msg); - } +/// Invoke a WeavePy [`BuiltinFn`] honouring keyword arguments, mirroring +/// the VM's own builtin dispatch (`crate::interp` / `Interpreter::call`): +/// prefer the keyword-aware entry point, fall back to the positional one +/// only when there are no keywords, and otherwise raise the CPython +/// "takes no keyword arguments" `TypeError`. +fn invoke_builtin( + bf: &weavepy_vm::object::BuiltinFn, + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { + if let Some(call_kw) = bf.call_kw.as_ref() { + call_kw(args, kwargs) + } else if kwargs.is_empty() { + (bf.call)(args) + } else { + Err(weavepy_vm::error::type_error(format!( + "{}() takes no keyword arguments", + bf.name + ))) } } +fn install_runtime_error(err: RuntimeError) { + // Delegate to the centralised bridge, which preserves a real exception + // *instance* verbatim (keeping custom attributes such as numpy's + // `_UFuncBinaryResolutionError.ufunc`/`.dtypes`). The previous inline + // version stringified the message, dropping those attributes and making a + // later `str(exc)` raise `AttributeError`. + crate::errors::set_pending_from_runtime(err); +} + #[no_mangle] pub unsafe extern "C" fn PyObject_IsTrue(o: *mut PyObject) -> c_int { if o.is_null() { return -1; } let obj = unsafe { crate::object::clone_object(o) }; + // RFC 0046 (wave 4): a *foreign* object (a numpy scalar such as + // `np.bool_`, a 0-d array, …) is opaque to the VM. Cloning it yields an + // `Object::Foreign`, which `truthy`'s catch-all reports as `true` — so a + // *false* `np.bool_` would test truthy. numpy's `polyfit` does + // `if rank != order and not full:` where `rank != order` is exactly an + // `np.bool_`; the false positive raised a spurious `RankWarning` that + // `_mac_os_check` escalates to a hard `RuntimeError` on import. Dispatch + // through the object's own `nb_bool` / `mp_length` / `sq_length` slots, + // faithful to CPython's `PyObject_IsTrue`. + if matches!(obj, Object::Foreign(_)) { + return unsafe { foreign_is_true(o) }; + } + // A *faithful instance* wearing a real C type whose `tp_as_number->nb_bool` + // is defined (a numpy `ndarray` crosses as an `Object::Instance`, not a + // `Foreign`) must drive that slot — CPython's `PyObject_IsTrue`. A + // multi-element array's `nb_bool` raises `ValueError` ("truth value ... + // ambiguous"); the naive `truthy` catch-all (`_ => true`) reported every + // instance truthy, silently dropping numpy's error so + // `PyObject_RichCompareBool(scalar, array)` (pandas `array_equivalent_object`, + // `Series.equals` over object arrays) returned a bogus match. + if matches!(obj, Object::Instance(_)) && unsafe { type_has_nb_bool(o) } { + return unsafe { foreign_is_true(o) }; + } truthy(&obj).into() } +/// True when `o`'s C type defines an `nb_bool` slot (numpy `ndarray`, +/// numpy scalars, …). Used to decide whether a faithful instance's +/// truthiness must go through the CPython slot chain rather than the naive +/// native `truthy`. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn type_has_nb_bool(o: *mut PyObject) -> bool { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return false; + } + let nb = unsafe { (*ty).tp_as_number }; + !nb.is_null() && !unsafe { (*nb).nb_bool }.is_null() +} + +/// CPython-faithful truthiness for a *foreign* extension object +/// (RFC 0046, wave 4): consult `nb_bool`, then `mp_length`, then +/// `sq_length`, defaulting to true when none is defined — exactly the +/// fallback chain in CPython's `PyObject_IsTrue`. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_is_true(o: *mut PyObject) -> c_int { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return 1; + } + // `nb_bool` (inquiry): `int (*)(PyObject*)` returning 1 / 0 / -1. + let nb = unsafe { (*ty).tp_as_number }; + if !nb.is_null() { + let slot = unsafe { (*nb).nb_bool }; + if !slot.is_null() { + let f: unsafe extern "C" fn(*mut PyObject) -> c_int = + unsafe { std::mem::transmute(slot) }; + return unsafe { f(o) }; + } + } + // `mp_length` / `sq_length` (lenfunc): `Py_ssize_t (*)(PyObject*)`; + // truthy iff non-zero, propagating a negative (error) result. + let mp = unsafe { (*ty).tp_as_mapping }; + if !mp.is_null() { + let slot = unsafe { (*mp).mp_length }; + if !slot.is_null() { + return len_to_truth(unsafe { + let f: unsafe extern "C" fn(*mut PyObject) -> PySsizeT = std::mem::transmute(slot); + f(o) + }); + } + } + let sq = unsafe { (*ty).tp_as_sequence }; + if !sq.is_null() { + let slot = unsafe { (*sq).sq_length }; + if !slot.is_null() { + return len_to_truth(unsafe { + let f: unsafe extern "C" fn(*mut PyObject) -> PySsizeT = std::mem::transmute(slot); + f(o) + }); + } + } + 1 +} + +/// CPython-faithful `int()` for a *foreign* extension object (numpy +/// scalars such as `np.int64`): consult `nb_int`, then `nb_index`, read +/// straight off `tp_as_number` (the slots `attr_lookup` cannot see on an +/// opaque foreign object). Returns a new reference, the slot's pending +/// error (null with the exception set), or — when neither slot exists — +/// null with *no* pending error so the caller raises its own TypeError. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_as_int(o: *mut PyObject) -> *mut PyObject { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + let nb = unsafe { (*ty).tp_as_number }; + if nb.is_null() { + return ptr::null_mut(); + } + for slot in [unsafe { (*nb).nb_int }, unsafe { (*nb).nb_index }] { + if !slot.is_null() { + let f: unsafe extern "C" fn(*mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(slot) }; + // The slot result (or its pending error) is authoritative; do + // not fall through to the next slot once one is present. + return unsafe { f(o) }; + } + } + ptr::null_mut() +} + +/// CPython-faithful `PyNumber_Index` for a *foreign* extension object: call +/// its `tp_as_number->nb_index` slot directly (the slot `attr_lookup` +/// cannot see on an opaque foreign object). Unlike [`foreign_as_int`], this +/// consults **only** `nb_index` — CPython's `PyNumber_Index` never falls +/// back to `nb_int`. Returns a new reference on success, NULL with a +/// pending error when the slot raised, or — when no `nb_index` exists — +/// null with *no* pending error so the caller raises its own TypeError. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_nb_index(o: *mut PyObject) -> *mut PyObject { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + let nb = unsafe { (*ty).tp_as_number }; + if nb.is_null() { + return ptr::null_mut(); + } + let slot = unsafe { (*nb).nb_index }; + if slot.is_null() { + return ptr::null_mut(); + } + let f: unsafe extern "C" fn(*mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(slot) }; + unsafe { f(o) } +} + +/// CPython-faithful `float()` for a *foreign* extension object (numpy's +/// `float64`/`float32`): call its `tp_as_number->nb_float` slot directly. +/// CPython's `PyFloat_AsDouble` reads `nb_float` off the type, but the +/// getattro-based `__float__` lookup used for WeavePy-owned objects walks +/// numpy's *own* C dict and misses the dunder inherited from the mirror +/// base (the same blind spot `complex128.__complex__` hit). Returns a new +/// reference on success, NULL with a pending error when the slot raised, +/// or — when no `nb_float` exists — null with *no* pending error so the +/// caller falls through to its own protocol/error. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +pub(crate) unsafe fn foreign_nb_float(o: *mut PyObject) -> *mut PyObject { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + let nb = unsafe { (*ty).tp_as_number }; + if nb.is_null() { + return ptr::null_mut(); + } + let slot = unsafe { (*nb).nb_float }; + if slot.is_null() { + return ptr::null_mut(); + } + let f: unsafe extern "C" fn(*mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(slot) }; + unsafe { f(o) } +} + +/// The four unary `tp_as_number` slots, selecting which field +/// [`foreign_unary`] dispatches to. +#[derive(Clone, Copy)] +enum UnarySlot { + Negative, + Positive, + Absolute, + Invert, +} + +/// Call a *foreign* extension object's unary `tp_as_number` slot +/// (`nb_negative`/`nb_positive`/`nb_absolute`/`nb_invert`) directly. +/// +/// These slots are invisible to [`attr_lookup`] on an opaque foreign +/// object, yet a stock Cython type defines them — CPython's +/// `PyNumber_Negative` and friends dispatch straight through +/// `Py_TYPE(o)->tp_as_number->nb_*`. This matters for numpy's +/// **object-dtype** unary ufunc loop: `np.negative(arr)` / +/// `arr.__neg__()` call `PyNumber_Negative` once per element, so the old +/// `_ => null` arm planted raw NULL `PyObject*`s in the output object +/// array. Iterating them from Python coerced NULL→`None` (masking the +/// bug), but pandas' C-level `assert_series_equal` formatting dereferenced +/// the NULL and segfaulted. pandas `Timedelta` (a Cython class) is the +/// concrete trigger (`-Series([Timedelta(...)], dtype=object)`). +/// +/// Returns a new reference on success, NULL with a pending error when the +/// slot raised, or NULL with *no* pending error when the slot is absent so +/// the caller can fall back to the dunder path / raise its own TypeError. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_unary(o: *mut PyObject, which: UnarySlot) -> *mut PyObject { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + let nb = unsafe { (*ty).tp_as_number }; + if nb.is_null() { + return ptr::null_mut(); + } + let slot = unsafe { + match which { + UnarySlot::Negative => (*nb).nb_negative, + UnarySlot::Positive => (*nb).nb_positive, + UnarySlot::Absolute => (*nb).nb_absolute, + UnarySlot::Invert => (*nb).nb_invert, + } + }; + if slot.is_null() { + return ptr::null_mut(); + } + let f: unsafe extern "C" fn(*mut PyObject) -> *mut PyObject = + unsafe { std::mem::transmute(slot) }; + unsafe { f(o) } +} + +/// Shared fallback for the unary numeric ops when the operand is not one of +/// WeavePy's native numeric scalars. +/// +/// Routes through the VM's full unary-dunder dispatch — identical to what +/// the `UNARY_OP` bytecode (`-x`/`~x`) or the `abs()` builtin would do: a +/// *foreign* extension operand resolves `__neg__`/`__abs__`/… through its +/// type's method table (the path that makes scalar `-Timedelta(...)` work), +/// and a VM / user instance through its `__op__`. This is what the mirrored +/// foreign `tp_as_number` slot cannot always do — a stock Cython +/// `pandas.Timedelta` has no `nb_negative` slot in WeavePy's mirror, so the +/// old slot-only path (and the even older `_ => null` arm) returned NULL, +/// which numpy's object-dtype unary ufunc loop planted straight into the +/// output array. +/// +/// When no interpreter is active (a C extension calling in before any VM +/// frame), fall back to the foreign object's unary `tp_as_number` slot, then +/// the CPython unary-operator TypeError. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*`; `other` is its cloned VM view. +unsafe fn unary_fallback( + o: *mut PyObject, + other: &Object, + which: UnarySlot, + err: &str, +) -> *mut PyObject { + let vm = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| match which { + UnarySlot::Negative => { + interp.op_unary_public(other, weavepy_compiler::UnaryKind::Neg) + } + UnarySlot::Positive => { + interp.op_unary_public(other, weavepy_compiler::UnaryKind::Pos) + } + UnarySlot::Invert => { + interp.op_unary_public(other, weavepy_compiler::UnaryKind::Invert) + } + UnarySlot::Absolute => interp.abs_public(other), + }) + }); + match vm { + Some(Ok(v)) => return crate::object::into_owned(v), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + return ptr::null_mut(); + } + None => {} + } + // No active interpreter: dispatch the foreign object's unary slot directly. + if matches!(other, Object::Foreign(_)) { + let r = unsafe { foreign_unary(o, which) }; + if !r.is_null() || crate::errors::pending().is_some() { + return r; + } + } + crate::errors::set_type_error(err); + ptr::null_mut() +} + +/// Map a `lenfunc` result to a `PyObject_IsTrue` code: negative is an +/// error (passed through), zero is false, positive is true. +fn len_to_truth(n: PySsizeT) -> c_int { + match n.cmp(&0) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } +} + #[no_mangle] pub unsafe extern "C" fn PyObject_Not(o: *mut PyObject) -> c_int { let r = unsafe { PyObject_IsTrue(o) }; @@ -539,22 +1354,307 @@ fn truthy(o: &Object) -> bool { } } +/// Route a rich comparison through the VM's `do_richcompare` +/// ([`Interpreter::rich_compare_public`]). This is the equivalent of a +/// native type's `tp_richcompare` slot: it handles recursive container +/// comparison (tuple/list ordering, per-element `__eq__`), built-in +/// scalars, and user / `cdef`-class `__op__`/`__rop__` overloads — the +/// cases the capi's scalar-only `compare_objects` cannot. +/// +/// Returns `Some(result)` when an interpreter handled the comparison +/// (a new reference, or NULL with a pending error when a dunder raised / +/// the ordering is unsupported), or `None` when no VM is active so the +/// caller can fall back to its native scalar path. +/// +/// # Safety +/// `a` and `b` must be live, non-null `PyObject*`. +unsafe fn richcompare_via_vm( + a: *mut PyObject, + b: *mut PyObject, + op: c_int, +) -> Option<*mut PyObject> { + let kind = weavepy_compiler::CompareKind::from_arg(op as u32)?; + let oa = unsafe { crate::object::clone_object(a) }; + let ob = unsafe { crate::object::clone_object(b) }; + crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.rich_compare_public(&oa, &ob, kind)) + }) + .map(|res| match res { + Ok(v) => crate::object::into_owned(v), + Err(e) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } + }) +} + #[no_mangle] pub unsafe extern "C" fn PyObject_RichCompare( a: *mut PyObject, b: *mut PyObject, op: c_int, ) -> *mut PyObject { - let r = unsafe { PyObject_RichCompareBool(a, b, op) }; - if r < 0 { + if a.is_null() || b.is_null() { + crate::errors::set_type_error("bad argument to internal function"); return ptr::null_mut(); } - if r != 0 { - unsafe { crate::object::Py_IncRef(crate::singletons::true_ptr()) }; + if std::env::var_os("WEAVEPY_CMP_BT").is_some() { + let oa = unsafe { crate::object::clone_object(a) }; + let ob = unsafe { crate::object::clone_object(b) }; + let na = type_name(&oa); + let nb = type_name(&ob); + if na == "NoneType" || nb == "NoneType" { + eprintln!( + "[CMP_BT] op={} '{}' vs '{}'\n{:?}", + op, + na, + nb, + std::backtrace::Backtrace::force_capture() + ); + } + } + let _wpg = WpDepthGuard::enter("PyObject_RichCompare", a, b); + // RFC 0047 (wave 5): CPython's `do_richcompare` dispatches through the + // operands' `tp_richcompare` slots first — this is how a *foreign* + // object (a numpy scalar's comparison, `float32 < float`) is compared. + // WeavePy previously only knew native scalars, so foreign ordering was + // a hard "not supported". + let r = unsafe { richcompare_via_slot(a, b, op) }; + if r.is_null() { + return ptr::null_mut(); + } + if r != crate::singletons::not_implemented_ptr() { + return r; + } + unsafe { crate::object::Py_DecRef(r) }; + // RFC 0047 (wave 5): the C `tp_richcompare` slots declined (or are + // absent — WeavePy-native tuples/lists carry no C slot). Route through + // the VM's `do_richcompare` so container ordering, per-element + // comparison, and native operator overloads resolve exactly as the + // `COMPARE_OP` bytecode would. Cython's import-time `(major, minor)` + // version-tuple checks (`sys.version_info[:2] >= (3, 9)`) land here. + if let Some(out) = unsafe { richcompare_via_vm(a, b, op) } { + return out; + } + // No interpreter active (very early init): native scalar fallback — + // built-in scalars / identity for ==,!=. + let rb = unsafe { PyObject_RichCompareBool(a, b, op) }; + if rb < 0 { + // No native ordering and no slot: `==`/`!=` already resolved to + // identity inside `RichCompareBool`; an ordering op is unsupported. + let oa = unsafe { crate::object::clone_object(a) }; + let ob = unsafe { crate::object::clone_object(b) }; + let sym = match op { + 0 => "<", + 1 => "<=", + 4 => ">", + 5 => ">=", + _ => "compare", + }; + if std::env::var_os("WEAVEPY_CMP_BT").is_some() { + eprintln!( + "[CMP_BT] '{}' between '{}' and '{}'\n{:?}", + sym, + type_name(&oa), + type_name(&ob), + std::backtrace::Backtrace::force_capture() + ); + } + crate::errors::set_type_error(format!( + "'{}' not supported between instances of '{}' and '{}'", + sym, + type_name(&oa), + type_name(&ob) + )); + return ptr::null_mut(); + } + let truth = if rb != 0 { crate::singletons::true_ptr() } else { - unsafe { crate::object::Py_IncRef(crate::singletons::false_ptr()) }; crate::singletons::false_ptr() + }; + unsafe { crate::object::Py_IncRef(truth) }; + truth +} + +/// CPython `do_richcompare` over the operands' `tp_richcompare` slots: try +/// `type(a)`'s slot with `op`, then (reflected, when `type(b)` differs) +/// `type(b)`'s with the swapped op, honouring the `NotImplemented` +/// protocol. Returns a new reference on success, NULL with a pending error +/// when a slot raised, or the (incref'd) `NotImplemented` singleton when +/// both decline / are absent (the caller then applies the native default). +/// +/// # Safety +/// `a` and `b` must be live, non-null `PyObject*` with readable `ob_type`. +pub(crate) unsafe fn richcompare_via_slot( + a: *mut PyObject, + b: *mut PyObject, + op: c_int, +) -> *mut PyObject { + type RichCmpFunc = + unsafe extern "C" fn(*mut PyObject, *mut PyObject, c_int) -> *mut PyObject; + // `_Py_SwappedOp`: Py_LT<->Py_GT, Py_LE<->Py_GE, Py_EQ/Py_NE unchanged. + const SWAPPED: [c_int; 6] = [4, 5, 2, 3, 0, 1]; + + unsafe fn richcompare_slot(o: *mut PyObject) -> *mut std::ffi::c_void { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + unsafe { (*ty).tp_richcompare } + } + + if !(0..=5).contains(&op) { + return ptr::null_mut(); + } + let not_impl = crate::singletons::not_implemented_ptr(); + let ta = unsafe { (*a).ob_type }; + let tb = unsafe { (*b).ob_type }; + let slot_a = unsafe { richcompare_slot(a) }; + let slot_b = if ta == tb { + ptr::null_mut() + } else { + unsafe { richcompare_slot(b) } + }; + if !slot_a.is_null() { + let f: RichCmpFunc = unsafe { std::mem::transmute(slot_a) }; + let r = unsafe { f(a, b, op) }; + if r.is_null() { + return ptr::null_mut(); + } + if r != not_impl { + return r; + } + unsafe { crate::object::Py_DecRef(r) }; + } + if !slot_b.is_null() { + let f: RichCmpFunc = unsafe { std::mem::transmute(slot_b) }; + let r = unsafe { f(b, a, SWAPPED[op as usize]) }; + if r.is_null() { + return ptr::null_mut(); + } + if r != not_impl { + return r; + } + unsafe { crate::object::Py_DecRef(r) }; + } + unsafe { crate::object::Py_IncRef(not_impl) }; + not_impl +} + +/// Invoke an object's own `tp_hash` slot directly, bypassing the VM hash +/// router. This is the C side of the VM→C `fwd_hash` bridge (foreign.rs): +/// the VM has already decided the operand is foreign and is asking C for its +/// native hash. Routing through `PyObject_Hash` here would bounce straight +/// back into the VM (`hash_public` → `py_hash_value` → `foreign::hash` → +/// here), an unbounded ping-pong that overflows the stack — exactly the numpy +/// scalar case (`hash(np.int64(0))`). Consulting only the type slot lets a +/// numpy `int64`/`float64` hash like the equal Python scalar so numpy's +/// `np.roll` `shifts` dict (keyed by Python-int axes, probed with numpy ints) +/// resolves instead of raising `KeyError`. +/// +/// Returns `None` when the type carries no `tp_hash` (an unhashable foreign +/// type); the caller then falls back to an identity hash. When the slot is +/// present its result is returned verbatim (a `-1` return leaves the slot's +/// pending exception set, mirroring CPython). +pub(crate) unsafe fn hash_via_slot(o: *mut PyObject) -> Option { + type HashFunc = unsafe extern "C" fn(*mut PyObject) -> PyHashT; + if o.is_null() { + return None; + } + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return None; + } + let slot = unsafe { (*ty).tp_hash }; + if slot.is_null() { + return None; + } + // A foreign object whose C `tp_hash` is WeavePy's own VM-forwarding + // bridge would ping-pong forever: `fwd_hash → hash_via_slot → + // synth_tp_hash → PyObject_Hash → hash_public → py_hash_value(Foreign) + // → foreign::hash → fwd_hash`. The bridge is inherited by a numpy scalar + // that subclasses a WeavePy builtin (`np.float64 : float`, + // `np.complex128 : complex`), so hash it by *value* — exactly the + // float/complex `__hash__` CPython inherits (which reads the shared C + // body) — preserving `hash(np.float64(x)) == hash(x)`. Any other kind + // returns `None`, so the caller falls back to an identity hash, matching + // `object.__hash__`. + if slot == crate::types::synth_tp_hash_addr() { + return unsafe { foreign_numeric_value_hash(o) }; + } + let f: HashFunc = unsafe { std::mem::transmute(slot) }; + Some(unsafe { f(o) }) +} + +/// Value-based hash for a foreign scalar whose C `tp_hash` is WeavePy's own +/// VM-forwarding bridge (inherited from a builtin numeric base). Builds the +/// native `float`/`complex` value and hashes it through the VM's single hash +/// source of truth so it agrees bit-for-bit with the equal Python scalar +/// (`hash(np.float64(x)) == hash(x)`). Returns `None` for a non-numeric kind, +/// leaving the caller to fall back to an identity hash (CPython's +/// `object.__hash__`). +/// +/// Classification goes through the *number protocol*, not a subtype test: a +/// numpy scalar's single `tp_base` chain is the numpy hierarchy +/// (`np.float64 → np.floating → … → object`) and its bridged VM type does not +/// re-expose Python `float`/`complex`, so `PyType_IsSubtype` cannot see the +/// relationship. Reading through the *complex* protocol subsumes both cases: +/// a real scalar reports a zero imaginary part, and `hash(complex(x, 0)) == +/// hash(x)`, so a zero imag is hashed as a plain float — matching CPython's +/// `complex_hash` (and hence the inherited `float`/`complex` `__hash__`) for +/// numpy float *and* complex scalars alike. (Probing `__float__` first would +/// misclassify a complex scalar: numpy's `complex.__float__` returns the real +/// part with a `ComplexWarning` rather than raising.) +/// +/// # Safety +/// `o` must be a live `PyObject*` whose `ob_type` is readable. +unsafe fn foreign_numeric_value_hash(o: *mut PyObject) -> Option { + // Clear any stale pending error so our own probe's error signal is + // unambiguous. + let _ = crate::errors::take_pending(); + let re = unsafe { crate::numbers::PyComplex_RealAsDouble(o) }; + if crate::errors::take_pending().is_some() { + return None; // not a numeric scalar -> identity fallback + } + // numpy's complex scalar exposes neither a `__complex__` nor a working + // `PyComplex_ImagAsDouble` (its `__float__` yields only the real part), + // so read the imaginary component from the `.imag` attribute — every + // numpy numeric scalar carries it (`0.0` for a real scalar). A + // missing/failing attribute is treated as real. `hash(complex(x, 0)) == + // hash(x)`, so a zero imag hashes as a plain float, matching CPython. + let im = unsafe { foreign_attr_double(o, b"imag\0".as_ptr().cast()) }.unwrap_or(0.0); + let value = if im == 0.0 { + Object::Float(re) + } else { + Object::new_complex(re, im) + }; + match weavepy_vm::builtins::hash_object(&value) { + // CPython reserves `-1` for "error"; a real hash of `-1` becomes `-2`. + Ok(Object::Int(h)) => Some(if h == -1 { -2 } else { h as PyHashT }), + _ => None, + } +} + +/// Read numeric attribute `name` off a foreign scalar as an `f64`, or `None` +/// when the attribute is absent or not float-convertible. Consumes any pending +/// error so the probe stays side-effect free. +/// +/// # Safety +/// `o` must be a live `PyObject*` and `name` a NUL-terminated C string. +unsafe fn foreign_attr_double(o: *mut PyObject, name: *const std::os::raw::c_char) -> Option { + let attr = unsafe { PyObject_GetAttrString(o, name) }; + if attr.is_null() { + let _ = crate::errors::take_pending(); + return None; + } + let d = unsafe { crate::numbers::PyFloat_AsDouble(attr) }; + let err = crate::errors::take_pending().is_some(); + unsafe { crate::object::Py_DecRef(attr) }; + if err { + None + } else { + Some(d) } } @@ -567,19 +1667,103 @@ pub unsafe extern "C" fn PyObject_RichCompareBool( if a.is_null() || b.is_null() { return -1; } + // CPython's `PyObject_RichCompareBool` opens with an identity shortcut so + // that identity implies equality (e.g. `[nan].index(nan)` finds the NaN, + // and pandas relies on it for object-array membership): when the operands + // are the *same* object, `Py_EQ` is trivially true and `Py_NE` trivially + // false without consulting `__eq__`. + if std::ptr::eq(a, b) { + if op == 2 { + return 1; + } + if op == 3 { + return 0; + } + } + let _wpg = WpDepthGuard::enter("PyObject_RichCompareBool", a, b); let oa = unsafe { crate::object::clone_object(a) }; let ob = unsafe { crate::object::clone_object(b) }; - let cmp = compare_objects(&oa, &ob); - match (cmp, op) { - (Some(o), 0) => i32::from(o == std::cmp::Ordering::Less), - (Some(o), 1) => i32::from(o != std::cmp::Ordering::Greater), - (Some(o), 2) => i32::from(o == std::cmp::Ordering::Equal), - (Some(o), 3) => i32::from(o != std::cmp::Ordering::Equal), - (Some(o), 4) => i32::from(o == std::cmp::Ordering::Greater), - (Some(o), 5) => i32::from(o != std::cmp::Ordering::Less), - // Equality without ordering: 2/3 do object equality. - (None, 2) => i32::from(oa.eq_value(&ob)), - (None, 3) => i32::from(!oa.eq_value(&ob)), + let _cmp_trace = std::env::var_os("WEAVEPY_TRACE_CMP").is_some(); + if _cmp_trace { + eprintln!( + "[CMP] op={} a={:?}<{}> b={:?}<{}> a_id=0x{:x} b_id=0x{:x}", + op, + oa.repr(), + oa.type_name_owned(), + ob.repr(), + ob.type_name_owned(), + a as usize, + b as usize, + ); + } + if std::env::var_os("WEAVEPY_CMP_BT").is_some() { + let na = type_name(&oa); + let nb = type_name(&ob); + if (na == "NoneType" || nb == "NoneType") && (na != nb) { + eprintln!( + "[CMP_BT bool] op={} '{}' vs '{}'\n{:?}", + op, + na, + nb, + std::backtrace::Backtrace::force_capture() + ); + } + } + // Fast path: a pair of native scalars the ordering table can resolve + // directly (int/float/str/bytes/bool). Equivalent to the faithful + // compare-then-`IsTrue` path below but avoids a VM round-trip. + if let Some(o) = compare_objects(&oa, &ob) { + let r = match op { + 0 => i32::from(o == std::cmp::Ordering::Less), + 1 => i32::from(o != std::cmp::Ordering::Greater), + 2 => i32::from(o == std::cmp::Ordering::Equal), + 3 => i32::from(o != std::cmp::Ordering::Equal), + 4 => i32::from(o == std::cmp::Ordering::Greater), + 5 => i32::from(o != std::cmp::Ordering::Less), + _ => -1, + }; + if _cmp_trace { + eprintln!("[CMP] -> fastpath ord={o:?} result={r}"); + } + return r; + } + if _cmp_trace { + eprintln!("[CMP] -> no fastpath (types not both scalar); falling to slot/vm"); + } + // General path — CPython's `PyObject_RichCompare(v, w, op)` followed by + // `PyObject_IsTrue`. This is mandatory for objects with a custom + // comparison (a pandas `Period`/`Timestamp` C `tp_richcompare`, a + // WeavePy-native container, an instance's `__eq__`): the earlier + // structural `eq_value` shortcut here compared two equal-but-distinct + // `Period`s as unequal, so `pandas._libs.ops.vec_compare` (which calls + // this with `Py_EQ`) returned all-`False` for object-dtype Period arrays. + // Inlined (rather than delegating to `PyObject_RichCompare`) so the + // no-interpreter fallback below cannot recurse back into this function. + let slot_r = unsafe { richcompare_via_slot(a, b, op) }; + if slot_r.is_null() { + return -1; + } + if slot_r != crate::singletons::not_implemented_ptr() { + let truth = unsafe { PyObject_IsTrue(slot_r) }; + unsafe { crate::object::Py_DecRef(slot_r) }; + return truth; + } + unsafe { crate::object::Py_DecRef(slot_r) }; + if let Some(r) = unsafe { richcompare_via_vm(a, b, op) } { + if r.is_null() { + return -1; + } + let truth = unsafe { PyObject_IsTrue(r) }; + unsafe { crate::object::Py_DecRef(r) }; + return truth; + } + // No `tp_richcompare` slot and no active interpreter (very early init): + // last-resort structural equality for `==`/`!=`, matching CPython's + // default object comparison (identity-based) closely enough for the + // scalar bootstrap. Ordering without a resolver stays unsupported. + match op { + 2 => i32::from(oa.eq_value(&ob)), + 3 => i32::from(!oa.eq_value(&ob)), _ => -1, } } @@ -603,8 +1787,33 @@ pub unsafe extern "C" fn PyObject_Hash(o: *mut PyObject) -> PyHashT { if o.is_null() { return -1; } - use std::hash::{Hash, Hasher}; + let _wpg = WpDepthGuard::enter("PyObject_Hash", o, ptr::null_mut()); let obj = unsafe { crate::object::clone_object(o) }; + // RFC 0047 (wave 5): route through the VM's `do_hash_call` (the same + // path the `hash()` builtin uses) so a value hashed from inside a C + // extension matches the VM's CPython-faithful hash bit-for-bit. Cython's + // `__hash__` idiom `hash(tuple(self._items))` compares the C-API result + // against a VM-computed hash, so the two must agree. + if let Some(res) = + crate::interp::ensure_active(|| crate::interp::with_interp_mut(|i| i.hash_public(&obj))) + { + return match res { + Ok(h) => { + if h == -1 { + -2 + } else { + h as PyHashT + } + } + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } + // No interpreter active (very early init): fall back to a structural + // hash so callers still get a stable, non-error value. + use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); DictKey(obj).hash(&mut hasher); let h = hasher.finish() as PyHashT; @@ -634,19 +1843,57 @@ pub unsafe extern "C" fn PyObject_IsInstance(o: *mut PyObject, cls: *mut PyObjec if o.is_null() || cls.is_null() { return 0; } - let ob = unsafe { crate::object::clone_object(o) }; - let class = match unsafe { crate::object::clone_object(cls) } { + let ob = unsafe { crate::object::clone_object(o) }; + let classinfo = unsafe { crate::object::clone_object(cls) }; + // Route through the interpreter's full `isinstance()` protocol so ABCMeta + // virtual subclasses (`numbers.Number`, `collections.abc`, pandas' + // `ABCSeries`/`ABCIndex`), tuples of classinfos and PEP 604 unions all + // resolve exactly as the `isinstance` builtin would. CPython's + // `PyObject_IsInstance` shares its implementation with the builtin, so the + // C-API and bytecode paths must never diverge: a Cython `isinstance(x, + // numbers.Number)` (pandas `NAType.__add__`/`__pow__`) has to see a plain + // `int` as a number, which only the metaclass `__instancecheck__` reveals. + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.isinstance_public(&ob, &classinfo)) + }) { + Some(Ok(true)) => 1, + Some(Ok(false)) => 0, + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + // No active interpreter (very early import machinery) — fall back to + // the structural MRO walk. ABC/tuple classinfos are vanishingly + // unlikely this early, and this preserves best-effort behaviour. + None => isinstance_structural(&ob, &classinfo), + } +} + +/// Best-effort structural `isinstance` used only when no interpreter is +/// active (before/without a running VM). Handles tuples of classinfos and a +/// bare MRO subclass check; it deliberately cannot see ABC-registered virtual +/// subclasses — that requires the interpreter path above. +fn isinstance_structural(ob: &Object, classinfo: &Object) -> c_int { + if let Object::Tuple(items) = classinfo { + for it in items.iter() { + if isinstance_structural(ob, it) == 1 { + return 1; + } + } + return 0; + } + let class = match classinfo { Object::Type(t) => t, _ => return 0, }; - let actual = match &ob { + let actual = match ob { Object::Instance(inst) => Some(inst.cls()), Object::Type(_) => Some(weavepy_vm::builtin_types::builtin_types().type_.clone()), _ => weavepy_vm::builtin_types::builtin_types() - .by_name(type_name(&ob)) + .by_name(type_name(ob)) .clone(), }; - actual.map_or(0, |a| i32::from(a.is_subclass_of(&class))) + actual.map_or(0, |a| i32::from(a.is_subclass_of(class))) } #[no_mangle] @@ -654,15 +1901,28 @@ pub unsafe extern "C" fn PyObject_IsSubclass(o: *mut PyObject, cls: *mut PyObjec if o.is_null() || cls.is_null() { return 0; } - let oa = match unsafe { crate::object::clone_object(o) } { - Object::Type(t) => t, - _ => return 0, - }; - let oc = match unsafe { crate::object::clone_object(cls) } { - Object::Type(t) => t, - _ => return 0, - }; - i32::from(oa.is_subclass_of(&oc)) + let derived = unsafe { crate::object::clone_object(o) }; + let classinfo = unsafe { crate::object::clone_object(cls) }; + // Same reasoning as `PyObject_IsInstance`: dispatch through the VM's + // `issubclass()` protocol so metaclass `__subclasscheck__` (ABCMeta) and + // tuple/union classinfos are honoured. + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.issubclass_public(&derived, &classinfo)) + }) { + Some(Ok(true)) => 1, + Some(Ok(false)) => 0, + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + None => { + let Object::Type(oa) = &derived else { return 0 }; + let Object::Type(oc) = &classinfo else { + return 0; + }; + i32::from(oa.is_subclass_of(oc)) + } + } } #[no_mangle] @@ -671,7 +1931,44 @@ pub unsafe extern "C" fn PyObject_Length(o: *mut PyObject) -> PySsizeT { return -1; } let obj = unsafe { crate::object::clone_object(o) }; - sequence_len(&obj).unwrap_or(-1) + if let Some(n) = sequence_len(&obj) { + return n; + } + // Genuinely foreign extension objects (numpy `ndarray`/`dtype`, Cython + // `cdef class` instances) carry their length in their *own* C + // `tp_as_sequence->sq_length` / `tp_as_mapping->mp_length` slot; read it + // directly, exactly like CPython's `PyObject_Size`. + if matches!(obj, Object::Foreign(_)) { + if let Some(n) = unsafe { foreign_len(o) } { + return n; + } + } + // Any other VM object — a `list`/`dict`/… *subclass* instance, a + // generator, … — resolves `__len__` through the interpreter. Routing an + // instance through `foreign_len` would invoke our own generic + // `sq_length` bridge, which calls straight back into `PyObject_Length`: + // unbounded recursion (numpy's `np.array(frozenlist, dtype=…)` once + // `PySequence_Check` reports the subclass a sequence). + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.len_object(&obj)) + }) { + return match res { + Ok(n) => n as PySsizeT, + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } + // No active interpreter: last-ditch bridged length slot. + if let Some(n) = unsafe { foreign_len(o) } { + return n; + } + crate::errors::set_type_error(format!( + "object of type '{}' has no len()", + type_name(&obj) + )); + -1 } #[no_mangle] @@ -679,11 +1976,43 @@ pub unsafe extern "C" fn PyObject_Size(o: *mut PyObject) -> PySsizeT { unsafe { PyObject_Length(o) } } +/// Read `len(o)` from a foreign type's `tp_as_sequence->sq_length` or +/// `tp_as_mapping->mp_length` slot. Returns `None` when neither slot is +/// present (the object genuinely has no length). +/// +/// # Safety +/// `o` must be a live `PyObject*`. +unsafe fn foreign_len(o: *mut PyObject) -> Option { + type LenFunc = unsafe extern "C" fn(*mut PyObject) -> PySsizeT; + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return None; + } + let seq = unsafe { (*ty).tp_as_sequence }; + if !seq.is_null() { + let slot = unsafe { (*seq).sq_length }; + if !slot.is_null() { + let f: LenFunc = unsafe { std::mem::transmute(slot) }; + return Some(unsafe { f(o) }); + } + } + let map = unsafe { (*ty).tp_as_mapping }; + if !map.is_null() { + let slot = unsafe { (*map).mp_length }; + if !slot.is_null() { + let f: LenFunc = unsafe { std::mem::transmute(slot) }; + return Some(unsafe { f(o) }); + } + } + None +} + fn sequence_len(o: &Object) -> Option { use Object as O; Some(match o { O::Str(s) => s.chars().count() as PySsizeT, O::Bytes(b) => b.len() as PySsizeT, + O::ByteArray(rc) => rc.borrow().len() as PySsizeT, O::Tuple(items) => items.len() as PySsizeT, O::List(rc) => rc.borrow().len() as PySsizeT, O::Dict(rc) => rc.borrow().len() as PySsizeT, @@ -696,10 +2025,27 @@ fn sequence_len(o: &Object) -> Option { #[no_mangle] pub unsafe extern "C" fn PyObject_GetItem(o: *mut PyObject, key: *mut PyObject) -> *mut PyObject { if o.is_null() || key.is_null() { + crate::errors::set_type_error("bad argument to internal function"); return ptr::null_mut(); } let obj = unsafe { crate::object::clone_object(o) }; let k = unsafe { crate::object::clone_object(key) }; + // RFC 0047 (wave 5): route through the VM's full `__getitem__` dispatch + // — the same logic `BINARY_SUBSCR` runs — so instance dunders, foreign + // `mp_subscript`/`sq_item` slot wrappers (numpy `ndarray`/`flatiter`), + // metaclass `__getitem__`, and PEP 585 aliases all resolve identically. + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.subscr_get_public(&obj, &k)) + }) { + return match res { + Ok(v) => crate::object::into_owned(v), + Err(e) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } + }; + } + // No active interpreter: native-only fallback. match get_item(&obj, &k) { Ok(v) => crate::object::into_owned(v), Err(err) => { @@ -768,17 +2114,50 @@ pub unsafe extern "C" fn PyObject_SetItem( } else { unsafe { crate::object::clone_object(v) } }; + // RFC 0047 (wave 5): route through the VM's full `__setitem__` dispatch + // — the same logic `STORE_SUBSCR` runs — so instance dunders and foreign + // `mp_ass_subscript`/`sq_ass_item` slot wrappers (numpy `ndarray`) work. + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.subscr_set_public(&obj, &k, val.clone())) + }) { + return match res { + Ok(()) => { + unsafe { crate::mirror::sync_dict_ma_used(o) }; + // RFC 0047 (wave 5): the VM updated the shared prefix `Rc`, + // but a faithful list mirror's inline `ob_item` buffer — read + // directly by Cython's `__Pyx_PyList_GetItemRefFast` / + // `PyList_GET_ITEM` macros — is now stale. The VM→C flush only + // runs on *entry* to `ensure_active` (before this write), and + // the next C read is an inlined macro that bypasses every + // WeavePy function, so nothing would republish it. Re-sync now + // so a `lst[i] = x` performed while C iterates `lst` (exactly + // `guess_datetime_format`'s `format_guess[i] = attr_format` + // inside `for i, _ in enumerate(format_guess)`) is visible. + unsafe { crate::mirror::sync_list_ob_item(o) }; + 0 + } + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } + // No active interpreter: native-only fallback. match obj { Object::Dict(rc) => { rc.borrow_mut().insert(DictKey(k), val); + unsafe { crate::mirror::sync_dict_ma_used(o) }; 0 } Object::List(rc) => match k { Object::Int(i) => { let idx = i as usize; - let mut g = rc.borrow_mut(); - if idx < g.len() { - g[idx] = val; + let len = rc.borrow().len(); + if idx < len { + rc.borrow_mut()[idx] = val; + // Keep the faithful mirror's `ob_item` coherent (see the + // active-interpreter arm above). + unsafe { crate::mirror::sync_list_ob_item(o) }; 0 } else { crate::errors::set_value_error("list assignment index out of range"); @@ -800,20 +2179,46 @@ pub unsafe extern "C" fn PyObject_SetItem( #[no_mangle] pub unsafe extern "C" fn PyObject_DelItem(o: *mut PyObject, key: *mut PyObject) -> c_int { if o.is_null() || key.is_null() { + crate::errors::set_type_error("bad argument to internal function"); return -1; } let obj = unsafe { crate::object::clone_object(o) }; let k = unsafe { crate::object::clone_object(key) }; + // RFC 0047 (wave 5): route through the VM's full `__delitem__` dispatch + // — the same logic `DELETE_SUBSCR` runs — so instance dunders and foreign + // `mp_ass_subscript`(NULL) slot wrappers resolve identically. + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.subscr_del_public(&obj, &k)) + }) { + return match res { + Ok(()) => { + unsafe { crate::mirror::sync_dict_ma_used(o) }; + // A list deletion shifts every trailing element and shrinks + // `ob_size`; republish so inline macro reads stay coherent. + unsafe { crate::mirror::sync_list_ob_item(o) }; + 0 + } + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } + // No active interpreter: native-only fallback. match obj { Object::Dict(rc) => { if rc.borrow_mut().shift_remove(&DictKey(k)).is_some() { + unsafe { crate::mirror::sync_dict_ma_used(o) }; 0 } else { crate::errors::set_value_error("KeyError"); -1 } } - _ => -1, + _ => { + crate::errors::set_type_error("object does not support item deletion"); + -1 + } } } @@ -907,8 +2312,66 @@ fn binop(a: *mut PyObject, b: *mut PyObject, op: BinOp) -> *mut PyObject { } let oa = unsafe { crate::object::clone_object(a) }; let ob = unsafe { crate::object::clone_object(b) }; - match apply_binop(&oa, &ob, op) { - Some(v) => crate::object::into_owned(v), + if std::env::var_os("WEAVEPY_TSDBG").is_some() { + eprintln!( + "[TSDBG] binop {op:?} a_type={} a_kind={} b_type={} b_kind={}", + unsafe { crate::object::debug_type_name(a) }, + oa.type_name(), + unsafe { crate::object::debug_type_name(b) }, + ob.type_name() + ); + } + if let Some(v) = apply_binop(&oa, &ob, op) { + return crate::object::into_owned(v); + } + // RFC 0046 (wave 4): when either operand is a *foreign* extension + // object, dispatch through the operands' `tp_as_number` slots + // (CPython's `binary_op1`) — a numpy scalar's `nb_subtract`, an + // extension type's `nb_add`. Without this, `float32 - float32` + // (numpy's import-time `getlimits` math) is a hard "unsupported + // operand". Native operands fall through to the VM below. + let either_foreign = + matches!(oa, Object::Foreign(_)) || matches!(ob, Object::Foreign(_)); + if either_foreign { + let r = unsafe { number_slot_binop(a, b, op) }; + if r.is_null() { + // A slot raised; its exception is pending. + if std::env::var_os("WEAVEPY_TSDBG").is_some() { + eprintln!("[TSDBG] binop {op:?} foreign-slot RESULT=NULL (slot raised)"); + } + return ptr::null_mut(); + } + if r == crate::singletons::not_implemented_ptr() { + unsafe { crate::object::Py_DecRef(r) }; + if std::env::var_os("WEAVEPY_TSDBG").is_some() { + eprintln!("[TSDBG] binop {op:?} foreign-slot RESULT=NotImplemented"); + } + crate::errors::set_type_error(format!("unsupported operand type for {op:?}")); + return ptr::null_mut(); + } + if std::env::var_os("WEAVEPY_TSDBG").is_some() { + eprintln!( + "[TSDBG] binop {op:?} foreign-slot RESULT type={}", + unsafe { crate::object::debug_type_name(r) } + ); + } + return r; + } + // RFC 0047 (wave 5): both operands are WeavePy-native, so dispatch the + // full VM binary-op protocol — `str % args` formatting (Cython's + // `PyUnicode_Format` routes here), sequence concat/repeat, and user / + // `cdef` class `__op__`/`__rop__` overloads — exactly as the + // `BINARY_OP` bytecode would. The native scalar fast path above only + // knew built-in numeric/`str+str` combinations. + let kind = binop_kind(op); + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.binary_op_public(&oa, &ob, kind)) + }) { + Some(Ok(v)) => crate::object::into_owned(v), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } None => { crate::errors::set_type_error(format!("unsupported operand type for {op:?}")); ptr::null_mut() @@ -916,6 +2379,167 @@ fn binop(a: *mut PyObject, b: *mut PyObject, op: BinOp) -> *mut PyObject { } } +/// Map the C-API [`BinOp`] tag to the VM's [`weavepy_compiler::BinOpKind`] +/// so [`binop`] can defer native operands to the bytecode dispatcher. +fn binop_kind(op: BinOp) -> weavepy_compiler::BinOpKind { + use weavepy_compiler::BinOpKind as K; + match op { + BinOp::Add => K::Add, + BinOp::Sub => K::Sub, + BinOp::Mul => K::Mult, + BinOp::TrueDiv => K::Div, + BinOp::FloorDiv => K::FloorDiv, + BinOp::Rem => K::Mod, + BinOp::Pow => K::Pow, + BinOp::And => K::BitAnd, + BinOp::Or => K::BitOr, + BinOp::Xor => K::BitXor, + BinOp::Lshift => K::LShift, + BinOp::Rshift => K::RShift, + } +} + +/// CPython `binary_op1` over the operands' `tp_as_number` slots: try +/// `type(a)`'s slot, then (when `type(b)` differs) `type(b)`'s, honouring +/// the `NotImplemented` decline protocol — both slots are invoked as +/// `slot(a, b)` (the slot itself resolves which operand is its own type). +/// Returns a new reference on success, NULL with a pending error when a +/// slot raised, or the (incref'd) `NotImplemented` singleton when both +/// decline / are absent. +/// +/// # Safety +/// `a` and `b` must be live, non-null `PyObject*` with readable `ob_type`. +unsafe fn number_slot_binop(a: *mut PyObject, b: *mut PyObject, op: BinOp) -> *mut PyObject { + type BinaryFunc = unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject; + type TernaryFunc = + unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> *mut PyObject; + + unsafe fn number_suite(o: *mut PyObject) -> *mut crate::layout::PyNumberMethods { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + return ptr::null_mut(); + } + unsafe { (*ty).tp_as_number } + } + let slot_of = |nb: *mut crate::layout::PyNumberMethods| -> *mut std::ffi::c_void { + if nb.is_null() { + return ptr::null_mut(); + } + unsafe { + match op { + BinOp::Add => (*nb).nb_add, + BinOp::Sub => (*nb).nb_subtract, + BinOp::Mul => (*nb).nb_multiply, + BinOp::TrueDiv => (*nb).nb_true_divide, + BinOp::FloorDiv => (*nb).nb_floor_divide, + BinOp::Rem => (*nb).nb_remainder, + BinOp::Pow => (*nb).nb_power, + BinOp::And => (*nb).nb_and, + BinOp::Or => (*nb).nb_or, + BinOp::Xor => (*nb).nb_xor, + BinOp::Lshift => (*nb).nb_lshift, + BinOp::Rshift => (*nb).nb_rshift, + } + } + }; + let invoke = |slot: *mut std::ffi::c_void| -> *mut PyObject { + if matches!(op, BinOp::Pow) { + // `nb_power` is a ternaryfunc; pass `None` for the modulus. + let f: TernaryFunc = unsafe { std::mem::transmute(slot) }; + unsafe { f(a, b, crate::singletons::none_ptr()) } + } else { + let f: BinaryFunc = unsafe { std::mem::transmute(slot) }; + unsafe { f(a, b) } + } + }; + + // Resolve `op`'s number slot the way a readied type's *flattened* + // `tp_as_number` would: the object's own type suite, else the first + // non-NULL slot inherited from a `tp_base` ancestor. CPython's + // `PyType_Ready` bakes inherited `nb_*` into every subtype, but WeavePy + // may not have readied a Cython-extension-over-pure-Python subclass + // (pandas `Timedelta(_Timedelta(datetime.timedelta))`), leaving the + // leaf's own `nb_add`/`nb_subtract`/… NULL while the Cython base still + // carries them. Walking `tp_base` here reproduces the flattening at call + // time (the RFC 0046 §2.7 stop-gap, applied to the numeric suite) so a + // reflected `numpy.timedelta64 + Timedelta` finds `_Timedelta`'s slot. + let resolve_slot = |o: *mut PyObject| -> *mut std::ffi::c_void { + let mut ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + let mut guard = 0; + while !ty.is_null() && guard < 100 { + let s = slot_of(unsafe { (*ty).tp_as_number }); + if !s.is_null() { + return s; + } + ty = unsafe { (*ty).tp_base }; + guard += 1; + } + ptr::null_mut() + }; + + let not_impl = crate::singletons::not_implemented_ptr(); + let ta = unsafe { (*a).ob_type }; + let slot_a = resolve_slot(a); + let slot_b_raw = resolve_slot(b); + // CPython's `binary_op1` drops the reflected slot when it is the *same* + // function as the left slot (both inherited from a shared base), so the + // slot never runs twice. Compare the resolved pointers, not the types. + let slot_b = if slot_b_raw == slot_a { + ptr::null_mut() + } else { + slot_b_raw + }; + if std::env::var_os("WEAVEPY_SLOTDBG").is_some() { + let nsa = unsafe { number_suite(a) }; + let base_a = unsafe { + let tf = ta as *mut crate::layout::PyTypeObjectFull; + if tf.is_null() { ptr::null_mut() } else { (*tf).tp_base } + }; + eprintln!( + "[SLOTDBG] {op:?} a_ty={:p} ({}) tp_as_number={:p} nb_add={:p} nb_sub={:p} tp_base={:p} readied={}", + ta, + unsafe { crate::object::debug_type_name(a) }, + nsa, + if nsa.is_null() { ptr::null_mut() } else { unsafe { (*nsa).nb_add } }, + if nsa.is_null() { ptr::null_mut() } else { unsafe { (*nsa).nb_subtract } }, + base_a, + crate::types::readied_slot_table(ta).is_some(), + ); + } + + let dbg = std::env::var_os("WEAVEPY_TSDBG").is_some(); + for (idx, slot) in [slot_a, slot_b].into_iter().enumerate() { + if slot.is_null() { + if dbg { + eprintln!("[TSDBG] slot[{idx}] (a=0,b=1) is NULL for {op:?}"); + } + continue; + } + let r = invoke(slot); + if r.is_null() { + if dbg { + eprintln!("[TSDBG] slot[{idx}] {op:?} RAISED"); + } + return ptr::null_mut(); + } + if r != not_impl { + if dbg { + eprintln!( + "[TSDBG] slot[{idx}] {op:?} returned type={}", + unsafe { crate::object::debug_type_name(r) } + ); + } + return r; + } + if dbg { + eprintln!("[TSDBG] slot[{idx}] {op:?} returned NotImplemented"); + } + unsafe { crate::object::Py_DecRef(r) }; + } + unsafe { crate::object::Py_IncRef(not_impl) }; + not_impl +} + #[derive(Clone, Copy, Debug)] enum BinOp { Add, @@ -925,43 +2549,65 @@ enum BinOp { FloorDiv, Rem, Pow, + And, + Or, + Xor, + Lshift, + Rshift, } fn apply_binop(a: &Object, b: &Object, op: BinOp) -> Option { use Object as O; match (a, b) { - (O::Int(x), O::Int(y)) => Some(match op { - BinOp::Add => O::Int(x.wrapping_add(*y)), - BinOp::Sub => O::Int(x.wrapping_sub(*y)), - BinOp::Mul => O::Int(x.wrapping_mul(*y)), + (O::Int(x), O::Int(y)) => match op { + // CPython ints are arbitrary precision. Two `i64`s always fit + // in `i128` for +/-/*, so promote via `int_from_i128` (which + // re-demotes to `Int` when the product still fits) instead of + // the old `wrapping_*`. Silent wraparound didn't just give + // wrong answers — it defeated C extensions' overflow + // *detection*: Cython's `x * 1_000_000_000` computes in C + // `long long`, and on overflow falls back to + // `Py_TYPE(x)->tp_as_number->nb_multiply` expecting a promoted + // big int (pandas `Timedelta(days=10**6)` relies on this to + // raise `OutOfBoundsTimedelta`). + BinOp::Add => Some(weavepy_vm::object::int_from_i128(*x as i128 + *y as i128)), + BinOp::Sub => Some(weavepy_vm::object::int_from_i128(*x as i128 - *y as i128)), + BinOp::Mul => Some(weavepy_vm::object::int_from_i128(*x as i128 * *y as i128)), BinOp::TrueDiv => { if *y == 0 { return None; } - O::Float(*x as f64 / *y as f64) - } - BinOp::FloorDiv => { - if *y == 0 { - return None; - } - O::Int(x.div_euclid(*y)) - } - BinOp::Rem => { - if *y == 0 { - return None; - } - O::Int(x.rem_euclid(*y)) + Some(O::Float(*x as f64 / *y as f64)) } - BinOp::Pow => O::Int((*x).pow((*y).max(0) as u32)), - }), - (O::Float(x), O::Float(y)) => Some(match op { - BinOp::Add => O::Float(x + y), - BinOp::Sub => O::Float(x - y), - BinOp::Mul => O::Float(x * y), - BinOp::TrueDiv | BinOp::FloorDiv => O::Float(x / y), - BinOp::Rem => O::Float(x.rem_euclid(*y)), - BinOp::Pow => O::Float(x.powf(*y)), - }), + // Floor-division / remainder: defer zero-division (VM raises), + // the sole i64 overflow (`i64::MIN // -1`, which would panic), + // and — since `i64::div_euclid`/`rem_euclid` don't match + // Python's floor semantics for mixed signs — every case to the + // VM's faithful arbitrary-precision implementation. + BinOp::FloorDiv | BinOp::Rem => None, + // `**` can overflow i64, grow without bound, or (negative + // exponent) produce a float — hand the whole thing to the VM. + BinOp::Pow => None, + // Bitwise of two machine ints is always a machine int and + // matches Python's infinite two's-complement within i64. + BinOp::And => Some(O::Int(x & y)), + BinOp::Or => Some(O::Int(x | y)), + BinOp::Xor => Some(O::Int(x ^ y)), + // Shifts can grow past i64 (`1 << 100`) or take a negative + // count; defer to the VM for the faithful arbitrary-precision + // result rather than truncating. + BinOp::Lshift | BinOp::Rshift => None, + }, + (O::Float(x), O::Float(y)) => match op { + BinOp::Add => Some(O::Float(x + y)), + BinOp::Sub => Some(O::Float(x - y)), + BinOp::Mul => Some(O::Float(x * y)), + BinOp::TrueDiv | BinOp::FloorDiv => Some(O::Float(x / y)), + BinOp::Rem => Some(O::Float(x.rem_euclid(*y))), + BinOp::Pow => Some(O::Float(x.powf(*y))), + // Bitwise/shift on floats is a TypeError; let the VM raise it. + BinOp::And | BinOp::Or | BinOp::Xor | BinOp::Lshift | BinOp::Rshift => None, + }, (O::Float(x), O::Int(y)) => apply_binop(&O::Float(*x), &O::Float(*y as f64), op), (O::Int(x), O::Float(y)) => apply_binop(&O::Float(*x as f64), &O::Float(*y), op), (O::Str(x), O::Str(y)) if matches!(op, BinOp::Add) => { @@ -974,6 +2620,99 @@ fn apply_binop(a: &Object, b: &Object, op: BinOp) -> Option { } } +/// A `tp_as_number` binary-slot bridge for WeavePy's built-in numeric +/// types. Cython reads these slots off `Py_TYPE(x)->tp_as_number` and +/// calls them **directly** (e.g. `__Pyx_PyInt_MultiplyObjC`'s overflow +/// fallback), so a NULL slot is a hard crash (`blr NULL`). Unlike the +/// public [`PyNumber_Add`] & friends this never re-enters the *foreign* +/// `tp_as_number` dispatch (which would recurse, since *this* is one of +/// those slots): a foreign or otherwise-unhandled operand yields +/// `NotImplemented` so CPython's `binary_op1` protocol tries the other +/// operand's slot. +/// +/// # Safety +/// `a` and `b` must be live, non-null `PyObject*`. +unsafe fn number_slot_native(a: *mut PyObject, b: *mut PyObject, op: BinOp) -> *mut PyObject { + if a.is_null() || b.is_null() { + return ptr::null_mut(); + } + let oa = unsafe { crate::object::clone_object(a) }; + let ob = unsafe { crate::object::clone_object(b) }; + // Native scalar fast path (promotes int overflow to big-int). + if let Some(v) = apply_binop(&oa, &ob, op) { + return crate::object::into_owned(v); + } + // Decline foreign operands so the foreign type's own slot can run — + // and, crucially, so we don't recurse back through `binop`. + if matches!(oa, Object::Foreign(_)) || matches!(ob, Object::Foreign(_)) { + let ni = crate::singletons::not_implemented_ptr(); + unsafe { crate::object::Py_IncRef(ni) }; + return ni; + } + // Both operands native but the fast path declined (big-int `//`/`%`/ + // `**`, `str % tuple`, sequence concat/repeat): route the faithful VM + // binary-op protocol, mapping "no applicable rule" to NotImplemented. + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.binary_op_public(&oa, &ob, binop_kind(op))) + }) { + Some(Ok(v)) => crate::object::into_owned(v), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } + None => { + let ni = crate::singletons::not_implemented_ptr(); + unsafe { crate::object::Py_IncRef(ni) }; + ni + } + } +} + +/// Generate a `#[no_mangle]` `binaryfunc` bridge for each numeric slot. +macro_rules! nb_binary_slot { + ($name:ident, $op:expr) => { + /// `binaryfunc` bridge installed into built-in numeric + /// `tp_as_number` suites; see [`number_slot_native`]. + /// + /// # Safety + /// `a`/`b` must be valid `PyObject*` (the slot ABI contract). + pub unsafe extern "C" fn $name(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { + unsafe { number_slot_native(a, b, $op) } + } + }; +} + +nb_binary_slot!(nb_slot_add, BinOp::Add); +nb_binary_slot!(nb_slot_subtract, BinOp::Sub); +nb_binary_slot!(nb_slot_multiply, BinOp::Mul); +nb_binary_slot!(nb_slot_remainder, BinOp::Rem); +nb_binary_slot!(nb_slot_floor_divide, BinOp::FloorDiv); +nb_binary_slot!(nb_slot_true_divide, BinOp::TrueDiv); +nb_binary_slot!(nb_slot_lshift, BinOp::Lshift); +nb_binary_slot!(nb_slot_rshift, BinOp::Rshift); +nb_binary_slot!(nb_slot_and, BinOp::And); +nb_binary_slot!(nb_slot_or, BinOp::Or); +nb_binary_slot!(nb_slot_xor, BinOp::Xor); + +/// `ternaryfunc` bridge for `nb_power`. `a ** b` compiles to +/// `nb_power(a, b, Py_None)`; the 3-arg `pow(a, b, m)` form passes a real +/// modulus. Without a modulus we defer to the shared numeric slot path; +/// with one we fall back to the full [`PyNumber_Power`] protocol. +/// +/// # Safety +/// `a`/`b` must be valid `PyObject*`; `m` may be `Py_None`/NULL/modulus. +pub unsafe extern "C" fn nb_slot_power( + a: *mut PyObject, + b: *mut PyObject, + m: *mut PyObject, +) -> *mut PyObject { + let no_mod = m.is_null() || m == crate::singletons::none_ptr(); + if no_mod { + return unsafe { number_slot_native(a, b, BinOp::Pow) }; + } + unsafe { PyNumber_Power(a, b, m) } +} + #[no_mangle] pub unsafe extern "C" fn PyNumber_Add(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { binop(a, b, BinOp::Add) @@ -1018,12 +2757,33 @@ pub unsafe extern "C" fn PyNumber_Negative(o: *mut PyObject) -> *mut PyObject { if o.is_null() { return ptr::null_mut(); } - match unsafe { crate::object::clone_object(o) } { + let obj = unsafe { crate::object::clone_object(o) }; + if std::env::var_os("WEAVEPY_TSDBG").is_some() { + eprintln!( + "[TSDBG] PyNumber_Negative in_type={} cloned={:?}", + unsafe { crate::object::debug_type_name(o) }, + obj.type_name() + ); + } + let res = match obj { Object::Int(i) => crate::object::into_owned(Object::Int(-i)), Object::Float(f) => crate::object::into_owned(Object::Float(-f)), Object::Long(b) => crate::object::into_owned(Object::Long(Rc::new((*b).clone() * -1))), - _ => ptr::null_mut(), + // A foreign Cython/numpy operand (pandas `Timedelta`, a numpy + // scalar) dispatches through `nb_negative`; a VM/user object + // through `__neg__`. The old `_ => null` return planted NULLs in + // numpy object arrays (`np.negative(arr)`) and later segfaulted. + ref other => unsafe { + unary_fallback(o, other, UnarySlot::Negative, "bad operand type for unary -") + }, + }; + if std::env::var_os("WEAVEPY_TSDBG").is_some() && !res.is_null() { + eprintln!( + "[TSDBG] PyNumber_Negative RESULT type={}", + unsafe { crate::object::debug_type_name(res) } + ); } + res } #[no_mangle] @@ -1032,7 +2792,16 @@ pub unsafe extern "C" fn PyNumber_Positive(o: *mut PyObject) -> *mut PyObject { return ptr::null_mut(); } let obj = unsafe { crate::object::clone_object(o) }; - crate::object::into_owned(obj) + match obj { + // `+x` is the identity for the native numeric scalars (CPython's + // `long_pos`/`float_pos` return the operand unchanged). + Object::Int(_) | Object::Bool(_) | Object::Float(_) | Object::Long(_) => { + crate::object::into_owned(obj) + } + ref other => unsafe { + unary_fallback(o, other, UnarySlot::Positive, "bad operand type for unary +") + }, + } } #[no_mangle] @@ -1040,7 +2809,8 @@ pub unsafe extern "C" fn PyNumber_Absolute(o: *mut PyObject) -> *mut PyObject { if o.is_null() { return ptr::null_mut(); } - match unsafe { crate::object::clone_object(o) } { + let obj = unsafe { crate::object::clone_object(o) }; + match obj { Object::Int(i) => crate::object::into_owned(Object::Int(i.abs())), Object::Float(f) => crate::object::into_owned(Object::Float(f.abs())), Object::Long(b) => { @@ -1051,7 +2821,11 @@ pub unsafe extern "C" fn PyNumber_Absolute(o: *mut PyObject) -> *mut PyObject { }; crate::object::into_owned(Object::Long(Rc::new(abs))) } - _ => ptr::null_mut(), + // Foreign operand → `nb_absolute`; VM/user object → `__abs__`. + // (Same NULL-in-object-array hazard as `PyNumber_Negative`.) + ref other => unsafe { + unary_fallback(o, other, UnarySlot::Absolute, "bad operand type for abs()") + }, } } @@ -1065,14 +2839,61 @@ pub unsafe extern "C" fn PyNumber_Long(o: *mut PyObject) -> *mut PyObject { Object::Bool(b) => crate::object::into_owned(Object::Int(i64::from(b))), Object::Float(f) => crate::object::into_owned(Object::Int(f.trunc() as i64)), Object::Long(big) => crate::object::into_owned(Object::Long(big)), - Object::Str(s) => match s.parse::() { - Ok(v) => crate::object::into_owned(Object::Int(v)), - Err(_) => { - crate::errors::set_value_error("invalid literal for int()"); - ptr::null_mut() + Object::Str(s) => { + // `int(str)` is arbitrary-precision in CPython. `s.parse::()` + // rejected anything past i64::MAX (and any leading/trailing + // whitespace, sign, or `_` separator), so `int("47393996303418497800")` + // raised "invalid literal for int()" instead of yielding a big int. + // pandas' `maybe_convert_numeric` does `int(s)` and then range-checks + // itself, so that spurious failure surfaced as the wrong message + // ("invalid literal…" instead of "Integer out of range…"). Delegate + // to the full base-10 string parser, which produces an `Object::Long` + // on overflow and matches CPython's whitespace/sign/underscore rules + // and error text. + match std::ffi::CString::new(s.as_bytes()) { + Ok(cs) => unsafe { + crate::numbers::PyLong_FromString(cs.as_ptr(), ptr::null_mut(), 10) + }, + Err(_) => { + // Embedded NUL — not a valid integer literal. + crate::errors::set_value_error("invalid literal for int() with base 10"); + ptr::null_mut() + } + } + } + other => { + // RFC 0046 (wave 4): CPython's `PyNumber_Long` consults + // `nb_int`, then `nb_index`, then `__trunc__`. A numpy scalar / + // foreign object (or a user instance) reaches us here, so try + // `__int__` then `__index__` via the dunder shim — the same + // route `PyNumber_Index` already uses for `__index__`. + // + // RFC 0047 (wave 5): a *foreign* extension object is opaque to + // `attr_lookup`, so dispatch through its `nb_int`/`nb_index` + // slots directly (real numpy calls `int(np.int64(...))` during + // `_multiarray_umath` init — the hermetic wave-4 gate's + // `zeros @ ones` never exercised it). + if matches!(other, Object::Foreign(_)) { + let r = unsafe { foreign_as_int(o) }; + if !r.is_null() || crate::errors::pending().is_some() { + return r; + } + } + for attr in ["__int__", "__index__"] { + if let Some(dunder) = attr_lookup(&other, attr) { + let dunder_o = crate::object::into_owned(dunder); + let result = unsafe { PyObject_CallOneArg(dunder_o, o) }; + unsafe { crate::object::Py_DecRef(dunder_o) }; + return result; + } + } + if std::env::var_os("WEAVEPY_DEBUG_INT").is_some() { + eprintln!( + "[PyNumber_Long] cannot convert to int: type={} debug={:?}", + other.type_name(), + other + ); } - }, - _ => { crate::errors::set_type_error("cannot convert to int"); ptr::null_mut() } @@ -1084,11 +2905,40 @@ pub unsafe extern "C" fn PyNumber_Float(o: *mut PyObject) -> *mut PyObject { if o.is_null() { return ptr::null_mut(); } - let v = unsafe { crate::numbers::PyFloat_AsDouble(o) }; - if v == -1.0 && crate::errors::pending().is_some() { - return ptr::null_mut(); + let obj = unsafe { crate::object::clone_object(o) }; + match &obj { + // CPython fast-paths an exact `float`; the other numeric builtins + // convert through `nb_float`/`nb_index` to the identical double, so + // short-circuit them here too. + Object::Float(f) => return crate::object::into_owned(Object::Float(*f)), + Object::Int(i) => return crate::object::into_owned(Object::Float(*i as f64)), + Object::Long(big) => { + use num_traits::ToPrimitive; + return crate::object::into_owned(Object::Float(big.to_f64().unwrap_or(f64::INFINITY))); + } + Object::Bool(b) => return crate::object::into_owned(Object::Float(f64::from(*b as i32))), + _ => {} + } + match unsafe { crate::numbers::float_number_protocol(o, &obj) } { + crate::numbers::FloatProtocol::Value(v) => crate::object::into_owned(Object::Float(v)), + crate::numbers::FloatProtocol::Raised => ptr::null_mut(), + crate::numbers::FloatProtocol::NoProtocol => { + // CPython's `PyNumber_Float` parses a `str`/`bytes` argument via + // `PyFloat_FromString` before giving up. + if matches!(obj, Object::Str(_) | Object::Bytes(_)) { + return unsafe { crate::wave4::PyFloat_FromString(o) }; + } + // The `float()` builtin's distinctive message (differs from + // `PyFloat_AsDouble`'s "must be real number, not X") — pandas' + // groupby-`corr` over an object column matches on this exact + // wording ("must be a string or a.* number"). + crate::errors::set_type_error(format!( + "float() argument must be a string or a real number, not '{}'", + unsafe { crate::object::debug_type_name(o) } + )); + ptr::null_mut() + } } - crate::object::into_owned(Object::Float(v)) } #[no_mangle] @@ -1096,11 +2946,41 @@ pub unsafe extern "C" fn PyNumber_Check(o: *mut PyObject) -> c_int { if o.is_null() { return 0; } - matches!( - unsafe { crate::object::clone_object(o) }, + let obj = unsafe { crate::object::clone_object(o) }; + // Native numerics. + if matches!( + obj, Object::Int(_) | Object::Long(_) | Object::Float(_) | Object::Bool(_) | Object::Complex(_) - ) - .into() + ) { + return 1; + } + // CPython's `PyNumber_Check` is exactly `nb_index || nb_int || nb_float`. + // A foreign object / faithful instance wearing a real C type (numpy + // scalars) exposes these slots; read them straight off the type. + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if !ty.is_null() { + let nb = unsafe { (*ty).tp_as_number }; + if !nb.is_null() + && (!unsafe { (*nb).nb_index }.is_null() + || !unsafe { (*nb).nb_int }.is_null() + || !unsafe { (*nb).nb_float }.is_null()) + { + return 1; + } + } + // A pure-Python numeric class (`decimal.Decimal`, `fractions.Fraction`, a + // user class with `__int__`/`__float__`/`__index__`) carries its + // conversions as VM dunders, not populated C slots. `is_scalar(Decimal(x))` + // — and hence `pd.isna(Decimal("NaN"))`, `to_numeric`, and Decimal + // extension arrays — hinge on this returning True. + if let Object::Instance(inst) = &obj { + let cls = inst.cls(); + let has = |name: &str| !matches!(cls.lookup(name), None | Some(Object::None)); + if has("__index__") || has("__int__") || has("__float__") { + return 1; + } + } + 0 } // ---------------------------------------------------------------- @@ -1112,11 +2992,55 @@ pub unsafe extern "C" fn PySequence_Check(o: *mut PyObject) -> c_int { if o.is_null() { return 0; } - matches!( - unsafe { crate::object::clone_object(o) }, - Object::List(_) | Object::Tuple(_) | Object::Str(_) | Object::Bytes(_) - ) - .into() + let obj = unsafe { crate::object::clone_object(o) }; + // CPython: `PyDict_Check(s)` short-circuits to 0 (a mapping is never a + // sequence even though it carries `mp_subscript`). + if matches!(obj, Object::Dict(_)) { + return 0; + } + // CPython returns true iff `tp_as_sequence->sq_item != NULL`: the built-in + // sequences (`list`/`tuple`/`str`/`bytes`/`bytearray`/`range`) *and their + // subclasses* — but **not** sets (no `sq_item`) nor a plain class that only + // defines `__getitem__` (that installs `mp_subscript`, not `sq_item`). + if sequence_object_has_sq_item(&obj) { + return 1; + } + // Foreign extension objects (numpy `ndarray`, …) carry a real C type; + // consult its `tp_as_sequence->sq_item` directly, exactly like CPython. + if matches!(obj, Object::Foreign(_)) { + let ty = unsafe { (*o).ob_type } as *mut crate::layout::PyTypeObjectFull; + if !ty.is_null() { + let seq = unsafe { (*ty).tp_as_sequence }; + if !seq.is_null() && !unsafe { (*seq).sq_item }.is_null() { + return 1; + } + } + } + 0 +} + +/// CPython's `PySequence_Check` predicate for native objects: a value has a +/// sequence `sq_item` slot iff it is a built-in sequence or a subclass of one. +/// +/// numpy's array coercion (`np.array(x, dtype=…)`) leans on this: an object +/// that fails the check is treated as a **scalar** and handed to the dtype's +/// `int()`/`float()` setter, so a false negative for a `list` subclass (e.g. +/// pandas' `FrozenList`, passed to `np.array(codes, dtype="int64")` when +/// building a `MultiIndex` engine) surfaces as "cannot convert to int". +fn sequence_object_has_sq_item(o: &Object) -> bool { + use Object as O; + match o { + O::List(_) | O::Tuple(_) | O::Str(_) | O::Bytes(_) | O::ByteArray(_) | O::Range(_) => true, + // A subclass of a built-in sequence *is* that sequence — it wraps the + // primitive in `native` — so it inherits `sq_item` just like CPython. + O::Instance(inst) => matches!( + inst.native.as_ref(), + Some( + O::List(_) | O::Tuple(_) | O::Str(_) | O::Bytes(_) | O::ByteArray(_) | O::Range(_) + ) + ), + _ => false, + } } #[no_mangle] @@ -1171,7 +3095,72 @@ pub unsafe extern "C" fn PySequence_Contains(o: *mut PyObject, v: *mut PyObject) }, Object::Set(rc) => i32::from(rc.borrow().contains(&DictKey(needle))), Object::FrozenSet(s) => i32::from(s.contains(&DictKey(needle))), - _ => -1, + // `key in dict`. CPython dispatches the dict's `sq_contains`; Cython + // compiles `val in ` (pandas' `_try_infer_map`'s + // `if val in _TYPE_MAP`) to `PySequence_Contains`, *not* + // `PyDict_Contains`. Without this arm the dict fell through to the old + // `_ => -1` — an error return with no exception set — which surfaced as + // `infer_dtype` failing with "C extension reported failure without + // setting an exception" for *every* input (the function's first act is + // `_try_infer_map`). + Object::Dict(rc) => i32::from(rc.borrow().contains_key(&DictKey(needle))), + // Everything else (mappingproxy, dict views, ranges, bytes, a user + // `__contains__`, a foreign object's `sq_contains`) resolves through + // the VM's containment, matching CPython's `sq_contains` / + // `_PySequence_IterSearch` dispatch and — crucially — installing a real + // exception on failure instead of the bare `-1`. + other => { + let res = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.py_contains(&other, &needle)) + }); + match res { + Some(Ok(found)) => i32::from(found), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + // No interpreter active (pure C-side): best-effort native test. + None => match other.contains(&needle) { + Ok(found) => i32::from(found), + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }, + } + } + } +} + +/// Collect every item of an arbitrary iterable `o` by driving the VM's +/// iterator protocol (`iter()` then repeated `next()`), exactly as +/// CPython's `PySequence_List`/`PySequence_Tuple` do via `PyObject_GetIter` +/// + `PyIter_Next`. Returns the items, or `None` with a pending exception +/// when `o` is not iterable or an element access raised. +/// +/// # Safety +/// `o` must be a live, non-null `PyObject*`. +pub(crate) unsafe fn collect_iterable(o: *mut PyObject) -> Option> { + let it = unsafe { PyObject_GetIter(o) }; + if it.is_null() { + // Not iterable — `PyObject_GetIter` set the TypeError. + return None; + } + let mut items = Vec::new(); + loop { + let item = unsafe { PyIter_Next(it) }; + if item.is_null() { + // Exhausted (no error) or an element raised (error pending). + break; + } + items.push(unsafe { crate::object::clone_object(item) }); + unsafe { crate::object::Py_DecRef(item) }; + } + unsafe { crate::object::Py_DecRef(it) }; + if unsafe { crate::errors::PyErr_Occurred() }.is_null() { + Some(items) + } else { + None } } @@ -1186,7 +3175,15 @@ pub unsafe extern "C" fn PySequence_List(o: *mut PyObject) -> *mut PyObject { Object::Tuple(items) => { crate::object::into_owned(Object::new_list(items.iter().cloned().collect())) } - _ => crate::object::into_owned(Object::new_list(Vec::new())), + // CPython's `PySequence_List(o)` is `o` coerced through the iterator + // protocol, *not* a no-op for non-sequences. Cython's + // `list(self)` (`cdef class` `__richcmp__`, `__hash__`, …) compiles + // straight to `PySequence_List`, so returning an empty list here + // silently corrupted every `list(cdef_instance)`. + _ => match unsafe { collect_iterable(o) } { + Some(items) => crate::object::into_owned(Object::new_list(items)), + None => ptr::null_mut(), + }, } } @@ -1199,7 +3196,12 @@ pub unsafe extern "C" fn PySequence_Tuple(o: *mut PyObject) -> *mut PyObject { match obj { Object::List(rc) => crate::object::into_owned(Object::new_tuple(rc.borrow().clone())), Object::Tuple(items) => crate::object::into_owned(Object::Tuple(items)), - _ => crate::object::into_owned(Object::new_tuple(Vec::new())), + // As with `PySequence_List`, coerce any iterable via its iterator + // protocol (`tuple(self)` → `PySequence_Tuple`). + _ => match unsafe { collect_iterable(o) } { + Some(items) => crate::object::into_owned(Object::new_tuple(items)), + None => ptr::null_mut(), + }, } } @@ -1304,6 +3306,7 @@ pub unsafe extern "C" fn PyMapping_DelItemString(o: *mut PyObject, key: *const c .into_owned(); let dk = DictKey(Object::from_str(key_s.clone())); if rc.borrow_mut().shift_remove(&dk).is_some() { + unsafe { crate::mirror::sync_dict_ma_used(o) }; 0 } else { crate::errors::set_pending( @@ -1508,8 +3511,44 @@ pub unsafe extern "C" fn PyObject_Bytes(o: *mut PyObject) -> *mut PyObject { } #[no_mangle] -pub unsafe extern "C" fn PyObject_Format(o: *mut PyObject, _spec: *mut PyObject) -> *mut PyObject { - // Minimal implementation: ignore format spec, call __str__. +pub unsafe extern "C" fn PyObject_Format(o: *mut PyObject, spec: *mut PyObject) -> *mut PyObject { + if o.is_null() { + return ptr::null_mut(); + } + // The format spec defaults to the empty string when NULL (CPython's + // `PyObject_Format`). A non-str spec is a `TypeError`, matching CPython. + let spec_str = if spec.is_null() { + String::new() + } else { + match unsafe { crate::object::clone_object(spec) } { + Object::Str(s) => s.to_string(), + other => { + crate::errors::set_type_error(format!( + "format() argument 2 must be str, not {}", + other.type_name_owned() + )); + return ptr::null_mut(); + } + } + }; + let obj = unsafe { crate::object::clone_object(o) }; + // Route through the VM's full `__format__` dispatch (the same logic the + // `FORMAT_VALUE` bytecode / `format()` builtin run): user `__format__`, + // built-in subclass native formatting, foreign-scalar `__format__`, and + // the numeric/`str` format mini-language. Cython lowers every f-string + // conversion `f'{x:spec}'` to this call, so the spec must be honoured. + match crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.format_public(&obj, &spec_str)) + }) { + Some(Ok(s)) => return crate::object::into_owned(Object::from_str(s)), + Some(Err(e)) => { + crate::errors::set_pending_from_runtime(e); + return ptr::null_mut(); + } + // No active interpreter (pure C-side construction before any VM + // frame): fall back to `str(o)`, the pre-RFC behaviour. + None => {} + } unsafe { PyObject_Str(o) } } @@ -1559,7 +3598,21 @@ pub unsafe extern "C" fn PyNumber_Index(o: *mut PyObject) -> *mut PyObject { ); ptr::null_mut() } - _ => { + other => { + // RFC 0047 (wave 5): a *foreign* extension scalar (numpy's + // `np.int32`/`np.intp`) carries `__index__` in its C `nb_index` + // slot, invisible to `attr_lookup`. CPython's `PyNumber_Index` + // reads `nb_index` directly; numpy's scalar comparison routes + // the operand through here (`np.intp(3) != 3` calls + // `PyNumber_Index` on the scalar), as does any size-arg coercion + // of a numpy integer. The hermetic wave-4 gate never exercised + // it because `zeros @ ones` passes only native ints. + if matches!(other, Object::Foreign(_)) { + let r = unsafe { foreign_nb_index(o) }; + if !r.is_null() || crate::errors::pending().is_some() { + return r; + } + } // Try `__index__` via the dunder shim. let attr = "__index__"; let dunder = match attr_lookup(&unsafe { crate::object::clone_object(o) }, attr) { @@ -1641,63 +3694,50 @@ pub unsafe extern "C" fn PyNumber_MatrixMultiply( #[no_mangle] pub unsafe extern "C" fn PyNumber_Lshift(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { - let av = unsafe { crate::numbers::PyLong_AsLong(a) }; - let bv = unsafe { crate::numbers::PyLong_AsLong(b) }; - if crate::errors::pending().is_some() { - return ptr::null_mut(); - } - let shift = bv.clamp(0, 63) as u32; - crate::object::into_owned(Object::Int(av.wrapping_shl(shift))) + binop(a, b, BinOp::Lshift) } #[no_mangle] pub unsafe extern "C" fn PyNumber_Rshift(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { - let av = unsafe { crate::numbers::PyLong_AsLong(a) }; - let bv = unsafe { crate::numbers::PyLong_AsLong(b) }; - if crate::errors::pending().is_some() { - return ptr::null_mut(); - } - let shift = bv.clamp(0, 63) as u32; - crate::object::into_owned(Object::Int(av.wrapping_shr(shift))) + binop(a, b, BinOp::Rshift) } #[no_mangle] pub unsafe extern "C" fn PyNumber_And(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { - let av = unsafe { crate::numbers::PyLong_AsLong(a) }; - let bv = unsafe { crate::numbers::PyLong_AsLong(b) }; - if crate::errors::pending().is_some() { - return ptr::null_mut(); - } - crate::object::into_owned(Object::Int(av & bv)) + binop(a, b, BinOp::And) } #[no_mangle] pub unsafe extern "C" fn PyNumber_Or(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { - let av = unsafe { crate::numbers::PyLong_AsLong(a) }; - let bv = unsafe { crate::numbers::PyLong_AsLong(b) }; - if crate::errors::pending().is_some() { - return ptr::null_mut(); - } - crate::object::into_owned(Object::Int(av | bv)) + binop(a, b, BinOp::Or) } #[no_mangle] pub unsafe extern "C" fn PyNumber_Xor(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { - let av = unsafe { crate::numbers::PyLong_AsLong(a) }; - let bv = unsafe { crate::numbers::PyLong_AsLong(b) }; - if crate::errors::pending().is_some() { - return ptr::null_mut(); - } - crate::object::into_owned(Object::Int(av ^ bv)) + binop(a, b, BinOp::Xor) } +/// `~o` — the bitwise inverse. `~x == -x - 1` at arbitrary precision, so +/// big ints invert faithfully (the prior `!PyLong_AsLong(o)` truncated to +/// 64 bits and overflowed on big ints). Foreign / user types dispatch to +/// `__invert__`. #[no_mangle] pub unsafe extern "C" fn PyNumber_Invert(o: *mut PyObject) -> *mut PyObject { - let v = unsafe { crate::numbers::PyLong_AsLong(o) }; - if crate::errors::pending().is_some() { + if o.is_null() { return ptr::null_mut(); } - crate::object::into_owned(Object::Int(!v)) + match unsafe { crate::object::clone_object(o) } { + Object::Int(i) => crate::object::into_owned(Object::Int(!i)), + Object::Bool(b) => crate::object::into_owned(Object::Int(!i64::from(b))), + Object::Long(big) => { + let inv = -((*big).clone() + num_bigint::BigInt::from(1)); + crate::object::into_owned(Object::int_from_bigint(inv)) + } + // Foreign operand → `nb_invert` slot; VM/user object → `__invert__`. + ref other => unsafe { + unary_fallback(o, other, UnarySlot::Invert, "bad operand type for unary ~") + }, + } } // In-place variants: we fall back to the immutable forms since @@ -1847,25 +3887,36 @@ pub unsafe extern "C" fn PySequence_Repeat(o: *mut PyObject, n: PySsizeT) -> *mu if o.is_null() { return ptr::null_mut(); } - let n = n.max(0) as usize; + let count = n.max(0) as usize; match unsafe { crate::object::clone_object(o) } { Object::List(rc) => { - let mut out = Vec::with_capacity(rc.borrow().len() * n); - for _ in 0..n { + let mut out = Vec::with_capacity(rc.borrow().len() * count); + for _ in 0..count { out.extend(rc.borrow().iter().cloned()); } crate::object::into_owned(Object::new_list(out)) } Object::Tuple(items) => { - let mut out = Vec::with_capacity(items.len() * n); - for _ in 0..n { + let mut out = Vec::with_capacity(items.len() * count); + for _ in 0..count { out.extend(items.iter().cloned()); } crate::object::into_owned(Object::new_tuple(out)) } + // `str`/`bytes`/`bytearray` and *foreign* sequences (numpy/pandas + // objects) carry repetition in their type's `sq_repeat`/`nb_multiply` + // slot, invisible to the arms above. Route `o * count` through the + // shared binop bridge exactly as `PyNumber_Multiply` does — the VM's + // `Mult` protocol implements `str * int`, `bytes * int`, and the + // foreign slot multiply, the same slot CPython's `PySequence_Repeat` + // ultimately reaches. pandas' non-ISO datetime parser repeats a + // `str` here (`"01-01-2013T…+0000"`); the old list/tuple-only match + // wrongly raised "not a sequence". _ => { - crate::errors::set_type_error("PySequence_Repeat: not a sequence"); - ptr::null_mut() + let count_obj = crate::object::into_owned(Object::Int(count as i64)); + let result = binop(o, count_obj, BinOp::Mul); + unsafe { crate::object::Py_DecRef(count_obj) }; + result } } } @@ -1980,12 +4031,18 @@ pub unsafe extern "C" fn PySequence_SetSlice( }; match unsafe { crate::object::clone_object(o) } { Object::List(rc) => { - let mut list = rc.borrow_mut(); - let len = list.len(); - let lo = (lo.max(0) as usize).min(len); - let hi = (hi.max(0) as usize).min(len); - let hi = hi.max(lo); - list.splice(lo..hi, replacement); + { + let mut list = rc.borrow_mut(); + let len = list.len(); + let lo = (lo.max(0) as usize).min(len); + let hi = (hi.max(0) as usize).min(len); + let hi = hi.max(lo); + list.splice(lo..hi, replacement); + } + // Keep the faithful mirror's `ob_item` coherent with the spliced + // prefix `Rc` (see `PyObject_SetItem`); a slice replacement can + // grow or shrink the list, so `ob_size` is republished too. + unsafe { crate::mirror::sync_list_ob_item(o) }; 0 } _ => -1, @@ -2027,6 +4084,11 @@ pub unsafe extern "C" fn PySequence_DelItem(o: *mut PyObject, idx: PySsizeT) -> return -1; } list.remove(i); + drop(list); + // Keep the faithful mirror's `ob_item` coherent (see + // `PyObject_SetItem`); a deletion shifts the tail and shrinks + // `ob_size`. + unsafe { crate::mirror::sync_list_ob_item(o) }; 0 } _ => -1, diff --git a/crates/weavepy-capi/src/argparse.rs b/crates/weavepy-capi/src/argparse.rs index 8351053..f20336b 100644 --- a/crates/weavepy-capi/src/argparse.rs +++ b/crates/weavepy-capi/src/argparse.rs @@ -84,10 +84,30 @@ pub unsafe extern "C" fn _WeavePy_Arg_Long(arg: *mut PyObject, dest: *mut i64) - -1 } }, - _ => { - crate::errors::set_type_error("an integer is required"); - -1 - } + // CPython's integer format codes ('i'/'l'/'n'/'L'...) run the arg + // through `PyLong_As*`, which coerces via `__index__` — so a numpy + // integer scalar passed positionally is accepted. Mirror that. + _ => match unsafe { crate::numbers::index_to_builtin_int(arg) } { + Some(Object::Int(i)) => { + unsafe { *dest = i }; + 0 + } + Some(Object::Bool(b)) => { + unsafe { *dest = i64::from(b) }; + 0 + } + Some(Object::Long(big)) => match big.to_i64() { + Some(v) => { + unsafe { *dest = v }; + 0 + } + None => { + crate::errors::set_overflow_error("Python int too large for C long"); + -1 + } + }, + _ => -1, + }, } } @@ -127,9 +147,20 @@ pub unsafe extern "C" fn _WeavePy_Arg_Double(arg: *mut PyObject, dest: *mut f64) unsafe { *dest = f64::from(b as i32) }; 0 } + // CPython's 'd'/'f' format codes run the argument through + // `PyFloat_AsDouble`, which honours the `nb_float`/`__float__` + // (then `__index__`) protocol. Route the fallback through it so a + // numpy `float64` scalar or a `float` subclass passed positionally + // converts here exactly as under CPython instead of being rejected + // outright — the "corr"/"quantile" style Cython paths in pandas + // hand numpy floats straight to `PyArg_ParseTuple(…"d"…)`. _ => { - crate::errors::set_type_error("a float is required"); - -1 + let v = unsafe { crate::numbers::PyFloat_AsDouble(arg) }; + if v == -1.0 && crate::errors::pending().is_some() { + return -1; + } + unsafe { *dest = v }; + 0 } } } diff --git a/crates/weavepy-capi/src/buffer.rs b/crates/weavepy-capi/src/buffer.rs index 48f1c3e..164b083 100644 --- a/crates/weavepy-capi/src/buffer.rs +++ b/crates/weavepy-capi/src/buffer.rs @@ -90,10 +90,27 @@ impl Py_buffer { /// code is responsible for matching alloc/free. #[derive(Debug)] pub(crate) struct BufferInternal { - /// Heap-allocated copy of the source data. We make a defensive - /// copy so the consumer can hold the buffer across - /// allocator events without invalidation. + /// Heap-allocated copy of the source data. Only used by + /// [`PyBuffer_FillInfo`] callers that hand us a raw pointer with no + /// owning object; the native bytes-like exporter uses `keepalive` + /// (zero-copy) instead. pub owned_buf: Option>, + /// Keep-alive for the exporter's *own* backing store. The native + /// exporter points `Py_buffer::buf` directly at the exporter's data + /// (no copy) and stashes a clone of the backing `Rc` here so the + /// window stays valid for the view's lifetime. + /// + /// This is load-bearing for zero-copy consumers such as numpy's + /// `PyArray_FromBuffer` (`np.frombuffer`, and hence protocol-5 + /// `_frombuffer` unpickling): numpy records `view.buf` as the array's + /// data pointer, pins the exporter as the array's `base`, and then + /// *releases the view*. CPython keeps working because a `bytes` + /// exporter's `view.buf` aliases `PyBytes_AS_STRING` — memory owned by + /// the (still-pinned) object, not the view. A defensive copy freed by + /// `PyBuffer_Release` would dangle the instant numpy released the view, + /// so the array would read freed memory (observed as corrupted + /// datetime/period/interval data on protocol-5 round-trips). + pub keepalive: Option, pub shape: Box<[PySsizeT]>, pub strides: Box<[PySsizeT]>, pub suboffsets: Box<[PySsizeT]>, @@ -128,21 +145,144 @@ pub unsafe extern "C" fn PyObject_GetBuffer( return -1; } unsafe { *view = Py_buffer::zeroed() }; + let trace = std::env::var_os("WEAVEPY_TRACE_BUF").is_some(); + if trace { + let tn = unsafe { + let ty = (*exporter).ob_type as *mut crate::layout::PyTypeObjectFull; + if ty.is_null() { + "".to_owned() + } else { + let np = (*ty).tp_name; + if np.is_null() { + "".to_owned() + } else { + core::ffi::CStr::from_ptr(np).to_string_lossy().into_owned() + } + } + }; + let has_st = unsafe { slot_table_for((*exporter).ob_type) } + .map(|t| !t.get(Py_bf_getbuffer).is_null()) + .unwrap_or(false); + let has_fb = !unsafe { foreign_bf_getbuffer((*exporter).ob_type) }.is_null(); + eprintln!( + "[WEAVEPY_TRACE_BUF] GetBuffer exporter type={tn} flags={flags:#x} slot_table_bf={has_st} foreign_bf={has_fb}" + ); + } - // 1) Heap-type slot dispatch. + // 1) Heap-type slot dispatch (WeavePy-managed slot table — types built + // through the dunder shim / `PyType_FromSpec`). let head = unsafe { &*exporter }; if let Some(slot_table) = unsafe { slot_table_for(head.ob_type) } { let slot = slot_table.get(Py_bf_getbuffer); if !slot.is_null() { let getbuf: unsafe extern "C" fn(*mut PyObject, *mut Py_buffer, c_int) -> c_int = unsafe { slot.cast() }; - return unsafe { getbuf(exporter, view, flags) }; + let rc = unsafe { getbuf(exporter, view, flags) }; + if trace { + let fmt = unsafe { + let f = (*view).format; + if f.is_null() { + "".to_owned() + } else { + core::ffi::CStr::from_ptr(f).to_string_lossy().into_owned() + } + }; + eprintln!( + "[WEAVEPY_TRACE_BUF] slot getbuf exp={exporter:p} rc={rc} format={fmt:?} itemsize={} ndim={} len={} buf={:p}", + unsafe { (*view).itemsize }, + unsafe { (*view).ndim }, + unsafe { (*view).len }, + unsafe { (*view).buf }, + ); + } + return rc; + } + } + + // 2) Foreign-type C-struct dispatch: a real extension type (numpy's + // `ndarray`, a Cython `cdef class` with `__getbuffer__`) stores its + // exporter in `tp_as_buffer->bf_getbuffer`. This is the path numpy's + // `numpy.random` Cython modules take when they acquire a typed + // `np.ndarray[uint32]` view (`SeedSequence.mix_entropy`). The slot + // owns filling `view` (incl. `view->obj`/refcount), exactly as + // CPython's `PyObject_GetBuffer` delegates. + let slot = unsafe { foreign_bf_getbuffer(head.ob_type) }; + if !slot.is_null() { + let getbuf: unsafe extern "C" fn(*mut PyObject, *mut Py_buffer, c_int) -> c_int = + unsafe { std::mem::transmute(slot) }; + let rc = unsafe { getbuf(exporter, view, flags) }; + if trace { + let fmt = unsafe { + let f = (*view).format; + if f.is_null() { + "".to_owned() + } else { + core::ffi::CStr::from_ptr(f).to_string_lossy().into_owned() + } + }; + let pend = crate::errors::pending().is_some(); + eprintln!( + "[WEAVEPY_TRACE_BUF] foreign getbuf rc={rc} format={fmt:?} itemsize={} ndim={} len={} pending_err={pend}", + unsafe { (*view).itemsize }, + unsafe { (*view).ndim }, + unsafe { (*view).len }, + ); } + return rc; } - // 2) Native fallback for built-in bytes-like types. + // 3) Native fallback for built-in bytes-like types. let obj = unsafe { crate::object::clone_object(exporter) }; - fill_native_buffer(exporter, &obj, view, flags) + let rc = fill_native_buffer(exporter, &obj, view, flags); + if trace { + let fmt = unsafe { + let f = (*view).format; + if f.is_null() { + "".to_owned() + } else { + core::ffi::CStr::from_ptr(f).to_string_lossy().into_owned() + } + }; + eprintln!( + "[WEAVEPY_TRACE_BUF] native getbuf rc={rc} format={fmt:?} itemsize={} ndim={} len={}", + unsafe { (*view).itemsize }, + unsafe { (*view).ndim }, + unsafe { (*view).len }, + ); + } + rc +} + +/// Read a foreign type's `tp_as_buffer->bf_getbuffer` slot (or NULL). +/// +/// # Safety +/// `ty` must be a live `PyObject*`-typed type pointer or NULL. +unsafe fn foreign_bf_getbuffer(ty: *mut crate::types::PyTypeObject) -> *mut std::ffi::c_void { + let tyf = ty as *mut crate::layout::PyTypeObjectFull; + if tyf.is_null() { + return ptr::null_mut(); + } + let procs = unsafe { (*tyf).tp_as_buffer }; + if procs.is_null() { + return ptr::null_mut(); + } + unsafe { (*procs).bf_getbuffer } +} + +/// Read a foreign type's `tp_as_buffer->bf_releasebuffer` slot (or NULL). +/// +/// # Safety +/// `ty` must be a live `PyObject*`-typed type pointer or NULL. +unsafe fn foreign_bf_releasebuffer(ty: *mut crate::types::PyTypeObject) -> *mut std::ffi::c_void { + let tyf = ty as *mut crate::layout::PyTypeObjectFull; + if tyf.is_null() { + return ptr::null_mut(); + } + let procs = unsafe { (*tyf).tp_as_buffer }; + if procs.is_null() { + return ptr::null_mut(); + } + unsafe { (*procs).bf_releasebuffer } } /// `PyBuffer_Release(view)` — release the resources backing `view`. @@ -160,8 +300,14 @@ pub unsafe extern "C" fn PyBuffer_Release(view: *mut Py_buffer) { if exporter.is_null() { return; } + if std::env::var_os("WEAVEPY_TRACE_BUF").is_some() { + eprintln!( + "[WEAVEPY_TRACE_BUF] Release exp={exporter:p} ndim={} buf={:p}", + v.ndim, v.buf, + ); + } - // 1) Heap-type slot dispatch. + // 1) Heap-type slot dispatch (WeavePy-managed slot table). let head = unsafe { &*exporter }; if let Some(slot_table) = unsafe { slot_table_for(head.ob_type) } { let slot = slot_table.get(Py_bf_releasebuffer); @@ -174,9 +320,32 @@ pub unsafe extern "C" fn PyBuffer_Release(view: *mut Py_buffer) { *v = Py_buffer::zeroed(); return; } + // A WeavePy slot-table exporter with no release slot: still drop the + // get-time ref below via the native path's DecRef. + if !slot_table.get(Py_bf_getbuffer).is_null() { + unsafe { crate::object::Py_DecRef(exporter) }; + *v = Py_buffer::zeroed(); + return; + } } - // 2) Native release path. We allocated a `BufferInternal` on + // 2) Foreign-type C-struct dispatch: mirror CPython's `PyBuffer_Release` + // — call `bf_releasebuffer` (if any), then drop the reference the + // exporter took in `bf_getbuffer`. + let rel = unsafe { foreign_bf_releasebuffer(head.ob_type) }; + let getb = unsafe { foreign_bf_getbuffer(head.ob_type) }; + if !rel.is_null() || !getb.is_null() { + if !rel.is_null() { + let release: unsafe extern "C" fn(*mut PyObject, *mut Py_buffer) = + unsafe { std::mem::transmute(rel) }; + unsafe { release(exporter, view) }; + } + unsafe { crate::object::Py_DecRef(exporter) }; + *v = Py_buffer::zeroed(); + return; + } + + // 3) Native release path. We allocated a `BufferInternal` on // the heap during `fill_native_buffer`; reclaim it now. if !v.internal.is_null() { let _ = unsafe { Box::from_raw(v.internal as *mut BufferInternal) }; @@ -200,6 +369,9 @@ pub unsafe extern "C" fn PyObject_CheckBuffer(o: *mut PyObject) -> c_int { return 1; } } + if !unsafe { foreign_bf_getbuffer(head.ob_type) }.is_null() { + return 1; + } matches!( unsafe { crate::object::clone_object(o) }, Object::Bytes(_) | Object::ByteArray(_) | Object::MemoryView(_) @@ -211,59 +383,170 @@ pub unsafe extern "C" fn PyObject_CheckBuffer(o: *mut PyObject) -> c_int { // Native fallback — handles bytes / bytearray / memoryview. // ---------------------------------------------------------------- -fn fill_native_buffer( - exporter: *mut PyObject, - obj: &Object, - view: *mut Py_buffer, - flags: c_int, -) -> c_int { - let (data, len, readonly) = match obj { - Object::Bytes(b) => (b.to_vec(), b.len(), 1), +/// A bytes-like exporter's buffer, fully described: raw window bytes, +/// element size, PEP 3118 `format` (not yet NUL-terminated) and the +/// per-dimension `shape` (elements) / `strides` (bytes). Most +/// bytes-likes are a flat 1-D `'B'`/itemsize-1 region, but a +/// `memoryview` re-export carries its own (possibly typed) format and +/// itemsize so that a consumer probing it — e.g. Cython fused-type +/// dispatch over `ndarray[object]` — observes the faithful `'O'`/8 +/// layout instead of a byte collapse. +struct NativeExport { + /// Pointer into the exporter's *own* backing store (no copy). Valid + /// as long as `keepalive` — and, after release, the pinned exporter — + /// is alive. + ptr: *mut u8, + len: usize, + itemsize: usize, + format: Vec, + shape: Vec, + strides: Vec, + readonly: c_int, + /// Clone of the exporter (or its inner backing) that keeps `ptr` valid. + keepalive: Object, +} + +/// Raw pointer to the first byte of a memoryview's backing region. All +/// three backings keep the region at a stable address for the `Rc`'s +/// lifetime (`Bytes`/`ByteArray` heap buffers never move without a +/// resize; `Shared` mmap/shared regions never move at all). +fn memoryview_backing_ptr(buf: &weavepy_vm::object::MemoryViewBuffer) -> *mut u8 { + use weavepy_vm::object::MemoryViewBuffer as B; + match buf { + B::Bytes(b) => b.as_ptr() as *mut u8, + B::ByteArray(rc) => rc.borrow().as_ptr() as *mut u8, + B::Shared(s) => s.data_ptr(), + } +} + +fn describe_native_export(obj: &Object) -> Result { + let export = match obj { + Object::Bytes(b) => NativeExport { + ptr: b.as_ptr() as *mut u8, + len: b.len(), + itemsize: 1, + format: b"B".to_vec(), + shape: vec![b.len() as PySsizeT], + strides: vec![1], + readonly: 1, + keepalive: obj.clone(), + }, Object::ByteArray(rc) => { - let data = rc.borrow().clone(); - let len = data.len(); - (data, len, 0) + let (ptr, len) = { + let borrowed = rc.borrow(); + (borrowed.as_ptr() as *mut u8, borrowed.len()) + }; + NativeExport { + ptr, + len, + itemsize: 1, + format: b"B".to_vec(), + shape: vec![len as PySsizeT], + strides: vec![1], + readonly: 0, + keepalive: obj.clone(), + } } Object::MemoryView(mv) => { if mv.released.get() { crate::errors::set_value_error("memoryview: released"); - return -1; + return Err(()); } - let bytes = mv.buffer.with_read(<[u8]>::to_vec); let len = mv.len.get(); let start = mv.start.get(); - let slice = bytes[start..start + len].to_vec(); - (slice, len, c_int::from(mv.readonly.get())) + let base_ptr = memoryview_backing_ptr(&mv.buffer); + let ptr = unsafe { base_ptr.add(start) }; + let itemsize = mv.itemsize.get().max(1); + let format = mv.format.borrow().clone().into_bytes(); + // Element shape/stride: honour an explicit shape, else derive a + // 1-D `[len / itemsize]` C-contiguous layout. This is what keeps + // a typed view's `itemsize`/`format` self-consistent with the + // dimensions a consumer reads back. + let stored_shape = mv.shape.borrow(); + let (shape, strides) = if stored_shape.is_empty() { + let n = if itemsize > 0 { len / itemsize } else { 0 }; + (vec![n as PySsizeT], vec![itemsize as PySsizeT]) + } else { + let shape: Vec = + stored_shape.iter().map(|&s| s as PySsizeT).collect(); + let stored_strides = mv.strides.borrow(); + let strides: Vec = if stored_strides.is_empty() { + let mut st = vec![0 as PySsizeT; shape.len()]; + let mut acc = itemsize as PySsizeT; + for i in (0..shape.len()).rev() { + st[i] = acc; + acc *= shape[i]; + } + st + } else { + stored_strides.iter().map(|&s| s as PySsizeT).collect() + }; + (shape, strides) + }; + NativeExport { + ptr, + len, + itemsize, + format, + shape, + strides, + readonly: c_int::from(mv.readonly.get()), + keepalive: obj.clone(), + } } _ => { crate::errors::set_buffer_error(format!( "a bytes-like object is required, not '{}'", type_name(obj) )); - return -1; + return Err(()); } }; + Ok(export) +} - if (flags & PYBUF_WRITABLE) != 0 && readonly != 0 { +fn fill_native_buffer( + exporter: *mut PyObject, + obj: &Object, + view: *mut Py_buffer, + flags: c_int, +) -> c_int { + let export = match describe_native_export(obj) { + Ok(e) => e, + Err(()) => return -1, + }; + + if (flags & PYBUF_WRITABLE) != 0 && export.readonly != 0 { crate::errors::set_buffer_error("Object is not writable"); return -1; } - let mut owned: Box<[u8]> = data.into_boxed_slice(); - let buf_ptr = owned.as_mut_ptr() as *mut std::ffi::c_void; + let len = export.len; + // Zero-copy: `view.buf` aliases the exporter's own storage. The + // `keepalive` clone stashed in `BufferInternal` keeps that storage + // resident for the view's lifetime; the pinned exporter keeps it + // resident afterwards (see `BufferInternal::keepalive`). + let buf_ptr = export.ptr as *mut std::ffi::c_void; - let format_bytes = format_string_for(FormatKind::UInt8, ByteOrder::Native); - let format_storage: Box<[u8]> = format_bytes.into_boxed_slice(); + // NUL-terminate the format for `Py_buffer::format`. + let mut format_vec = export.format; + if format_vec.is_empty() { + format_vec.push(b'B'); + } + format_vec.push(0); + let format_storage: Box<[u8]> = format_vec.into_boxed_slice(); let want_shape = (flags & PYBUF_ND) == PYBUF_ND; let want_strides = (flags & 0x0010) != 0; + let want_format = (flags & PYBUF_FORMAT) != 0; + let ndim = export.shape.len(); let shape_box: Box<[PySsizeT]> = if want_shape { - Box::new([len as PySsizeT]) + export.shape.into_boxed_slice() } else { Box::new([]) }; let strides_box: Box<[PySsizeT]> = if want_strides { - Box::new([1]) + export.strides.into_boxed_slice() } else { Box::new([]) }; @@ -271,7 +554,8 @@ fn fill_native_buffer( // Heap up the internal block — the release path relies on it. let internal = Box::new(BufferInternal { - owned_buf: Some(owned), + owned_buf: None, + keepalive: Some(export.keepalive), shape: shape_box, strides: strides_box, suboffsets: suboffsets_box, @@ -284,10 +568,10 @@ fn fill_native_buffer( (*view).buf = buf_ptr; (*view).obj = exporter; (*view).len = len as PySsizeT; - (*view).itemsize = 1; - (*view).readonly = readonly; - (*view).ndim = if want_shape { 1 } else { 0 }; - (*view).format = if (flags & PYBUF_FORMAT) != 0 { + (*view).itemsize = export.itemsize as PySsizeT; + (*view).readonly = export.readonly; + (*view).ndim = if want_shape { ndim as c_int } else { 0 }; + (*view).format = if want_format { internal_ref.format.as_ptr() as *mut c_char } else { ptr::null_mut() @@ -372,6 +656,7 @@ pub unsafe extern "C" fn PyBuffer_FillInfo( let internal = Box::new(BufferInternal { owned_buf: None, + keepalive: None, shape: shape_box, strides: strides_box, suboffsets: Box::new([]), diff --git a/crates/weavepy-capi/src/builtin_new.rs b/crates/weavepy-capi/src/builtin_new.rs new file mode 100644 index 0000000..6720b63 --- /dev/null +++ b/crates/weavepy-capi/src/builtin_new.rs @@ -0,0 +1,563 @@ +//! Faithful `tp_new` slots for WeavePy's exported built-in types +//! (RFC 0046, wave 4). +//! +//! WeavePy materialises Python `float`/`int`/`str`/… values as native VM +//! [`Object`]s, so the static `PyTypeObject`s it hands to C extensions +//! (`PyFloat_Type`, `PyUnicode_Type`, …) historically carried **no +//! `tp_new`**. That is invisible to most extensions, which build values +//! through `PyFloat_FromDouble`/`PyUnicode_FromString`/… — but a C type +//! that *subclasses* one of these builtins inherits, and may directly +//! call, the base's `tp_new`. +//! +//! NumPy is the motivating case. Its scalar types that subclass a Python +//! builtin — `numpy.float64 ← float`, `numpy.str_ ← str`, +//! `numpy.bytes_ ← bytes` — compile to a generated `_arrtype_new` +//! whose fast path is literally +//! +//! ```c +//! robj = PyFloat_Type->tp_new(subtype, args, kwds); // float.__new__ +//! if (robj != NULL) return robj; +//! ``` +//! +//! With a NULL slot that is a call through address `0`: `np.float64(1.0)` +//! — and NumPy's own import-time `_sanity_check()` / `_mac_os_check()` — +//! die with `SIGSEGV` at `pc = 0`. +//! +//! Each constructor here mirrors CPython's `_new` / +//! `_subtype_new`: for the **exact** built-in it returns a native VM +//! value; for a **subtype** it allocates a faithful inline body via the +//! subtype's `tp_alloc` (RFC 0045) and writes the payload at the +//! CPython-compatible offset, so the object handed back is byte-identical +//! to what a stock interpreter would produce. + +use std::os::raw::{c_char, c_void}; + +use weavepy_vm::object::Object; + +use crate::object::{clone_object, PyObject, PySsizeT}; +use crate::types::PyTypeObject; + +/// `allocfunc` — `PyObject *(*)(PyTypeObject *, Py_ssize_t)`. +type AllocFunc = unsafe extern "C" fn(*mut PyTypeObject, PySsizeT) -> *mut PyObject; + +/// Borrow the single positional argument from a `tp_new` `args` tuple, or +/// `None` for a zero-argument call. The reference is **borrowed** (no +/// refcount change), matching `PyTuple_GetItem`. +unsafe fn single_arg(args: *mut PyObject) -> Option<*mut PyObject> { + if args.is_null() { + return None; + } + let n = unsafe { crate::containers::PyTuple_Size(args) }; + if n <= 0 { + return None; + } + let item = unsafe { crate::containers::PyTuple_GetItem(args, 0) }; + if item.is_null() { + None + } else { + Some(item) + } +} + +/// Allocate a faithful subtype instance through `ty`'s `tp_alloc` +/// (defaulting to the generic allocator), reserving `nitems` items behind +/// the header for a variable-sized type. +unsafe fn subtype_alloc(ty: *mut PyTypeObject, nitems: PySsizeT) -> *mut PyObject { + let alloc = unsafe { (*ty).tp_alloc }; + if alloc.is_null() { + unsafe { crate::genericalloc::PyType_GenericAlloc(ty, nitems) } + } else { + let f: AllocFunc = unsafe { std::mem::transmute::<*mut c_void, AllocFunc>(alloc) }; + unsafe { f(ty, nitems) } + } +} + +/// True iff `ty` is the exact exported static type `slot` (pointer +/// identity), i.e. not a subclass. +unsafe fn is_exact(ty: *mut PyTypeObject, slot: &crate::types::StaticType) -> bool { + std::ptr::eq( + ty as *const PyTypeObject, + slot.as_ptr() as *const PyTypeObject, + ) +} + +// ==================================================================== +// float +// ==================================================================== + +/// `float.__new__(type, x=0.0)` — RFC 0046, wave 4. +/// +/// For the exact `float` type returns a native [`Object::Float`]; for a +/// subtype (e.g. `numpy.float64`) allocates the faithful body and writes +/// the `double` at `offsetof(PyFloatObject, ob_fval) == 16`, mirroring +/// CPython's `float_subtype_new`. +pub unsafe extern "C" fn float_new( + ty: *mut PyTypeObject, + args: *mut PyObject, + _kwds: *mut PyObject, +) -> *mut PyObject { + let value = match unsafe { float_value(args) } { + Ok(v) => v, + Err(()) => return std::ptr::null_mut(), + }; + if unsafe { is_exact(ty, &crate::types::PyFloat_Type) } { + return crate::object::into_owned(Object::Float(value)); + } + let obj = unsafe { subtype_alloc(ty, 0) }; + if obj.is_null() { + return std::ptr::null_mut(); + } + // `PyFloatObject.ob_fval` is at offset 16 (asserted in `layout.rs`); a + // `float` subtype is layout-compatible and inherits that slot. + unsafe { + *((obj as *mut u8).add(16) as *mut f64) = value; + } + obj +} + +/// Resolve the `double` a `float(...)` call would produce from its +/// optional single argument, mirroring CPython's `float_new_impl` +/// (numeric coercion + string parse). Returns `Err` with a pending +/// exception set on failure. +unsafe fn float_value(args: *mut PyObject) -> Result { + let Some(item) = (unsafe { single_arg(args) }) else { + return Ok(0.0); + }; + match unsafe { clone_object(item) } { + Object::Float(f) => Ok(f), + Object::Str(s) => parse_py_float(&s), + // `int`/`bool`/`bignum`/`__float__`/`__index__` all coerce through + // `PyFloat_AsDouble` (which sets the exception on failure). + _ => { + let v = unsafe { crate::numbers::PyFloat_AsDouble(item) }; + if v == -1.0 && crate::errors::pending().is_some() { + Err(()) + } else { + Ok(v) + } + } + } +} + +/// Parse a Python `float(str)` literal: surrounding whitespace, an +/// optional sign, `inf`/`infinity`/`nan` (case-insensitive), and single +/// underscores between digits are accepted. Lenient on underscore +/// placement (NumPy never emits such strings); raises `ValueError` +/// otherwise. +fn parse_py_float(s: &str) -> Result { + let trimmed = s.trim(); + let cleaned: String = trimmed.chars().filter(|&c| c != '_').collect(); + let lower = cleaned.to_ascii_lowercase(); + let parsed = match lower.as_str() { + "inf" | "+inf" | "infinity" | "+infinity" => Some(f64::INFINITY), + "-inf" | "-infinity" => Some(f64::NEG_INFINITY), + "nan" | "+nan" | "-nan" => Some(f64::NAN), + _ => cleaned.parse::().ok(), + }; + match parsed { + Some(v) => Ok(v), + None => { + crate::errors::set_value_error(format!("could not convert string to float: '{s}'")); + Err(()) + } + } +} + +// ==================================================================== +// str +// ==================================================================== + +/// Borrow a positional or keyword argument from a `tp_new` `(args, kwds)` +/// pair: positional index `i` first, falling back to the keyword whose +/// (NUL-terminated) name is `kw`. Borrowed (no refcount change). +unsafe fn arg_or_kw( + args: *mut PyObject, + kwds: *mut PyObject, + i: PySsizeT, + kw: &[u8], +) -> Option<*mut PyObject> { + let nargs = if args.is_null() { + 0 + } else { + unsafe { crate::containers::PyTuple_Size(args) }.max(0) + }; + if i < nargs { + let it = unsafe { crate::containers::PyTuple_GetItem(args, i) }; + if !it.is_null() { + return Some(it); + } + } + if !kwds.is_null() { + let v = unsafe { crate::containers::PyDict_GetItemString(kwds, kw.as_ptr() as *const c_char) }; + if !v.is_null() { + return Some(v); + } + } + None +} + +/// Resolve the `str` value a `str(...)` call would produce from its +/// `tp_new` `(args, kwds)`, mirroring CPython's `unicode_new` / +/// `unicode_new_impl` (`str(object='', encoding=…, errors=…)`): +/// +/// * no `object` → the empty string, +/// * `object` only → `str(object)` (`PyObject_Str`), +/// * `object` + encoding/errors → `object.decode(encoding, errors)`. +/// +/// Returns `Err` with a pending exception on failure. +unsafe fn str_value(args: *mut PyObject, kwds: *mut PyObject) -> Result { + let object = unsafe { arg_or_kw(args, kwds, 0, b"object\0") }; + let encoding = unsafe { arg_or_kw(args, kwds, 1, b"encoding\0") }; + let errors = unsafe { arg_or_kw(args, kwds, 2, b"errors\0") }; + + let Some(object) = object else { + return Ok(String::new()); + }; + + let result = if encoding.is_none() && errors.is_none() { + unsafe { crate::abstract_::PyObject_Str(object) } + } else { + // `str(bytes-like, encoding, errors)` — decode. The codec names + // default to CPython's (`utf-8` / `strict`) when omitted. + let enc = encoding + .map(|e| unsafe { crate::strings::PyUnicode_AsUTF8(e) }) + .filter(|p| !p.is_null()) + .unwrap_or(b"utf-8\0".as_ptr() as *const c_char); + let err = errors + .map(|e| unsafe { crate::strings::PyUnicode_AsUTF8(e) }) + .filter(|p| !p.is_null()) + .unwrap_or(b"strict\0".as_ptr() as *const c_char); + unsafe { crate::strings::PyUnicode_FromEncodedObject(object, enc, err) } + }; + if result.is_null() { + return Err(()); + } + let out = match unsafe { clone_object(result) } { + Object::Str(t) => t.to_string(), + _ => String::new(), + }; + unsafe { crate::object::Py_DecRef(result) }; + Ok(out) +} + +/// Write one PEP 393 code point of the given `kind` (1/2/4 bytes) into +/// `data[i]`. +/// +/// # Safety +/// `data` must address a writable buffer with room for `i + 1` units. +#[inline] +unsafe fn write_codepoint(data: *mut u8, kind: u32, i: usize, cp: u32) { + match kind { + 1 => unsafe { *data.add(i) = cp as u8 }, + 2 => unsafe { *(data as *mut u16).add(i) = cp as u16 }, + _ => unsafe { *(data as *mut u32).add(i) = cp }, + } +} + +/// Build a faithful `str` **subtype** instance for `value`, mirroring +/// CPython's `unicode_subtype_new`: allocate the subtype body through its +/// `tp_alloc` (the faithful inline body, RFC 0045) and populate a +/// **legacy / non-compact** `PyUnicodeObject` — the character data lives +/// in a separately allocated buffer reached through `data.any` (offset 56), +/// not inline, because a subtype's fixed `tp_basicsize` body has no room +/// for it (numpy's `PyUnicodeScalarObject` packs `obval`/`buffer_fmt` +/// right after the unicode base). A stock reader resolves the buffer via +/// `PyUnicode_DATA`'s non-compact branch, so the result is byte-identical +/// to what `numpy.str_.__new__` expects back from `PyUnicode_Type.tp_new`. +unsafe fn unicode_subtype_new(ty: *mut PyTypeObject, value: &str) -> *mut PyObject { + let chars: Vec = value.chars().map(|c| c as u32).collect(); + let length = chars.len(); + let maxchar = chars.iter().copied().max().unwrap_or(0); + let (kind, ascii, char_size): (u32, bool, usize) = if maxchar < 0x80 { + (crate::layout::ustate::KIND_1BYTE, true, 1) + } else if maxchar < 0x100 { + (crate::layout::ustate::KIND_1BYTE, false, 1) + } else if maxchar < 0x1_0000 { + (crate::layout::ustate::KIND_2BYTE, false, 2) + } else { + (crate::layout::ustate::KIND_4BYTE, false, 4) + }; + + let obj = unsafe { subtype_alloc(ty, 0) }; + if obj.is_null() { + return std::ptr::null_mut(); + } + // One extra code unit for the NUL terminator CPython always keeps. + let nbytes = (length + 1) * char_size; + let data = unsafe { crate::memory::PyMem_Malloc(nbytes) } as *mut u8; + if data.is_null() { + unsafe { crate::object::Py_DecRef(obj) }; + unsafe { crate::errors::PyErr_NoMemory() }; + return std::ptr::null_mut(); + } + unsafe { + std::ptr::write_bytes(data, 0, nbytes); + for (i, &cp) in chars.iter().enumerate() { + write_codepoint(data, kind, i, cp); + } + // PyASCIIObject head: length / hash(-1, unhashed) / state. + let ao = obj as *mut crate::layout::PyASCIIObject; + (*ao).length = length as PySsizeT; + (*ao).hash = -1; + (*ao).state = crate::layout::ustate::pack(0, kind, false, ascii, false); + // PyCompactUnicodeObject head: UTF-8 cache left empty (computed + // lazily by `PyUnicode_AsUTF8`). + let co = obj as *mut crate::layout::PyCompactUnicodeObject; + (*co).utf8 = std::ptr::null_mut(); + (*co).utf8_length = 0; + // PyUnicodeObject `data.any` → the out-of-line character buffer. + let uo = obj as *mut crate::layout::PyUnicodeObject; + (*uo).data = data as *mut c_void; + } + obj +} + +/// `str.__new__(type, object='', encoding=…, errors=…)` — RFC 0046. +/// +/// For the exact `str` type returns a native [`Object::Str`]; for a +/// subtype (e.g. `numpy.str_`) builds the faithful legacy unicode body via +/// [`unicode_subtype_new`], mirroring CPython's `unicode_new`. NumPy's +/// `unicode_arrtype_new` calls this slot directly (`PyUnicode_Type.tp_new`) +/// to let `str` do the value conversion before stamping its own scalar +/// fields, so a NULL slot SIGSEGV'd on `np.str_(...)` / `arr.astype(str)`. +pub unsafe extern "C" fn str_new( + ty: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let value = match unsafe { str_value(args, kwds) } { + Ok(v) => v, + Err(()) => return std::ptr::null_mut(), + }; + if unsafe { is_exact(ty, &crate::types::PyUnicode_Type) } { + return crate::object::into_owned(Object::from_str(value)); + } + unsafe { unicode_subtype_new(ty, &value) } +} + +// ==================================================================== +// bytes +// ==================================================================== + +/// Build the native `bytes` value a `bytes(...)` call would produce from +/// the `tp_new` `(args, kwds)`, mirroring CPython's `bytes_new` argument +/// handling (`bytes(source=b'', encoding=…, errors=…)`): +/// +/// * no `source` → the empty byte string, +/// * `source` str + `encoding` → `source.encode(encoding, errors)`, +/// * `source` str without `encoding` → `TypeError`, +/// * any other `source` → `PyBytes_FromObject` (a bytes-like +/// object, or an iterable of ints). +/// +/// Returns an **owned** native `bytes` `PyObject*` (for a bytes-like +/// source, a new reference to an equal value), or NULL with a pending +/// exception on failure. +unsafe fn bytes_value_obj(args: *mut PyObject, kwds: *mut PyObject) -> *mut PyObject { + let source = unsafe { arg_or_kw(args, kwds, 0, b"source\0") }; + let encoding = unsafe { arg_or_kw(args, kwds, 1, b"encoding\0") }; + let errors = unsafe { arg_or_kw(args, kwds, 2, b"errors\0") }; + + let Some(source) = source else { + if encoding.is_some() || errors.is_some() { + crate::errors::set_type_error("encoding or errors without a string argument"); + return std::ptr::null_mut(); + } + return unsafe { crate::strings::PyBytes_FromStringAndSize(std::ptr::null(), 0) }; + }; + + // A `str` source (including numpy's `str_`, which clones to `Object::Str`) + // must be encoded and *requires* an encoding, matching CPython. + if matches!(unsafe { clone_object(source) }, Object::Str(_)) { + if encoding.is_none() { + crate::errors::set_type_error("string argument without an encoding"); + return std::ptr::null_mut(); + } + // The codec/errors names are accepted; the current codec layer + // resolves them to UTF-8 (see `PyUnicode_AsEncodedString`). + let _ = errors; + return unsafe { + crate::strings::PyUnicode_AsEncodedString(source, std::ptr::null(), std::ptr::null()) + }; + } + if encoding.is_some() || errors.is_some() { + crate::errors::set_type_error("encoding or errors without a string argument"); + return std::ptr::null_mut(); + } + unsafe { crate::strings::PyBytes_FromObject(source) } +} + +/// `bytes.__new__(type, source=b'', encoding=…, errors=…)` — RFC 0046. +/// +/// For the exact `bytes` type returns the native [`Object::Bytes`] value; +/// for a subtype (e.g. `numpy.bytes_`) builds the faithful variable-length +/// `PyBytesObject` body, mirroring CPython's `bytes_subtype_new`: allocate +/// `n` items through the subtype's `tp_alloc` (the faithful inline body, +/// RFC 0045) and write `ob_size` / `ob_shash` / the inline `ob_sval` char +/// array (with its trailing NUL) at the CPython offsets, so a stock reader +/// (`PyBytes_AS_STRING` / `PyBytes_GET_SIZE`) sees a real bytes object. +/// +/// NumPy's `string_arrtype_new` calls this slot directly +/// (`PyBytes_Type.tp_new`) to let `bytes` do the value conversion before +/// stamping its own scalar fields, so a NULL slot SIGSEGV'd on +/// `np.bytes_(...)` / `arr.astype("S")`. +pub unsafe extern "C" fn bytes_new( + ty: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let value_obj = unsafe { bytes_value_obj(args, kwds) }; + if value_obj.is_null() { + return std::ptr::null_mut(); + } + if unsafe { is_exact(ty, &crate::types::PyBytes_Type) } { + return value_obj; + } + // Snapshot the raw bytes, then drop the temporary native `bytes`. + let data: Vec = match unsafe { clone_object(value_obj) } { + Object::Bytes(b) => b.to_vec(), + _ => Vec::new(), + }; + unsafe { crate::object::Py_DecRef(value_obj) }; + + let n = data.len() as PySsizeT; + let obj = unsafe { subtype_alloc(ty, n) }; + if obj.is_null() { + return std::ptr::null_mut(); + } + // Faithful `PyBytesObject` body: `ob_size` (16), `ob_shash` (24, + // unhashed sentinel `-1`), inline `ob_sval` (32) + trailing NUL. + unsafe { + let bo = obj as *mut crate::layout::PyBytesObject; + (*bo).ob_base.ob_size = n; + (*bo).ob_shash = -1; + let sval = (*bo).ob_sval.as_mut_ptr() as *mut u8; + std::ptr::copy_nonoverlapping(data.as_ptr(), sval, data.len()); + *sval.add(data.len()) = 0; + } + obj +} + +// ==================================================================== +// complex +// ==================================================================== + +/// Gather the (up to two) constructor arguments a `complex(...)` call +/// receives through its `tp_new` `(args, kwds)` into native [`Object`]s, +/// honouring the `real`/`imag` keyword names. Positional arguments take +/// precedence; a keyword only fills a position the positional args did not. +unsafe fn complex_args(args: *mut PyObject, kwds: *mut PyObject) -> Vec { + let mut objs: Vec = Vec::new(); + let nargs = if args.is_null() { + 0 + } else { + unsafe { crate::containers::PyTuple_Size(args) }.max(0) + }; + for i in 0..nargs { + let it = unsafe { crate::containers::PyTuple_GetItem(args, i) }; + if it.is_null() { + break; + } + objs.push(unsafe { clone_object(it) }); + } + if !kwds.is_null() { + // `real` fills position 0 when no positional `real` was given. + if objs.is_empty() { + let r = unsafe { + crate::containers::PyDict_GetItemString(kwds, b"real\0".as_ptr() as *const c_char) + }; + if !r.is_null() { + objs.push(unsafe { clone_object(r) }); + } + } + // `imag` fills position 1; CPython allows `complex(imag=…)` with a + // defaulted real, so materialise a zero real first if needed. + if objs.len() < 2 { + let im = unsafe { + crate::containers::PyDict_GetItemString(kwds, b"imag\0".as_ptr() as *const c_char) + }; + if !im.is_null() { + if objs.is_empty() { + objs.push(Object::Float(0.0)); + } + objs.push(unsafe { clone_object(im) }); + } + } + } + objs +} + +/// `complex.__new__(type, real=0, imag=0)` — RFC 0047, wave 5. +/// +/// For the exact `complex` type returns a native [`Object::Complex`]; for a +/// subtype (e.g. `numpy.complex128`, whose scalar `tp_new` calls this slot +/// directly and whose `CDOUBLE_setitem` uses it to parse a string element) +/// allocates the faithful body and writes the `Py_complex cval` at +/// `offsetof(PyComplexObject, cval) == 16`, mirroring CPython's +/// `complex_subtype_from_c_complex`. A NULL slot SIGSEGV'd `complex128("1+2j")` +/// / `arr.astype(complex)` on a string array. +pub unsafe extern "C" fn complex_new( + ty: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let objs = unsafe { complex_args(args, kwds) }; + let (real, imag) = match weavepy_vm::builtins::b_complex(&objs) { + Ok(Object::Complex(c)) => (c.real, c.imag), + Ok(_) => (0.0, 0.0), + Err(e) => { + crate::errors::set_pending_from_runtime(e); + return std::ptr::null_mut(); + } + }; + if unsafe { is_exact(ty, &crate::types::PyComplex_Type) } { + return unsafe { crate::numbers::PyComplex_FromDoubles(real, imag) }; + } + let obj = unsafe { subtype_alloc(ty, 0) }; + if obj.is_null() { + return std::ptr::null_mut(); + } + // `PyComplexObject.cval` is at offset 16 (`PyObject_HEAD` + the + // `Py_complex` pair); a `complex` subtype is layout-compatible and + // inherits that field. + unsafe { + *((obj as *mut u8).add(16) as *mut crate::layout::PyComplexValue) = + crate::layout::PyComplexValue { real, imag }; + } + obj +} + +// ==================================================================== +// Installation +// ==================================================================== + +/// Wire the faithful `tp_new` slots onto the exported static built-ins. +/// Called from [`crate::types::init_static_types`] after the type table +/// is populated. Idempotent (writes the same pointers each time). +pub fn install_builtin_constructors() { + unsafe { + let fnew: unsafe extern "C" fn( + *mut PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = float_new; + (*crate::types::PyFloat_Type.as_ptr()).tp_new = fnew as *mut c_void; + let snew: unsafe extern "C" fn( + *mut PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = str_new; + (*crate::types::PyUnicode_Type.as_ptr()).tp_new = snew as *mut c_void; + let bnew: unsafe extern "C" fn( + *mut PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = bytes_new; + (*crate::types::PyBytes_Type.as_ptr()).tp_new = bnew as *mut c_void; + let cnew: unsafe extern "C" fn( + *mut PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = complex_new; + (*crate::types::PyComplex_Type.as_ptr()).tp_new = cnew as *mut c_void; + } +} diff --git a/crates/weavepy-capi/src/builtin_slots.rs b/crates/weavepy-capi/src/builtin_slots.rs new file mode 100644 index 0000000..ef7cf84 --- /dev/null +++ b/crates/weavepy-capi/src/builtin_slots.rs @@ -0,0 +1,483 @@ +//! C-level protocol slots for the built-in static types. +//! +//! Macro-heavy Cython reads a type's protocol suites **directly** off the +//! `PyTypeObject` struct rather than going through the abstract C-API. For +//! example `__Pyx_PyObject_GetItem` is +//! +//! ```c +//! PyMappingMethods *mm = Py_TYPE(obj)->tp_as_mapping; +//! if (mm && mm->mp_subscript) return mm->mp_subscript(obj, key); +//! PySequenceMethods *sm = Py_TYPE(obj)->tp_as_sequence; +//! if (sm && sm->sq_item) return __Pyx_PyObject_GetIndex(obj, key); +//! /* else: "'%.200s' object is not subscriptable" */ +//! ``` +//! +//! WeavePy's exported `PyList_Type`/`PyTuple_Type`/… historically left +//! `tp_as_sequence`/`tp_as_mapping` NULL (native dispatch happens in the VM, +//! which never reads these slots). The consequence: a Cython extension that +//! indexes / iterates / concatenates one of *our* built-in containers saw it +//! as "not subscriptable" even though the VM handles the operation. This is +//! exactly the gap `frozenlist` (`cdef list _items; … self._items[index]`) +//! tripped over. +//! +//! Each slot here is a thin `extern "C"` bridge that forwards to the +//! corresponding abstract entry point (`PyObject_GetItem`, +//! `PySequence_Concat`, …), which already dispatches through the VM on the +//! runtime `Object`. The bridges are therefore recursion-safe: the abstract +//! functions match on the Rust `Object` enum and never re-read these C +//! slots. The protocol-method suites are leaked once at init (immortal, +//! like the static types themselves). + +use std::ffi::c_void; +use std::os::raw::c_int; + +use crate::layout::{PyMappingMethods, PyNumberMethods, PySequenceMethods}; +use crate::object::{PyObject, PySsizeT}; +use crate::types::StaticType; + +// --------------------------------------------------------------------------- +// Sequence-protocol bridges (`tp_as_sequence`). +// --------------------------------------------------------------------------- + +unsafe extern "C" fn sq_length(o: *mut PyObject) -> PySsizeT { + unsafe { crate::abstract_::PyObject_Length(o) } +} + +unsafe extern "C" fn sq_concat(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PySequence_Concat(a, b) } +} + +unsafe extern "C" fn sq_repeat(o: *mut PyObject, n: PySsizeT) -> *mut PyObject { + unsafe { crate::abstract_::PySequence_Repeat(o, n) } +} + +unsafe extern "C" fn sq_item(o: *mut PyObject, i: PySsizeT) -> *mut PyObject { + unsafe { crate::abstract_::PySequence_GetItem(o, i) } +} + +/// `sq_ass_item` carries both assignment (`v != NULL`) and deletion +/// (`v == NULL`), matching CPython's `ssizeobjargproc` contract. +unsafe extern "C" fn sq_ass_item(o: *mut PyObject, i: PySsizeT, v: *mut PyObject) -> c_int { + if v.is_null() { + unsafe { crate::abstract_::PySequence_DelItem(o, i) } + } else { + unsafe { crate::abstract_::PySequence_SetItem(o, i, v) } + } +} + +unsafe extern "C" fn sq_contains(o: *mut PyObject, v: *mut PyObject) -> c_int { + unsafe { crate::abstract_::PySequence_Contains(o, v) } +} + +unsafe extern "C" fn sq_inplace_concat(a: *mut PyObject, b: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PySequence_InPlaceConcat(a, b) } +} + +unsafe extern "C" fn sq_inplace_repeat(o: *mut PyObject, n: PySsizeT) -> *mut PyObject { + unsafe { crate::abstract_::PySequence_InPlaceRepeat(o, n) } +} + +// --------------------------------------------------------------------------- +// Mapping-protocol bridges (`tp_as_mapping`). +// --------------------------------------------------------------------------- + +unsafe extern "C" fn mp_length(o: *mut PyObject) -> PySsizeT { + unsafe { crate::abstract_::PyObject_Length(o) } +} + +unsafe extern "C" fn mp_subscript(o: *mut PyObject, k: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_GetItem(o, k) } +} + +/// `mp_ass_subscript` carries both assignment and deletion; +/// `PyObject_SetItem` already routes a NULL value to `PyObject_DelItem`. +unsafe extern "C" fn mp_ass_subscript( + o: *mut PyObject, + k: *mut PyObject, + v: *mut PyObject, +) -> c_int { + unsafe { crate::abstract_::PyObject_SetItem(o, k, v) } +} + +/// `dict`'s `in` checks *keys*, not values; `PySequence_Contains` only knows +/// the linear sequence types, so dict gets a dedicated `sq_contains`. +unsafe extern "C" fn dict_sq_contains(o: *mut PyObject, v: *mut PyObject) -> c_int { + unsafe { crate::containers::PyDict_Contains(o, v) } +} + +// --------------------------------------------------------------------------- +// Number-protocol bridges (`tp_as_number`). +// --------------------------------------------------------------------------- +// +// Macro-heavy Cython reads a built-in number's conversion slots *directly* +// off `Py_TYPE(x)->tp_as_number` rather than through the abstract C-API. For +// example `x` compiles to `__Pyx_PyNumber_IntOrLong(x)`, which reads +// `Py_TYPE(x)->tp_as_number->nb_int`; a NULL suite makes it raise +// "an integer is required". pandas' `Timedelta("1 day")` parser casts a +// Python `float` to `` this way (`cast_from_unit` in +// `conversion.pyx`), so a WeavePy `float` with no `nb_int` broke every +// " " timedelta string (while the `hh:mm:ss` branch, which casts a +// pre-built `int`, worked). +// +// Only conversion/inquiry/unary slots are wired. Their contract is "return a +// result or raise" — none of the "return `NotImplemented`" dance the *binary* +// arithmetic slots require. Each bridge forwards to an abstract entry point +// that resolves a *native* operand entirely in Rust and never re-reads these +// C slots, so the forward is recursion-safe: `abstract_::binop` only consults +// C `nb_*` slots when an operand is *foreign*, which a WeavePy int / float / +// complex / bool never is. + +unsafe extern "C" fn nb_int(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Long(o) } +} + +unsafe extern "C" fn nb_index(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Index(o) } +} + +unsafe extern "C" fn nb_float(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Float(o) } +} + +unsafe extern "C" fn nb_bool(o: *mut PyObject) -> c_int { + unsafe { crate::abstract_::PyObject_IsTrue(o) } +} + +unsafe extern "C" fn nb_negative(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Negative(o) } +} + +unsafe extern "C" fn nb_positive(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Positive(o) } +} + +unsafe extern "C" fn nb_absolute(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Absolute(o) } +} + +/// Which number slots a given built-in advertises. `nb_bool` is always +/// installed; the rest mirror CPython's per-type `PyNumberMethods` (e.g. +/// `float` has no `nb_index`, `complex` has neither `nb_int`/`nb_float` +/// /`nb_index` nor `nb_negative`/`nb_absolute` — the latter aren't yet +/// resolvable natively by `PyNumber_Negative`/`PyNumber_Absolute`). +#[derive(Clone, Copy, Default)] +struct NumSpec { + int: bool, + index: bool, + float_: bool, + unary: bool, + /// The numeric-tower binary ops shared by `int`/`float`/`complex` + /// (`+ - * % // / **`). Macro-heavy Cython reads these straight off + /// `Py_TYPE(x)->tp_as_number` and calls them directly — e.g. the + /// overflow fallback of `__Pyx_PyInt_MultiplyObjC` invokes + /// `nb_multiply`, so a NULL slot is a hard `blr NULL` crash. + arith: bool, + /// Bitwise / shift ops (`<< >> & | ^`), present on `int`/`bool` only. + bitwise: bool, +} + +/// Build, leak, and attach a `PyNumberMethods` suite to `ty`. +unsafe fn install_number(ty: &StaticType, spec: NumSpec) { + use crate::abstract_ as ab; + let mut n: PyNumberMethods = unsafe { std::mem::zeroed() }; + n.nb_bool = nb_bool as *mut c_void; + if spec.int { + n.nb_int = nb_int as *mut c_void; + } + if spec.index { + n.nb_index = nb_index as *mut c_void; + } + if spec.float_ { + n.nb_float = nb_float as *mut c_void; + } + if spec.unary { + n.nb_negative = nb_negative as *mut c_void; + n.nb_positive = nb_positive as *mut c_void; + n.nb_absolute = nb_absolute as *mut c_void; + } + if spec.arith { + n.nb_add = ab::nb_slot_add as *mut c_void; + n.nb_subtract = ab::nb_slot_subtract as *mut c_void; + n.nb_multiply = ab::nb_slot_multiply as *mut c_void; + n.nb_remainder = ab::nb_slot_remainder as *mut c_void; + n.nb_floor_divide = ab::nb_slot_floor_divide as *mut c_void; + n.nb_true_divide = ab::nb_slot_true_divide as *mut c_void; + n.nb_power = ab::nb_slot_power as *mut c_void; + } + if spec.bitwise { + n.nb_lshift = ab::nb_slot_lshift as *mut c_void; + n.nb_rshift = ab::nb_slot_rshift as *mut c_void; + n.nb_and = ab::nb_slot_and as *mut c_void; + n.nb_or = ab::nb_slot_or as *mut c_void; + n.nb_xor = ab::nb_slot_xor as *mut c_void; + } + unsafe { + (*ty.as_ptr()).tp_as_number = Box::into_raw(Box::new(n)) as *mut c_void; + } +} + +/// Populate `tp_as_number` on the exported built-in numeric static types. +/// Called from [`crate::types::init_static_types`] alongside [`install`]. +pub fn install_numbers() { + use crate::types::{PyBool_Type, PyComplex_Type, PyFloat_Type, PyLong_Type}; + unsafe { + // int: full conversion surface + unary sign/abs + the complete + // arithmetic and bitwise binary suites. + install_number( + &PyLong_Type, + NumSpec { + int: true, + index: true, + float_: true, + unary: true, + arith: true, + bitwise: true, + }, + ); + // float: int/float conversion + unary + numeric-tower arithmetic; + // no `__index__` (CPython deliberately omits it so a float can't + // be used as an index) and no bitwise ops. + install_number( + &PyFloat_Type, + NumSpec { + int: true, + index: false, + float_: true, + unary: true, + arith: true, + bitwise: false, + }, + ); + // bool (an int subclass): conversion surface + full int-style + // arithmetic/bitwise binary suites (so a direct `nb_*` slot read + // on `True`/`False` never hits NULL). + install_number( + &PyBool_Type, + NumSpec { + int: true, + index: true, + float_: true, + unary: false, + arith: true, + bitwise: true, + }, + ); + // complex: only truthiness is resolvable natively today. + install_number(&PyComplex_Type, NumSpec::default()); + } +} + +// --------------------------------------------------------------------------- +// Type-level bridges. +// --------------------------------------------------------------------------- + +unsafe extern "C" fn tp_iter(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_GetIter(o) } +} + +/// `tp_iter` for an *iterator*: CPython's iterator types return `self` +/// (incref) from `__iter__` so `iter(it) is it`. Cython's `for` loop and +/// `iter()` codegen rely on this identity. +unsafe extern "C" fn iter_self(o: *mut PyObject) -> *mut PyObject { + if !o.is_null() { + unsafe { crate::object::Py_IncRef(o) }; + } + o +} + +/// `tp_iternext` bridge: forwards to [`crate::abstract_::PyIter_Next`], +/// which advances the shared `Object::Iter` cursor and returns NULL with +/// **no** exception set on normal exhaustion — exactly the `tp_iternext` +/// contract Cython's `__Pyx_PyIter_Next` / `for`-loop codegen expects. +unsafe extern "C" fn tp_iternext_bridge(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyIter_Next(o) } +} + +/// Wire a type as a faithful iterator: `tp_iter` returns self and +/// `tp_iternext` drives WeavePy's iteration. Used for `PySeqIter_Type` +/// (the `Object::Iter` umbrella) and the generator type. +pub unsafe fn install_iterator(ty: &StaticType) { + unsafe { + (*ty.as_ptr()).tp_iter = iter_self as *mut c_void; + (*ty.as_ptr()).tp_iternext = tp_iternext_bridge as *mut c_void; + } +} + +// --------------------------------------------------------------------------- +// Suite construction + installation. +// --------------------------------------------------------------------------- + +/// Which sequence slots a given built-in advertises. Mirrors CPython's +/// per-type `PySequenceMethods` (e.g. `tuple` has no `sq_ass_item`, `range` +/// has no `sq_concat`/`sq_repeat`). +#[derive(Clone, Copy, Default)] +struct SeqSpec { + length: bool, + concat: bool, + repeat: bool, + item: bool, + ass_item: bool, + contains: bool, + inplace: bool, +} + +#[derive(Clone, Copy, Default)] +struct MapSpec { + length: bool, + subscript: bool, + ass_subscript: bool, +} + +/// Build, leak, and attach a `PySequenceMethods` suite to `ty`. +unsafe fn install_sequence(ty: &StaticType, spec: SeqSpec) { + let mut s: PySequenceMethods = unsafe { std::mem::zeroed() }; + if spec.length { + s.sq_length = sq_length as *mut c_void; + } + if spec.concat { + s.sq_concat = sq_concat as *mut c_void; + } + if spec.repeat { + s.sq_repeat = sq_repeat as *mut c_void; + } + if spec.item { + s.sq_item = sq_item as *mut c_void; + } + if spec.ass_item { + s.sq_ass_item = sq_ass_item as *mut c_void; + } + if spec.contains { + s.sq_contains = sq_contains as *mut c_void; + } + if spec.inplace { + s.sq_inplace_concat = sq_inplace_concat as *mut c_void; + s.sq_inplace_repeat = sq_inplace_repeat as *mut c_void; + } + unsafe { + (*ty.as_ptr()).tp_as_sequence = Box::into_raw(Box::new(s)) as *mut c_void; + } +} + +/// Build, leak, and attach a `PyMappingMethods` suite to `ty`. +unsafe fn install_mapping(ty: &StaticType, spec: MapSpec) { + let mut m: PyMappingMethods = unsafe { std::mem::zeroed() }; + if spec.length { + m.mp_length = mp_length as *mut c_void; + } + if spec.subscript { + m.mp_subscript = mp_subscript as *mut c_void; + } + if spec.ass_subscript { + m.mp_ass_subscript = mp_ass_subscript as *mut c_void; + } + unsafe { + (*ty.as_ptr()).tp_as_mapping = Box::into_raw(Box::new(m)) as *mut c_void; + } +} + +unsafe fn set_iter(ty: &StaticType) { + unsafe { + (*ty.as_ptr()).tp_iter = tp_iter as *mut c_void; + } +} + +/// Populate the protocol slots on the exported built-in static types. +/// Called from [`crate::types::init_static_types`] after the type table and +/// faithful `tp_new`s are in place. Idempotent in practice (init runs once +/// under a lock); each call leaks a fresh suite, so it must not be invoked +/// repeatedly. +pub fn install() { + use crate::types::{ + PyByteArray_Type, PyBytes_Type, PyDict_Type, PyFrozenSet_Type, PyList_Type, PyRange_Type, + PySet_Type, PyTuple_Type, PyUnicode_Type, + }; + + // Mutable sequences: the full read/write/in-place surface. + let mutable_seq = SeqSpec { + length: true, + concat: true, + repeat: true, + item: true, + ass_item: true, + contains: true, + inplace: true, + }; + let mutable_map = MapSpec { + length: true, + subscript: true, + ass_subscript: true, + }; + + // Immutable sequences: read-only indexing + concat/repeat. + let immutable_seq = SeqSpec { + length: true, + concat: true, + repeat: true, + item: true, + ass_item: false, + contains: true, + inplace: false, + }; + let immutable_map = MapSpec { + length: true, + subscript: true, + ass_subscript: false, + }; + + unsafe { + // list / bytearray — mutable. + install_sequence(&PyList_Type, mutable_seq); + install_mapping(&PyList_Type, mutable_map); + set_iter(&PyList_Type); + + install_sequence(&PyByteArray_Type, mutable_seq); + install_mapping(&PyByteArray_Type, mutable_map); + set_iter(&PyByteArray_Type); + + // tuple / str / bytes — immutable sequences. + install_sequence(&PyTuple_Type, immutable_seq); + install_mapping(&PyTuple_Type, immutable_map); + set_iter(&PyTuple_Type); + + install_sequence(&PyUnicode_Type, immutable_seq); + install_mapping(&PyUnicode_Type, immutable_map); + set_iter(&PyUnicode_Type); + + install_sequence(&PyBytes_Type, immutable_seq); + install_mapping(&PyBytes_Type, immutable_map); + set_iter(&PyBytes_Type); + + // range — read-only length + indexing, no concat/repeat. + install_sequence( + &PyRange_Type, + SeqSpec { + length: true, + item: true, + contains: true, + ..Default::default() + }, + ); + install_mapping(&PyRange_Type, immutable_map); + set_iter(&PyRange_Type); + + // dict — mapping protocol; `in` checks keys via a dedicated contains. + install_mapping(&PyDict_Type, mutable_map); + let mut dseq: PySequenceMethods = std::mem::zeroed(); + dseq.sq_contains = dict_sq_contains as *mut c_void; + (*PyDict_Type.as_ptr()).tp_as_sequence = Box::into_raw(Box::new(dseq)) as *mut c_void; + set_iter(&PyDict_Type); + + // set / frozenset — length + membership + iteration (the numeric + // set algebra `|`/`&`/`^`/`-` routes through `PyNumber_*` → the VM, + // which needs no `nb_*` slot for native operands). + let set_seq = SeqSpec { + length: true, + contains: true, + ..Default::default() + }; + install_sequence(&PySet_Type, set_seq); + set_iter(&PySet_Type); + install_sequence(&PyFrozenSet_Type, set_seq); + set_iter(&PyFrozenSet_Type); + } +} diff --git a/crates/weavepy-capi/src/capsule.rs b/crates/weavepy-capi/src/capsule.rs index 99d0ece..b14d401 100644 --- a/crates/weavepy-capi/src/capsule.rs +++ b/crates/weavepy-capi/src/capsule.rs @@ -26,14 +26,44 @@ //! preserve the dotted-name → import-and-fetch behaviour //! numpy / scipy rely on. +use std::cell::RefCell; +use std::collections::HashMap; use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_int}; use std::ptr; -use weavepy_vm::object::Object; +use weavepy_vm::object::{Object, PyCapsuleSoul}; +use weavepy_vm::sync::Rc; use crate::object::{PyObject, PyObjectBox}; +/// TEMP (RFC 0047 wave 5 capsule-UAF debug): gate for `[CAP]` tracing, +/// enabled by `WEAVEPY_TRACE_CAPSULE`. +pub fn cap_trace_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_TRACE_CAPSULE").is_some()) +} + +fn refcnt_of(p: *mut PyObject) -> i64 { + if p.is_null() { + return -999; + } + unsafe { (*p).ob_refcnt as i64 } +} + +/// True if `p` is a capsule box that still has a live VM-side soul +/// (used by `free_box` to detect a use-after-free). +pub fn soul_alive(p: *mut PyObject) -> bool { + let key = p as usize; + CAPSULE_SOULS.with(|m| { + m.borrow() + .get(&key) + .and_then(weavepy_vm::sync::Weak::upgrade) + .is_some() + }) +} + #[repr(C)] struct CapsuleState { pointer: *mut std::ffi::c_void, @@ -82,7 +112,32 @@ pub unsafe extern "C" fn PyCapsule_New( destructor, }, }); - Box::into_raw(bx) as *mut PyObject + let raw = Box::into_raw(bx) as *mut PyObject; + crate::object::register_minted(raw); + if cap_trace_enabled() { + let nm = name_owned_str(unsafe { &*(raw as *const PyObjectBox) }); + eprintln!( + "[CAP] New box=0x{:x} refcnt={} name={:?} dtor={}", + raw as usize, + refcnt_of(raw), + nm, + destructor.is_some(), + ); + } + raw +} + +fn name_owned_str(bx: &PyObjectBox) -> String { + let st = bx.payload.user_data as *mut CapsuleState; + if st.is_null() { + return "".to_string(); + } + unsafe { &*st } + .name + .as_deref() + .and_then(|b| CStr::from_bytes_with_nul(b).ok()) + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) } fn capsule_state(p: *mut PyObject) -> Option<*mut CapsuleState> { @@ -97,6 +152,144 @@ fn capsule_state(p: *mut PyObject) -> Option<*mut CapsuleState> { Some(bx.payload.user_data as *mut CapsuleState) } +// --------------------------------------------------------------------------- +// Capsule <-> VM round-trip (RFC 0045, wave 3) +// +// A capsule is a legacy `PyObjectBox` whose *identity* is the box pointer and +// whose state (the wrapped `void*`, name, …) lives in `user_data` — its +// `payload.obj` is `Object::None`. That made it collapse to `None` the moment +// it crossed into the VM (a module dict / attribute), breaking the load-bearing +// `import_array()` idiom: `PyModule_AddObject(m, "_API", capsule)` stored +// `None`, so a later `PyCapsule_Import(...)` fetched a non-capsule and failed. +// +// The fix mirrors wave 3's stable-instance-body design: the capsule keeps its +// box, but the VM holds an identity-stable [`Object::Capsule`] *soul* that maps +// back to the **same** box. The soul retains one C reference on the box for its +// whole life, hands that same pointer back out on each crossing, and releases +// the retain when the last soul drops (the [`capsule_soul_free`] hook). +// --------------------------------------------------------------------------- + +thread_local! { + /// Dedup map `capsule box pointer -> Weak`. Lets a capsule that + /// crosses into the VM more than once resolve to the *same* soul (so + /// `cap is cap` holds and exactly one VM-side retain is kept). Advisory: + /// a missing/stale entry only costs an extra soul, never correctness — + /// each soul independently balances its own retain. + static CAPSULE_SOULS: RefCell>> = + RefCell::new(HashMap::new()); +} + +/// Install the VM hook that releases a capsule's retained box when its +/// VM-side [`PyCapsuleSoul`] drops (RFC 0045, wave 3). Idempotent; called +/// from [`crate::interp::ensure_initialised`]. +pub fn install() { + weavepy_vm::object::register_capsule_free(capsule_soul_free); +} + +/// True if `p` is a live capsule box (its `ob_type` is `PyCapsule_Type`). +pub fn is_capsule(p: *mut PyObject) -> bool { + capsule_state(p).is_some() +} + +/// Resolve a capsule box crossing into the VM to its identity-stable +/// [`Object::Capsule`] soul (RFC 0045). On the **first** crossing the box +/// is retained once (the soul's lifelong reference) and registered; later +/// crossings of the same box return the same soul without a new retain. +/// +/// # Safety +/// `p` must be a live capsule box ([`is_capsule`]). +pub unsafe fn capsule_soul(p: *mut PyObject) -> Object { + let key = p as usize; + if let Some(existing) = CAPSULE_SOULS.with(|m| { + m.borrow() + .get(&key) + .and_then(weavepy_vm::sync::Weak::upgrade) + }) { + if cap_trace_enabled() { + eprintln!( + "[CAP] soul_in p=0x{:x} REUSE existing soul refcnt={}", + key, + refcnt_of(p), + ); + } + return Object::Capsule(existing); + } + // First crossing: read the name for `repr`, take the soul's lifelong + // retain on the box, and register the soul for dedup. + let name = capsule_state(p).and_then(|s| unsafe { + (*s).name.as_deref().map(|bytes| { + let text = CStr::from_bytes_with_nul(bytes) + .ok() + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned()); + weavepy_vm::sync::Rc::::from(text.as_str()) + }) + }); + let before = refcnt_of(p); + unsafe { crate::object::Py_IncRef(p) }; + let soul = Rc::new(PyCapsuleSoul { name, handle: key }); + CAPSULE_SOULS.with(|m| m.borrow_mut().insert(key, Rc::downgrade(&soul))); + if cap_trace_enabled() { + eprintln!( + "[CAP] soul_in p=0x{:x} NEW soul refcnt {}->{}", + key, + before, + refcnt_of(p), + ); + } + Object::Capsule(soul) +} + +/// Hand a capsule soul back to C as its original box (RFC 0045): bump the +/// box's C refcount and return the same pointer. The box is guaranteed +/// live — the soul holds a retain on it for as long as it exists. +pub fn capsule_box_from_soul(soul: &Rc) -> *mut PyObject { + let p = soul.handle as *mut PyObject; + if cap_trace_enabled() { + let ok = capsule_state(p).is_some(); + let ty = if p.is_null() { + 0 + } else { + unsafe { (*p).ob_type as usize } + }; + eprintln!( + "[CAP] box_out handle=0x{:x} refcnt_before={} is_capsule={} ob_type=0x{:x} expected=0x{:x}", + p as usize, + refcnt_of(p), + ok, + ty, + crate::types::PyCapsule_Type.as_ptr() as usize, + ); + } + unsafe { crate::object::Py_IncRef(p) }; + p +} + +/// VM hook (registered by [`install`]): the last [`PyCapsuleSoul`] for a +/// capsule has dropped, so release the lifelong retain its box was holding. +/// Drops the dedup entry first, then decrefs — the decref may reach zero and +/// free the box (running any `PyCapsule` destructor), which must not see a +/// borrow of [`CAPSULE_SOULS`] held. +fn capsule_soul_free(handle: usize) { + if handle == 0 { + return; + } + // Best-effort dedup cleanup. Advisory only: removing the wrong entry (a + // pathological cross-thread reuse of the same address) costs at most an + // extra soul later, never a refcount imbalance. + CAPSULE_SOULS.with(|m| { + m.borrow_mut().remove(&handle); + }); + if cap_trace_enabled() { + eprintln!( + "[CAP] soul_free handle=0x{:x} refcnt_before_dec={}", + handle, + refcnt_of(handle as *mut PyObject), + ); + } + unsafe { crate::object::Py_DecRef(handle as *mut PyObject) }; +} + #[no_mangle] pub unsafe extern "C" fn PyCapsule_GetPointer( capsule: *mut PyObject, @@ -258,6 +451,9 @@ pub unsafe extern "C" fn PyCapsule_Import( crate::errors::set_value_error("PyCapsule_Import: empty name"); return ptr::null_mut(); } + if cap_trace_enabled() { + eprintln!("[CAP] Import ENTER name={dotted:?} parts={parts:?}"); + } // Step 1: walk longest-prefix module loads, then fall back to // attribute lookups for the remainder. This matches CPython's @@ -271,6 +467,13 @@ pub unsafe extern "C" fn PyCapsule_Import( Err(_) => continue, }; let module = unsafe { crate::module::PyImport_ImportModule(c_prefix.as_ptr()) }; + if cap_trace_enabled() { + eprintln!( + "[CAP] Import try-prefix i={i} prefix={:?} -> {}", + parts[..i].join("."), + if module.is_null() { "NULL" } else { "ok" }, + ); + } if !module.is_null() { object_ptr = module; consumed = i; @@ -304,6 +507,18 @@ pub unsafe extern "C" fn PyCapsule_Import( } }; let next = unsafe { crate::abstract_::PyObject_GetAttrString(object_ptr, c_attr.as_ptr()) }; + if cap_trace_enabled() { + eprintln!( + "[CAP] Import getattr {:?} on 0x{:x} -> {}", + attr, + object_ptr as usize, + if next.is_null() { + "NULL".to_string() + } else { + format!("0x{:x} is_capsule={}", next as usize, is_capsule(next)) + }, + ); + } if next.is_null() { // RFC 0029: built-in C-API capsules (e.g. // `datetime.datetime_CAPI`, `numpy.core.multiarray._ARRAY_API`) @@ -332,6 +547,18 @@ pub unsafe extern "C" fn PyCapsule_Import( } }; let p = unsafe { PyCapsule_GetPointer(object_ptr, cname.as_ptr()) }; + if cap_trace_enabled() { + eprintln!( + "[CAP] Import FINAL GetPointer on 0x{:x} (is_capsule={}) -> {}", + object_ptr as usize, + is_capsule(object_ptr), + if p.is_null() { + "NULL".to_string() + } else { + format!("0x{:x}", p as usize) + }, + ); + } unsafe { crate::object::Py_DecRef(object_ptr) }; p } @@ -355,13 +582,18 @@ fn try_install_well_known_capsule( parent_module: *mut PyObject, ) -> Option<*mut PyObject> { if dotted == "datetime.datetime_CAPI" { - // Build the capsule from the static API table. + // RFC 0029 (wave 5): mint the faithful datetime types + dynamic + // capsule table (size-correct type slots) before publishing, and + // best-effort fill the `TimeZone_UTC` singleton. Falls back to + // the static table (NULL type slots) if `datetime` can't be + // located, which keeps the function-pointer constructors usable. + crate::datetime_api::ensure_datetime_bridge(); + crate::datetime_api::fill_utc_singleton(); let name = match CString::new("datetime.datetime_CAPI") { Ok(s) => s, Err(_) => return None, }; - let payload = - &crate::datetime_api::PyDateTimeAPI_Instance as *const _ as *mut std::ffi::c_void; + let payload = crate::datetime_api::capi_table_void_ptr(); let capsule = unsafe { PyCapsule_New(payload, name.as_ptr(), None) }; if capsule.is_null() { return None; @@ -376,11 +608,10 @@ fn try_install_well_known_capsule( crate::abstract_::PyObject_SetAttrString(parent_module, attr.as_ptr(), capsule) }; // Also publish the global pointer for the `PyDateTimeAPI` - // macro in `Python.h`. + // macro in `Python.h` (the dynamic table when ready, else static). unsafe { - crate::datetime_api::PyDateTimeAPI = &crate::datetime_api::PyDateTimeAPI_Instance - as *const _ - as *mut crate::datetime_api::PyDateTimeCAPI; + crate::datetime_api::PyDateTimeAPI = + payload as *mut crate::datetime_api::PyDateTimeCAPI; } return Some(capsule); } diff --git a/crates/weavepy-capi/src/code_obj.rs b/crates/weavepy-capi/src/code_obj.rs new file mode 100644 index 0000000..0e5c428 --- /dev/null +++ b/crates/weavepy-capi/src/code_obj.rs @@ -0,0 +1,350 @@ +//! RFC 0047 (wave 5): code / frame / traceback object facade. +//! +//! Genuine **Cython-generated** extensions create a `__code__` object per +//! `def`/`cpdef`/`cdef` function during *module init* (`__Pyx_CreateCodeObjects` +//! → `PyUnstable_Code_NewWithPosOnlyArgs`) and then write +//! `result->_co_firsttraceable = 0` **directly into the struct**, store it +//! on the function, and `Py_DECREF` it at teardown. The traceback builder +//! (`__Pyx_AddTraceback`) additionally reaches for `PyCode_NewEmpty`, +//! `PyFrame_New`, `PyTraceBack_Here`, and the `PyCode_Type`/`PyFrame_Type`/ +//! `PyTraceBack_Type` identity statics. +//! +//! WeavePy executes these functions through their C entry points, not a +//! code object, so a code object here is **metadata only**: a byte-faithful +//! CPython 3.13 `PyCodeObject` body (so the direct `_co_firsttraceable` +//! write and any field read land on real memory), refcounted correctly +//! (the object owns the `tp_*`-stored sub-objects and releases them in +//! `tp_dealloc`), and otherwise opaque to the VM (handled as a foreign +//! object — see [`crate::object::clone_object`]). +//! +//! The hermetic wave-5 `_stockcython.c` fixture hand-rolled its types and +//! never created a single code object, so this whole surface was missing +//! until a *real* Cython `.so` linked it. + +#![allow(clippy::missing_safety_doc)] + +use core::ffi::{c_char, c_int}; +use std::alloc::{self, Layout}; +use std::ptr; +use std::sync::Mutex; + +use crate::lifecycle::PyThreadState; +use crate::object::{PyObject, IMMORTAL_REFCNT}; +use crate::types::StaticType; + +// --------------------------------------------------------------------------- +// PyCodeObject 3.13 layout (machine-checked against stock `cpython/code.h`: +// `PyObject_VAR_HEAD` is 24 bytes, then the fields below). +// --------------------------------------------------------------------------- +const OFF_CONSTS: usize = 24; // PyObject *co_consts +const OFF_NAMES: usize = 32; // PyObject *co_names +const OFF_EXCEPTIONTABLE: usize = 40; // PyObject *co_exceptiontable +const OFF_FLAGS: usize = 48; // int co_flags +const OFF_ARGCOUNT: usize = 52; // int co_argcount +const OFF_POSONLY: usize = 56; // int co_posonlyargcount +const OFF_KWONLY: usize = 60; // int co_kwonlyargcount +const OFF_STACKSIZE: usize = 64; // int co_stacksize +const OFF_FIRSTLINENO: usize = 68; // int co_firstlineno +const OFF_NLOCALS: usize = 80; // int co_nlocals +const OFF_LOCALSPLUSNAMES: usize = 96; // PyObject *co_localsplusnames +const OFF_FILENAME: usize = 112; // PyObject *co_filename +const OFF_NAME: usize = 120; // PyObject *co_name +const OFF_QUALNAME: usize = 128; // PyObject *co_qualname +const OFF_LINETABLE: usize = 136; // PyObject *co_linetable +const OFF_FIRSTTRACEABLE: usize = 184; // int _co_firsttraceable +/// Offset of the flexible `co_code_adaptive[]` member. WeavePy never +/// executes the bytecode, so we allocate a fixed body covering every named +/// field plus a small `co_code_adaptive` head; `tp_basicsize` matches +/// CPython's `sizeof(PyCodeObject)` for a one-unit body. +const CODE_BASE: usize = 200; +/// Total body we allocate per code object (all named fields fit; rounded +/// to 8). CPython would append `(ncodeunits-1)*2` more bytes for the real +/// bytecode, which we deliberately omit (never executed). +const CODE_BODY_SIZE: usize = 208; + +/// The `PyObject*` fields a code object owns a strong reference to and must +/// release in `tp_dealloc`. `co_code_adaptive` holds the bytecode *inline* +/// in CPython (the `code` constructor arg is copied, not retained), so it is +/// intentionally not in this list; neither are `freevars`/`cellvars`, which +/// CPython folds into `co_localsplusnames`. +const OWNED_FIELD_OFFSETS: [usize; 8] = [ + OFF_CONSTS, + OFF_NAMES, + OFF_EXCEPTIONTABLE, + OFF_LOCALSPLUSNAMES, + OFF_FILENAME, + OFF_NAME, + OFF_QUALNAME, + OFF_LINETABLE, +]; + +// `Py_TPFLAGS_DEFAULT` baseline (`Py_TPFLAGS_HAVE_VERSION_TAG`). +const TPFLAGS_DEFAULT: u64 = 1 << 18; + +// --------------------------------------------------------------------------- +// Identity statics. Cython references `&PyCode_Type` / `&PyFrame_Type` / +// `&PyTraceBack_Type` for `Py_IS_TYPE` checks and (for code) as the +// `ob_type` of objects it creates. +// --------------------------------------------------------------------------- +#[no_mangle] +pub static PyCode_Type: StaticType = StaticType::new(); +#[no_mangle] +pub static PyFrame_Type: StaticType = StaticType::new(); +#[no_mangle] +pub static PyTraceBack_Type: StaticType = StaticType::new(); + +static INIT_LOCK: Mutex = Mutex::new(false); + +/// Lazily wire the three facade type objects (idempotent). Runs before any +/// code object is created, so `ob_type`/`tp_dealloc`/`tp_basicsize` are +/// valid by the time one exists. Requires `PyType_Type` to be initialised, +/// which it always is by the time an extension's `PyInit_*` runs. +fn ensure_types() { + let mut done = INIT_LOCK.lock().unwrap(); + if *done { + return; + } + *done = true; + let meta = crate::types::PyType_Type.as_ptr(); + unsafe { + let code = &mut *PyCode_Type.as_ptr(); + code.head.ob_refcnt = IMMORTAL_REFCNT; + code.head.ob_type = meta; + code.tp_name = b"code\0".as_ptr() as *const c_char; + code.tp_basicsize = CODE_BODY_SIZE as crate::object::PySsizeT; + code.tp_itemsize = 2; // sizeof(_Py_CODEUNIT) + code.tp_flags = TPFLAGS_DEFAULT; + code.tp_dealloc = Some(code_dealloc); + + for (slot, name) in [ + (PyFrame_Type.as_ptr(), b"frame\0".as_ref()), + (PyTraceBack_Type.as_ptr(), b"traceback\0".as_ref()), + ] { + let ty = &mut *slot; + ty.head.ob_refcnt = IMMORTAL_REFCNT; + ty.head.ob_type = meta; + ty.tp_name = name.as_ptr() as *const c_char; + ty.tp_flags = TPFLAGS_DEFAULT; + } + } +} + +#[inline] +unsafe fn write_int(base: *mut u8, off: usize, v: c_int) { + unsafe { ptr::write_unaligned(base.add(off) as *mut c_int, v) }; +} + +#[inline] +unsafe fn store_obj(base: *mut u8, off: usize, o: *mut PyObject) { + if !o.is_null() { + unsafe { crate::object::Py_IncRef(o) }; + } + unsafe { ptr::write_unaligned(base.add(off) as *mut *mut PyObject, o) }; +} + +/// Allocate and zero a faithful `PyCodeObject` body with the head set +/// (`ob_refcnt = 1`, `ob_type = &PyCode_Type`). Returns the object pointer. +unsafe fn alloc_code() -> *mut PyObject { + ensure_types(); + let layout = Layout::from_size_align(CODE_BODY_SIZE, 8).expect("code layout"); + let raw = unsafe { alloc::alloc_zeroed(layout) }; + if raw.is_null() { + unsafe { crate::errors::PyErr_NoMemory() }; + return ptr::null_mut(); + } + let obj = raw as *mut PyObject; + unsafe { + (*obj).ob_refcnt = 1; + (*obj).ob_type = PyCode_Type.as_ptr(); + } + obj +} + +/// `tp_dealloc` for a facade code object: release the owned sub-objects and +/// free the body with the exact layout [`alloc_code`] used. +unsafe extern "C" fn code_dealloc(obj: *mut PyObject) { + if obj.is_null() { + return; + } + let base = obj as *mut u8; + for off in OWNED_FIELD_OFFSETS { + let field = unsafe { ptr::read_unaligned(base.add(off) as *const *mut PyObject) }; + if !field.is_null() { + unsafe { crate::object::Py_DecRef(field) }; + } + } + let layout = Layout::from_size_align(CODE_BODY_SIZE, 8).expect("code layout"); + unsafe { alloc::dealloc(base, layout) }; +} + +/// `PyUnstable_Code_NewWithPosOnlyArgs` — the 3.13 public code-object +/// constructor Cython emits for every function in `__Pyx_CreateCodeObjects`. +/// We retain the metadata fields (names, filename, qualname, consts) and +/// leave the bytecode (`co_code_adaptive`) empty — WeavePy never runs it. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn PyUnstable_Code_NewWithPosOnlyArgs( + argcount: c_int, + posonlyargcount: c_int, + kwonlyargcount: c_int, + nlocals: c_int, + stacksize: c_int, + flags: c_int, + _code: *mut PyObject, + consts: *mut PyObject, + names: *mut PyObject, + varnames: *mut PyObject, + _freevars: *mut PyObject, + _cellvars: *mut PyObject, + filename: *mut PyObject, + name: *mut PyObject, + qualname: *mut PyObject, + firstlineno: c_int, + linetable: *mut PyObject, + exceptiontable: *mut PyObject, +) -> *mut PyObject { + let obj = unsafe { alloc_code() }; + if obj.is_null() { + return ptr::null_mut(); + } + let base = obj as *mut u8; + unsafe { + write_int(base, OFF_ARGCOUNT, argcount); + write_int(base, OFF_POSONLY, posonlyargcount); + write_int(base, OFF_KWONLY, kwonlyargcount); + write_int(base, OFF_NLOCALS, nlocals); + write_int(base, OFF_STACKSIZE, stacksize); + write_int(base, OFF_FLAGS, flags); + write_int(base, OFF_FIRSTLINENO, firstlineno); + store_obj(base, OFF_CONSTS, consts); + store_obj(base, OFF_NAMES, names); + store_obj(base, OFF_LOCALSPLUSNAMES, varnames); + store_obj(base, OFF_FILENAME, filename); + store_obj(base, OFF_NAME, name); + store_obj(base, OFF_QUALNAME, qualname); + store_obj(base, OFF_LINETABLE, linetable); + store_obj(base, OFF_EXCEPTIONTABLE, exceptiontable); + } + obj +} + +/// `PyUnstable_Code_New` — same as the pos-only variant with +/// `posonlyargcount == 0` (the 17-arg legacy spelling). +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn PyUnstable_Code_New( + argcount: c_int, + kwonlyargcount: c_int, + nlocals: c_int, + stacksize: c_int, + flags: c_int, + code: *mut PyObject, + consts: *mut PyObject, + names: *mut PyObject, + varnames: *mut PyObject, + freevars: *mut PyObject, + cellvars: *mut PyObject, + filename: *mut PyObject, + name: *mut PyObject, + qualname: *mut PyObject, + firstlineno: c_int, + linetable: *mut PyObject, + exceptiontable: *mut PyObject, +) -> *mut PyObject { + unsafe { + PyUnstable_Code_NewWithPosOnlyArgs( + argcount, + 0, + kwonlyargcount, + nlocals, + stacksize, + flags, + code, + consts, + names, + varnames, + freevars, + cellvars, + filename, + name, + qualname, + firstlineno, + linetable, + exceptiontable, + ) + } +} + +/// `PyCode_NewEmpty(filename, funcname, firstlineno)` — the traceback +/// builder's minimal code-object constructor. Must return non-NULL or +/// Cython's `__Pyx_AddTraceback` discards the *original* pending exception. +#[no_mangle] +pub unsafe extern "C" fn PyCode_NewEmpty( + filename: *const c_char, + funcname: *const c_char, + firstlineno: c_int, +) -> *mut PyObject { + if std::env::var_os("WEAVEPY_TRACE_NULL").is_some() { + let fname = if funcname.is_null() { + "".to_string() + } else { + unsafe { std::ffi::CStr::from_ptr(funcname) } + .to_string_lossy() + .into_owned() + }; + let file = if filename.is_null() { + "".to_string() + } else { + unsafe { std::ffi::CStr::from_ptr(filename) } + .to_string_lossy() + .into_owned() + }; + eprintln!("[WEAVEPY_TRACE_NULL] PyCode_NewEmpty tb-frame: {file}:{firstlineno} in {fname}"); + } + let obj = unsafe { alloc_code() }; + if obj.is_null() { + return ptr::null_mut(); + } + let base = obj as *mut u8; + unsafe { + write_int(base, OFF_FIRSTLINENO, firstlineno); + if !filename.is_null() { + let f = crate::strings::PyUnicode_FromString(filename); + // store_obj would double-incref a fresh ref; install directly. + ptr::write_unaligned(base.add(OFF_FILENAME) as *mut *mut PyObject, f); + } + if !funcname.is_null() { + let n = crate::strings::PyUnicode_FromString(funcname); + ptr::write_unaligned(base.add(OFF_NAME) as *mut *mut PyObject, n); + } + } + obj +} + +// --------------------------------------------------------------------------- +// Frame / traceback. WeavePy has no C-visible frame stack; the only caller +// is Cython's `__Pyx_AddTraceback`, which on a NULL frame simply skips +// appending its synthetic traceback line and lets the *already-restored* +// original exception propagate unchanged. +// --------------------------------------------------------------------------- + +/// `PyFrame_New(tstate, code, globals, locals)` — returns NULL (no error +/// set). The caller treats this as "couldn't build a traceback frame" and +/// preserves the pending exception. +#[no_mangle] +pub unsafe extern "C" fn PyFrame_New( + _tstate: *mut PyThreadState, + _code: *mut PyObject, + _globals: *mut PyObject, + _locals: *mut PyObject, +) -> *mut PyObject { + ptr::null_mut() +} + +/// `PyTraceBack_Here(frame)` — prepend a traceback entry for `frame`. +/// WeavePy keeps tracebacks on the VM side; this C-level shim is a sound +/// no-op (returns success). +#[no_mangle] +pub unsafe extern "C" fn PyTraceBack_Here(_frame: *mut PyObject) -> c_int { + 0 +} diff --git a/crates/weavepy-capi/src/containers.rs b/crates/weavepy-capi/src/containers.rs index 8a564bd..1e91a13 100644 --- a/crates/weavepy-capi/src/containers.rs +++ b/crates/weavepy-capi/src/containers.rs @@ -15,7 +15,7 @@ use weavepy_vm::sync::RefCell; use weavepy_vm::object::{DictData, DictKey, Object, SetData}; -use crate::object::{PyObject, PySsizeT}; +use crate::object::{PyHashT, PyObject, PySsizeT}; thread_local! { /// Interned `*mut PyObject` cache for `PyTuple_GetItem` / @@ -26,6 +26,120 @@ thread_local! { /// return the same `*mut PyObject` (matching CPython). static BORROWED_ITEM_CACHE: StdRefCell> = StdRefCell::new(HashMap::new()); + + /// Per-dict value-box cache (RFC 0046, wave 4). A C extension that + /// `PyDict_New()`s a dict, stores it under a key, then `Py_DECREF`s its + /// own reference relies on the *parent* dict keeping the value's + /// `PyObject` alive — and frequently retains the raw pointer (numpy + /// stashes module sub-dicts in `npy_static_pydata`). WeavePy's parent + /// dict only retains the *native* value, so the freshly-minted value + /// box would be freed by that `Py_DECREF`, dangling the extension's + /// pointer. Keyed on `(dict box ptr, key repr)`, this holds one + /// reference to each stored value box for as long as the dict lives, so + /// `PyDict_GetItem*` round-trips the *same* pointer the setter stored. + /// Drained by [`invalidate_borrowed_cache`] when the dict is freed. + /// + /// The cached value carries the *native* [`Object`] it was minted from + /// alongside the box. `PyDict_GetItem*` return a borrowed reference to + /// the value *currently* stored, so on a read we compare the live value + /// against this snapshot (`Object::is_same`): if the slot was reassigned + /// (a monkeypatch, or any post-init global rebind — the exact idiom + /// `pytest.monkeypatch`/`mock.patch` use on a compiled module, which + /// Cython reads back via `__Pyx_GetModuleGlobalName` → + /// `_PyDict_GetItem_KnownHash`), we mint a fresh box for the new value + /// instead of handing back the stale one. Storing the `Object` (rather + /// than re-deriving it from the box) keeps identity stable for unchanged + /// mirrored builtins (str/int round-trip to a *fresh* Rc), so repeated + /// reads of an unchanged value keep returning the same pointer. + static DICT_BOX_CACHE: StdRefCell> = + StdRefCell::new(HashMap::new()); +} + +/// A stable cache key for a dict key object (matches `==`-equal keys, the +/// dict contract numpy relies on for string / int / DType-class keys). +fn dict_key_id(key: &Object) -> String { + match key { + Object::Str(s) => format!("s\0{s}"), + Object::Int(_) | Object::Long(_) | Object::Bool(_) => format!("i\0{}", key.to_str()), + other => format!("r\0{}", other.repr()), + } +} + +/// Retain `value` (the *caller's* box) as `dict[key]`'s canonical C +/// reference, releasing any previous box for that slot. Increfs `value` +/// so it survives the caller's matching `Py_DECREF`. `value_obj` is the +/// native [`Object`] `value` boxes; it is stashed so a later read can tell +/// whether the slot still holds the same value (see [`dict_borrowed_box`]). +fn dict_retain_value(dict: *mut PyObject, key: String, value: *mut PyObject, value_obj: Object) { + if value.is_null() { + return; + } + unsafe { crate::object::Py_IncRef(value) }; + let old = + DICT_BOX_CACHE.with(|cell| cell.borrow_mut().insert((dict as usize, key), (value, value_obj))); + if let Some((old, _)) = old { + if old != value { + unsafe { crate::object::Py_DecRef(old) }; + } else { + // Same pointer re-stored: undo the extra incref. + unsafe { crate::object::Py_DecRef(value) }; + } + } +} + +/// The canonical value box for `dict[key]`, if one was stored through a +/// C setter and is still live, paired with the native [`Object`] it was +/// minted from. A *borrowed* reference (not incref'd). +fn dict_cached_value(dict: *mut PyObject, key: &str) -> Option<(*mut PyObject, Object)> { + DICT_BOX_CACHE.with(|cell| cell.borrow().get(&(dict as usize, key.to_owned())).cloned()) +} + +/// Diagnostic: log `_engine`/`_cache` dict operations (RFC 0045 body-reuse +/// debugging). Gated on `WEAVEPY_BODY_TRACE`. +fn engine_dict_trace(tag: &str, dict: *mut PyObject, key_id: &str, found: bool, boxp: *mut PyObject) { + if !crate::mirror::body_trace_enabled() { + return; + } + if !key_id.contains("_engine") { + return; + } + let ty = if boxp.is_null() { + "".to_string() + } else { + unsafe { crate::object::debug_type_name(boxp) } + }; + let cached = dict_cached_value(dict, key_id) + .map(|(p, _)| p as usize) + .unwrap_or(0); + eprintln!( + "[EDICT] {} dict=0x{:x} key={} found={} box=0x{:x} boxtype={} cached=0x{:x}", + tag, + dict as usize, + key_id, + found, + boxp as usize, + ty, + cached, + ); +} + +/// Drop every cached dict value box pinned to `container`. +fn invalidate_dict_box_cache(container: *mut PyObject) { + let key = container as usize; + let drained: Vec<*mut PyObject> = DICT_BOX_CACHE.with(|cell| { + let mut map = cell.borrow_mut(); + let stale: Vec<(usize, String)> = map.keys().filter(|(c, _)| *c == key).cloned().collect(); + let mut out = Vec::with_capacity(stale.len()); + for k in stale { + if let Some((p, _)) = map.remove(&k) { + out.push(p); + } + } + out + }); + for p in drained { + unsafe { crate::object::Py_DecRef(p) }; + } } /// Install or reuse the interned borrowed-reference pointer for the @@ -75,6 +189,20 @@ pub(crate) fn invalidate_borrowed_cache(container: *mut crate::object::PyObject) for p in drained { unsafe { crate::object::Py_DecRef(p) }; } + invalidate_dict_box_cache(container); +} + +/// True iff the container caches' thread-local storage is still live. +/// +/// Returns `false` during thread/process teardown, when these thread-locals +/// have already been destroyed and touching them (`.with`) would panic with +/// `AccessError` → `abort`. [`crate::object::free_box`] probes this before +/// consulting any cache so it can leak (the OS reclaims memory at exit) +/// instead of aborting when a deep exception pinned in another thread-local +/// is dropped in that window and decref-s its C mirror pointers back through +/// `free_box`. +pub(crate) fn caches_alive() -> bool { + BORROWED_ITEM_CACHE.try_with(|_| ()).is_ok() && DICT_BOX_CACHE.try_with(|_| ()).is_ok() } // ---------------------------------------------------------------- @@ -92,6 +220,15 @@ pub unsafe extern "C" fn PyList_Append(list: *mut PyObject, item: *mut PyObject) if list.is_null() || item.is_null() { return -1; } + // RFC 0046 (wave 4): a faithful list stores its elements in the inline + // `ob_item` buffer (the source of truth every read-back — including a + // stock `PyList_GET_ITEM` macro — consults), so the append must land + // there, not on the now-vestigial staged native list. + if unsafe { crate::mirror::is_faithful_list(list) } { + unsafe { crate::mirror::list_append(list, item) }; + return 0; + } + // Defensive fallback for a (today unreachable) non-mirror list box. match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { rc.borrow_mut() @@ -114,6 +251,10 @@ pub unsafe extern "C" fn PyList_Insert( if list.is_null() || item.is_null() { return -1; } + if unsafe { crate::mirror::is_faithful_list(list) } { + unsafe { crate::mirror::list_insert(list, index, item) }; + return 0; + } match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { let mut v = rc.borrow_mut(); @@ -138,6 +279,20 @@ pub unsafe extern "C" fn PyList_SetItem( if list.is_null() { return -1; } + // RFC 0046 (wave 4): a faithful list stores elements inline; steal + // `item` straight into the `ob_item` slot (CPython `PyList_SetItem` + // takes ownership), releasing the prior occupant. This is the write + // that keeps the inline buffer — the source of truth — coherent. + if unsafe { crate::mirror::is_faithful_list(list) } { + if unsafe { crate::mirror::list_store(list, index, item) } { + return 0; + } + if !item.is_null() { + unsafe { crate::object::Py_DecRef(item) }; + } + crate::errors::set_value_error("list assignment index out of range"); + return -1; + } let result = match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { let mut v = rc.borrow_mut(); @@ -168,6 +323,23 @@ pub unsafe extern "C" fn PyList_GetItem(list: *mut PyObject, index: PySsizeT) -> if list.is_null() { return ptr::null_mut(); } + // RFC 0046 (wave 4): hand back the actual `ob_item` slot (a borrowed + // reference, per `PyList_GetItem`'s contract) so the pointer is the + // exact one a prior `PyList_SetItem` / `PyList_Append` stored — stock + // code compares list elements by identity. + if unsafe { crate::mirror::is_faithful_list(list) } { + let n = unsafe { crate::mirror::list_size(list) }; + if index < 0 || index >= n { + crate::errors::set_value_error("list index out of range"); + return ptr::null_mut(); + } + return match unsafe { crate::mirror::list_slot(list, index) } { + Some(slot) if !slot.is_null() => slot, + // A NULL placeholder (`PyList_New(n)` slot never filled) reads + // as the immortal `None`, matching CPython's NULL-slot handling. + _ => crate::singletons::none_ptr(), + }; + } match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { let v = rc.borrow(); @@ -212,6 +384,15 @@ pub unsafe extern "C" fn PyList_Reverse(list: *mut PyObject) -> c_int { if list.is_null() { return -1; } + // RFC 0046 (wave 4): permute the inline `ob_item` pointers in place + // (a pure reordering — no refcount change) so the source of truth is + // reversed, not a throwaway reconstruction. + if unsafe { crate::mirror::is_faithful_list(list) } { + let mut ptrs = unsafe { crate::mirror::list_ptrs(list) }; + ptrs.reverse(); + unsafe { crate::mirror::list_permute(list, &ptrs) }; + return 0; + } match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { rc.borrow_mut().reverse(); @@ -226,6 +407,20 @@ pub unsafe extern "C" fn PyList_Sort(list: *mut PyObject) -> c_int { if list.is_null() { return -1; } + // RFC 0046 (wave 4): sort the inline `ob_item` pointers by their + // resolved values, then write the permutation back — keeping every + // element's identity (the same `PyObject*`) and the inline buffer + // authoritative. + if unsafe { crate::mirror::is_faithful_list(list) } { + let mut ptrs = unsafe { crate::mirror::list_ptrs(list) }; + ptrs.sort_by(|&a, &b| { + let oa = unsafe { crate::object::clone_object(a) }; + let ob = unsafe { crate::object::clone_object(b) }; + natural_cmp(&oa, &ob) + }); + unsafe { crate::mirror::list_permute(list, &ptrs) }; + return 0; + } match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { let mut items = rc.borrow_mut(); @@ -256,17 +451,17 @@ pub unsafe extern "C" fn PyList_Check(o: *mut PyObject) -> c_int { #[no_mangle] pub unsafe extern "C" fn PyTuple_New(n: PySsizeT) -> *mut PyObject { - // The new-then-fill pattern needs mutable storage, but tuples - // are immutable on the WeavePy side. Carry the staging area in a - // `List` payload but advertise `PyTuple_Type` so callers see it - // as a tuple; `PyObject_GetItem` / `PyTuple_GetItem` / - // `clone_object` all special-case the tuple-typed list and - // freeze it into an `Object::Tuple` on read. + // RFC 0046 (wave 4): mint a faithful tuple mirror whose inline + // `ob_item` array is `n` immortal-`None` placeholders. A stock + // extension fills it with the `PyTuple_SET_ITEM` macro — a direct + // write into that inline array — and reads it back with + // `PyTuple_GET_ITEM`; both touch the C body, so it must be a real + // `PyTupleObject` (not the old `List`-staged stand-in, whose + // out-of-line `ob_item` pointer sits exactly where the macro would + // scribble element 0). `clone_object` reconstructs the native tuple + // from this inline array on read. let len = n.max(0) as usize; - crate::object::into_owned_with_type( - Object::new_list(vec![Object::None; len]), - crate::types::PyTuple_Type.as_ptr(), - ) + crate::object::into_owned(Object::new_tuple(vec![Object::None; len])) } #[no_mangle] @@ -278,6 +473,20 @@ pub unsafe extern "C" fn PyTuple_SetItem( if tuple.is_null() { return -1; } + // RFC 0046 (wave 4): a faithful tuple stores its elements inline; steal + // `item` into the slot (CPython's `PyTuple_SetItem` takes ownership) + // and release the prior occupant. This is also what keeps the inline + // array — the source of truth for every read — in sync. + if unsafe { crate::mirror::is_faithful_tuple(tuple) } { + if unsafe { crate::mirror::tuple_store(tuple, pos, item) } { + return 0; + } + if !item.is_null() { + unsafe { crate::object::Py_DecRef(item) }; + } + crate::errors::set_value_error("tuple assignment index out of range"); + return -1; + } // Use the raw payload here (not `clone_object`) so the // staged-list-with-PyTuple_Type backing isn't frozen mid-fill. let raw = unsafe { crate::object::raw_payload(tuple) }; @@ -311,8 +520,7 @@ pub unsafe extern "C" fn PyTuple_SetItem( } v[pos as usize] = unsafe { crate::object::clone_object(item) }; unsafe { - let bx = &mut *(tuple as *mut crate::object::PyObjectBox); - bx.payload.obj = Object::Tuple(Rc::from(v.into_boxed_slice())); + crate::object::set_payload(tuple, Object::Tuple(Rc::from(v.into_boxed_slice()))); } 0 } @@ -335,6 +543,18 @@ pub unsafe extern "C" fn PyTuple_GetItem(tuple: *mut PyObject, pos: PySsizeT) -> if tuple.is_null() { return ptr::null_mut(); } + // RFC 0046 (wave 4): a faithful tuple's inline `ob_item` is the source + // of truth; return the borrowed slot directly (as CPython does) so the + // pointer numpy stored with `PyTuple_SET_ITEM` round-trips by identity. + if unsafe { crate::mirror::is_faithful_tuple(tuple) } { + return match unsafe { crate::mirror::tuple_slot(tuple, pos) } { + Some(p) => p, + None => { + crate::errors::set_value_error("tuple index out of range"); + ptr::null_mut() + } + }; + } // Use the raw payload so a staged-list-backed tuple still works // when read mid-fill. let raw = match unsafe { crate::object::raw_payload(tuple) } { @@ -375,6 +595,12 @@ pub unsafe extern "C" fn PyTuple_Size(tuple: *mut PyObject) -> PySsizeT { if tuple.is_null() { return -1; } + // RFC 0046 (wave 4): read `ob_size` straight off a faithful tuple so we + // don't materialise (and incref/decref) every element just to count. + if unsafe { crate::mirror::is_faithful_tuple(tuple) } { + let vo = tuple as *const crate::layout::PyVarObject; + return unsafe { (*vo).ob_size }; + } match unsafe { crate::object::clone_object(tuple) } { Object::Tuple(items) => items.len() as PySsizeT, Object::List(rc) => rc.borrow().len() as PySsizeT, @@ -431,7 +657,11 @@ pub unsafe extern "C" fn PyDict_SetItem( Object::Dict(rc) => { let key = unsafe { crate::object::clone_object(k) }; let val = unsafe { crate::object::clone_object(v) }; - rc.borrow_mut().insert(DictKey(key), val); + let key_id = dict_key_id(&key); + rc.borrow_mut().insert(DictKey(key), val.clone()); + dict_retain_value(d, key_id.clone(), v, val); + engine_dict_trace("SET", d, &key_id, true, v); + unsafe { crate::mirror::sync_dict_ma_used(d) }; 0 } _ => { @@ -454,16 +684,21 @@ pub unsafe extern "C" fn PyDict_SetItemString( match unsafe { crate::object::clone_object(d) } { Object::Dict(rc) => { let val = unsafe { crate::object::clone_object(v) }; - rc.borrow_mut().insert(DictKey(Object::from_str(key)), val); + let key_id = dict_key_id(&Object::from_str(key.clone())); + rc.borrow_mut().insert(DictKey(Object::from_str(key)), val.clone()); + dict_retain_value(d, key_id, v, val); + unsafe { crate::mirror::sync_dict_ma_used(d) }; 0 } Object::Module(m) => { // Convenience: PyDict_SetItemString on a module's dict // is a common idiom. let val = unsafe { crate::object::clone_object(v) }; + let key_id = dict_key_id(&Object::from_str(key.clone())); m.dict .borrow_mut() - .insert(DictKey(Object::from_str(key)), val); + .insert(DictKey(Object::from_str(key)), val.clone()); + dict_retain_value(d, key_id, v, val); 0 } _ => -1, @@ -478,20 +713,75 @@ pub unsafe extern "C" fn PyDict_GetItem(d: *mut PyObject, k: *mut PyObject) -> * match unsafe { crate::object::clone_object(d) } { Object::Dict(rc) => { let key = unsafe { crate::object::clone_object(k) }; + let key_id = dict_key_id(&key); let result = rc.borrow().get(&DictKey(key)).cloned(); match result { Some(v) => { - let p = crate::object::into_owned(v); - unsafe { crate::object::Py_DecRef(p) }; - p + let bx = dict_borrowed_box(d, key_id.clone(), v); + engine_dict_trace("GET", d, &key_id, true, bx); + bx + } + None => { + engine_dict_trace("GET", d, &key_id, false, ptr::null_mut()); + ptr::null_mut() } - None => ptr::null_mut(), } } _ => ptr::null_mut(), } } +/// Return a *borrowed* (non-incref'd) box for a dict value: the canonical +/// box a C setter stored if one exists, otherwise a freshly minted box +/// retained in the dict cache so it lives as long as the dict (the +/// borrowed-reference contract). RFC 0046, wave 4. +fn dict_borrowed_box(dict: *mut PyObject, key_id: String, value: Object) -> *mut PyObject { + if let Some((p, cached_obj)) = dict_cached_value(dict, &key_id) { + // Reuse the pinned box only while it still represents the value the + // slot *currently* holds. CPython's `PyDict_GetItem*` return a + // borrowed reference to the live value, so once a slot is rebound + // (e.g. a test monkeypatches a compiled module's global — the VM + // writes straight into `module.dict`, bypassing the C setter that + // seeded this cache) the old box is stale and must not be returned: + // Cython would otherwise keep reading the pre-patch value through + // `__Pyx_GetModuleGlobalName` → `_PyDict_GetItem_KnownHash`. + if cached_obj.is_same(&value) { + return p; + } + } + let value_obj = value.clone(); + let p = crate::object::into_owned(value); + // `dict_retain_value` increfs; balance the `into_owned` +1 so the + // cache holds exactly one reference (released when the dict is freed). + // Replacing a stale entry drops the previous box's cache reference. + dict_retain_value(dict, key_id, p, value_obj); + unsafe { crate::object::Py_DecRef(p) }; + p +} + +/// A *borrowed* box for a dict *key*, pinned for the dict's lifetime like +/// [`dict_borrowed_box`] but under a private namespace so a key box never +/// collides with the value box stored under the same `key_id`. Used by +/// [`PyDict_Next`], whose key reference is borrowed and which Cython's +/// vectorcall kwargs path immediately increfs. +fn dict_borrowed_key_box(dict: *mut PyObject, key_id: String, key: Object) -> *mut PyObject { + dict_borrowed_box(dict, format!("\u{0}__key__\u{0}{key_id}"), key) +} + +/// `_PyDict_GetItem_KnownHash(dict, key, hash)` — a private dict lookup +/// that accepts a precomputed `key` hash to skip rehashing. WeavePy's dict +/// hashes the key internally, so the supplied hash is advisory; we delegate +/// to [`PyDict_GetItem`] (same borrowed-reference, error-suppressing +/// contract). Cython's `__Pyx_PyDict_GetItem` fast path links this. +#[no_mangle] +pub unsafe extern "C" fn _PyDict_GetItem_KnownHash( + d: *mut PyObject, + k: *mut PyObject, + _hash: PyHashT, +) -> *mut PyObject { + unsafe { PyDict_GetItem(d, k) } +} + #[no_mangle] pub unsafe extern "C" fn PyDict_GetItemString(d: *mut PyObject, k: *const c_char) -> *mut PyObject { if d.is_null() || k.is_null() { @@ -503,13 +793,10 @@ pub unsafe extern "C" fn PyDict_GetItemString(d: *mut PyObject, k: *const c_char Object::Module(m) => m.dict.clone(), _ => return ptr::null_mut(), }; + let key_id = dict_key_id(&Object::from_str(key.clone())); let result = dict.borrow().get(&DictKey(Object::from_str(key))).cloned(); match result { - Some(v) => { - let p = crate::object::into_owned(v); - unsafe { crate::object::Py_DecRef(p) }; - p - } + Some(v) => dict_borrowed_box(d, key_id, v), None => ptr::null_mut(), } } @@ -523,6 +810,7 @@ pub unsafe extern "C" fn PyDict_DelItem(d: *mut PyObject, k: *mut PyObject) -> c Object::Dict(rc) => { let key = unsafe { crate::object::clone_object(k) }; if rc.borrow_mut().shift_remove(&DictKey(key)).is_some() { + unsafe { crate::mirror::sync_dict_ma_used(d) }; 0 } else { crate::errors::set_value_error("KeyError"); @@ -546,6 +834,7 @@ pub unsafe extern "C" fn PyDict_DelItemString(d: *mut PyObject, k: *const c_char .shift_remove(&DictKey(Object::from_str(key))) .is_some() { + unsafe { crate::mirror::sync_dict_ma_used(d) }; 0 } else { -1 @@ -563,9 +852,20 @@ pub unsafe extern "C" fn PyDict_Contains(d: *mut PyObject, k: *mut PyObject) -> match unsafe { crate::object::clone_object(d) } { Object::Dict(rc) => { let key = unsafe { crate::object::clone_object(k) }; - i32::from(rc.borrow().contains_key(&DictKey(key))) + let key_id = dict_key_id(&key); + let has = rc.borrow().contains_key(&DictKey(key)); + engine_dict_trace("CONTAINS", d, &key_id, has, ptr::null_mut()); + i32::from(has) + } + other => { + if std::env::var_os("WEAVEPY_TRACE_NULL").is_some() { + eprintln!( + "[WEAVEPY_TRACE_NULL] PyDict_Contains: d is not a dict, got {}", + other.type_name() + ); + } + -1 } - _ => -1, } } @@ -595,24 +895,35 @@ pub unsafe extern "C" fn PyDict_Next( _ => return 0, }; let pos = unsafe { *ppos }; - let dict_borrow = dict.borrow(); - if pos < 0 || pos >= dict_borrow.len() as PySsizeT { - return 0; - } - let entry = dict_borrow.get_index(pos as usize); + // Clone the entry out and *drop the dict borrow* before minting any + // boxes: `dict_borrowed_box` can re-enter (into_owned / cache ops), + // and we must not hold the RefCell borrow across that. + let entry = { + let dict_borrow = dict.borrow(); + if pos < 0 || pos >= dict_borrow.len() as PySsizeT { + return 0; + } + dict_borrow + .get_index(pos as usize) + .map(|(k, v)| (k.0.clone(), v.clone())) + }; match entry { - Some((k, v)) => { + Some((key_obj, val_obj)) => { + // CPython's `PyDict_Next` hands back *borrowed* references the + // dict keeps alive — the caller may then `Py_INCREF` them (as + // Cython's `__Pyx_PyVectorcall_FastCallDict_kw` does for the + // `size=` kwarg). Mint each box once and pin it in the dict's + // borrowed-box cache for the dict's lifetime; never hand back a + // box we immediately free (that was a use-after-free that + // crashed `Generator.integers`). + let key_id = dict_key_id(&key_obj); unsafe { *ppos = pos + 1; if !pkey.is_null() { - let p = crate::object::into_owned(k.0.clone()); - crate::object::Py_DecRef(p); - *pkey = p; + *pkey = dict_borrowed_key_box(d, key_id.clone(), key_obj); } if !pvalue.is_null() { - let p = crate::object::into_owned(v.clone()); - crate::object::Py_DecRef(p); - *pvalue = p; + *pvalue = dict_borrowed_box(d, key_id, val_obj); } } 1 @@ -642,13 +953,74 @@ pub unsafe extern "C" fn PyDict_Values(d: *mut PyObject) -> *mut PyObject { } match unsafe { crate::object::clone_object(d) } { Object::Dict(rc) => { - let values: Vec = rc.borrow().values().cloned().collect(); - crate::object::into_owned(Object::new_list(values)) + // RFC 0046 (wave 4): hand back *new references to the dict's + // canonical value boxes* (the very pointers `PyDict_GetItem` + // returns, pinned in `DICT_BOX_CACHE` for the dict's lifetime) — + // never freshly-minted throwaway boxes. CPython's `PyDict_Values` + // returns new refs to the same objects the dict still owns, and + // numpy's `resolve_implementation_info` leans on that: it borrows + // an element of `PyDict_Values(ufunc->_loops)` into `*out_info`, + // then `Py_DECREF`s the values list. A throwaway box owned solely + // by that list is freed by the decref and the borrowed pointer + // dangles — a use-after-free that surfaces as a NULL `ob_type` + // read deep in ufunc dispatch (`promote_and_get_info_and_ufuncimpl`). + // The cache keeps each box alive until the dict itself is freed. + let pairs: Vec<(String, Object)> = rc + .borrow() + .iter() + .map(|(k, v)| (dict_key_id(&k.0), v.clone())) + .collect(); + let boxes: Vec<*mut PyObject> = pairs + .into_iter() + .map(|(kid, v)| { + let b = dict_borrowed_box(d, kid, v); + unsafe { crate::object::Py_IncRef(b) }; + b + }) + .collect(); + unsafe { list_owning_boxes(boxes) } } _ => ptr::null_mut(), } } +/// Build a faithful list that *owns* the supplied boxes outright: each box +/// is written straight into the list's `ob_item` buffer — the buffer a +/// stock `PyList_GET_ITEM` / `PySequence_Fast_GET_ITEM` reads and that +/// WeavePy's `read_list` treats as authoritative — so the elements keep +/// their exact pointer identity (no per-crossing rebox). One reference per +/// box is consumed; `free_mirror` releases them when the list dies. +/// +/// # Safety +/// Each pointer in `boxes` must be a live owned reference the caller hands +/// over. +unsafe fn list_owning_boxes(boxes: Vec<*mut PyObject>) -> *mut PyObject { + let n = boxes.len(); + let list = unsafe { PyList_New(n as PySsizeT) }; + let ok = !list.is_null() && unsafe { crate::mirror::is_faithful_list(list) }; + let base = if ok { + let lo = list as *mut crate::layout::PyListObject; + unsafe { (*lo).ob_item } + } else { + ptr::null_mut() + }; + if base.is_null() { + for b in boxes { + if !b.is_null() { + unsafe { crate::object::Py_DecRef(b) }; + } + } + return list; + } + for (i, b) in boxes.into_iter().enumerate() { + // The slot currently holds an immortal `None` placeholder from + // `PyList_New` (no decref needed — `None` is immortal); overwrite it + // so the buffer owns exactly the boxes handed in. + unsafe { *base.add(i) = b }; + } + list +} + #[no_mangle] pub unsafe extern "C" fn PyDict_Items(d: *mut PyObject) -> *mut PyObject { if d.is_null() { @@ -704,12 +1076,15 @@ pub unsafe extern "C" fn PyDict_Merge( _ => return -1, }; let src_snapshot = src_dict.borrow().clone(); - let mut dst_borrow = dst.borrow_mut(); - for (k, v) in src_snapshot { - if override_ != 0 || !dst_borrow.contains_key(&k) { - dst_borrow.insert(k, v); + { + let mut dst_borrow = dst.borrow_mut(); + for (k, v) in src_snapshot { + if override_ != 0 || !dst_borrow.contains_key(&k) { + dst_borrow.insert(k, v); + } } } + unsafe { crate::mirror::sync_dict_ma_used(a) }; 0 } @@ -721,6 +1096,7 @@ pub unsafe extern "C" fn PyDict_Clear(d: *mut PyObject) -> c_int { match unsafe { crate::object::clone_object(d) } { Object::Dict(rc) => { rc.borrow_mut().clear(); + unsafe { crate::mirror::sync_dict_ma_used(d) }; 0 } _ => -1, @@ -771,7 +1147,35 @@ fn seed_set(data: &mut SetData, iterable: *mut PyObject) { data.insert(DictKey(item.clone())); } } - _ => {} + // Any other iterable — a `dict` (its *keys*), `set`/`frozenset`, `str`, + // `range`, a generator, or a foreign extension iterable — is drained + // through the iteration protocol, exactly as CPython's + // `set_update_internal` does for the non-list/tuple case. The prior + // `_ => {}` silently produced an *empty* set, so a Cython `set(x)` + // (which Cython compiles to `PySet_New(x)`) over anything but a + // list/tuple came back empty — e.g. pandas' `Timedelta(days=1)` kwarg + // validation does `set(kwargs)` over a dict. + other => { + if std::env::var_os("WEAVEPY_TRACE_SETSEED").is_some() { + eprintln!( + "[WEAVEPY_TRACE_SETSEED] seed_set general-iter over {}", + other.type_name() + ); + } + let it = unsafe { crate::abstract_::PyObject_GetIter(iterable) }; + if it.is_null() { + return; + } + loop { + let item = unsafe { crate::abstract_::PyIter_Next(it) }; + if item.is_null() { + break; + } + data.insert(DictKey(unsafe { crate::object::clone_object(item) })); + unsafe { crate::object::Py_DecRef(item) }; + } + unsafe { crate::object::Py_DecRef(it) }; + } } } @@ -784,6 +1188,7 @@ pub unsafe extern "C" fn PySet_Add(s: *mut PyObject, item: *mut PyObject) -> c_i Object::Set(rc) => { rc.borrow_mut() .insert(DictKey(unsafe { crate::object::clone_object(item) })); + unsafe { crate::mirror::sync_set_used(s) }; 0 } _ => -1, @@ -816,6 +1221,7 @@ pub unsafe extern "C" fn PySet_Discard(s: *mut PyObject, item: *mut PyObject) -> Object::Set(rc) => { rc.borrow_mut() .shift_remove(&DictKey(unsafe { crate::object::clone_object(item) })); + unsafe { crate::mirror::sync_set_used(s) }; 0 } _ => -1, @@ -936,6 +1342,7 @@ pub unsafe extern "C" fn PyDict_SetDefault( }; map.insert(key, default_o.clone()); drop(map); + unsafe { crate::mirror::sync_dict_ma_used(d) }; crate::object::into_owned(default_o) } } @@ -943,11 +1350,115 @@ pub unsafe extern "C" fn PyDict_SetDefault( } } +/// Public CPython 3.13 API — `int PyDict_Pop(dict, key, PyObject **result)`. +/// +/// Removes `key` from `dict`. On success writes the removed value to +/// `*result` (ownership transferred to the caller) and returns `1`; if the +/// key is absent writes `NULL` to `*result` and returns `0`; on error writes +/// `NULL` and returns `-1`. `result` may be `NULL`, in which case the popped +/// value is simply released. +/// +/// The 3rd parameter is an **out-pointer**, *not* a default value — that is +/// the (older, private) `_PyDict_Pop` contract, exposed separately below. +/// Cython's keyword parser (`__Pyx_ParseKeywordDictToDict`) calls this +/// function with `&value` and reads back `*result`; getting the signature +/// wrong left every `**kwds` argument uninitialised (garbage), which crashed +/// e.g. `pandas.offsets.DateOffset(n=…)` on the following `self.__init__`. +/// +/// # Safety +/// `result`, if non-null, must be a writable `*mut PyObject` slot. #[no_mangle] pub unsafe extern "C" fn PyDict_Pop( d: *mut PyObject, k: *mut PyObject, - default: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + unsafe { + if !result.is_null() { + *result = ptr::null_mut(); + } + } + if d.is_null() || k.is_null() { + crate::errors::set_type_error("PyDict_Pop: NULL argument"); + return -1; + } + match unsafe { crate::object::clone_object(d) } { + Object::Dict(rc) => { + let key = DictKey(unsafe { crate::object::clone_object(k) }); + let popped = rc.borrow_mut().shift_remove(&key); + match popped { + Some(v) => { + unsafe { crate::mirror::sync_dict_ma_used(d) }; + let p = crate::object::into_owned(v); + unsafe { + if result.is_null() { + crate::object::Py_DecRef(p); + } else { + *result = p; + } + } + 1 + } + None => 0, + } + } + _ => { + crate::errors::set_type_error("PyDict_Pop: not a dict"); + -1 + } + } +} + +/// Public CPython 3.13 API — `int PyDict_PopString(dict, const char *key, +/// PyObject **result)`. Identical to [`PyDict_Pop`] but takes the key as a +/// UTF-8 C string. +/// +/// # Safety +/// `key` must be a valid NUL-terminated C string; `result`, if non-null, +/// must be a writable `*mut PyObject` slot. +#[no_mangle] +pub unsafe extern "C" fn PyDict_PopString( + d: *mut PyObject, + key: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + unsafe { + if !result.is_null() { + *result = ptr::null_mut(); + } + } + if key.is_null() { + crate::errors::set_type_error("PyDict_PopString: NULL key"); + return -1; + } + let s = match unsafe { CStr::from_ptr(key) }.to_str() { + Ok(s) => s.to_owned(), + Err(_) => { + crate::errors::set_type_error("PyDict_PopString: key is not valid UTF-8"); + return -1; + } + }; + let key_obj = crate::object::into_owned(Object::from_str(s)); + let r = unsafe { PyDict_Pop(d, key_obj, result) }; + unsafe { crate::object::Py_DecRef(key_obj) }; + r +} + +/// Private CPython API — `PyObject *_PyDict_Pop(dict, key, default_value)`. +/// +/// Removes `key` and returns its value (ownership transferred). If the key is +/// absent, returns a new reference to `default_value` when it is non-null, or +/// sets `KeyError` and returns `NULL` when it is null. This is the historical +/// signature WeavePy exposed under the `PyDict_Pop` name before 3.13 promoted +/// the out-pointer form to public API. +/// +/// # Safety +/// `d`/`k` must be valid `*mut PyObject`; `default_value` may be null. +#[no_mangle] +pub unsafe extern "C" fn _PyDict_Pop( + d: *mut PyObject, + k: *mut PyObject, + default_value: *mut PyObject, ) -> *mut PyObject { if d.is_null() || k.is_null() { return ptr::null_mut(); @@ -957,17 +1468,20 @@ pub unsafe extern "C" fn PyDict_Pop( let key = DictKey(unsafe { crate::object::clone_object(k) }); let popped = rc.borrow_mut().shift_remove(&key); match popped { - Some(v) => crate::object::into_owned(v), + Some(v) => { + unsafe { crate::mirror::sync_dict_ma_used(d) }; + crate::object::into_owned(v) + } None => { - if default.is_null() { + if default_value.is_null() { crate::errors::set_pending( Some(weavepy_vm::builtin_types::builtin_types().key_error.clone()), key.0, ); ptr::null_mut() } else { - unsafe { crate::object::Py_IncRef(default) }; - default + unsafe { crate::object::Py_IncRef(default_value) }; + default_value } } } @@ -991,6 +1505,17 @@ pub unsafe extern "C" fn PyList_Extend(list: *mut PyObject, iterable: *mut PyObj return -1; } }; + // RFC 0046 (wave 4): append each element to the inline `ob_item` + // buffer (the source of truth), materialising it as an owned C + // reference and handing the list its own reference. + if unsafe { crate::mirror::is_faithful_list(list) } { + for item in new_items { + let p = crate::object::into_owned(item); + unsafe { crate::mirror::list_append(list, p) }; + unsafe { crate::object::Py_DecRef(p) }; + } + return 0; + } match unsafe { crate::object::clone_object(list) } { Object::List(rc) => { rc.borrow_mut().append(&mut new_items); @@ -1031,6 +1556,7 @@ pub unsafe extern "C" fn PySet_Pop(s: *mut PyObject) -> *mut PyObject { Some(k) => { set.shift_remove(&k); drop(set); + unsafe { crate::mirror::sync_set_used(s) }; crate::object::into_owned(k.0) } None => { @@ -1054,6 +1580,7 @@ pub unsafe extern "C" fn PySet_Clear(s: *mut PyObject) -> c_int { match unsafe { crate::object::clone_object(s) } { Object::Set(rc) => { rc.borrow_mut().clear(); + unsafe { crate::mirror::sync_set_used(s) }; 0 } _ => -1, @@ -1104,16 +1631,28 @@ pub unsafe extern "C" fn PySequence_Fast(o: *mut PyObject, msg: *const c_char) - let items: Vec = rc.borrow().keys().map(|k| k.0.clone()).collect(); crate::object::into_owned(Object::new_list(items)) } - _ => { - crate::errors::set_type_error(if msg.is_null() { - "expected list, tuple, or iterable".to_owned() - } else { - unsafe { CStr::from_ptr(msg) } - .to_string_lossy() - .into_owned() - }); - ptr::null_mut() - } + // Any other iterable (a `cdef class` instance, a foreign + // extension object with `__iter__`, a generator, …) is + // coerced through its iterator protocol, matching CPython's + // `PySequence_Fast` (which calls `PySequence_List` for the + // non-fast path). The previous hard error broke + // `PySequence_Fast(cdef_instance)`. + _ => match unsafe { crate::abstract_::collect_iterable(o) } { + Some(items) => crate::object::into_owned(Object::new_list(items)), + None => { + if !msg.is_null() && crate::errors::pending().is_some() { + // Replace the generic iterator TypeError with the + // caller-supplied context message, as CPython does. + crate::errors::clear_thread_local(); + crate::errors::set_type_error( + unsafe { CStr::from_ptr(msg) } + .to_string_lossy() + .into_owned(), + ); + } + ptr::null_mut() + } + }, } } } diff --git a/crates/weavepy-capi/src/datetime_api.rs b/crates/weavepy-capi/src/datetime_api.rs index 8fe832e..25061c1 100644 --- a/crates/weavepy-capi/src/datetime_api.rs +++ b/crates/weavepy-capi/src/datetime_api.rs @@ -24,12 +24,17 @@ //! which is fine because the struct is immutable. use std::ffi::CString; -use std::os::raw::c_int; +use std::os::raw::{c_char, c_int, c_void}; use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Mutex; -use weavepy_vm::object::Object; +use weavepy_vm::object::{DictData, DictKey, Object}; +use weavepy_vm::sync::{Rc, RefCell}; +use weavepy_vm::types::{PyInstance, TypeObject}; -use crate::object::PyObject; +use crate::layout::tpflags; +use crate::object::{PyObject, PySsizeT}; use crate::types::PyTypeObject; /// Layout of `PyDateTime_CAPI` (from `Include/datetime.h`). @@ -317,6 +322,12 @@ fn construct_timezone(offset: *mut PyObject, name: *mut PyObject) -> *mut PyObje /// on lookup failure returns NULL and sets an `ImportError` so /// the C-side can propagate. fn invoke_class(class_name: &str, args: Vec) -> *mut PyObject { + invoke_class_kw(class_name, &args, &[]) +} + +/// As [`invoke_class`], but forwarding keyword arguments — needed for +/// `timedelta(weeks=…, hours=…)` normalisation in [`delta_tp_new`]. +fn invoke_class_kw(class_name: &str, args: &[Object], kwargs: &[(String, Object)]) -> *mut PyObject { let class_obj = match lookup_datetime_class(class_name) { Some(c) => c, None => { @@ -332,7 +343,7 @@ fn invoke_class(class_name: &str, args: Vec) -> *mut PyObject { } }; let res = - crate::interp::with_interp_mut(|interp| interp.call_object(class_obj.clone(), &args, &[])); + crate::interp::with_interp_mut(|interp| interp.call_object(class_obj.clone(), args, kwargs)); match res { Some(Ok(v)) => crate::object::into_owned(v), Some(Err(e)) => { @@ -422,18 +433,23 @@ pub static PyDateTimeAPI_Instance: PyDateTimeCAPI = PyDateTimeCAPI { Time_FromTimeAndFold: time_from_time_and_fold, }; -/// Address-of-table — what the capsule wraps. Stored in a -/// `static` so the pointer is stable across the program. -fn capi_table_ptr() -> *mut std::ffi::c_void { - &PyDateTimeAPI_Instance as *const _ as *mut std::ffi::c_void -} - -/// Address-of-table cleanup — kept private; the capsule -/// machinery publishes the table via -/// [`crate::capsule::try_install_well_known_capsule`]. +/// Address-of-table — what the capsule wraps. +/// +/// Once [`ensure_datetime_bridge`] has run we publish the **dynamic** +/// table (its `DateType`/`DateTimeType`/… slots point at the faithful +/// heap types and `TimeZone_UTC` is filled), so a Cython `cimport +/// datetime` sees real, size-correct, type-checkable type objects. +/// Before that (or if the `datetime` module can't be located) we fall +/// back to the static table, whose type slots are NULL — enough for the +/// function-pointer constructors but not the `PyDateTimeAPI->DateType` +/// macros. #[doc(hidden)] pub fn capi_table_void_ptr() -> *mut std::ffi::c_void { - capi_table_ptr() + let dynamic = CAPI_TABLE.load(Ordering::Acquire); + if dynamic != 0 { + return dynamic as *mut std::ffi::c_void; + } + &PyDateTimeAPI_Instance as *const _ as *mut std::ffi::c_void } // --------------------------------------------------------------------- @@ -672,6 +688,938 @@ fn is_class_named_exact(o: *mut PyObject, name: &str) -> c_int { } } +// --------------------------------------------------------------------- +// Faithful datetime C-ABI types (RFC 0029, wave 5). +// +// CPython's `_datetime` C module defines genuine `PyTypeObject`s +// (`PyDateTime_DateType`, …) whose instances carry the field bytes +// **inline** at fixed offsets, and `Lib/datetime.py` re-exports them. +// Macro-heavy Cython (`cimport datetime`) reads those bytes directly — +// `((PyDateTime_Date*)o)->data[0]` — and validates the type with a +// `tp_basicsize` size-check (`Expected 48 from C header`). +// +// WeavePy's `datetime` module is the pure-Python `_pydatetime` +// fallback, so its classes are `Object::Type` with no C layout. We +// close the gap by minting faithful C `PyTypeObject`s **bridged** to +// those live classes: the bridge gives `PyDate_Check` / subclassing +// (`Timestamp ← datetime`) the correct MRO for free (WeavePy's +// `PyType_IsSubtype` resolves through the bridge), the faithful +// `tp_basicsize` satisfies the size-check, and registering them as +// inline-instance types means a datetime instance crossing into C is +// materialised into a byte-faithful body that the inlined accessor +// macros read correctly. +// --------------------------------------------------------------------- + +// CPython 3.13 `tp_basicsize` for each datetime type on LP64 +// (`PyObject_HEAD` = 16, `Py_hash_t` = 8). Derived from `datetime.h`: +// date = HEAD(16) + hashcode(8) + hastzinfo(1) + data[4] -> 29 -> 32 +// datetime = …(25) + data[10] + fold(1) + pad + tzinfo(8) -> 48 +// time = …(25) + data[6] + fold(1) + tzinfo(8) -> 40 +// delta = HEAD(16) + hashcode(8) + days/seconds/us (3 * i32) -> 36 -> 40 +// tzinfo = HEAD(16) -> 16 +// timezone = HEAD(16) + offset(8) + name(8) -> 32 +const SIZE_DATE: PySsizeT = 32; +const SIZE_DATETIME: PySsizeT = 48; +const SIZE_TIME: PySsizeT = 40; +const SIZE_DELTA: PySsizeT = 40; +const SIZE_TZINFO: PySsizeT = 16; +const SIZE_TIMEZONE: PySsizeT = 32; + +// Instance-body field offsets (relative to the `PyObject` head), shared +// by date/datetime/time per the `_PyTZINFO_HEAD` macro. +const OFF_HASHCODE: usize = 16; // Py_hash_t +const OFF_HASTZINFO: usize = 24; // char +const OFF_DATA: usize = 25; // unsigned char data[] +const OFF_DT_FOLD: usize = 35; // datetime: after data[10] +const OFF_DT_TZINFO: usize = 40; // datetime: 8-aligned after fold +const OFF_TIME_FOLD: usize = 31; // time: after data[6] +const OFF_TIME_TZINFO: usize = 32; // time: 8-aligned after fold +const OFF_DELTA_DAYS: usize = 24; // int +const OFF_DELTA_SECONDS: usize = 28; // int +const OFF_DELTA_US: usize = 32; // int + +/// `true` once the six faithful type shells + the dynamic capsule table +/// are minted. They are **interpreter-independent** (layout, flags, and +/// the `tp_base` chain only — none of which depend on a VM class), so +/// this is a genuine process-global one-shot, mirroring CPython, whose +/// `_datetime` C types are themselves process-global statics. +static DT_READY: AtomicBool = AtomicBool::new(false); +/// Init lock: at most one thread mints the faithful type shells. +static DT_INIT_LOCK: Mutex = Mutex::new(false); +/// The leaked dynamic [`PyDateTimeCAPI`] (type slots filled), as `usize`. +static CAPI_TABLE: AtomicUsize = AtomicUsize::new(0); + +// The minted faithful `PyTypeObject *` shells (as `usize`, lock-free for +// the hot instance-packing path). One per `datetime` class name; their +// layout is fixed, so a single global shell serves every interpreter. +macro_rules! dt_slot { + ($ptr:ident) => { + static $ptr: AtomicUsize = AtomicUsize::new(0); + }; +} +dt_slot!(PTR_DATE); +dt_slot!(PTR_DATETIME); +dt_slot!(PTR_TIME); +dt_slot!(PTR_DELTA); +dt_slot!(PTR_TZINFO); +dt_slot!(PTR_TIMEZONE); + +/// Identity map: a *live VM datetime class* (`Rc::as_ptr`, in any +/// interpreter) → the global faithful type shell it resolves to. +/// +/// Populated lazily as each class is first validated, so resolution is +/// correct across **multiple interpreters in one process** (the test +/// harness creates a fresh `Interpreter` per case): every interpreter's +/// `datetime.date` is recorded against the same global shell, rather +/// than the registry being frozen to whichever interpreter happened to +/// import `datetime` first. A user class that merely *shares* the name +/// is rejected up front by [`class_is_datetime`], so it never lands +/// here. Keyed/valued by `usize` (a raw `Rc`/type pointer) to stay +/// `Send`; the `Rc` whose address is the key keeps the class alive for +/// as long as any datetime instance of it can reach C. +fn dt_identity() -> &'static Mutex> { + static MAP: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + MAP.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +thread_local! { + /// Per-thread guard: the six shells have been registered as + /// inline-instance types *on this thread* (the + /// [`crate::types::INLINE_TYPES`] set is thread-local, so each thread + /// that crosses datetime instances must opt them in). Set once, the + /// first time a datetime class resolves on the thread. + static INLINE_DONE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Cheap gate: is `name` one of the six `datetime` module class names? +fn is_datetime_class_name(name: &str) -> bool { + matches!( + name, + "date" | "datetime" | "time" | "timedelta" | "tzinfo" | "timezone" + ) +} + +/// Is `t` genuinely a `datetime`-module class (not a user class that +/// merely shares one of the six names)? Decided from the class's own +/// `__module__` — `_pydatetime` (where WeavePy's pure-Python `datetime` +/// classes are defined and re-exported from) — read straight off the +/// `TypeObject`'s dict. Deliberately **interpreter-free**: this runs on +/// the argument-marshalling hot path (inside [`crate::object::into_owned`]), +/// where re-entering the VM (`with_interp_mut`) to consult the live +/// module would alias the `&mut Interpreter` the caller already holds. +fn class_is_datetime(t: &Rc) -> bool { + let key = DictKey(Object::from_static("__module__")); + matches!( + t.dict.borrow().get(&key), + Some(Object::Str(s)) if matches!(&**s, "_pydatetime" | "datetime") + ) +} + +/// The global faithful shell for a `datetime` class name (post-mint). +fn ptr_for_name(name: &str) -> Option<*mut PyTypeObject> { + let p = match name { + "date" => PTR_DATE.load(Ordering::Relaxed), + "datetime" => PTR_DATETIME.load(Ordering::Relaxed), + "time" => PTR_TIME.load(Ordering::Relaxed), + "timedelta" => PTR_DELTA.load(Ordering::Relaxed), + "tzinfo" => PTR_TZINFO.load(Ordering::Relaxed), + "timezone" => PTR_TIMEZONE.load(Ordering::Relaxed), + _ => 0, + }; + (p != 0).then_some(p as *mut PyTypeObject) +} + +/// Register the six shells as inline-instance types on the current +/// thread (idempotent; [`crate::types::maybe_register_inline_type`] +/// skips `tzinfo`, whose `tp_basicsize` is just the object head). +fn register_inline_on_thread() { + INLINE_DONE.with(|c| { + if c.get() { + return; + } + for slot in [ + &PTR_DATE, + &PTR_DATETIME, + &PTR_TIME, + &PTR_DELTA, + &PTR_TZINFO, + &PTR_TIMEZONE, + ] { + let p = slot.load(Ordering::Relaxed); + if p != 0 { + crate::types::maybe_register_inline_type(p as *mut PyTypeObject); + } + } + c.set(true); + }); +} + +/// The faithful C type for a `datetime`-module class, minting the global +/// shells on first use. Returns `None` for every other class, *including* +/// a user class that merely shares one of the six names ([`class_is_datetime`] +/// decides by `__module__`, not by name). Called by +/// [`crate::types::find_type_ptr`] so both the class-object crossing (the +/// `__Pyx_ImportType` size-check) and the instance crossing (`ob_type`) +/// resolve to the size-correct type. +/// +/// Robust across interpreters: the resolved shell is recorded against +/// *this* class's identity ([`dt_identity`]), so a second interpreter's +/// `datetime.date` resolves correctly too — the registry is never frozen +/// to the first interpreter that imported `datetime`. +pub fn faithful_type_for_class(t: &Rc) -> Option<*mut PyTypeObject> { + if !is_datetime_class_name(&t.name) { + return None; + } + let key = Rc::as_ptr(t) as usize; + // Fast path: this exact class has been validated before. + let cached = dt_identity().lock().ok().and_then(|m| m.get(&key).copied()); + if let Some(p) = cached { + register_inline_on_thread(); + return Some(p as *mut PyTypeObject); + } + // Validate genuinely-datetime (interpreter-free) before minting. + if !class_is_datetime(t) { + return None; + } + ensure_dt_types(); + let p = ptr_for_name(&t.name)?; + // Record the identity and, once, point the shell's bridge at a live + // class (best-effort: it backs the rarely-taken C-side `tp_alloc` + // path; the instance- and class-crossing paths never need it). Both + // under the one lock so concurrent first-crossers don't race the + // `bridge` write. + if let Ok(mut map) = dt_identity().lock() { + map.insert(key, p as usize); + unsafe { + if (*p).bridge.is_null() { + (*p).bridge = Box::into_raw(Box::new(t.clone())); + } + } + } + register_inline_on_thread(); + Some(p) +} + +/// Mint a faithful heap `PyTypeObject` *shell* for a `datetime` class. +/// +/// Modelled on [`crate::types::install_user_type`]: immortal refcount, +/// `ob_type = type`, the CPython `tp_name`, the **faithful +/// `tp_basicsize`**, `DEFAULT | BASETYPE | READY` flags, and a `tp_base` +/// chain. The `bridge` is left null — it is filled lazily, per +/// interpreter, by [`faithful_type_for_class`], because the shell's +/// layout (all this function sets) is interpreter-independent. Registered +/// in the heap-type registry (so `bridge_type` / `find_type_ptr` see it) +/// and — when it declares storage past the object head — as an +/// inline-instance type on the minting thread. +fn make_dt_type(name: &str, basicsize: PySsizeT, base: *mut PyTypeObject) -> *mut PyTypeObject { + let cname = CString::new(name).unwrap_or_else(|_| CString::new("datetime.object").unwrap()); + let mut ty = PyTypeObject::new_zeroed(); + ty.head.ob_type = crate::types::PyType_Type.as_ptr(); + ty.tp_name = cname.into_raw() as *const c_char; + ty.tp_basicsize = basicsize; + ty.tp_itemsize = 0; + ty.tp_dealloc = Some(crate::object::_PyWeavePy_Dealloc); + ty.tp_flags = tpflags::DEFAULT | tpflags::BASETYPE | tpflags::READY; + ty.tp_base = base; + ty.bridge = ptr::null_mut(); + let p = Box::into_raw(Box::new(ty)); + crate::types::register_heap_type(p); + // Inline-instance registration is gated on `tp_basicsize > + // sizeof(PyObject)` inside `maybe_register_inline_type`; tzinfo (16) + // is therefore skipped (it has no inline data and pandas never reads + // its bytes via a macro), which is exactly what we want. + crate::types::maybe_register_inline_type(p); + p +} + +/// Public alias kept for the capsule-import call site +/// ([`crate::capsule`]): mint the faithful datetime types + dynamic +/// capsule table. See [`ensure_dt_types`]. +pub fn ensure_datetime_bridge() { + ensure_dt_types(); +} + +/// Idempotently mint the six faithful datetime type shells and publish +/// the dynamic capsule table. **Interpreter-independent**: it sets only +/// layout (`tp_basicsize`), flags, the `tp_base` chain, and the +/// constructor function pointers — none of which depend on a VM class — +/// so a single global set of types serves every interpreter (matching +/// CPython, whose `_datetime` C types are process-global statics). The +/// per-interpreter `bridge` is wired lazily by [`faithful_type_for_class`]. +/// +/// Safe to call from any C-triggered path (capsule import, +/// `PyObject_GetAttrString(datetime, "datetime")`, an instance +/// crossing): it never re-enters the bytecode loop. +fn ensure_dt_types() { + if DT_READY.load(Ordering::Acquire) { + return; + } + let mut done = match DT_INIT_LOCK.lock() { + Ok(g) => g, + Err(_) => return, + }; + if *done { + return; + } + + let obj = crate::types::PyBaseObject_Type.as_ptr(); + // `tp_base` chain mirrors CPython: object → date → datetime, + // object → time, object → timedelta, object → tzinfo → timezone. + let date = make_dt_type("datetime.date", SIZE_DATE, obj); + let datetime = make_dt_type("datetime.datetime", SIZE_DATETIME, date); + let time = make_dt_type("datetime.time", SIZE_TIME, obj); + let delta = make_dt_type("datetime.timedelta", SIZE_DELTA, obj); + let tzinfo = make_dt_type("datetime.tzinfo", SIZE_TZINFO, obj); + let timezone = make_dt_type("datetime.timezone", SIZE_TIMEZONE, tzinfo); + + // RFC 0029 (wave 5): give each shell a faithful `tp_new`. A Cython + // cdef class subclassing one of them (`_NaT ← datetime`, pandas' + // `_Timedelta ← timedelta`, `Timestamp ← datetime`) inherits the + // base's `tp_new` and *calls it directly* (`(*t->tp_base->tp_new)(t, + // a, k)`); a NULL slot is a jump through address 0. The shell builds + // the subclass instance, packs the byte-faithful body the inlined + // `PyDateTime_GET_*` macros read, and seeds the VM slots so the + // Python view agrees. + unsafe { + (*date).tp_new = date_tp_new as *mut c_void; + (*datetime).tp_new = datetime_tp_new as *mut c_void; + (*time).tp_new = time_tp_new as *mut c_void; + (*delta).tp_new = delta_tp_new as *mut c_void; + (*tzinfo).tp_new = tzinfo_tp_new as *mut c_void; + (*timezone).tp_new = timezone_tp_new as *mut c_void; + } + if std::env::var_os("WEAVEPY_TRACE_NEW").is_some() { + eprintln!( + "[DT] shells minted date={date:p}(tp_new={:p}) datetime={datetime:p}(tp_new={:p})", + unsafe { (*date).tp_new }, + unsafe { (*datetime).tp_new }, + ); + } + + for (slot, ptr) in [ + (&PTR_DATE, date), + (&PTR_DATETIME, datetime), + (&PTR_TIME, time), + (&PTR_DELTA, delta), + (&PTR_TZINFO, tzinfo), + (&PTR_TIMEZONE, timezone), + ] { + slot.store(ptr as usize, Ordering::Relaxed); + } + + // Publish the dynamic capsule table with the faithful type slots. + let table = Box::new(PyDateTimeCAPI { + DateType: date, + DateTimeType: datetime, + TimeType: time, + DeltaType: delta, + TZInfoType: tzinfo, + TimeZone_UTC: ptr::null_mut(), // filled best-effort by `fill_utc_singleton` + Date_FromDate: date_from_date, + DateTime_FromDateAndTime: datetime_from_date_and_time, + Time_FromTime: time_from_time, + Delta_FromDelta: delta_from_delta, + TimeZone_FromTimeZone: timezone_from_timezone, + DateTime_FromTimestamp: datetime_from_timestamp, + Date_FromTimestamp: date_from_timestamp, + DateTime_FromDateAndTimeAndFold: datetime_from_date_and_time_and_fold, + Time_FromTimeAndFold: time_from_time_and_fold, + }); + let table_ptr = Box::into_raw(table); + CAPI_TABLE.store(table_ptr as usize, Ordering::Release); + unsafe { PyDateTimeAPI = table_ptr }; + + DT_READY.store(true, Ordering::Release); + *done = true; +} + +/// Best-effort: fill the capsule's `TimeZone_UTC` singleton from the +/// `datetime` module's exported `UTC`. Called after +/// [`ensure_datetime_bridge`] from the capsule-import path (a safe, +/// C-triggered context for crossing the UTC instance into C). A failure +/// leaves the slot NULL — only tz-aware extension paths need it, and a +/// basic DataFrame never touches it. +pub fn fill_utc_singleton() { + let table = CAPI_TABLE.load(Ordering::Acquire); + if table == 0 { + return; + } + let table = table as *mut PyDateTimeCAPI; + if !unsafe { (*table).TimeZone_UTC }.is_null() { + return; + } + let utc = crate::interp::with_interp_mut(|interp| { + match interp.module_cache().get("datetime")? { + Object::Module(m) => m.dict.borrow().get(&DictKey(Object::from_static("UTC"))).cloned(), + _ => None, + } + }) + .flatten(); + if let Some(utc) = utc { + let p = crate::object::into_owned(utc); + if !p.is_null() { + unsafe { (*table).TimeZone_UTC = p }; + } + } +} + +/// Pack a VM datetime instance into its faithful inline C body (RFC +/// 0029). Called once, on an instance's first crossing into C +/// ([`crate::instance::instance_body_out`]); datetime objects are +/// immutable, so the byte image never goes stale. A no-op for every +/// non-datetime inline type (numpy arrays, extension instances). +/// +/// Reads field values straight from the instance's `__slots__` side +/// table ([`PyInstance::slot_get`]) — **not** the bytecode loop — so it +/// is safe to invoke while the VM is mid-marshal of a call's arguments. +pub fn maybe_pack_datetime_body(body: *mut PyObject, ty: *mut PyTypeObject, inst: &Rc) { + if body.is_null() || !DT_READY.load(Ordering::Acquire) { + return; + } + // Walk `ty` and its `tp_base` chain to find the datetime family it + // belongs to. A base shell matches on the first step; a *per-subclass* + // shell (minted for a pure-Python `class Sub(datetime)`) matches at its + // `tp_base`. `datetime` is checked before `date` at every level because + // it is the more-derived layout (its own `data[10]` superset). A short + // guard bounds the walk against a malformed cycle. + let (date, dt, time, delta) = ( + PTR_DATE.load(Ordering::Relaxed), + PTR_DATETIME.load(Ordering::Relaxed), + PTR_TIME.load(Ordering::Relaxed), + PTR_DELTA.load(Ordering::Relaxed), + ); + let mut cur = ty; + let mut guard = 0u32; + while !cur.is_null() && guard < 16 { + let c = cur as usize; + if c == dt { + unsafe { pack_datetime(body, inst) }; + return; + } else if c == date { + unsafe { pack_date(body, inst) }; + return; + } else if c == time { + unsafe { pack_time(body, inst) }; + return; + } else if c == delta { + unsafe { pack_delta(body, inst) }; + return; + } + cur = unsafe { (*cur).tp_base }; + guard += 1; + } +} + +// --------------------------------------------------------------------- +// Faithful `tp_new` for the datetime shells (RFC 0029, wave 5). +// +// CPython's `_datetime` defines a `datetime_new` / `date_new` / … as the +// `tp_new` of each type. A Cython cdef class subclassing one of them +// inherits that slot and *invokes the base's `tp_new` pointer directly* +// from its own generated `__pyx_tp_new` — so the slot must be non-NULL +// and must return a fully-formed instance of the *subclass* `type_`. +// +// Two shapes are served: +// +// * Constructing the **base** type itself (`datetime(2020, 1, 1)` from +// C) routes through the pure-Python VM constructor ([`invoke_class`]), +// which validates ranges and applies the real datetime semantics; the +// instance packs its faithful body lazily on its first crossing. +// * Constructing a **subclass** (`type_ != shell`, e.g. pandas' `_NaT`, +// `Timestamp`, `_Timedelta`) allocates `type_`'s faithful inline body +// via `tp_alloc`, packs the byte image at the declared offsets, and +// seeds the VM `__slots__` so a later Python-level `.year` agrees. +// The subclass's own cdef fields live *past* the base `tp_basicsize`, +// so packing the base bytes never disturbs them. +// --------------------------------------------------------------------- + +/// Decode the `(args, kwds)` a `tp_new` receives into positional +/// [`Object`]s plus the keyword dict (if any). +fn new_args( + args: *mut PyObject, + kwds: *mut PyObject, +) -> (Vec, Option>>) { + let pos = if args.is_null() { + Vec::new() + } else { + match unsafe { crate::object::clone_object(args) } { + Object::Tuple(items) => items.iter().cloned().collect(), + Object::None => Vec::new(), + other => vec![other], + } + }; + let kw = if kwds.is_null() { + None + } else if let Object::Dict(d) = unsafe { crate::object::clone_object(kwds) } { + Some(d) + } else { + None + }; + (pos, kw) +} + +fn obj_to_i64(o: &Object) -> Option { + match o { + Object::Int(i) => Some(*i), + Object::Bool(b) => Some(i64::from(*b)), + _ => None, + } +} + +/// Resolve a datetime constructor argument from positional slot `idx` or +/// keyword `name`, falling back to `dflt`. +fn arg_i64( + pos: &[Object], + kw: &Option>>, + idx: usize, + name: &str, + dflt: i64, +) -> i64 { + if let Some(v) = pos.get(idx).and_then(obj_to_i64) { + return v; + } + if let Some(d) = kw { + if let Some(o) = d.borrow().get(&DictKey(Object::from_str(name))).cloned() { + if let Some(v) = obj_to_i64(&o) { + return v; + } + } + } + dflt +} + +/// Resolve a `tzinfo` argument (`None` for naive — slot absent or +/// `Py_None`). +fn arg_tzinfo(pos: &[Object], kw: &Option>>, idx: usize) -> Option { + let raw = pos.get(idx).cloned().or_else(|| { + kw.as_ref().and_then(|d| { + d.borrow() + .get(&DictKey(Object::from_static("tzinfo"))) + .cloned() + }) + }); + match raw { + Some(Object::None) | None => None, + other => other, + } +} + +/// The native [`PyInstance`] backing a freshly-allocated inline body, so +/// the constructor can seed its VM `__slots__`. `None` for the (rare) +/// non-inline subclass whose body is a plain identity box. +fn body_instance(body: *mut PyObject) -> Option> { + match unsafe { crate::object::clone_object(body) } { + Object::Instance(inst) => Some(inst), + _ => None, + } +} + +unsafe extern "C" fn date_tp_new( + type_: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let (pos, kw) = new_args(args, kwds); + let y = arg_i64(&pos, &kw, 0, "year", 1); + let mo = arg_i64(&pos, &kw, 1, "month", 1); + let d = arg_i64(&pos, &kw, 2, "day", 1); + if type_ as usize == PTR_DATE.load(Ordering::Relaxed) { + return construct_date(y as c_int, mo as c_int, d as c_int); + } + let body = unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) }; + if body.is_null() { + return ptr::null_mut(); + } + unsafe { + wi64(body, OFF_HASHCODE, -1); + wbyte(body, OFF_HASTZINFO, 0); + pack_ymd(body, y, mo, d); + } + if let Some(inst) = body_instance(body) { + inst.slot_set("_year", Object::Int(y)); + inst.slot_set("_month", Object::Int(mo)); + inst.slot_set("_day", Object::Int(d)); + // The pure-Python `date.__new__` seeds `_hashcode = -1` (its + // `__hash__` reads this slot lazily). A Cython subclass that reaches + // this base `tp_new` never runs that Python `__new__`, so seed it + // here too — otherwise `hash(subclass_instance)` raises + // `AttributeError: '_hashcode'`. + inst.slot_set("_hashcode", Object::Int(-1)); + } + body +} + +unsafe extern "C" fn datetime_tp_new( + type_: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let (pos, kw) = new_args(args, kwds); + let y = arg_i64(&pos, &kw, 0, "year", 1); + let mo = arg_i64(&pos, &kw, 1, "month", 1); + let d = arg_i64(&pos, &kw, 2, "day", 1); + let hh = arg_i64(&pos, &kw, 3, "hour", 0); + let mi = arg_i64(&pos, &kw, 4, "minute", 0); + let ss = arg_i64(&pos, &kw, 5, "second", 0); + let us = arg_i64(&pos, &kw, 6, "microsecond", 0); + let fold = arg_i64(&pos, &kw, 8, "fold", 0); + let tz = arg_tzinfo(&pos, &kw, 7); + if std::env::var_os("WEAVEPY_TRACE_NEW").is_some() { + eprintln!( + "[DT] datetime_tp_new type={:p} name={} y={y} mo={mo} d={d}", + type_, + crate::types::ctor_trace_name(type_), + ); + } + if type_ as usize == PTR_DATETIME.load(Ordering::Relaxed) { + let mut a = vec![ + Object::Int(y), + Object::Int(mo), + Object::Int(d), + Object::Int(hh), + Object::Int(mi), + Object::Int(ss), + Object::Int(us), + ]; + if let Some(o) = &tz { + a.push(o.clone()); + } + return invoke_class("datetime", a); + } + let body = unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) }; + if body.is_null() { + return ptr::null_mut(); + } + unsafe { + wi64(body, OFF_HASHCODE, -1); + pack_ymd(body, y, mo, d); + wbyte(body, OFF_DATA + 4, (hh & 0xff) as u8); + wbyte(body, OFF_DATA + 5, (mi & 0xff) as u8); + wbyte(body, OFF_DATA + 6, (ss & 0xff) as u8); + wbyte(body, OFF_DATA + 7, ((us >> 16) & 0xff) as u8); + wbyte(body, OFF_DATA + 8, ((us >> 8) & 0xff) as u8); + wbyte(body, OFF_DATA + 9, (us & 0xff) as u8); + wbyte(body, OFF_DT_FOLD, (fold & 0xff) as u8); + match &tz { + Some(o) => { + let p = crate::object::into_owned(o.clone()); + wbyte(body, OFF_HASTZINFO, 1); + wptr(body, OFF_DT_TZINFO, p); + } + None => { + wbyte(body, OFF_HASTZINFO, 0); + wptr(body, OFF_DT_TZINFO, crate::object::into_owned(Object::None)); + } + } + } + if let Some(inst) = body_instance(body) { + inst.slot_set("_year", Object::Int(y)); + inst.slot_set("_month", Object::Int(mo)); + inst.slot_set("_day", Object::Int(d)); + inst.slot_set("_hour", Object::Int(hh)); + inst.slot_set("_minute", Object::Int(mi)); + inst.slot_set("_second", Object::Int(ss)); + inst.slot_set("_microsecond", Object::Int(us)); + inst.slot_set("_fold", Object::Int(fold)); + inst.slot_set("_tzinfo", tz.unwrap_or(Object::None)); + // See the `date`/`timedelta` note: seed the lazy hash cache slot so a + // Cython subclass reaching this base `tp_new` can still be hashed. + inst.slot_set("_hashcode", Object::Int(-1)); + } + body +} + +unsafe extern "C" fn time_tp_new( + type_: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let (pos, kw) = new_args(args, kwds); + let hh = arg_i64(&pos, &kw, 0, "hour", 0); + let mi = arg_i64(&pos, &kw, 1, "minute", 0); + let ss = arg_i64(&pos, &kw, 2, "second", 0); + let us = arg_i64(&pos, &kw, 3, "microsecond", 0); + let fold = arg_i64(&pos, &kw, 5, "fold", 0); + let tz = arg_tzinfo(&pos, &kw, 4); + if type_ as usize == PTR_TIME.load(Ordering::Relaxed) { + let tzp = match &tz { + Some(o) => crate::object::into_owned(o.clone()), + None => ptr::null_mut(), + }; + let r = construct_time( + hh as c_int, + mi as c_int, + ss as c_int, + us as c_int, + tzp, + fold as c_int, + ); + if !tzp.is_null() { + unsafe { crate::object::Py_DecRef(tzp) }; + } + return r; + } + let body = unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) }; + if body.is_null() { + return ptr::null_mut(); + } + unsafe { + wi64(body, OFF_HASHCODE, -1); + wbyte(body, OFF_DATA, (hh & 0xff) as u8); + wbyte(body, OFF_DATA + 1, (mi & 0xff) as u8); + wbyte(body, OFF_DATA + 2, (ss & 0xff) as u8); + wbyte(body, OFF_DATA + 3, ((us >> 16) & 0xff) as u8); + wbyte(body, OFF_DATA + 4, ((us >> 8) & 0xff) as u8); + wbyte(body, OFF_DATA + 5, (us & 0xff) as u8); + wbyte(body, OFF_TIME_FOLD, (fold & 0xff) as u8); + match &tz { + Some(o) => { + let p = crate::object::into_owned(o.clone()); + wbyte(body, OFF_HASTZINFO, 1); + wptr(body, OFF_TIME_TZINFO, p); + } + None => { + wbyte(body, OFF_HASTZINFO, 0); + wptr(body, OFF_TIME_TZINFO, crate::object::into_owned(Object::None)); + } + } + } + if let Some(inst) = body_instance(body) { + inst.slot_set("_hour", Object::Int(hh)); + inst.slot_set("_minute", Object::Int(mi)); + inst.slot_set("_second", Object::Int(ss)); + inst.slot_set("_microsecond", Object::Int(us)); + inst.slot_set("_fold", Object::Int(fold)); + inst.slot_set("_tzinfo", tz.unwrap_or(Object::None)); + // See the `date`/`timedelta` note: seed the lazy hash cache slot so a + // Cython subclass reaching this base `tp_new` can still be hashed. + inst.slot_set("_hashcode", Object::Int(-1)); + } + body +} + +unsafe extern "C" fn delta_tp_new( + type_: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + // `timedelta(...)` normalisation (weeks/hours/minutes/milliseconds → + // days/seconds/microseconds, with carries) is intricate; reuse the + // VM constructor for it. For the base type that *is* the result; for + // a subclass we normalise via a throwaway base `timedelta`, read the + // canonical three fields back, then pack them into the subclass body. + let (pos, kw) = new_args(args, kwds); + let mut kwa: Vec<(String, Object)> = Vec::new(); + if let Some(d) = &kw { + for (k, v) in d.borrow().iter() { + if let Object::Str(s) = &k.0 { + kwa.push((s.to_string(), v.clone())); + } + } + } + if type_ as usize == PTR_DELTA.load(Ordering::Relaxed) { + return invoke_class_kw("timedelta", &pos, &kwa); + } + // Normalise through the base constructor. + let tmp = invoke_class_kw("timedelta", &pos, &kwa); + if tmp.is_null() { + return ptr::null_mut(); + } + let days = get_int_attr(tmp, "days"); + let secs = get_int_attr(tmp, "seconds"); + let us = get_int_attr(tmp, "microseconds"); + unsafe { crate::object::Py_DecRef(tmp) }; + let body = unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) }; + if body.is_null() { + return ptr::null_mut(); + } + unsafe { + wi64(body, OFF_HASHCODE, -1); + wi32(body, OFF_DELTA_DAYS, days); + wi32(body, OFF_DELTA_SECONDS, secs); + wi32(body, OFF_DELTA_US, us); + } + if let Some(inst) = body_instance(body) { + inst.slot_set("_days", Object::Int(days as i64)); + inst.slot_set("_seconds", Object::Int(secs as i64)); + inst.slot_set("_microseconds", Object::Int(us as i64)); + // The pure-Python `timedelta.__new__` seeds `_hashcode = -1`; its + // `__hash__` (which pandas' `_Timedelta.__hash__` defers to for ns/us + // resolutions) reads this slot lazily. Without it, + // `hash(pd.Timedelta(...))` raised `AttributeError: '_hashcode'` + // (and SIGSEGV'd deep in Cython during pytest collection). + inst.slot_set("_hashcode", Object::Int(-1)); + } + body +} + +/// `tzinfo` carries no inline data; a subclass that reaches the base +/// `tp_new` just needs a non-NULL slot returning a fresh allocation. +unsafe extern "C" fn tzinfo_tp_new( + type_: *mut PyTypeObject, + _args: *mut PyObject, + _kwds: *mut PyObject, +) -> *mut PyObject { + if type_ as usize == PTR_TZINFO.load(Ordering::Relaxed) { + return invoke_class("tzinfo", Vec::new()); + } + unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) } +} + +unsafe extern "C" fn timezone_tp_new( + type_: *mut PyTypeObject, + args: *mut PyObject, + kwds: *mut PyObject, +) -> *mut PyObject { + let (pos, _kw) = new_args(args, kwds); + if type_ as usize == PTR_TIMEZONE.load(Ordering::Relaxed) { + return invoke_class("timezone", pos); + } + unsafe { crate::genericalloc::PyType_GenericAlloc(type_, 0) } +} + +// --- raw body writers (offsets are head-relative; head is 0..16) --- + +#[inline] +unsafe fn wbyte(body: *mut PyObject, off: usize, v: u8) { + unsafe { *(body as *mut u8).add(off) = v }; +} +#[inline] +unsafe fn wi32(body: *mut PyObject, off: usize, v: i32) { + unsafe { ((body as *mut u8).add(off) as *mut i32).write_unaligned(v) }; +} +#[inline] +unsafe fn wi64(body: *mut PyObject, off: usize, v: i64) { + unsafe { ((body as *mut u8).add(off) as *mut i64).write_unaligned(v) }; +} +#[inline] +unsafe fn wptr(body: *mut PyObject, off: usize, v: *mut PyObject) { + unsafe { ((body as *mut u8).add(off) as *mut *mut PyObject).write_unaligned(v) }; +} + +/// Read an integer `__slots__`/`__dict__` field, defaulting to 0. +fn field_i64(inst: &Rc, name: &str) -> i64 { + let v = inst.slot_get(name).or_else(|| { + inst.dict + .borrow() + .get(&DictKey(Object::from_str(name))) + .cloned() + }); + match v { + Some(Object::Int(i)) => i, + Some(Object::Bool(b)) => i64::from(b), + _ => 0, + } +} + +/// Read the `_tzinfo` field: `Some(obj)` when tz-aware, `None` when +/// naive (the slot is absent or `None`). +fn field_tzinfo(inst: &Rc) -> Option { + match inst.slot_get("_tzinfo") { + Some(Object::None) | None => None, + other => other, + } +} + +/// Write `data[]` year/month/day (big-endian year) at `OFF_DATA`. +unsafe fn pack_ymd(body: *mut PyObject, y: i64, mo: i64, d: i64) { + unsafe { + wbyte(body, OFF_DATA, ((y >> 8) & 0xff) as u8); + wbyte(body, OFF_DATA + 1, (y & 0xff) as u8); + wbyte(body, OFF_DATA + 2, (mo & 0xff) as u8); + wbyte(body, OFF_DATA + 3, (d & 0xff) as u8); + } +} + +unsafe fn pack_date(body: *mut PyObject, inst: &Rc) { + let (y, mo, d) = ( + field_i64(inst, "_year"), + field_i64(inst, "_month"), + field_i64(inst, "_day"), + ); + unsafe { + wi64(body, OFF_HASHCODE, -1); + wbyte(body, OFF_HASTZINFO, 0); + pack_ymd(body, y, mo, d); + } +} + +unsafe fn pack_datetime(body: *mut PyObject, inst: &Rc) { + let y = field_i64(inst, "_year"); + let mo = field_i64(inst, "_month"); + let d = field_i64(inst, "_day"); + let hh = field_i64(inst, "_hour"); + let mi = field_i64(inst, "_minute"); + let ss = field_i64(inst, "_second"); + let us = field_i64(inst, "_microsecond"); + let fold = field_i64(inst, "_fold"); + unsafe { + wi64(body, OFF_HASHCODE, -1); + pack_ymd(body, y, mo, d); + wbyte(body, OFF_DATA + 4, (hh & 0xff) as u8); + wbyte(body, OFF_DATA + 5, (mi & 0xff) as u8); + wbyte(body, OFF_DATA + 6, (ss & 0xff) as u8); + wbyte(body, OFF_DATA + 7, ((us >> 16) & 0xff) as u8); + wbyte(body, OFF_DATA + 8, ((us >> 8) & 0xff) as u8); + wbyte(body, OFF_DATA + 9, (us & 0xff) as u8); + wbyte(body, OFF_DT_FOLD, (fold & 0xff) as u8); + pack_tzinfo(body, inst, OFF_HASTZINFO, OFF_DT_TZINFO); + } +} + +unsafe fn pack_time(body: *mut PyObject, inst: &Rc) { + let hh = field_i64(inst, "_hour"); + let mi = field_i64(inst, "_minute"); + let ss = field_i64(inst, "_second"); + let us = field_i64(inst, "_microsecond"); + let fold = field_i64(inst, "_fold"); + unsafe { + wi64(body, OFF_HASHCODE, -1); + wbyte(body, OFF_DATA, (hh & 0xff) as u8); + wbyte(body, OFF_DATA + 1, (mi & 0xff) as u8); + wbyte(body, OFF_DATA + 2, (ss & 0xff) as u8); + wbyte(body, OFF_DATA + 3, ((us >> 16) & 0xff) as u8); + wbyte(body, OFF_DATA + 4, ((us >> 8) & 0xff) as u8); + wbyte(body, OFF_DATA + 5, (us & 0xff) as u8); + wbyte(body, OFF_TIME_FOLD, (fold & 0xff) as u8); + pack_tzinfo(body, inst, OFF_HASTZINFO, OFF_TIME_TZINFO); + } +} + +/// Shared tz packing: set `hastzinfo` + the `tzinfo` pointer. Naive +/// objects store `hastzinfo = 0` and `Py_None` (the +/// `PyDateTime_DATE_GET_TZINFO` macro short-circuits to `Py_None` +/// without reading the field); aware objects retain one owned reference +/// to the crossed tzinfo for the body's lifetime. +unsafe fn pack_tzinfo(body: *mut PyObject, inst: &Rc, off_flag: usize, off_ptr: usize) { + match field_tzinfo(inst) { + Some(tz) => { + let p = crate::object::into_owned(tz); + unsafe { + wbyte(body, off_flag, 1); + wptr(body, off_ptr, p); + } + } + None => unsafe { + wbyte(body, off_flag, 0); + wptr(body, off_ptr, crate::object::into_owned(Object::None)); + }, + } +} + +unsafe fn pack_delta(body: *mut PyObject, inst: &Rc) { + let days = field_i64(inst, "_days"); + let secs = field_i64(inst, "_seconds"); + let us = field_i64(inst, "_microseconds"); + unsafe { + wi64(body, OFF_HASHCODE, -1); + wi32(body, OFF_DELTA_DAYS, days as i32); + wi32(body, OFF_DELTA_SECONDS, secs as i32); + wi32(body, OFF_DELTA_US, us as i32); + } +} + /// Force-linker keep-alive for the static. pub fn touch() -> *const PyDateTimeCAPI { &PyDateTimeAPI_Instance as *const _ diff --git a/crates/weavepy-capi/src/dunder_shim.rs b/crates/weavepy-capi/src/dunder_shim.rs index b9bda87..1c1989a 100644 --- a/crates/weavepy-capi/src/dunder_shim.rs +++ b/crates/weavepy-capi/src/dunder_shim.rs @@ -53,6 +53,14 @@ type GetAttroFunc = unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut P type SetAttroFunc = unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> c_int; type HashFunc = unsafe extern "C" fn(*mut PyObject) -> isize; type InitProc = unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> c_int; +type DescrGetFunc = + unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> *mut PyObject; +type DescrSetFunc = unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> c_int; +type NewFunc = unsafe extern "C" fn( + *mut crate::types::PyTypeObject, + *mut PyObject, + *mut PyObject, +) -> *mut PyObject; // ---------------------------------------------------------------- // Dunder synthesis. @@ -98,6 +106,48 @@ pub fn install_dunder_shims(table: &SlotTable, type_name: String) -> Vec<(String &name, ); + // Reflected numeric dunders (`__radd__`, `__rmul__`, …). CPython has no + // dedicated reflected C slot — the *same* `nb_*` slot serves both + // directions: `binary_op1` tries the right operand's `nb_*` with the + // operands in their original order when the left operand's slot declines. + // A numpy scalar/array defines `nb_multiply` but no Python-level + // `__rmul__`, so `1 * np.float64(2)` — where the VM `int` on the left + // cannot handle the numpy RHS — must still reach the RHS's `nb_multiply`. + // The reflected shim forwards to the forward slot with the operands + // swapped back into original (left, right) order (see + // [`install_binary_reflected`]). + install_binary_reflected(&mut out, table, ids::Py_nb_add, "__radd__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_subtract, "__rsub__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_multiply, "__rmul__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_remainder, "__rmod__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_divmod, "__rdivmod__", &name); + install_binary_reflected( + &mut out, + table, + ids::Py_nb_floor_divide, + "__rfloordiv__", + &name, + ); + install_binary_reflected( + &mut out, + table, + ids::Py_nb_true_divide, + "__rtruediv__", + &name, + ); + install_binary_reflected(&mut out, table, ids::Py_nb_lshift, "__rlshift__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_rshift, "__rrshift__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_and, "__rand__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_xor, "__rxor__", &name); + install_binary_reflected(&mut out, table, ids::Py_nb_or, "__ror__", &name); + install_binary_reflected( + &mut out, + table, + ids::Py_nb_matrix_multiply, + "__rmatmul__", + &name, + ); + install_binary(&mut out, table, ids::Py_nb_inplace_add, "__iadd__", &name); install_binary( &mut out, @@ -171,28 +221,54 @@ pub fn install_dunder_shims(table: &SlotTable, type_name: String) -> Vec<(String install_ternary(&mut out, table, ids::Py_nb_power, "__pow__", &name); install_ternary(&mut out, table, ids::Py_nb_inplace_power, "__ipow__", &name); + // Reflected power. `nb_power` is the only reflectable numeric slot that is + // a `ternaryfunc`, so `install_binary_reflected` (above) skips it and a + // numpy ndarray/scalar ends up with `__pow__` but no `__rpow__`. Without + // it `float ** ndarray` — pandas' `roperator.rpow` does `right ** left` + // for masked-array reflected power — finds no reflected handler on the RHS + // and raises `TypeError`. Synthesize `__rpow__` from the same slot with the + // operands swapped and a `None` modulus (reflected power never carries one). + install_ternary_reflected(&mut out, table, ids::Py_nb_power, "__rpow__", &name); // Sequence protocol → __len__ / __getitem__ / __setitem__ / … install_lenfunc(&mut out, table, ids::Py_sq_length, "__len__", &name); install_ssize_arg(&mut out, table, ids::Py_sq_item, "__getitem__", &name); install_ssize_obj_arg(&mut out, table, ids::Py_sq_ass_item, "__setitem__", &name); install_obj_obj(&mut out, table, ids::Py_sq_contains, "__contains__", &name); - install_binary(&mut out, table, ids::Py_sq_concat, "__add__", &name); - install_ssize_arg(&mut out, table, ids::Py_sq_repeat, "__mul__", &name); - install_binary( - &mut out, - table, - ids::Py_sq_inplace_concat, - "__iadd__", - &name, - ); - install_ssize_arg( - &mut out, - table, - ids::Py_sq_inplace_repeat, - "__imul__", - &name, - ); + // `__add__`/`__mul__` (and their in-place forms) are shared between the + // number and sequence protocols. CPython resolves the dunder to the + // *number* slot when the type defines one, falling back to the sequence + // slot only when it does not (`slot_nb_add` precedes `slot_sq_concat` in + // `slotdefs`). A numpy ndarray defines BOTH `nb_add` (real elementwise + // add) and `sq_concat` (`array_concat`, which deliberately raises + // "Concatenation operation is not implemented"); installing the concat + // shim unconditionally last would clobber the numeric one and make + // `arr + arr` raise. Only install the sequence shim when the numeric + // counterpart is absent. + if table.get(ids::Py_nb_add).is_null() { + install_binary(&mut out, table, ids::Py_sq_concat, "__add__", &name); + } + if table.get(ids::Py_nb_multiply).is_null() { + install_ssize_arg(&mut out, table, ids::Py_sq_repeat, "__mul__", &name); + } + if table.get(ids::Py_nb_inplace_add).is_null() { + install_binary( + &mut out, + table, + ids::Py_sq_inplace_concat, + "__iadd__", + &name, + ); + } + if table.get(ids::Py_nb_inplace_multiply).is_null() { + install_ssize_arg( + &mut out, + table, + ids::Py_sq_inplace_repeat, + "__imul__", + &name, + ); + } // Mapping protocol takes precedence over sq_item where both are // defined: install the mapping shim last so its dunder @@ -225,6 +301,18 @@ pub fn install_dunder_shims(table: &SlotTable, type_name: String) -> Vec<(String ); install_setattro(&mut out, table, ids::Py_tp_setattro, "__setattr__", &name); + // Descriptor protocol → __get__ / __set__ (RFC 0044, WS3). + install_descr_get(&mut out, table, ids::Py_tp_descr_get, &name); + install_descr_set(&mut out, table, ids::Py_tp_descr_set, &name); + + // Async protocol → __await__ / __aiter__ / __anext__ (RFC 0044, WS3). + install_get_iter(&mut out, table, ids::Py_am_await, "__await__", &name); + install_get_iter(&mut out, table, ids::Py_am_aiter, "__aiter__", &name); + install_anext(&mut out, table, ids::Py_am_anext, "__anext__", &name); + + // Construction → __new__ (RFC 0044, WS3). + install_new(&mut out, table, ids::Py_tp_new, &name); + out } @@ -288,6 +376,43 @@ fn install_binary( )); } +/// Reflected sibling of [`install_binary`]: installs `__radd__`-style +/// dunders that forward to a *forward* `nb_*` slot. A reflected dunder +/// `self.__rop__(other)` computes `other OP self`; the C slot computes +/// `left OP right`, so the operands — which arrive bound as `(self, other)` +/// — are swapped back to `(other, self)` before the call. CPython reaches +/// the same slot through `binary_op1` trying the right operand's `nb_*` +/// with the operands in their original order. +fn install_binary_reflected( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + name: &str, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + let mname = name.to_owned(); + let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); + let tn = type_name.clone(); + let f = move |args: &[Object]| -> Result { + let func: BinaryFunc = unsafe { slot.cast() }; + let (self_p, other_p) = binary_args(args, static_name, &tn)?; + invoke_binary(func, other_p, self_p) + }; + out.push(( + mname, + Object::Builtin(Rc::new(BuiltinFn { + name: static_name, + binds_instance: true, + call: Box::new(f), + call_kw: None, + })), + )); +} + fn install_ternary( out: &mut Vec<(String, Object)>, table: &SlotTable, @@ -338,6 +463,54 @@ fn install_ternary( )); } +/// Reflected sibling of [`install_ternary`] for `nb_power` → `__rpow__`. +/// `self.__rpow__(other)` computes `other ** self`, i.e. the ternary +/// `nb_power(other, self, None)`. Reflected power never carries a modulus — +/// the 3-arg `pow(base, exp, mod)` form only ever dispatches to the *forward* +/// `__pow__` — so the shim is effectively binary and always passes `None` for +/// the modulus. CPython reaches the same `nb_power` through `binary_op1` +/// trying the right operand's slot (with the operands in original order) when +/// the left operand declines; this shim reproduces that for a numpy +/// ndarray/scalar RHS, which exposes `nb_power` but no Python `__rpow__`. +fn install_ternary_reflected( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + name: &str, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + let mname = name.to_owned(); + let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); + let tn = type_name.clone(); + let f = move |args: &[Object]| -> Result { + let func: TernaryFunc = unsafe { slot.cast() }; + // Bound as (self, other); reflected power computes `other ** self`. + let (self_p, other_p) = binary_args(args, static_name, &tn)?; + let none = crate::singletons::none_ptr(); + unsafe { crate::object::Py_IncRef(none) }; + let raw = crate::interp::ensure_active(|| unsafe { func(other_p, self_p, none) }); + unsafe { + crate::object::Py_DecRef(self_p); + crate::object::Py_DecRef(other_p); + crate::object::Py_DecRef(none); + } + unwrap_pyobject(raw) + }; + out.push(( + mname, + Object::Builtin(Rc::new(BuiltinFn { + name: static_name, + binds_instance: true, + call: Box::new(f), + call_kw: None, + })), + )); +} + fn install_inquiry( out: &mut Vec<(String, Object)>, table: &SlotTable, @@ -733,6 +906,19 @@ fn install_richcmp( let f = move |args: &[Object]| -> Result { let func: RichCmpFunc = unsafe { slot.cast() }; let (a, b) = binary_args(args, static_name, &tn)?; + // TEMP diagnostic: catch dangling operands before numpy derefs them. + if std::env::var_os("WEAVEPY_RCMP_DIAG").is_some() { + wp_rcmp_diag(&args[0], a, "self"); + wp_rcmp_diag(&args[1], b, "other"); + if (a as usize) <= 0x10000 || (b as usize) <= 0x10000 { + eprintln!("[RCMP-BAD] skipping func call to avoid crash"); + unsafe { + crate::object::Py_DecRef(a); + crate::object::Py_DecRef(b); + } + return Ok(weavepy_vm::vm_singletons::not_implemented()); + } + } let raw = crate::interp::ensure_active(|| unsafe { func(a, b, op) }); unsafe { crate::object::Py_DecRef(a); @@ -765,7 +951,8 @@ fn install_call( } let mname = name.to_owned(); let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); - let _ = type_name; + let tname_pos: &'static str = Box::leak(type_name.to_string().into_boxed_str()); + let tname_kw = tname_pos; let f_pos = move |args: &[Object]| -> Result { let func: TernaryFunc = unsafe { slot.cast() }; if args.is_empty() { @@ -773,12 +960,15 @@ fn install_call( } let self_p = crate::object::into_owned(args[0].clone()); let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); - let kw = crate::object::into_owned(Object::new_dict()); + // No keyword arguments → CPython hands the slot a NULL `kwds`. + let kw: *mut PyObject = std::ptr::null_mut(); let raw = crate::interp::ensure_active(|| unsafe { func(self_p, arg_tuple, kw) }); unsafe { crate::object::Py_DecRef(self_p); crate::object::Py_DecRef(arg_tuple); - crate::object::Py_DecRef(kw); + } + if raw.is_null() && std::env::var_os("WEAVEPY_TRACE_NULL").is_some() { + eprintln!("[WEAVEPY_TRACE_NULL] __call__ NULL from type {tname_pos}"); } unwrap_pyobject(raw) }; @@ -790,16 +980,16 @@ fn install_call( } let self_p = crate::object::into_owned(args[0].clone()); let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); - let kw_dict = build_kw_dict(kwargs); - let kw_p = crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( - weavepy_vm::sync::RefCell::new(kw_dict), - ))); + let kw_p = kwds_ptr(kwargs); let raw = crate::interp::ensure_active(|| unsafe { func(self_p, arg_tuple, kw_p) }); unsafe { crate::object::Py_DecRef(self_p); crate::object::Py_DecRef(arg_tuple); crate::object::Py_DecRef(kw_p); } + if raw.is_null() && std::env::var_os("WEAVEPY_TRACE_NULL").is_some() { + eprintln!("[WEAVEPY_TRACE_NULL] __call__ NULL from type {tname_kw}"); + } unwrap_pyobject(raw) }; out.push(( @@ -834,12 +1024,12 @@ fn install_init( } let self_p = crate::object::into_owned(args[0].clone()); let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); - let kw = crate::object::into_owned(Object::new_dict()); + // No keyword arguments → CPython hands `tp_init` a NULL `kwds`. + let kw: *mut PyObject = std::ptr::null_mut(); let r = crate::interp::ensure_active(|| unsafe { func(self_p, arg_tuple, kw) }); unsafe { crate::object::Py_DecRef(self_p); crate::object::Py_DecRef(arg_tuple); - crate::object::Py_DecRef(kw); } if r < 0 { return Err(take_pending_or_default()); @@ -854,10 +1044,7 @@ fn install_init( } let self_p = crate::object::into_owned(args[0].clone()); let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); - let kw_dict = build_kw_dict(kwargs); - let kw_p = crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( - weavepy_vm::sync::RefCell::new(kw_dict), - ))); + let kw_p = kwds_ptr(kwargs); let r = crate::interp::ensure_active(|| unsafe { func(self_p, arg_tuple, kw_p) }); unsafe { crate::object::Py_DecRef(self_p); @@ -926,6 +1113,21 @@ fn install_getattro( if slot.is_null() { return; } + // A type whose `tp_getattro` is WeavePy's own generic getattr resolves + // attributes through the default `object.__getattribute__` — which the + // VM's `load_attr_instance` already performs natively. Installing a + // `__getattribute__` shim that calls `PyObject_GenericGetAttr` would + // re-enter the full dispatch (`load_attr_instance` → shim → + // `PyObject_GenericGetAttr` → `PyObject_GetAttr` → `load_attr_instance`) + // and recurse until the stack overflows. CPython likewise only wraps a + // *custom* `tp_getattro`; the generic one is never exposed as a dict + // `__getattribute__`. Mirrors [`crate::abstract_::foreign_getattr_dispatch`]. + let generic_getattro = crate::genericalloc::PyObject_GenericGetAttr + as unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject + as usize; + if slot.as_void() as usize == generic_getattro { + return; + } let mname = name.to_owned(); let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); let tn = type_name.clone(); @@ -969,6 +1171,15 @@ fn install_setattro( if slot.is_null() { return; } + // Symmetric to `install_getattro`: a type using WeavePy's generic + // `tp_setattro` uses the default `object.__setattr__`; wrapping it + // would recurse through `PyObject_GenericSetAttr` → `PyObject_SetAttr`. + let generic_setattro = crate::genericalloc::PyObject_GenericSetAttr + as unsafe extern "C" fn(*mut PyObject, *mut PyObject, *mut PyObject) -> c_int + as usize; + if slot.as_void() as usize == generic_setattro { + return; + } let mname = name.to_owned(); let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); let _ = type_name; @@ -1005,6 +1216,302 @@ fn install_setattro( )); } +/// `tp_descr_get` → `__get__(self, obj, type)`. Following CPython's +/// `wrap_descr_get`, a `None` `obj`/`type` is passed to the C slot as +/// `NULL` (extension descriptors test `obj == NULL` to mean "accessed +/// on the class"). +fn install_descr_get( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + let tn = type_name.clone(); + let f = move |args: &[Object]| -> Result { + let func: DescrGetFunc = unsafe { slot.cast() }; + if args.is_empty() { + return Err(type_error(format!("{tn}.__get__() requires self"))); + } + let self_p = crate::object::into_owned(args[0].clone()); + let obj_p = match args.get(1) { + Some(Object::None) | None => std::ptr::null_mut(), + Some(o) => crate::object::into_owned(o.clone()), + }; + let type_p = match args.get(2) { + Some(Object::None) | None => std::ptr::null_mut(), + Some(o) => crate::object::into_owned(o.clone()), + }; + let raw = crate::interp::ensure_active(|| unsafe { func(self_p, obj_p, type_p) }); + unsafe { + crate::object::Py_DecRef(self_p); + if !obj_p.is_null() { + crate::object::Py_DecRef(obj_p); + } + if !type_p.is_null() { + crate::object::Py_DecRef(type_p); + } + } + unwrap_pyobject(raw) + }; + out.push(( + "__get__".to_owned(), + Object::Builtin(Rc::new(BuiltinFn { + name: "__get__", + binds_instance: true, + call: Box::new(f), + call_kw: None, + })), + )); +} + +/// `tp_descr_set` → `__set__(self, obj, value)`. +fn install_descr_set( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + let tn = type_name.clone(); + let f = move |args: &[Object]| -> Result { + let func: DescrSetFunc = unsafe { slot.cast() }; + if args.len() != 3 { + return Err(type_error(format!( + "{tn}.__set__() takes 3 args ({} given)", + args.len() + ))); + } + let self_p = crate::object::into_owned(args[0].clone()); + let obj_p = crate::object::into_owned(args[1].clone()); + let val_p = crate::object::into_owned(args[2].clone()); + let r = crate::interp::ensure_active(|| unsafe { func(self_p, obj_p, val_p) }); + unsafe { + crate::object::Py_DecRef(self_p); + crate::object::Py_DecRef(obj_p); + crate::object::Py_DecRef(val_p); + } + if r < 0 { + return Err(take_pending_or_default()); + } + Ok(Object::None) + }; + out.push(( + "__set__".to_owned(), + Object::Builtin(Rc::new(BuiltinFn { + name: "__set__", + binds_instance: true, + call: Box::new(f), + call_kw: None, + })), + )); +} + +/// `am_anext` → `__anext__`. Like [`install_iter_next`] but a NULL +/// return without a pending exception raises `StopAsyncIteration`. +fn install_anext( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + name: &str, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + let mname = name.to_owned(); + let static_name: &'static str = Box::leak(name.to_string().into_boxed_str()); + let tn = type_name.clone(); + let f = move |args: &[Object]| -> Result { + let func: IterNextFunc = unsafe { slot.cast() }; + let self_p = primary_self(args, static_name, &tn)?; + let raw = crate::interp::ensure_active(|| unsafe { func(self_p) }); + unsafe { crate::object::Py_DecRef(self_p) }; + if raw.is_null() { + if let Some(p) = crate::errors::take_pending() { + return Err(crate::errors::to_runtime_error(p)); + } + return Err(weavepy_vm::error::RuntimeError::PyException( + weavepy_vm::error::PyException::new(weavepy_vm::builtin_types::make_exception( + "StopAsyncIteration", + String::new(), + )), + )); + } + let out = unsafe { crate::object::clone_object(raw) }; + unsafe { crate::object::Py_DecRef(raw) }; + Ok(out) + }; + out.push(( + mname, + Object::Builtin(Rc::new(BuiltinFn { + name: static_name, + binds_instance: true, + call: Box::new(f), + call_kw: None, + })), + )); +} + +/// `tp_new` → `__new__(cls, *args, **kwargs)`. Installed as a plain +/// `Builtin` (not a `staticmethod`-wrapped sentinel), so the VM's +/// construction path treats it as a user `__new__`: it is looked up on +/// the class and called with `cls` pushed in front of the constructor +/// args. The C `newfunc` receives the `*mut PyTypeObject` for `cls`. +/// Choose the `tp_new` to invoke for a `cls` resolved inside a `__new__` +/// shim, mirroring CPython's `type_call` (which calls `type->tp_new`). +/// +/// A `__new__` shim is installed per bridged base type and captures *that +/// base's* `tp_new` (`captured`). Python-level `__new__` resolution walks +/// the MRO and picks the **first** base that carries a `__new__` — which, +/// for a class that multiply-inherits C extension types, need not be the +/// *solid base* (`best_base`, the base owning the widest inline instance +/// layout). CPython instead inherits `type->tp_new` from the solid base, +/// so its `cdef object` fields get initialised (Cython's `__pyx_tp_new` +/// zeroes them to `None`). +/// +/// `install_user_type` / `synth_type_for_class` already bake the solid +/// base's faithful `tp_new` into the resolved `cls`'s own type struct. So +/// when `cls` is an inline-body type whose own `tp_new` is a real +/// (non-generic) slot differing from this shim's captured base slot, that +/// is the solid-base constructor — dispatch to it. This is the fix for +/// pandas' `MultiIndexUIntEngine(BaseMultiIndexCodesEngine, UInt64Engine)`, +/// whose MRO-first base leaves the inherited `IndexEngine` fields NULL. +/// +/// In every other case (`cls` is the bridged base itself, or a +/// single-solid-base subclass, or a non-inline subclass) `cls->tp_new` +/// equals `captured` or is the generic allocator, so the captured slot is +/// kept and behaviour is unchanged. +fn effective_new_slot(type_ptr: *mut crate::types::PyTypeObject, captured: SlotPtr) -> SlotPtr { + if type_ptr.is_null() { + return captured; + } + let own = unsafe { (*type_ptr).tp_new }; + let generic: unsafe extern "C" fn( + *mut crate::types::PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = crate::genericalloc::PyType_GenericNew; + let own_addr = own as usize; + if own_addr != 0 + && own_addr != captured.0 as usize + && own_addr != generic as usize + && crate::types::is_inline_instance_type(type_ptr) + { + SlotPtr(own) + } else { + captured + } +} + +fn install_new( + out: &mut Vec<(String, Object)>, + table: &SlotTable, + slot_id: c_int, + type_name: &Rc, +) { + let slot = table.get(slot_id); + if slot.is_null() { + return; + } + fn cls_ptr( + arg: &Object, + tn: &Rc, + ) -> Result<*mut crate::types::PyTypeObject, RuntimeError> { + match arg { + // Resolve `cls` to its *canonical* C `PyTypeObject*` — the + // registered static / heap / readied pointer — exactly as + // `crate::object::into_owned(Object::Type)` does. A bare + // `install_user_type` would mint a fresh generic mirror that + // drops the real type's `tp_basicsize`/inline-storage/`tp_alloc` + // setup, so a faithful `tp_new` (e.g. the datetime shells') would + // allocate a plain box and write the subclass's inline fields over + // the box payload (RFC 0029: pandas `_NaT.__new__(NaTType, …)`). + Object::Type(t) => Ok(crate::types::type_ptr_for_class(t) + .or_else(|| crate::types::synth_type_for_class(t)) + .unwrap_or_else(|| crate::types::install_user_type(t))), + _ => Err(type_error(format!("{tn}.__new__(X): X is not a type"))), + } + } + let tn = type_name.clone(); + let f_pos = move |args: &[Object]| -> Result { + if slot.0.is_null() { + return Err(type_error(format!( + "{tn}.__new__: tp_new slot is NULL (unbound base constructor)" + ))); + } + if std::env::var_os("WEAVEPY_TRACE_NEW").is_some() { + let a0 = match args.first() { + Some(Object::Type(t)) => format!("Type({})", t.name), + Some(other) => format!("{:?}", std::mem::discriminant(other)), + None => "none".to_owned(), + }; + eprintln!("[NEW] type={tn:?} slot={:p} args0={a0}", slot.0); + } + if args.is_empty() { + return Err(type_error( + "__new__() requires the type as its first argument", + )); + } + let type_ptr = cls_ptr(&args[0], &tn)?; + // Dispatch to `cls->tp_new` when it is the faithful solid-base slot + // (mirrors CPython's `type_call`); falls back to this shim's captured + // base slot otherwise (identical for single-solid-base subclasses). + let func: NewFunc = unsafe { effective_new_slot(type_ptr, slot).cast() }; + let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); + // No keyword arguments → CPython hands `tp_new` a NULL `kwds`. + let kw: *mut PyObject = std::ptr::null_mut(); + let raw = crate::interp::ensure_active(|| unsafe { func(type_ptr, arg_tuple, kw) }); + unsafe { + crate::object::Py_DecRef(arg_tuple); + } + unwrap_pyobject(raw) + }; + let tn_kw = type_name.clone(); + let f_kw = + move |args: &[Object], kwargs: &[(String, Object)]| -> Result { + if slot.0.is_null() { + return Err(type_error(format!( + "{tn_kw}.__new__: tp_new slot is NULL (unbound base constructor)" + ))); + } + if std::env::var_os("WEAVEPY_TRACE_NEW").is_some() { + eprintln!("[NEW-KW] type={tn_kw:?} slot={:p} nargs={}", slot.0, args.len()); + } + if args.is_empty() { + return Err(type_error( + "__new__() requires the type as its first argument", + )); + } + let type_ptr = cls_ptr(&args[0], &tn_kw)?; + let func: NewFunc = unsafe { effective_new_slot(type_ptr, slot).cast() }; + let arg_tuple = crate::object::into_owned(Object::new_tuple(args[1..].to_vec())); + let kw_p = kwds_ptr(kwargs); + let raw = crate::interp::ensure_active(|| unsafe { func(type_ptr, arg_tuple, kw_p) }); + unsafe { + crate::object::Py_DecRef(arg_tuple); + crate::object::Py_DecRef(kw_p); + } + unwrap_pyobject(raw) + }; + out.push(( + "__new__".to_owned(), + Object::Builtin(Rc::new(BuiltinFn { + name: "__new__", + binds_instance: false, + call: Box::new(f_pos), + call_kw: Some(Box::new(f_kw)), + })), + )); +} + // ---------------------------------------------------------------- // Helpers // ---------------------------------------------------------------- @@ -1023,6 +1530,46 @@ fn primary_self( Ok(crate::object::into_owned(args[0].clone())) } +/// TEMP diagnostic: describe an operand and its marshalled pointer, +/// flagging dangling/near-null pointers before a C slot dereferences them. +fn wp_rcmp_diag(obj: &Object, p: *mut PyObject, role: &str) { + let kind = match obj { + Object::Foreign(s) => format!("Foreign({}) ptr=0x{:x}", s.type_name, s.ptr), + Object::Instance(i) => { + format!("Instance(cls={} c_body=0x{:x})", i.cls().name, i.c_body.get()) + } + Object::None => "None".to_string(), + Object::Int(_) => "Int".to_string(), + Object::Str(_) => "Str".to_string(), + Object::List(_) => "List".to_string(), + Object::Tuple(_) => "Tuple".to_string(), + _ => "other".to_string(), + }; + let pv = p as usize; + if pv <= 0x10000 { + eprintln!("[RCMP-BAD] {role} obj={kind} p=0x{pv:x} "); + return; + } + let rc = unsafe { (*p).ob_refcnt } as i64; + if rc > 0 && rc < (1i64 << 40) { + return; // looks healthy + } + let ty = unsafe { (*p).ob_type }; + let tyname = if (ty as usize) > 0x10000 { + let np = unsafe { (*(ty as *mut crate::layout::PyTypeObjectFull)).tp_name }; + if np.is_null() { + "".to_string() + } else { + unsafe { std::ffi::CStr::from_ptr(np) } + .to_string_lossy() + .into_owned() + } + } else { + format!("", ty as usize) + }; + eprintln!("[RCMP-BAD] {role} obj={kind} p=0x{pv:x} refcnt={rc} ty={tyname}"); +} + fn binary_args( args: &[Object], name: &'static str, @@ -1074,6 +1621,12 @@ fn take_pending_or_default() -> RuntimeError { if let Some(p) = crate::errors::take_pending() { crate::errors::to_runtime_error(p) } else { + if std::env::var_os("WEAVEPY_TRACE_NULL").is_some() { + eprintln!( + "[WEAVEPY_TRACE_NULL] C slot returned NULL with no exception set\n{}", + std::backtrace::Backtrace::force_capture() + ); + } weavepy_vm::error::runtime_error( "C extension reported failure without setting an exception", ) @@ -1088,6 +1641,26 @@ fn build_kw_dict(kwargs: &[(String, Object)]) -> weavepy_vm::object::DictData { out } +/// Build the `kwds` argument for a C `(args, kwds)` slot (`tp_new`, +/// `tp_init`, ternary `METH_KEYWORDS`-style calls). +/// +/// Returns **NULL** when there are no keyword arguments, exactly as +/// CPython's `type_call` / `PyObject_Call` hand the callee. Extensions +/// branch on `kwds != NULL` (numpy's `array_converter_new` raises +/// "Array creation helper doesn't support keywords." for any non-NULL, +/// non-empty `kwds`), and reading an *empty* WeavePy dict mirror through +/// the `PyDict_GET_SIZE` macro yields garbage — so a keyword-less call +/// must pass a genuine NULL, not a fresh empty dict. +fn kwds_ptr(kwargs: &[(String, Object)]) -> *mut PyObject { + if kwargs.is_empty() { + return std::ptr::null_mut(); + } + let kw_dict = build_kw_dict(kwargs); + crate::object::into_owned(Object::Dict(Rc::new(weavepy_vm::sync::RefCell::new( + kw_dict, + )))) +} + // Suppress dead-code on the unused SlotPtr re-export helper. #[allow(dead_code)] fn _slot_ptr_helper(p: SlotPtr) -> bool { diff --git a/crates/weavepy-capi/src/errors.rs b/crates/weavepy-capi/src/errors.rs index acaf971..ef4ed18 100644 --- a/crates/weavepy-capi/src/errors.rs +++ b/crates/weavepy-capi/src/errors.rs @@ -24,50 +24,181 @@ use std::os::raw::{c_char, c_int}; use std::ptr; use std::sync::Mutex; use weavepy_vm::sync::Rc; -use weavepy_vm::sync::RefCell; -use weavepy_vm::builtin_types::{builtin_types, make_exception}; +use weavepy_vm::builtin_types::{builtin_types, make_exception_with_class}; use weavepy_vm::error::{PyException, RuntimeError}; use weavepy_vm::object::{DictData, DictKey, Object}; use weavepy_vm::types::TypeObject; use crate::object::PyObject; -thread_local! { - static PENDING: RefCell> = const { RefCell::new(None) }; -} - #[derive(Clone)] pub struct PendingError { pub ty: Option>, pub value: Object, } +// ---------------------------------------------------------------- +// Pending-exception store — backed by `tstate->current_exception`. +// +// RFC 0047 (wave 5): genuine Cython output (`CYTHON_FAST_THREAD_STATE 1`) +// reads and writes `tstate->current_exception` *directly* at its struct +// offset, bypassing this call surface. To interoperate, the canonical +// pending-exception cell is exactly that field (see [`crate::pystate`]), +// not a private Rust thread-local: an error raised by a WeavePy C-API call +// is then visible to Cython's inlined read, and an exception Cython stashes +// is visible here. The slot holds either NULL or **one owned reference** to +// a normalised exception *instance* (CPython's 3.12+ single-object model); +// every mutator preserves that invariant. +// ---------------------------------------------------------------- + +/// Derive the exception's class from the stored value. +fn type_of_value(value: &Object) -> Option> { + match value { + Object::Instance(inst) => Some(inst.cls()), + Object::Type(t) => Some(t.clone()), + _ => None, + } +} + +/// Normalise `(ty, value)` into an exception **instance** object (CPython's +/// `PyErr_SetObject` rule): an instance already satisfying `ty` (or any +/// instance when `ty` is None) *is* the exception; otherwise build +/// `ty(value)` — preserving the historical message mapping for non-instance +/// payloads via [`message_for`]. +fn exception_instance(ty: Option>, value: Object) -> Object { + if let Object::Instance(inst) = &value { + let satisfies = match &ty { + Some(cls) => inst.cls().is_subclass_of(cls), + None => true, + }; + if satisfies { + return value; + } + } + let class = ty.unwrap_or_else(|| builtin_types().runtime_error.clone()); + // CPython `PyErr_SetObject` / `_PyErr_NormalizeException`: a *tuple* value + // is the exception constructor's argument list, not a message. Run the + // type's real `__new__`/`__init__` (through the interpreter) so a + // C-extension exception with a custom constructor keeps the attributes its + // `__str__` reads. numpy raises `_UFuncBinaryResolutionError` via + // `PyErr_SetObject(exc, (ufunc, (dt0, dt1)))`, and only + // `UFuncTypeError.__init__` stores the `self.ufunc`/`self.dtypes` that its + // `__str__` dereferences; the message-stringifying fallback below collapses + // the tuple into a single `args[0]`, so `str(exc)` then raised + // `AttributeError: '_UFuncBinaryResolutionError' object has no attribute + // 'ufunc'`. Construction runs before we publish the slot below, so it may + // freely use the (still-old) pending cell. + if matches!(&value, Object::Tuple(_)) { + if let Some(Ok(inst @ Object::Instance(_))) = crate::interp::with_interp_mut(|interp| { + interp.construct_exception_from_capi(class.clone(), value.clone()) + }) { + return inst; + } + } + make_exception_with_class(class, message_for(&value)) +} + /// Clear the per-thread pending error cell. Called at the start of /// every extension call. pub fn clear_thread_local() { - PENDING.with(|cell| cell.borrow_mut().take()); + let slot = crate::pystate::current_exception_slot(); + unsafe { + let old = *slot; + if !old.is_null() { + *slot = ptr::null_mut(); + crate::object::Py_DecRef(old); + } + } } /// Install `(ty, value)` as the pending exception. Replaces any /// previously-pending error. pub fn set_pending(ty: Option>, value: Object) { - PENDING.with(|cell| { - *cell.borrow_mut() = Some(PendingError { ty, value }); - }); + let inst = exception_instance(ty, value); + if std::env::var_os("WEAVEPY_TRACE_RAISE").is_some() { + let name = match &inst { + Object::Instance(i) => i.cls().name.to_string(), + _ => String::new(), + }; + if name == "RuntimeError" { + eprintln!( + "[WEAVEPY_TRACE_RAISE] RuntimeError msg={:?}\n{}", + message_for(&inst), + std::backtrace::Backtrace::force_capture() + ); + } + } + // Diagnostic: print a Rust backtrace for *any* exception whose type name + // contains the `WEAVEPY_TRACE_RAISE_ANY` needle. Unlike the RuntimeError + // trace above this covers ValueError/TypeError/etc., which is how we locate + // the C-API primitive that raises inside a compiled Cython call (e.g. a + // spurious ValueError swallowed by `guess_datetime_format`'s `except`). + if let Some(needle) = std::env::var_os("WEAVEPY_TRACE_RAISE_ANY") { + let name = match &inst { + Object::Instance(i) => i.cls().name.to_string(), + other => other.type_name_owned(), + }; + let needle = needle.to_string_lossy(); + if needle.is_empty() || name.contains(needle.as_ref()) { + eprintln!( + "[WEAVEPY_TRACE_RAISE_ANY] {name} msg={:?}\n{}", + message_for(&inst), + std::backtrace::Backtrace::force_capture() + ); + } + } + // Seed a real traceback (pointing at the current Python frame) so the + // exception matches CPython's "unwinding attaches a traceback" invariant. + // Cython's `except X:` handler decrefs the fetched traceback *unguarded*, + // so a NULL `__traceback__` there is a hard crash (see + // `Interpreter::attach_c_traceback`). + if matches!(inst, Object::Instance(_)) { + crate::interp::with_interp_mut(|interp| interp.attach_c_traceback(&inst)); + } + let p = crate::object::into_owned(inst); + let slot = crate::pystate::current_exception_slot(); + unsafe { + let old = *slot; + *slot = p; + if !old.is_null() { + crate::object::Py_DecRef(old); + } + } } -/// Read the pending exception, leaving the cell intact. The -/// optional `Rc` is set when the caller installed -/// one explicitly via `PyErr_SetObject` / `PyErr_SetString` (we -/// always carry a class reference for those). +/// Read the pending exception, leaving the cell (and its owned reference) +/// intact. pub fn pending() -> Option { - PENDING.with(|cell| cell.borrow().clone()) + let slot = crate::pystate::current_exception_slot(); + let p = unsafe { *slot }; + if p.is_null() { + return None; + } + // Borrowing read: for a WeavePy box this is an `Rc` clone of the payload + // (the slot keeps its C reference); for a foreign pointer `clone_object` + // pins its own reference that the returned `Object` releases on drop. + let value = unsafe { crate::object::clone_object(p) }; + let ty = type_of_value(&value); + Some(PendingError { ty, value }) } -/// Take the pending exception out of the cell. +/// Take the pending exception out of the cell, transferring the slot's +/// owned reference out. pub fn take_pending() -> Option { - PENDING.with(|cell| cell.borrow_mut().take()) + let slot = crate::pystate::current_exception_slot(); + let p = unsafe { *slot }; + if p.is_null() { + return None; + } + unsafe { *slot = ptr::null_mut() }; + let value = unsafe { crate::object::clone_object(p) }; + let ty = type_of_value(&value); + // Release the slot's reference now that the payload lives in `value`. + // For a cached instance box this drops it to zero and `free_box` clears + // the instance's `c_body`, so a later crossing re-mints cleanly. + unsafe { crate::object::Py_DecRef(p) }; + Some(PendingError { ty, value }) } /// Take the pending exception out of the cell and convert to a @@ -79,13 +210,31 @@ pub fn take_pending_error_runtime() -> Option { /// Convert a [`PendingError`] to a [`RuntimeError`] suitable for /// returning from the VM. pub fn to_runtime_error(p: PendingError) -> RuntimeError { + // Preserve the pending exception *instance* verbatim when it is a real + // exception object. Its class carries the faithful MRO — e.g. a C + // extension's custom `DateParseError(ValueError)` (pandas' Cython + // date parser) — which `except (ValueError, …)` matching and + // `isinstance` depend on. The old path rebuilt the exception from its + // class *name* via `make_exception`, whose `by_name` lookup only knows + // the built-ins and so collapsed every non-builtin exception to a bare + // `Exception`, silently dropping its base classes. + if let Object::Instance(inst) = &p.value { + if inst.cls().is_subclass_of(&builtin_types().base_exception) { + return RuntimeError::PyException(PyException::new(p.value.clone())); + } + } + // Otherwise rebuild from the class *object* (not its name) so a custom + // class still keeps its identity, falling back to `RuntimeError` when + // the pending error carried no resolvable type. (Tuple/constructor + // normalisation already happened eagerly in `exception_instance` when the + // error was set, so `p.value` is an instance or a plain message here.) let class = p.ty.unwrap_or_else(|| builtin_types().runtime_error.clone()); - let inst = make_exception(&class.name, message_for(&p.value)); + let inst = make_exception_with_class(class, message_for(&p.value)); RuntimeError::PyException(PyException::new(inst)) } -fn message_for(o: &Object) -> String { +pub(crate) fn message_for(o: &Object) -> String { match o { Object::Str(s) => s.to_string(), Object::Instance(inst) => { @@ -121,11 +270,25 @@ pub fn set_runtime_error(msg: impl Into) { pub fn set_pending_from_runtime(err: RuntimeError) { match err { RuntimeError::PyException(pe) => { - let cls = match &pe.instance { - Object::Instance(inst) => Some(inst.cls()), - _ => None, - }; - set_pending(cls, Object::from_str(pe.message())); + // Preserve the exception *instance* verbatim rather than reducing + // it to `(class, message)`. A real exception instance may carry + // attributes that only its constructor set and that its `__str__` + // later dereferences — numpy's `_UFuncBinaryResolutionError` + // stores `self.ufunc`/`self.dtypes`. The old + // `set_pending(cls, from_str(pe.message()))` dropped them, so a + // downstream `str(exc)` raised `AttributeError: … has no attribute + // 'ufunc'`. Passing the instance as the pending *value* lets + // `exception_instance` take its "already an instance" fast path. + match pe.instance { + inst @ Object::Instance(_) => { + let cls = match &inst { + Object::Instance(i) => Some(i.cls()), + _ => None, + }; + set_pending(cls, inst); + } + other => set_pending(None, Object::from_str(message_for(&other))), + } } RuntimeError::Internal(msg) => { set_runtime_error(msg); @@ -135,9 +298,23 @@ pub fn set_pending_from_runtime(err: RuntimeError) { /// Helper used by argument-parsing code to install a `TypeError`. pub fn set_type_error(msg: impl Into) { + let msg = msg.into(); + // Diagnostic: when `WEAVEPY_TRACE_TYPEERR` is a substring of the message, + // dump the Rust backtrace so we can see which C-API entry a C extension + // called. Gated + off by default; costs nothing in the common path. + if let Some(needle) = std::env::var_os("WEAVEPY_TRACE_TYPEERR") { + if let Some(needle) = needle.to_str() { + if !needle.is_empty() && msg.contains(needle) { + eprintln!( + "[WEAVEPY_TRACE_TYPEERR] TypeError msg={msg:?}\n{}", + std::backtrace::Backtrace::force_capture() + ); + } + } + } set_pending( Some(builtin_types().type_error.clone()), - Object::from_str(msg.into()), + Object::from_str(msg), ); } @@ -171,6 +348,14 @@ pub fn set_attribute_error(msg: impl Into) { ); } +/// Helper used by the relative-import resolver to install an `ImportError`. +pub fn set_import_error(msg: impl Into) { + set_pending( + Some(builtin_types().import_error.clone()), + Object::from_str(msg.into()), + ); +} + /// Helper used by descriptor / generic-allocator code to install a /// `RuntimeError`. pub fn set_index_error(msg: impl Into) { @@ -224,6 +409,7 @@ exc_cell! { PyExc_NameError; PyExc_NotImplementedError; PyExc_OSError; + PyExc_IOError; PyExc_OverflowError; PyExc_RecursionError; PyExc_ReferenceError; @@ -328,6 +514,9 @@ pub fn init_static_exceptions() { bt.not_implemented_error.clone(), ); publish(&raw mut PyExc_OSError, bt.os_error.clone()); + // IOError is an alias of OSError in Python 3; share the slot so + // `PyExc_IOError is PyExc_OSError`. + publish(&raw mut PyExc_IOError, bt.os_error.clone()); publish(&raw mut PyExc_OverflowError, bt.overflow_error.clone()); publish(&raw mut PyExc_RecursionError, bt.recursion_error.clone()); publish(&raw mut PyExc_ReferenceError, bt.runtime_error.clone()); @@ -532,8 +721,33 @@ pub unsafe extern "C" fn PyErr_Fetch( pub unsafe extern "C" fn PyErr_Restore( ty: *mut PyObject, value: *mut PyObject, - _tb: *mut PyObject, + tb: *mut PyObject, ) { + // CPython uses a NULL *type* as the "no exception" sentinel: restoring + // `(NULL, …)` clears the error state. Cython's `tp_dealloc` brackets + // *every* deallocation with `PyErr_Fetch`/`PyErr_Restore`; with nothing + // pending it restores `(NULL, NULL, NULL)`, which must clear — not + // synthesise a bare `RuntimeError`. The old unconditional `set_pending` + // (with `type_object_for(NULL)` defaulting to `RuntimeError`) installed a + // spurious message-less `` on the way out of dealloc, which + // then aborted the *next* operation that read the slot (pandas' + // `_rebuild_blknos_and_blklocs`, whose `for … in enumerate(bp)` frees the + // iterator inside the loop). + if ty.is_null() { + let slot = crate::pystate::current_exception_slot(); + let old = unsafe { *slot }; + unsafe { *slot = ptr::null_mut() }; + if !old.is_null() { + unsafe { crate::object::Py_DecRef(old) }; + } + if !value.is_null() { + unsafe { crate::object::Py_DecRef(value) }; + } + if !tb.is_null() { + unsafe { crate::object::Py_DecRef(tb) }; + } + return; + } let cls = type_object_for(ty); let v = if value.is_null() { Object::None @@ -543,6 +757,32 @@ pub unsafe extern "C" fn PyErr_Restore( set_pending(cls, v); } +/// `PyErr_GetRaisedException()` (3.12+) — detach and return the active +/// exception *instance* (a new reference), leaving no error set. This is +/// the modern single-object spelling Cython's `__Pyx_AddTraceback` +/// preamble uses; it transfers the slot's owned reference straight out. +#[no_mangle] +pub unsafe extern "C" fn PyErr_GetRaisedException() -> *mut PyObject { + crate::interp::ensure_initialised(); + let slot = crate::pystate::current_exception_slot(); + let p = unsafe { *slot }; + unsafe { *slot = ptr::null_mut() }; + p +} + +/// `PyErr_SetRaisedException(exc)` (3.12+) — make `exc` the active +/// exception, stealing the reference. Releases any previously-set one. +#[no_mangle] +pub unsafe extern "C" fn PyErr_SetRaisedException(exc: *mut PyObject) { + crate::interp::ensure_initialised(); + let slot = crate::pystate::current_exception_slot(); + let old = unsafe { *slot }; + unsafe { *slot = exc }; + if !old.is_null() { + unsafe { crate::object::Py_DecRef(old) }; + } +} + /// Match a given exception against a type (or tuple of types). #[no_mangle] pub unsafe extern "C" fn PyErr_GivenExceptionMatches( @@ -623,11 +863,100 @@ pub unsafe extern "C" fn PyErr_BadInternalCall() { #[no_mangle] pub unsafe extern "C" fn PyErr_WarnEx( - _category: *mut PyObject, - _msg: *const c_char, - _stacklevel: isize, + category: *mut PyObject, + msg: *const c_char, + stacklevel: isize, +) -> c_int { + unsafe { warn_via_warnings(category, msg, stacklevel) } +} + +/// Route a C-level warning (`PyErr_WarnEx` / `PyErr_WarnFormat`) through +/// the Python `warnings` module, exactly as CPython's `do_warn` does. +/// +/// This is what lets `warnings.catch_warnings(record=True)`, +/// `simplefilter(...)`, and the `"error"` filter observe warnings raised +/// by C extensions. numpy 2.x, for instance, raises a `DeprecationWarning` +/// from `np.timedelta64()`; pandas' test suite asserts on it via +/// `tm.assert_produces_warning`. Dropping the warning (the old stub) made +/// those assertions silently disagree with CPython. +/// +/// Returns 0 normally, or -1 with a pending exception if the active filter +/// escalated the warning into one (`filterwarnings("error")`). +unsafe fn warn_via_warnings( + category: *mut PyObject, + msg: *const c_char, + stacklevel: isize, ) -> c_int { - // Accept and ignore — `warnings` integration is RFC 0023 work. + crate::interp::ensure_initialised(); + if msg.is_null() { + return 0; + } + // Never clobber a live exception: emitting a warning runs Python + // (the `warnings` machinery) which may install its own error state. + // CPython's callers only warn from a clean state; skipping here keeps + // us safe without changing observable behaviour in practice. + if pending().is_some() { + return 0; + } + + let message = unsafe { crate::strings::PyUnicode_FromString(msg) }; + if message.is_null() { + return -1; + } + // A NULL category means `RuntimeWarning` (CPython's default). + let cat = if category.is_null() { + unsafe { PyExc_RuntimeWarning } + } else { + category + }; + + let module = unsafe { + crate::module::PyImport_ImportModule(b"warnings\0".as_ptr() as *const c_char) + }; + if module.is_null() { + unsafe { crate::object::Py_DecRef(message) }; + return -1; + } + let warn_fn = unsafe { + crate::abstract_::PyObject_GetAttrString(module, b"warn\0".as_ptr() as *const c_char) + }; + unsafe { crate::object::Py_DecRef(module) }; + if warn_fn.is_null() { + unsafe { crate::object::Py_DecRef(message) }; + return -1; + } + + let stack = unsafe { crate::numbers::PyLong_FromLong(stacklevel as i64) }; + let args = unsafe { crate::containers::PyTuple_New(3) }; + if args.is_null() { + unsafe { + crate::object::Py_DecRef(message); + crate::object::Py_DecRef(stack); + crate::object::Py_DecRef(warn_fn); + } + return -1; + } + // `PyTuple_SetItem` steals a reference to each item. We own `message` + // and `stack` outright; `cat` is borrowed (the caller's category, or + // the immortal `PyExc_RuntimeWarning` static), so bump it first. + unsafe { + crate::object::Py_IncRef(cat); + crate::containers::PyTuple_SetItem(args, 0, message); + crate::containers::PyTuple_SetItem(args, 1, cat); + crate::containers::PyTuple_SetItem(args, 2, stack); + } + + let res = unsafe { crate::abstract_::PyObject_CallObject(warn_fn, args) }; + unsafe { + crate::object::Py_DecRef(args); + crate::object::Py_DecRef(warn_fn); + } + if res.is_null() { + // The active filter turned the warning into an exception; leave it + // pending and report failure, matching CPython's `PyErr_WarnEx`. + return -1; + } + unsafe { crate::object::Py_DecRef(res) }; 0 } diff --git a/crates/weavepy-capi/src/force_link_table.rs b/crates/weavepy-capi/src/force_link_table.rs index 810c841..3c90b1d 100644 --- a/crates/weavepy-capi/src/force_link_table.rs +++ b/crates/weavepy-capi/src/force_link_table.rs @@ -20,21 +20,28 @@ use crate::abstract_ as ab; use crate::argparse; use crate::buffer; use crate::capsule; +use crate::code_obj; use crate::containers; use crate::datetime_api as dt; use crate::errors; +use crate::gc_bridge; use crate::genericalloc; use crate::lifecycle; use crate::memory; use crate::memoryview; use crate::module; +use crate::monitoring; use crate::numbers; use crate::object; +use crate::pystate; use crate::singletons; use crate::slice; use crate::strings; use crate::types; use crate::vectorcall; +use crate::wave4; +use crate::wave5; +use crate::wave5_pandas as w5p; // The variadic helpers live in `varargs.c` and would otherwise be // dead-stripped from the host binary, since nothing on the Rust @@ -119,6 +126,19 @@ extern "C" { callable: *mut crate::object::PyObject, ... ) -> *mut crate::object::PyObject; + // RFC 0046 (wave 4): variadic tail numpy links, defined in varargs.c. + fn PyOS_snprintf( + str: *mut core::ffi::c_char, + size: usize, + fmt: *const core::ffi::c_char, + ... + ) -> core::ffi::c_int; + fn PyErr_WarnFormat( + category: *mut crate::object::PyObject, + stack_level: isize, + fmt: *const core::ffi::c_char, + ... + ) -> core::ffi::c_int; } macro_rules! addr { @@ -167,6 +187,8 @@ static FORCE_LINK: &[FnPtr] = &[ addr!(object::Py_DecRef), addr!(object::Py_NewRef), addr!(object::Py_XNewRef), + addr!(object::_Py_Dealloc), + addr!(object::_PyWeavePy_Dealloc), // numbers.rs addr!(numbers::PyLong_FromLong), addr!(numbers::PyLong_FromLongLong), @@ -235,6 +257,7 @@ static FORCE_LINK: &[FnPtr] = &[ addr!(containers::PyDict_SetItem), addr!(containers::PyDict_SetItemString), addr!(containers::PyDict_GetItem), + addr!(containers::_PyDict_GetItem_KnownHash), addr!(containers::PyDict_GetItemString), addr!(containers::PyDict_DelItem), addr!(containers::PyDict_DelItemString), @@ -263,6 +286,7 @@ static FORCE_LINK: &[FnPtr] = &[ addr!(ab::PyObject_SetAttrString), addr!(ab::PyObject_DelAttrString), addr!(ab::PyObject_HasAttr), + addr!(ab::PyObject_HasAttrWithError), addr!(ab::PyObject_HasAttrString), addr!(ab::PyObject_GetItem), addr!(ab::PyObject_SetItem), @@ -463,6 +487,13 @@ static FORCE_LINK: &[FnPtr] = &[ addr!(genericalloc::Py_HashPointer), addr!(genericalloc::_Py_HashBytes), addr!(genericalloc::Py_GenericAlias), + // gc_bridge.rs — GC allocation + tracking C-API (RFC 0044). + addr!(gc_bridge::_PyObject_GC_New), + addr!(gc_bridge::_PyObject_GC_NewVar), + addr!(gc_bridge::PyObject_GC_Track), + addr!(gc_bridge::PyObject_GC_UnTrack), + addr!(gc_bridge::PyObject_GC_IsTracked), + addr!(gc_bridge::PyObject_GC_Del), // slice.rs addr!(slice::PySlice_New), addr!(slice::PySlice_Check), @@ -548,6 +579,28 @@ static FORCE_LINK: &[FnPtr] = &[ addr_static!(singletons::_Py_FalseStruct), addr_static!(singletons::_Py_NotImplementedStruct), addr_static!(singletons::_Py_EllipsisObject), + // types.rs — the static built-in type objects. A stock extension + // compares `Py_TYPE(o) == &PyFloat_Type` etc., so these data + // symbols must be in the host's dynamic symbol table (RFC 0043). + addr_static!(types::PyType_Type), + addr_static!(types::PyBaseObject_Type), + addr_static!(types::PyLong_Type), + addr_static!(types::PyFloat_Type), + addr_static!(types::PyBool_Type), + addr_static!(types::PyComplex_Type), + addr_static!(types::PyUnicode_Type), + addr_static!(types::PyBytes_Type), + addr_static!(types::PyByteArray_Type), + addr_static!(types::PyTuple_Type), + addr_static!(types::PyList_Type), + addr_static!(types::PyDict_Type), + addr_static!(types::PySet_Type), + addr_static!(types::PyFrozenSet_Type), + addr_static!(types::PyRange_Type), + addr_static!(types::PyModule_Type), + addr_static!(types::PySlice_Type), + addr_static!(types::PyCapsule_Type), + addr_static!(types::PySeqIter_Type), // datetime_api.rs addr_static!(mut dt::PyDateTimeAPI), addr_static!(dt::PyDateTimeAPI_Instance), @@ -616,6 +669,329 @@ static FORCE_LINK: &[FnPtr] = &[ addr_static!(mut errors::PyExc_UnicodeWarning), addr_static!(mut errors::PyExc_BytesWarning), addr_static!(mut errors::PyExc_ResourceWarning), + // ---------------------------------------------------------------- + // RFC 0046 (wave 4): the CPython 3.13 C-API tail stock numpy's + // `_multiarray_umath` links. New leaf implementations live in + // `wave4.rs`; the rest were already implemented in waves 1-3 but had + // never been pinned into the dynamic symbol table. + // ---------------------------------------------------------------- + // wave4.rs — predicates / iteration + addr!(wave4::PyCallable_Check), + addr!(wave4::PyIndex_Check), + addr!(wave4::PyIter_Check), + addr!(wave4::PyObject_SelfIter), + addr!(wave4::PySeqIter_New), + // wave4.rs — sound no-ops + addr!(wave4::PyErr_CheckSignals), + addr!(wave4::PyTraceMalloc_Track), + addr!(wave4::PyTraceMalloc_Untrack), + addr!(wave4::PyType_Modified), + addr!(wave4::PyMutex_Lock), + addr!(wave4::PyMutex_Unlock), + addr!(wave4::PyObject_ClearWeakRefs), + addr!(wave4::PyErr_WriteUnraisable), + // wave4.rs — exception chaining + addr!(wave4::PyException_SetCause), + addr!(wave4::PyException_SetContext), + addr!(wave4::PyException_SetTraceback), + // wave4.rs — dict tail + addr!(wave4::PyDict_GetItemWithError), + addr!(wave4::PyDict_GetItemRef), + addr!(wave4::PyDict_GetItemStringRef), + addr!(wave4::PyDict_ContainsString), + addr!(wave4::PyDict_SetDefaultRef), + addr!(wave4::PyDictProxy_New), + // wave4.rs — numbers + addr!(wave4::PyComplex_AsCComplex), + addr!(wave4::PyComplex_FromCComplex), + addr!(wave4::_PyLong_Sign), + addr!(wave4::_Py_HashDouble), + addr!(wave4::PyLong_FromUnicodeObject), + addr!(wave4::PyFloat_FromString), + // wave4.rs — unicode tail + addr!(wave4::PyUnicode_AsUCS4), + addr!(wave4::PyUnicode_AsUCS4Copy), + addr!(wave4::PyUnicode_Format), + addr!(wave4::_PyUnicode_IsAlpha), + addr!(wave4::_PyUnicode_IsDecimalDigit), + addr!(wave4::_PyUnicode_IsDigit), + addr!(wave4::_PyUnicode_IsNumeric), + addr!(wave4::_PyUnicode_IsLowercase), + addr!(wave4::_PyUnicode_IsUppercase), + addr!(wave4::_PyUnicode_IsTitlecase), + addr!(wave4::_PyUnicode_IsWhitespace), + addr_static!(wave4::_Py_ascii_whitespace), + // wave4.rs — OS string parsing + addr!(wave4::PyOS_string_to_double), + addr!(wave4::PyOS_strtol), + addr!(wave4::PyOS_strtoul), + // wave4.rs — object tail + addr!(wave4::PyObject_AsFileDescriptor), + addr!(wave4::PyObject_GetOptionalAttr), + addr!(wave4::PyObject_Print), + addr!(wave4::PyMethod_New), + // wave4.rs — import / sys / eval + addr!(wave4::PyImport_Import), + addr!(wave4::PySys_GetObject), + addr!(wave4::PyEval_GetBuiltins), + addr!(wave4::PyInterpreterState_Main), + // wave4.rs — errors + addr!(wave4::_PyErr_BadInternalCall), + addr!(wave4::PyErr_SetFromErrno), + // wave4.rs — contextvars + addr!(wave4::PyContextVar_New), + addr!(wave4::PyContextVar_Get), + addr!(wave4::PyContextVar_Set), + // varargs.c — wave-4 variadic shims + addr!(PyOS_snprintf), + addr!(PyErr_WarnFormat), + // Already implemented in waves 1-3, now pinned for numpy. + addr!(numbers::PyLong_AsLongAndOverflow), + addr!(numbers::PyLong_AsLongLongAndOverflow), + addr!(numbers::PyLong_AsVoidPtr), + addr!(numbers::PyLong_FromVoidPtr), + addr!(ab::PyNumber_And), + addr!(ab::PyNumber_AsSsize_t), + addr!(ab::PyNumber_Divmod), + addr!(ab::PyNumber_Index), + addr!(ab::PyNumber_Invert), + addr!(ab::PyNumber_Lshift), + addr!(ab::PyNumber_Or), + addr!(ab::PyNumber_Rshift), + addr!(ab::PyNumber_Xor), + addr!(ab::PyObject_Bytes), + addr!(ab::PyObject_Format), + addr!(ab::PyObject_LengthHint), + addr!(ab::PySequence_Concat), + addr!(ab::PySequence_InPlaceConcat), + addr!(ab::PySequence_InPlaceRepeat), + addr!(ab::PySequence_Repeat), + addr!(containers::PySequence_Fast), + addr!(strings::PyUnicode_AsASCIIString), + addr!(strings::PyUnicode_AsLatin1String), + addr!(strings::PyUnicode_Compare), + addr!(strings::PyUnicode_Contains), + addr!(strings::PyUnicode_FromEncodedObject), + addr!(strings::PyUnicode_FromKindAndData), + addr!(strings::PyUnicode_Replace), + addr!(strings::PyUnicode_Substring), + addr!(strings::PyUnicode_FindChar), + addr!(strings::PyUnicode_Tailmatch), + addr!(ab::Py_EnterRecursiveCall), + addr!(ab::Py_LeaveRecursiveCall), + addr!(strings::PyUnicode_InternFromString), + // wave-4 type-object statics (numpy compares `Py_TYPE(x) == &…`). + addr_static!(types::PyMemoryView_Type), + addr_static!(types::PyDictProxy_Type), + addr_static!(types::PyGetSetDescr_Type), + addr_static!(types::PyMemberDescr_Type), + addr_static!(types::PyMethodDescr_Type), + addr_static!(types::PyWrapperDescr_Type), + addr_static!(types::PyModuleDef_Type), + // IOError alias of OSError. + addr_static!(mut errors::PyExc_IOError), + // ---------------------------------------------------------------- + // RFC 0047 (wave 5): the CPython 3.13 C-API leaf tail that + // Cython-generated extensions (and pandas) link. New leaf + // implementations live in `wave5.rs`; each delegates onto the + // wave-1/2/3 surface. + // ---------------------------------------------------------------- + addr!(wave5::_PyObject_GetDictPtr), + addr!(wave5::PyObject_GetOptionalAttrString), + addr!(wave5::PyMapping_GetOptionalItem), + addr!(wave5::PyMapping_GetOptionalItemString), + addr!(wave5::_PyObject_GetMethod), + addr!(wave5::PyObject_CallMethodOneArg), + addr!(wave5::_PyDict_NewPresized), + addr!(wave5::PyLong_AsInt), + addr!(wave5::PyImport_ImportModuleLevelObject), + // ---------------------------------------------------------------- + // RFC 0047 (wave 5): the *real* Cython-output tail. A genuine + // `cythonize`d `.so` (and pandas, ~70% Cython) links a faithful + // code/frame/traceback surface, a real `PyThreadState` whose + // `current_exception` slot it drives directly + // (`CYTHON_FAST_THREAD_STATE`), the MRO lookup, the module/import + // ref helpers, and a cluster of GC/managed-dict no-ops. These were + // invisible to the hermetic `_stockcython.c` fixture (which created + // no code objects and hand-rolled its own types). + // ---------------------------------------------------------------- + // code_obj.rs — code / frame / traceback facade + addr!(code_obj::PyUnstable_Code_NewWithPosOnlyArgs), + addr!(code_obj::PyUnstable_Code_New), + addr!(code_obj::PyCode_NewEmpty), + addr!(code_obj::PyFrame_New), + addr!(code_obj::PyTraceBack_Here), + addr_static!(code_obj::PyCode_Type), + addr_static!(code_obj::PyFrame_Type), + addr_static!(code_obj::PyTraceBack_Type), + // pystate.rs — faithful thread/interpreter state + addr!(pystate::PyThreadState_GetUnchecked), + addr!(pystate::PyInterpreterState_GetID), + addr!(pystate::PyGC_Enable), + addr!(pystate::PyGC_Disable), + // errors.rs — 3.12+ single-object exception API + addr!(errors::PyErr_GetRaisedException), + addr!(errors::PyErr_SetRaisedException), + // module.rs — import / module ref helpers + addr!(module::PyImport_AddModuleRef), + addr!(module::PyImport_GetModuleDict), + addr!(module::PyModule_NewObject), + addr!(module::PyClassMethod_New), + addr!(module::PyDescr_NewClassMethod), + // wave5.rs — MRO lookup, kwarg validation, GC/managed-dict no-ops + addr!(wave5::_PyType_Lookup), + addr!(wave5::PyArg_ValidateKeywordArguments), + addr!(wave5::PyObject_VisitManagedDict), + addr!(wave5::PyObject_ClearManagedDict), + addr!(wave5::PyObject_GC_IsFinalized), + addr!(wave5::PyObject_CallFinalizerFromDealloc), + addr_static!(wave5::Py_Version), + // ---------------------------------------------------------------- + // RFC 0047 (wave 5): the real numpy.random + pandas leaf tail + // (`crate::wave5_pandas`), plus existing entry points that only an + // extension references and that the linker therefore dead-stripped. + // ---------------------------------------------------------------- + addr!(w5p::PyThread_allocate_lock), + addr!(w5p::PyThread_free_lock), + addr!(w5p::PyThread_acquire_lock), + addr!(w5p::PyThread_acquire_lock_timed), + addr!(w5p::PyThread_release_lock), + addr!(w5p::PyThreadState_GetFrame), + addr!(w5p::PyModule_GetState), + addr!(w5p::PyState_FindModule), + addr!(w5p::PyList_SetSlice), + addr!(w5p::PyException_GetTraceback), + addr!(w5p::PyStaticMethod_New), + addr!(w5p::_PyLong_Copy), + addr!(w5p::PyUnicode_FromWideChar), + addr!(w5p::PyUnicode_DecodeLocale), + addr!(w5p::PyUnicode_EncodeLocale), + addr!(w5p::PyUnicode_Resize), + addr!(w5p::_Py_FatalErrorFunc), + addr!(w5p::PyCMethod_New), + addr_static!(w5p::_PyByteArray_empty_string), + addr!(module::PyCFunction_NewEx), + // Existing definitions referenced only by a dlopen'd extension. + addr!(strings::PyUnicode_FromOrdinal), + addr!(strings::PyUnicode_Decode), + addr!(strings::PyUnicode_New), + addr!(strings::PyUnicode_Split), + addr!(strings::PyUnicode_Join), + addr!(strings::PyUnicode_CopyCharacters), + addr!(strings::PyUnicode_WriteChar), + addr!(strings::PyUnicode_ReadChar), + addr!(ab::PyNumber_InPlaceRshift), + addr!(ab::PyNumber_InPlaceAnd), + addr!(containers::PySet_Pop), + // monitoring.rs — PEP 669 sys.monitoring C-API (no-op surface) + addr!(monitoring::PyMonitoring_EnterScope), + addr!(monitoring::PyMonitoring_ExitScope), + addr!(monitoring::_PyMonitoring_FirePyStartEvent), + addr!(monitoring::_PyMonitoring_FirePyResumeEvent), + addr!(monitoring::_PyMonitoring_FirePyReturnEvent), + addr!(monitoring::_PyMonitoring_FirePyYieldEvent), + addr!(monitoring::_PyMonitoring_FireCallEvent), + addr!(monitoring::_PyMonitoring_FireLineEvent), + addr!(monitoring::_PyMonitoring_FireJumpEvent), + addr!(monitoring::_PyMonitoring_FireBranchEvent), + addr!(monitoring::_PyMonitoring_FireCReturnEvent), + addr!(monitoring::_PyMonitoring_FirePyThrowEvent), + addr!(monitoring::_PyMonitoring_FireRaiseEvent), + addr!(monitoring::_PyMonitoring_FireReraiseEvent), + addr!(monitoring::_PyMonitoring_FireExceptionHandledEvent), + addr!(monitoring::_PyMonitoring_FireCRaiseEvent), + addr!(monitoring::_PyMonitoring_FirePyUnwindEvent), + addr!(monitoring::_PyMonitoring_FireStopIterationEvent), + // Already implemented in earlier waves, now pinned for real Cython. + addr!(containers::PyDict_Pop), + addr!(containers::PyDict_PopString), + addr!(containers::_PyDict_Pop), + addr!(containers::PyDict_SetDefault), + addr!(strings::PyUnicode_DecodeUTF8), + addr!(strings::PyUnicode_InternInPlace), + addr_static!(types::PyCFunction_Type), + addr_static!(types::PyMethod_Type), + // --------------------------------------------------------------- + // Completeness sweep (RFC 0047, wave 5): every `#[no_mangle]` + // `extern "C"` entry point this crate *defines* must be rooted + // here, or the linker dead-strips it and a dlopen'd extension that + // calls it jumps through an unbound stub to a NULL address and + // segfaults — exactly how numpy's `SeedSequence` (`n //= 2**32`, + // i.e. `PyNumber_InPlaceFloorDivide`) crashed. The + // `force_link_completeness` test (tests/force_link_completeness.rs) + // fails the build if any defined symbol is ever missing again, so + // this list is no longer a best-effort guess. + // + // abstract_ — number/sequence/mapping/object protocol. + addr!(ab::PyNumber_InPlaceAdd), + addr!(ab::PyNumber_InPlaceSubtract), + addr!(ab::PyNumber_InPlaceMultiply), + addr!(ab::PyNumber_InPlaceTrueDivide), + addr!(ab::PyNumber_InPlaceFloorDivide), + addr!(ab::PyNumber_InPlaceRemainder), + addr!(ab::PyNumber_InPlacePower), + addr!(ab::PyNumber_InPlaceMatrixMultiply), + addr!(ab::PyNumber_InPlaceLshift), + addr!(ab::PyNumber_InPlaceOr), + addr!(ab::PyNumber_InPlaceXor), + addr!(ab::PyNumber_ToBase), + addr!(ab::PyObject_DelAttr), + addr!(ab::PyObject_GetAttrId), + addr!(ab::PySequence_Count), + addr!(ab::PySequence_Index), + addr!(ab::PySequence_GetSlice), + addr!(ab::PySequence_SetSlice), + addr!(ab::PySequence_DelSlice), + addr!(ab::PyMapping_DelItem), + addr!(ab::PyMapping_DelItemString), + addr!(ab::PyMapping_Keys), + addr!(ab::PyMapping_Values), + addr!(ab::PyMapping_Items), + addr!(ab::_PyObject_GenericGetAttrWithDict), + addr!(ab::_PyObject_GenericSetAttrWithDict), + addr!(ab::_PyObject_LookupAttr), + addr!(ab::_PyObject_LookupAttrId), + addr!(ab::_Py_CheckRecursionLimit), + // containers — list/tuple/set fast accessors. + addr!(containers::PyList_Extend), + addr!(containers::_PyList_Extend), + addr!(containers::_PyList_GET_ITEM), + addr!(containers::_PyList_SET_ITEM), + addr!(containers::_PyTuple_GET_ITEM), + addr!(containers::_PyTuple_SET_ITEM), + addr!(containers::_PyTuple_Resize), + addr!(containers::PySequence_Fast_GET_ITEM), + addr!(containers::PySequence_Fast_GET_SIZE), + addr!(containers::PySequence_Fast_ITEMS), + addr!(containers::PySet_Clear), + // numbers — float/long introspection + IEEE pack/unpack. + addr!(numbers::PyFloat_GetInfo), + addr!(numbers::PyFloat_GetMax), + addr!(numbers::PyFloat_GetMin), + addr!(numbers::PyLong_GetInfo), + addr!(numbers::_PyFloat_Pack4), + addr!(numbers::_PyFloat_Pack8), + addr!(numbers::_PyFloat_Unpack4), + addr!(numbers::_PyFloat_Unpack8), + addr!(numbers::_PyLong_AsByteArray), + addr!(numbers::_PyLong_FromByteArray), + // strings — bytes/bytearray/unicode codecs + helpers. + addr!(strings::PyBytes_Concat), + addr!(strings::PyBytes_ConcatAndDel), + addr!(strings::PyBytes_FromFormat), + addr!(strings::PyByteArray_Concat), + addr!(strings::PyByteArray_Resize), + addr!(strings::PyUnicode_DecodeASCII), + addr!(strings::PyUnicode_DecodeLatin1), + addr!(strings::PyUnicode_DecodeFSDefault), + addr!(strings::PyUnicode_DecodeFSDefaultAndSize), + addr!(strings::PyUnicode_EncodeFSDefault), + addr!(strings::PyUnicode_EqualToUTF8), + addr!(strings::PyUnicode_EqualToUTF8AndSize), + addr!(strings::PyUnicode_Fill), + addr!(strings::PyUnicode_IsIdentifier), + addr!(strings::PyUnicode_RichCompare), + addr!(strings::PyUnicode_Splitlines), ]; /// Hand-out of the table to ensure the static is referenced from diff --git a/crates/weavepy-capi/src/foreign.rs b/crates/weavepy-capi/src/foreign.rs new file mode 100644 index 0000000..d87b36b --- /dev/null +++ b/crates/weavepy-capi/src/foreign.rs @@ -0,0 +1,445 @@ +//! Binary-ABI side of the foreign-object proxy (RFC 0046, wave 4). +//! +//! [`weavepy_vm::foreign`] defines the opaque [`Object::Foreign`] proxy +//! and a table of operation hooks; this module *implements* those hooks +//! on top of the real C-API (`PyObject_Repr`, `PyObject_Call`, +//! `PyNumber_*`, …) and installs them at interpreter start. It is the +//! counterpart of the capsule / instance-body hooks: the VM stays +//! ignorant of cpyext, and every operation on a foreign `PyObject` +//! (numpy's `ndarray`, a static `PyArray_Descr`, a builtin ufunc) +//! round-trips through here. +//! +//! Each hook mirrors the dunder-shim pattern: marshal VM [`Object`]s to +//! `*mut PyObject` with [`into_owned`], run the C call under an active +//! interpreter context ([`ensure_active`]), then convert the result +//! (and any pending exception) back with [`unwrap`]. + +use std::ffi::CStr; + +use weavepy_compiler::{BinOpKind, CompareKind}; +use weavepy_vm::error::{runtime_error, RuntimeError}; +use weavepy_vm::foreign::{self, ForeignHooks}; +use weavepy_vm::object::Object; +use weavepy_vm::sync::Rc; + +use crate::interp::ensure_active; +use crate::object::PyObject; + +/// Wrap a foreign `*mut PyObject` into an [`Object::Foreign`] proxy, +/// caching its `tp_name` and pinning a reference. Called from +/// [`crate::object::clone_object`] for any pointer WeavePy did not mint. +/// +/// # Safety +/// `p` must be a live, non-null `PyObject` whose `ob_type->tp_name` is a +/// valid C string (every real type sets it). +pub unsafe fn wrap_foreign(p: *mut PyObject) -> Object { + let tp_name = unsafe { foreign_tp_name(p) }; + // `type(x).__name__` is the bare tail; CPython's `tp_name`-based error + // messages keep the full dotted string (see `PyForeignSoul::tp_name`). + let bare: Rc = Rc::from(tp_name.rsplit('.').next().unwrap_or(&tp_name)); + if crate::object::freebox_trace_enabled() + && (tp_name.contains("Engine") + || tp_name.contains("Index") + || tp_name.contains("ndarray")) + { + eprintln!( + "[FOREIGN-WRAP] p=0x{:x} type={} refcnt={}", + p as usize, + tp_name, + unsafe { (*p).ob_refcnt } + ); + } + Object::Foreign(foreign::wrap(p as usize, bare, tp_name)) +} + +/// Read `Py_TYPE(p)->tp_name` (the full, unmodified dotted type name) as an +/// owned `Rc`. This is the exact string CPython uses in `tp_name`-based +/// error messages; the bare `__name__` tail is derived by the caller. +unsafe fn foreign_tp_name(p: *mut PyObject) -> Rc { + let ty = unsafe { (*p).ob_type }; + if ty.is_null() { + return Rc::from("object"); + } + let np = unsafe { (*ty).tp_name }; + if np.is_null() { + return Rc::from("object"); + } + Rc::from(unsafe { CStr::from_ptr(np) }.to_string_lossy().as_ref()) +} + +/// Run `body` (a call into compiled extension/Cython code) after +/// re-publishing every seeded faithful list's `ob_item` from its prefix +/// `Rc`. This is the VM→C boundary: a Python-side `list.__setitem__` on a +/// C-resident `cdef public list` (pandas' `BlockManager.axes[0] = …`) only +/// updated the prefix `Rc`, but the extension reads the list back through +/// the inlined `PyList_GET_ITEM` macro, which consults the C `ob_item` +/// buffer. Flushing here keeps the two coherent. Gated on an atomic, so a +/// program that never crossed a list into C pays a single relaxed load. +#[inline] +fn c_call(body: impl FnOnce() -> R) -> R { + // `ensure_active` performs the seeded-list flush at the outermost VM→C + // transition, so the foreign-hook path needs nothing extra here. + ensure_active(body) +} + +// --- result/error marshalling (mirrors dunder_shim's private helpers) --- + +fn pending_or_default() -> RuntimeError { + if let Some(p) = crate::errors::take_pending() { + crate::errors::to_runtime_error(p) + } else { + runtime_error("foreign object operation failed without setting an exception") + } +} + +/// Convert an owned `*mut PyObject` result into an `Object`, consuming +/// the reference. NULL ⇒ the pending exception. +fn unwrap(raw: *mut PyObject) -> Result { + if raw.is_null() { + return Err(pending_or_default()); + } + let obj = unsafe { crate::object::clone_object(raw) }; + unsafe { crate::object::Py_DecRef(raw) }; + Ok(obj) +} + +fn to_string(raw: *mut PyObject) -> Result { + Ok(unwrap(raw)?.to_str()) +} + +// --- the hooks --- + +fn fwd_incref(p: usize) { + crate::object::soul_inc(p); + unsafe { crate::object::Py_IncRef(p as *mut PyObject) }; +} + +fn fwd_decref(p: usize) { + // Drop the live-soul count *before* the decref: the last soul's own + // decref frees the box, and free_box must then see a zero count. + crate::object::soul_dec(p); + unsafe { crate::object::Py_DecRef(p as *mut PyObject) }; +} + +fn fwd_repr(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyObject_Repr(p as *mut PyObject) }); + to_string(raw) +} + +fn fwd_str(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyObject_Str(p as *mut PyObject) }); + to_string(raw) +} + +fn fwd_hash(p: usize) -> Result { + // Call the foreign type's own `tp_hash` slot, NOT `PyObject_Hash`: the + // latter routes back through the VM (`hash_public`), and the VM routes a + // foreign object right back here — an unbounded ping-pong that overflows + // the stack (`hash(np.int64(0))`). `hash_via_slot` consults only the C + // slot, so a numpy scalar hashes like the equal Python int. + let o = p as *mut PyObject; + match c_call(|| unsafe { crate::abstract_::hash_via_slot(o) }) { + Some(h) => { + if h == -1 { + if let Some(pe) = crate::errors::take_pending() { + return Err(crate::errors::to_runtime_error(pe)); + } + } + Ok(h as i64) + } + // No `tp_hash` slot ⇒ an unhashable foreign type. Report failure so + // the VM falls back to an identity hash (its prior behavior) rather + // than mistaking a sentinel for a real hash value. + None => Err(runtime_error("unhashable foreign type")), + } +} + +fn fwd_is_true(p: usize) -> Result { + let r = c_call(|| unsafe { crate::abstract_::PyObject_IsTrue(p as *mut PyObject) }); + if r < 0 { + return Err(pending_or_default()); + } + Ok(r != 0) +} + +fn fwd_call( + p: usize, + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + let keys: Vec<&str> = kwargs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!( + "[TRACE_FWDCALL] nargs={} kwargs={:?}", + args.len(), + keys + ); + } + let callable = p as *mut PyObject; + let args_tuple = crate::object::into_owned(Object::new_tuple(args.to_vec())); + let kw = if kwargs.is_empty() { + std::ptr::null_mut() + } else { + let mut d = weavepy_vm::object::DictData::new(); + for (k, v) in kwargs { + d.insert( + weavepy_vm::object::DictKey(Object::from_str(k.clone())), + v.clone(), + ); + } + crate::object::into_owned(Object::Dict(Rc::new(weavepy_vm::sync::RefCell::new(d)))) + }; + let raw = + c_call(|| unsafe { crate::abstract_::PyObject_Call(callable, args_tuple, kw) }); + unsafe { + crate::object::Py_DecRef(args_tuple); + if !kw.is_null() { + crate::object::Py_DecRef(kw); + } + } + unwrap(raw) +} + +fn fwd_getattr(p: usize, name: &str) -> Result { + if crate::object::freebox_trace_enabled() && (name == "is_unique" || name == "unique") { + let tyname = unsafe { crate::object::debug_type_name(p as *mut PyObject) }; + let rc = unsafe { (*(p as *mut PyObject)).ob_refcnt }; + eprintln!( + "[GETATTR] name={} p=0x{:x} type={} refcnt={}", + name, p, tyname, rc + ); + } + let cname = std::ffi::CString::new(name) + .map_err(|_| runtime_error("attribute name contains a NUL byte"))?; + let raw = c_call(|| unsafe { + crate::abstract_::PyObject_GetAttrString(p as *mut PyObject, cname.as_ptr()) + }); + unwrap(raw) +} + +fn fwd_setattr(p: usize, name: &str, value: Option<&Object>) -> Result<(), RuntimeError> { + let cname = std::ffi::CString::new(name) + .map_err(|_| runtime_error("attribute name contains a NUL byte"))?; + let val = match value { + Some(v) => crate::object::into_owned(v.clone()), + None => std::ptr::null_mut(), + }; + let rc = c_call(|| unsafe { + crate::abstract_::PyObject_SetAttrString(p as *mut PyObject, cname.as_ptr(), val) + }); + if !val.is_null() { + unsafe { crate::object::Py_DecRef(val) }; + } + if rc < 0 { + return Err(pending_or_default()); + } + Ok(()) +} + +fn fwd_getitem(p: usize, key: &Object) -> Result { + let kp = crate::object::into_owned(key.clone()); + let raw = + c_call(|| unsafe { crate::abstract_::PyObject_GetItem(p as *mut PyObject, kp) }); + unsafe { crate::object::Py_DecRef(kp) }; + unwrap(raw) +} + +fn fwd_setitem(p: usize, key: &Object, value: Option<&Object>) -> Result<(), RuntimeError> { + let kp = crate::object::into_owned(key.clone()); + let rc = match value { + Some(v) => { + let vp = crate::object::into_owned(v.clone()); + let rc = c_call(|| unsafe { + crate::abstract_::PyObject_SetItem(p as *mut PyObject, kp, vp) + }); + unsafe { crate::object::Py_DecRef(vp) }; + rc + } + None => { + c_call(|| unsafe { crate::abstract_::PyObject_DelItem(p as *mut PyObject, kp) }) + } + }; + unsafe { crate::object::Py_DecRef(kp) }; + if rc < 0 { + return Err(pending_or_default()); + } + Ok(()) +} + +fn fwd_length(p: usize) -> Result { + let n = c_call(|| unsafe { crate::abstract_::PyObject_Size(p as *mut PyObject) }); + if n < 0 { + return Err(pending_or_default()); + } + Ok(n) +} + +fn fwd_iter(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyObject_GetIter(p as *mut PyObject) }); + unwrap(raw) +} + +fn fwd_iternext(p: usize) -> Result, RuntimeError> { + let raw = c_call(|| unsafe { crate::abstract_::PyIter_Next(p as *mut PyObject) }); + if raw.is_null() { + // NULL with no pending exception ⇒ normal exhaustion. + if let Some(pe) = crate::errors::take_pending() { + return Err(crate::errors::to_runtime_error(pe)); + } + return Ok(None); + } + let obj = unsafe { crate::object::clone_object(raw) }; + unsafe { crate::object::Py_DecRef(raw) }; + Ok(Some(obj)) +} + +type BinFn = unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject; + +fn fwd_binop(op: BinOpKind, a: &Object, b: &Object) -> Result { + use BinOpKind as B; + let ap = crate::object::into_owned(a.clone()); + let bp = crate::object::into_owned(b.clone()); + let raw = c_call(|| unsafe { + match op { + // `**` takes a third (modulus) argument; pass None. + B::Pow => { + let none = crate::singletons::none_ptr(); + crate::object::Py_IncRef(none); + let r = crate::abstract_::PyNumber_Power(ap, bp, none); + crate::object::Py_DecRef(none); + r + } + other => { + let f: BinFn = match other { + B::Add => crate::abstract_::PyNumber_Add, + B::Sub => crate::abstract_::PyNumber_Subtract, + B::Mult => crate::abstract_::PyNumber_Multiply, + B::MatMult => crate::abstract_::PyNumber_MatrixMultiply, + B::Div => crate::abstract_::PyNumber_TrueDivide, + B::FloorDiv => crate::abstract_::PyNumber_FloorDivide, + B::Mod => crate::abstract_::PyNumber_Remainder, + B::LShift => crate::abstract_::PyNumber_Lshift, + B::RShift => crate::abstract_::PyNumber_Rshift, + B::BitOr => crate::abstract_::PyNumber_Or, + B::BitXor => crate::abstract_::PyNumber_Xor, + B::BitAnd => crate::abstract_::PyNumber_And, + B::Pow => unreachable!("handled above"), + }; + f(ap, bp) + } + } + }); + unsafe { + crate::object::Py_DecRef(ap); + crate::object::Py_DecRef(bp); + } + unwrap(raw) +} + +fn fwd_compare(op: CompareKind, a: &Object, b: &Object) -> Result { + use CompareKind as C; + // Mirror Python.h's Py_LT..Py_GE opcodes. + let opid: std::os::raw::c_int = match op { + C::Lt => 0, + C::LtE => 1, + C::Eq => 2, + C::NotEq => 3, + C::Gt => 4, + C::GtE => 5, + }; + let ap = crate::object::into_owned(a.clone()); + let bp = crate::object::into_owned(b.clone()); + // This is the VM→C bridge: the VM's `rich_compare_obj` already decided + // an operand is foreign and is asking the C side whether it can compare + // the pair. Consult ONLY the operands' C `tp_richcompare` slots + // (`richcompare_via_slot`) — NOT the full `PyObject_RichCompare`, which + // on a slot decline falls back to `richcompare_via_vm` and re-enters the + // VM for the *same* pair, producing an unbounded VM↔C ping-pong that + // overflows the native stack (seen with pandas `pivot_table`, where two + // foreign operands both carry declining/absent C compare slots). A + // `NotImplemented` from the C slots is returned to the VM caller, which + // then applies the native default (identity for `==`/`!=`, `TypeError` + // for an ordering) exactly as CPython's `do_richcompare` does. + let raw = c_call(|| unsafe { crate::abstract_::richcompare_via_slot(ap, bp, opid) }); + unsafe { + crate::object::Py_DecRef(ap); + crate::object::Py_DecRef(bp); + } + unwrap(raw) +} + +fn fwd_get_type(p: usize) -> Object { + let ty = unsafe { (*(p as *mut PyObject)).ob_type }; + if ty.is_null() { + return Object::None; + } + unsafe { crate::object::clone_object(ty as *mut PyObject) } +} + +fn fwd_as_float(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyNumber_Float(p as *mut PyObject) }); + unwrap(raw) +} + +fn fwd_as_int(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyNumber_Long(p as *mut PyObject) }); + unwrap(raw) +} + +fn fwd_as_index(p: usize) -> Result { + let raw = c_call(|| unsafe { crate::abstract_::PyNumber_Index(p as *mut PyObject) }); + unwrap(raw) +} + +/// `memoryview(foreign)` — wrap a foreign buffer exporter (numpy's +/// `ndarray`, a Cython `cdef class` with `__getbuffer__`, …) in a VM +/// memoryview. Routes through [`crate::memoryview::PyMemoryView_FromObject`] +/// which drives `PyObject_GetBuffer(PyBUF_FULL_RO)` and preserves the +/// exporter's faithful `format`/`itemsize`/`shape`/`strides`. +fn fwd_get_buffer(p: usize) -> Result { + let raw = c_call(|| unsafe { + crate::memoryview::PyMemoryView_FromObject(p as *mut PyObject) + }); + unwrap(raw) +} + +/// `memoryview(obj)` for an arbitrary VM object — a numpy `ndarray` crosses +/// as a faithful [`Object::Instance`] (wearing its real C type), so it has no +/// foreign soul pointer. Marshal it to a `*mut PyObject` and drive +/// `PyMemoryView_FromObject`, which calls the exporter's `bf_getbuffer`. The +/// temporary cross-reference is released afterwards; the resulting memoryview +/// snapshots the buffer, so it does not depend on `p` staying alive. +fn fwd_get_buffer_obj(obj: &Object) -> Result { + let p = crate::object::into_owned(obj.clone()); + let raw = c_call(|| unsafe { crate::memoryview::PyMemoryView_FromObject(p) }); + unsafe { crate::object::Py_DecRef(p) }; + unwrap(raw) +} + +/// Install the foreign-object bridge into the VM. Idempotent. +pub fn install() { + foreign::install(ForeignHooks { + incref: fwd_incref, + decref: fwd_decref, + repr: fwd_repr, + str: fwd_str, + hash: fwd_hash, + is_true: fwd_is_true, + call: fwd_call, + getattr: fwd_getattr, + setattr: fwd_setattr, + getitem: fwd_getitem, + setitem: fwd_setitem, + length: fwd_length, + iter: fwd_iter, + iternext: fwd_iternext, + binop: fwd_binop, + compare: fwd_compare, + get_type: fwd_get_type, + as_float: fwd_as_float, + as_int: fwd_as_int, + as_index: fwd_as_index, + get_buffer: fwd_get_buffer, + get_buffer_obj: fwd_get_buffer_obj, + }); +} diff --git a/crates/weavepy-capi/src/gc_bridge.rs b/crates/weavepy-capi/src/gc_bridge.rs new file mode 100644 index 0000000..b44319c --- /dev/null +++ b/crates/weavepy-capi/src/gc_bridge.rs @@ -0,0 +1,288 @@ +//! Cycle-collector bridge for C extension types (RFC 0044, WS4). +//! +//! WeavePy's tracing collector already walks an instance's `__dict__` +//! and `__slots__`, so a stock GC type that parks its children there is +//! *already* collectable. The gap this module closes is the other kind +//! of edge: a `Py_TPFLAGS_HAVE_GC` type that stores child references in +//! **C-managed memory** — a side table, a malloc'd struct, anything the +//! VM cannot see. CPython surfaces those edges through the type's +//! `tp_traverse` (enumerate children) and `tp_clear` (drop them to break +//! a cycle) slots. +//! +//! We register one [`traverse`](weavepy_vm::gc_trace::register_traverse) +//! and one [`clear`](weavepy_vm::gc_trace::register_clear) callback with +//! the VM. They fire for any `Object::Instance` whose class is bridged +//! from a C `PyTypeObject` (static, `PyType_FromSpec`, or readied via +//! `PyType_Ready`) that carries the corresponding slot, marshal the +//! instance back into a borrowed `PyObject *`, and invoke the C slot: +//! +//! * `tp_traverse(self, visit, arg)` — we hand it a [`gc_visit`] +//! trampoline that turns each reported child `PyObject *` back into +//! an [`Object`] and forwards it to the collector's visitor. +//! * `tp_clear(self)` — called during the collector's clear phase so +//! the extension drops the references it owns. +//! +//! The slot pointers live at fixed offsets (184 / 192) inside the +//! faithful 416-byte `PyTypeObject` prefix, so reading them is sound for +//! every bridged type pointer regardless of how the type was created. + +use std::os::raw::{c_int, c_void}; + +use weavepy_vm::object::Object; + +use crate::object::{PyObject, PySsizeT}; +use crate::types::PyTypeObject; + +/// `int (*visitproc)(PyObject *object, void *arg)`. +type VisitProc = unsafe extern "C" fn(*mut PyObject, *mut c_void) -> c_int; +/// `int (*traverseproc)(PyObject *self, visitproc visit, void *arg)`. +type TraverseProc = unsafe extern "C" fn(*mut PyObject, VisitProc, *mut c_void) -> c_int; +/// `int (*inquiry)(PyObject *self)` — the shape of `tp_clear`. +type InquiryProc = unsafe extern "C" fn(*mut PyObject) -> c_int; + +/// The live `PyTypeObject *` backing `obj`, if `obj` is an instance of a +/// C-bridged type. (`None` for built-in scalars and pure-Python +/// classes, which never appear as `Object::Instance` over a C type.) +fn instance_type_ptr(obj: &Object) -> Option<*mut PyTypeObject> { + match obj { + Object::Instance(i) => crate::types::type_ptr_for_class(&i.cls()), + _ => None, + } +} + +/// Trampoline handed to `tp_traverse`. `arg` is a thin pointer to the +/// collector's `&mut dyn FnMut(&Object)` visitor (see [`traverse`]). +unsafe extern "C" fn gc_visit(child: *mut PyObject, arg: *mut c_void) -> c_int { + if child.is_null() || arg.is_null() { + return 0; + } + // `arg` points at the `&mut dyn FnMut(&Object)` parked on + // `traverse`'s stack; reborrow it and forward the child. + let visit = unsafe { &mut *(arg as *mut &mut dyn FnMut(&Object)) }; + let obj = unsafe { crate::object::clone_object(child) }; + (*visit)(&obj); + 0 +} + +/// VM traverse callback: invoke the C `tp_traverse` so children held in +/// C-managed memory are reported to the collector. +fn traverse(obj: &Object, visit: &mut dyn FnMut(&Object)) { + let Some(ty) = instance_type_ptr(obj) else { + return; + }; + let slot = unsafe { (*ty).tp_traverse }; + if slot.is_null() { + return; + } + let func: TraverseProc = unsafe { std::mem::transmute::<*mut c_void, TraverseProc>(slot) }; + + // Borrow the instance into C as its declared type via a *fresh*, + // uncached box (RFC 0046, wave 4): traverse must not touch the refcount + // of the cached identity box that a C-held cycle edge points at, or the + // subsequent `tp_clear` cascade that runs the extension `tp_dealloc` + // would be throttled (see [`crate::object::into_owned_with_type_uncached`]). + // The fresh box shares the same underlying `Object`, so an attribute read + // inside `tp_traverse` (e.g. to locate this node's side-table slot) still + // sees the genuine instance dict. + let self_box = crate::object::into_owned_with_type_uncached(obj.clone(), ty); + + // Park the (fat) visitor behind a thin pointer we can smuggle through + // the C `void *arg`. + let mut visit_ref: &mut dyn FnMut(&Object) = visit; + let arg = &mut visit_ref as *mut &mut dyn FnMut(&Object) as *mut c_void; + + crate::interp::ensure_active(|| unsafe { + func(self_box, gc_visit, arg); + }); + unsafe { crate::object::Py_DecRef(self_box) }; +} + +/// VM clear callback: invoke the C `tp_clear` so the extension drops the +/// child references it owns, breaking cycles routed through C memory. +fn clear(obj: &Object) { + let Some(ty) = instance_type_ptr(obj) else { + return; + }; + let slot = unsafe { (*ty).tp_clear }; + if slot.is_null() { + return; + } + let func: InquiryProc = unsafe { std::mem::transmute::<*mut c_void, InquiryProc>(slot) }; + // Fresh, uncached box (RFC 0046, wave 4): `tp_clear` decrefs the *child* + // edges it owns, so the cached cycle-child boxes must be at exactly the + // refcount the extension expects for the stock `Py_CLEAR` cascade to run + // each node's `tp_dealloc` once. Borrowing `self` through the cache would + // pin one of those children and skip its cleanup. See + // [`crate::object::into_owned_with_type_uncached`]. + let self_box = crate::object::into_owned_with_type_uncached(obj.clone(), ty); + crate::interp::ensure_active(|| unsafe { + func(self_box); + }); + unsafe { crate::object::Py_DecRef(self_box) }; +} + +/// True when `obj` is an instance whose C type carries a `tp_traverse`. +fn has_traverse(obj: &Object) -> bool { + instance_type_ptr(obj) + .map(|ty| !unsafe { (*ty).tp_traverse }.is_null()) + .unwrap_or(false) +} + +/// True when `obj` is an instance whose C type carries a `tp_clear`. +fn has_clear(obj: &Object) -> bool { + instance_type_ptr(obj) + .map(|ty| !unsafe { (*ty).tp_clear }.is_null()) + .unwrap_or(false) +} + +/// Register the traverse/clear bridges with the VM collector. Called +/// once from [`crate::interp::ensure_initialised`], before any extension +/// code (hence any C GC type) can run. +pub fn install() { + weavepy_vm::gc_trace::register_traverse(has_traverse, traverse); + weavepy_vm::gc_trace::register_clear(has_clear, clear); +} + +// ==================================================================== +// GC allocation + tracking C-API (RFC 0044, WS4) +// +// A `Py_TPFLAGS_HAVE_GC` extension type allocates its instances through +// `PyObject_GC_New` / `_PyObject_GC_New` (never the plain object +// allocator), then enrols them with the collector via +// `PyObject_GC_Track` once their fields are initialised, and finally +// frees them with `PyObject_GC_Del` from `tp_dealloc`. We back the whole +// family by the existing `PyType_GenericAlloc` storage model and the VM +// collector's `track` / `untrack` registry. +// ==================================================================== + +/// `_PyObject_GC_New(tp)` — allocate a GC-tracked-capable instance. +/// +/// Allocation only: like CPython, the caller is responsible for the +/// follow-up `PyObject_GC_Track`. The storage and header are identical +/// to [`PyType_GenericAlloc`](crate::genericalloc::PyType_GenericAlloc), +/// so a readied type's instance is seeded with a real `Object::Instance` +/// payload. +/// +/// # Safety +/// `tp` must be a valid `PyTypeObject *` (typically a static GC type +/// finalised through `PyType_Ready`). +#[no_mangle] +pub unsafe extern "C" fn _PyObject_GC_New(tp: *mut PyTypeObject) -> *mut PyObject { + unsafe { crate::genericalloc::PyType_GenericAlloc(tp, 0) } +} + +/// `_PyObject_GC_NewVar(tp, nitems)` — variable-size GC allocation. +/// +/// # Safety +/// Same contract as [`_PyObject_GC_New`]. +#[no_mangle] +pub unsafe extern "C" fn _PyObject_GC_NewVar( + tp: *mut PyTypeObject, + nitems: PySsizeT, +) -> *mut PyObject { + unsafe { crate::genericalloc::PyType_GenericAlloc(tp, nitems) } +} + +/// `PyObject_GC_Track(op)` — start tracking `op` with the cycle +/// collector. Enrolling the bridged `Object::Instance` makes a cycle +/// routed through this object collectable, including edges that only its +/// `tp_traverse` can surface. +/// +/// # Safety +/// `op` must be a live object pointer previously produced by the GC +/// allocator (or any WeavePy box / mirror). +#[no_mangle] +pub unsafe extern "C" fn PyObject_GC_Track(op: *mut c_void) { + if op.is_null() { + return; + } + // A *foreign* pointer is never enrolled in WeavePy's cycle tracker (only + // `Object::Instance` is), and `clone_object` on a foreign pointer takes an + // owning reference (incref, then decref on drop). When a foreign type's + // `tp_dealloc` calls this at refcount 0, that transient toggle re-enters + // `free_box` → `tp_dealloc` (infinite recursion, seen in pandas' Cython + // `IndexEngine` dealloc). Skip foreign pointers entirely. + if !crate::object::is_weavepy_owned(op as *mut PyObject) { + return; + } + let obj = unsafe { crate::object::clone_object(op as *mut PyObject) }; + if matches!(obj, Object::Instance(_)) { + weavepy_vm::gc_trace::track(obj); + } +} + +/// `PyObject_GC_UnTrack(op)` — stop tracking `op` (the inverse of +/// [`PyObject_GC_Track`]). Idempotent and safe to call on an object that +/// was never tracked, matching CPython's contract for `tp_dealloc`. +/// +/// # Safety +/// Same contract as [`PyObject_GC_Track`]. +#[no_mangle] +pub unsafe extern "C" fn PyObject_GC_UnTrack(op: *mut c_void) { + if op.is_null() { + return; + } + // Foreign objects are never tracked (see [`PyObject_GC_Track`]), and + // `clone_object`-ing one here takes/drops an owning reference — fatal when + // a foreign `tp_dealloc` calls this at refcount 0 (re-entrant free → + // infinite recursion). Untracking a foreign pointer is a guaranteed no-op. + if !crate::object::is_weavepy_owned(op as *mut PyObject) { + return; + } + let obj = unsafe { crate::object::clone_object(op as *mut PyObject) }; + weavepy_vm::gc_trace::untrack(&obj); +} + +/// `PyObject_GC_IsTracked(op)` — 1 if `op` is currently tracked by the +/// cycle collector, else 0 (CPython 3.9+ public API). +/// +/// # Safety +/// Same contract as [`PyObject_GC_Track`]. +#[no_mangle] +pub unsafe extern "C" fn PyObject_GC_IsTracked(op: *mut c_void) -> c_int { + if op.is_null() { + return 0; + } + // Foreign objects are never tracked; avoid the owning `clone_object`. + if !crate::object::is_weavepy_owned(op as *mut PyObject) { + return 0; + } + let obj = unsafe { crate::object::clone_object(op as *mut PyObject) }; + let id = weavepy_vm::weakref_registry::id_of(&obj); + c_int::from(weavepy_vm::gc_trace::is_tracked(id)) +} + +/// `PyObject_GC_Del(op)` — free a GC object's storage (a GC type's +/// `tp_free`). Untracks defensively, then releases the box/mirror. +/// +/// # Safety +/// `op` must be a live object whose refcount has reached zero (exactly +/// how `tp_dealloc` invokes `tp_free`). +#[no_mangle] +pub unsafe extern "C" fn PyObject_GC_Del(op: *mut c_void) { + if op.is_null() { + return; + } + let p = op as *mut PyObject; + // RFC 0045 (wave 3): a faithful inline instance body is owned by its + // native instance. A stock GC-type `tp_dealloc` that ends with + // `PyObject_GC_Del(self)` is absorbed — the block is reclaimed when + // the instance is collected, not here. + if unsafe { crate::mirror::is_instance_body(p) } { + return; + } + // A *foreign* object's storage is not ours to reclaim, and both + // `clone_object` (which takes an owning reference) and + // `_PyWeavePy_Dealloc` (which re-dispatches `tp_dealloc`) would re-enter + // the very dealloc that called this `tp_free`, recursing until the stack + // overflows (pandas' Cython `IndexEngine`). `tp_free` on a foreign block + // is a no-op here (the extension keeps its own storage model). + if !crate::object::is_weavepy_owned(p) { + return; + } + let obj = unsafe { crate::object::clone_object(p) }; + weavepy_vm::gc_trace::untrack(&obj); + drop(obj); + // Release the storage through the canonical box/mirror free path. + unsafe { crate::object::_PyWeavePy_Dealloc(p) }; +} diff --git a/crates/weavepy-capi/src/genericalloc.rs b/crates/weavepy-capi/src/genericalloc.rs index 8ff510c..04e8022 100644 --- a/crates/weavepy-capi/src/genericalloc.rs +++ b/crates/weavepy-capi/src/genericalloc.rs @@ -51,6 +51,35 @@ pub unsafe extern "C" fn PyType_GenericAlloc( return ptr::null_mut(); } crate::interp::ensure_initialised(); + + if std::env::var_os("WEAVEPY_TRACE_CTOR").is_some() { + eprintln!( + "[CTOR] genericalloc name={} ty={:p} inline={} readied={} basicsize={} nitems={}", + crate::types::ctor_trace_name(ty), + ty, + crate::types::is_inline_instance_type(ty), + crate::types::is_readied_type(ty), + unsafe { (*ty).tp_basicsize }, + nitems, + ); + } + + // RFC 0045 (wave 3): an inline-storage type (`tp_basicsize > + // sizeof(PyObject)`) gets a faithful `tp_basicsize`-wide body whose + // fields live at their declared offsets — the `PyArrayObject` shape — + // rather than the legacy `PyObjectBox` (whose Rust payload would sit + // exactly where the extension expects `self->field`). The fresh + // instance is owned by the VM and pinned for C's borrow. + if crate::types::is_inline_instance_type(ty) { + let body = crate::instance::make_inline_instance(ty, nitems); + if body.is_null() { + crate::errors::set_runtime_error("PyType_GenericAlloc: type is not bridged"); + return ptr::null_mut(); + } + unsafe { crate::object::Py_IncRef(ty as *mut PyObject) }; + return body; + } + let basicsize = unsafe { (*ty).tp_basicsize }; let itemsize = unsafe { (*ty).tp_itemsize }; let total = basicsize.max(std::mem::size_of::() as PySsizeT) @@ -71,6 +100,23 @@ pub unsafe extern "C" fn PyType_GenericAlloc( return ptr::null_mut(); } + // Seed the payload with a real `Object::Instance` bound to the bridged + // class, so the extension's `tp_new`/`tp_init` and + // `PyObject_SetAttrString` operate on a genuine instance whose + // `__dict__` round-trips through `clone_object`. This covers both stock + // types finalised via `PyType_Ready` (RFC 0044, WS5) and the heap + // mirrors minted by `install_user_type` for a *VM* class that a foreign + // C `tp_new` allocates — pandas' `class NAType(C_NAType)`, whose cdef + // base's `__pyx_tp_new` calls `NAType->tp_alloc(NAType, 0)` and expects + // back a real `NAType` instance (not the `Object::None` placeholder). + // A foreign (un-bridged) extension type keeps the historical `None`. + let payload_obj = match unsafe { crate::types::bridge_type(ty) } { + Some(cls) => Object::Instance(weavepy_vm::sync::Rc::new( + weavepy_vm::types::PyInstance::new(cls), + )), + None => Object::None, + }; + // Use placement-style initialisation: write a fresh PyObjectBox // header into the start of the allocation. We use ptr::write // (not deref-assign) because the underlying storage is @@ -84,11 +130,18 @@ pub unsafe extern "C" fn PyType_GenericAlloc( ob_refcnt: 1, ob_type: ty, }, - payload: PayloadCell::from_object(Object::None), + payload: PayloadCell::from_object(payload_obj), }, ); crate::object::Py_IncRef(ty as *mut PyObject); } + crate::object::register_minted(raw as *mut PyObject); + if crate::object::freebox_trace_enabled() { + let tyname = unsafe { crate::object::debug_type_name(raw as *mut PyObject) }; + if tyname.contains("Engine") { + eprintln!("[ALLOC] genericalloc p=0x{:x} type={}", raw as usize, tyname); + } + } raw as *mut PyObject } @@ -156,23 +209,102 @@ pub unsafe extern "C" fn PyObject_InitVar( unsafe { PyObject_Init(o, ty) } } -/// `PyObject_GenericGetAttr(o, name)` — default `__getattribute__`. -/// Walks the type's MRO + the instance dict. +/// `PyObject_GenericGetAttr(o, name)` — default `__getattribute__`: +/// data descriptor → instance dict → class attr, the `object.__getattribute__` +/// body. +/// +/// Crucially this is the **generic** body, not full attribute dispatch: a C +/// extension type routinely sets `tp_getattro = PyObject_GenericGetAttr` (or +/// calls it as the fallback inside its own `tp_getattro`, e.g. a proxy that +/// special-cases one name). Delegating to [`PyObject_GetAttr`] would re-enter +/// that very slot — the VM's `LOAD_ATTR` dispatches the type's +/// `__getattribute__`, which is the C slot — and recurse until the stack +/// overflows. For a bridged instance we therefore call the VM's *default* +/// `object.__getattribute__` body, which never re-dispatches the override. #[no_mangle] pub unsafe extern "C" fn PyObject_GenericGetAttr( o: *mut PyObject, name: *mut PyObject, ) -> *mut PyObject { + if o.is_null() || name.is_null() { + return ptr::null_mut(); + } + let obj = unsafe { crate::object::clone_object(o) }; + // Only a bridged *instance* can route attribute access back through its + // type's `tp_getattro`; every other shape keeps the historical full + // dispatch (and so its `__getattr__` fallback, special getsets, …). + if matches!(obj, Object::Instance(_)) { + let key = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + _ => { + crate::errors::set_pending( + Some(weavepy_vm::builtin_types::builtin_types().type_error.clone()), + Object::from_str("attribute name must be string"), + ); + return ptr::null_mut(); + } + }; + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| interp.generic_getattr_public(&obj, &key)) + }) { + return match res { + Ok(v) => crate::object::into_owned(v), + Err(e) => { + crate::errors::set_pending_from_runtime(e); + ptr::null_mut() + } + }; + } + } unsafe { crate::abstract_::PyObject_GetAttr(o, name) } } -/// `PyObject_GenericSetAttr(o, name, value)` — default `__setattr__`. +/// `PyObject_GenericSetAttr(o, name, value)` — default `__setattr__` / +/// `__delattr__` (`value == NULL` deletes). +/// +/// See [`PyObject_GenericGetAttr`]: this is the generic `object.__setattr__` +/// body, *not* full dispatch, so a C type whose `tp_setattro` calls +/// `PyObject_GenericSetAttr` does not recurse back into its own slot. #[no_mangle] pub unsafe extern "C" fn PyObject_GenericSetAttr( o: *mut PyObject, name: *mut PyObject, value: *mut PyObject, ) -> c_int { + if o.is_null() || name.is_null() { + return -1; + } + let obj = unsafe { crate::object::clone_object(o) }; + if matches!(obj, Object::Instance(_)) { + let key = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + _ => { + crate::errors::set_pending( + Some(weavepy_vm::builtin_types::builtin_types().type_error.clone()), + Object::from_str("attribute name must be string"), + ); + return -1; + } + }; + let val = if value.is_null() { + None + } else { + Some(unsafe { crate::object::clone_object(value) }) + }; + if let Some(res) = crate::interp::ensure_active(|| { + crate::interp::with_interp_mut(|interp| { + interp.generic_setattr_public(&obj, &key, val.clone()) + }) + }) { + return match res { + Ok(()) => 0, + Err(e) => { + crate::errors::set_pending_from_runtime(e); + -1 + } + }; + } + } unsafe { crate::abstract_::PyObject_SetAttr(o, name, value) } } diff --git a/crates/weavepy-capi/src/getset.rs b/crates/weavepy-capi/src/getset.rs index 35e0691..7ac88cb 100644 --- a/crates/weavepy-capi/src/getset.rs +++ b/crates/weavepy-capi/src/getset.rs @@ -24,6 +24,12 @@ use weavepy_vm::sync::Rc; use crate::object::{PyObject, PySsizeT}; +fn blocks_diag_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_BLOCKS_DIAG").is_some()) +} + fn take_pending_or_default() -> RuntimeError { if let Some(err) = crate::errors::take_pending_error_runtime() { err @@ -145,21 +151,54 @@ fn make_getter( ))); } let self_p = crate::object::into_owned(args[0].clone()); + let self_body = self_p as usize; let raw = unsafe { g(self_p, closure as *mut std::ffi::c_void) }; unsafe { crate::object::Py_DecRef(self_p) }; if raw.is_null() { return Err(take_pending_or_default()); } let out = unsafe { crate::object::clone_object(raw) }; + if name == "blocks" { + let on = out.type_name_owned(); + if blocks_diag_enabled() && on != "tuple" && on != "list" { + let rawty = unsafe { crate::object::debug_type_name(raw) }; + eprintln!( + "[BLOCKS-BAD] self_body=0x{:x} raw=0x{:x} rawtype={} out={}", + self_body, + raw as usize, + rawty, + on, + ); + if let Some(hist) = crate::mirror::lookup_free_bt(raw as usize) { + eprintln!("[BLOCKS-BAD] raw 0x{:x} free history ({} frees):", raw as usize, hist.len()); + for (i, (freed_ty, bt)) in hist.iter().enumerate() { + eprintln!(" free #{i}: type={freed_ty} :: {bt}"); + } + } else { + eprintln!( + "[BLOCKS-BAD] raw 0x{:x} has no recorded mirror-free (freed via non-mirror path?)", + raw as usize + ); + } + } + if crate::mirror::watch_enabled() && (on == "tuple" || on == "list") { + crate::mirror::watch_ptr(raw as usize); + } + } unsafe { crate::object::Py_DecRef(raw) }; Ok(out) }; - Object::Builtin(Rc::new(BuiltinFn { + let getter = Object::Builtin(Rc::new(BuiltinFn { name, binds_instance: false, call: Box::new(body), call_kw: None, - })) + })); + // A getset getter is named after the C attribute (`numpy.dtype.str`); + // tag it so the VM invokes this closure directly rather than routing a + // `BuiltinFn { name: "str" }` through the `str(obj)` builtin fast-path. + weavepy_vm::descr_registry::mark_native_descr_accessor(&getter); + getter } fn make_setter( @@ -185,24 +224,29 @@ fn make_setter( } Ok(Object::None) }; - Object::Builtin(Rc::new(BuiltinFn { + let setter = Object::Builtin(Rc::new(BuiltinFn { name, binds_instance: false, call: Box::new(body), call_kw: None, - })) + })); + weavepy_vm::descr_registry::mark_native_descr_accessor(&setter); + setter } -/// Decode a null-terminated `PyMemberDef[]` array into descriptor -/// pairs. +/// Decode a null-terminated `PyMemberDef[]` array into descriptor pairs. +/// +/// Each member projects its declared `T_*` field, at its `offset`, in the +/// instance's **faithful inline body** (RFC 0045, wave 3) to/from a Python +/// value: `obj.field` (Python) and `self->field` (C) read and write *the +/// same bytes*. Read-only members (`READONLY` in `flags`) install a getter +/// with no setter, so assignment raises `AttributeError` via the property +/// protocol. The descriptor is a real [`Object::Property`], so data- +/// descriptor priority and automatic invocation on attribute access apply. /// -/// Members that mark `T_OBJECT*` simply return `None` for now; we -/// don't currently project into raw extension memory because heap -/// types backed by `PyType_FromSpec` use a `PyObjectBox` whose -/// extra storage is opaque to the runtime. Numeric members -/// (`T_INT`, `T_DOUBLE`, …) likewise return None — extensions that -/// declare them are responsible for synthesising a getset pair if -/// they want runtime access. +/// A member access on an object that has no faithful inline body (e.g. the +/// type is dict-backed, `tp_basicsize == sizeof(PyObject)`) reads as +/// `None` and rejects writes — the offset would not name a real field. pub unsafe fn collect_members(mut defs: *mut PyMemberDef) -> Vec<(String, Object)> { let mut out = Vec::new(); if defs.is_null() { @@ -217,30 +261,229 @@ pub unsafe fn collect_members(mut defs: *mut PyMemberDef) -> Vec<(String, Object .to_string_lossy() .into_owned(); let static_name: &'static str = Box::leak(name.clone().into_boxed_str()); - let _ = entry.ty; - let _ = entry.offset; - let f = move |args: &[Object]| -> Result { - // For now members are read-only stubs. Extensions that - // want full support of typed members should declare a - // getset pair instead. - if args.is_empty() { - return Err(type_error(format!( - "attribute '{}' invocation expects self", - static_name - ))); - } - Ok(Object::None) - }; + let readonly = (entry.flags & READONLY) != 0; out.push(( name, - Object::Builtin(Rc::new(BuiltinFn { - name: static_name, - binds_instance: false, - call: Box::new(f), - call_kw: None, - })), + make_member(static_name, entry.ty, entry.offset, readonly), )); defs = unsafe { defs.add(1) }; } out } + +/// Build the `Object::Property` descriptor for one `tp_members` entry +/// (RFC 0045, wave 3). The getter/setter cross `self` into C — which, for +/// an inline-storage instance, yields its stable faithful body (see +/// [`crate::object::into_owned`]) — then read/write the typed field at +/// `offset` directly in that body. +fn make_member(name: &'static str, ty: c_int, offset: PySsizeT, readonly: bool) -> Object { + let getter = { + let g = move |args: &[Object]| -> Result { + let self_obj = args + .first() + .ok_or_else(|| type_error(format!("member '{name}' getter expects self")))?; + let self_p = crate::object::into_owned(self_obj.clone()); + // A `tp_members` field lives at a fixed offset in the instance's + // C struct. That storage exists for two kinds of object: one of + // *our* inline-storage instances (RFC 0045) and a *foreign* + // object the extension allocated itself (its `PyObject*` points + // at a real C struct laid out by the declaring type — numpy's + // `PyArray_Descr.typeobj` is read this way). For a plain + // dict-backed instance the offset names no real field, so it + // reads as `None`. + let has_field = matches!(self_obj, Object::Foreign(_)) + || unsafe { crate::mirror::is_instance_body(self_p) }; + let out = if has_field { + unsafe { read_member(self_p, ty, offset) } + } else { + Object::None + }; + unsafe { crate::object::Py_DecRef(self_p) }; + Ok(out) + }; + let getter = Object::Builtin(Rc::new(BuiltinFn { + name, + binds_instance: false, + call: Box::new(g), + call_kw: None, + })); + weavepy_vm::descr_registry::mark_native_descr_accessor(&getter); + getter + }; + let setter = if readonly { + Object::None + } else { + let s = move |args: &[Object]| -> Result { + if args.len() != 2 { + return Err(type_error(format!( + "member '{name}' setter expects (self, value)" + ))); + } + let self_p = crate::object::into_owned(args[0].clone()); + let has_field = matches!(&args[0], Object::Foreign(_)) + || unsafe { crate::mirror::is_instance_body(self_p) }; + let res = if has_field { + unsafe { write_member(self_p, ty, offset, &args[1]) } + } else { + Err(type_error(format!( + "cannot set member '{name}' on an object without inline storage" + ))) + }; + unsafe { crate::object::Py_DecRef(self_p) }; + res.map(|()| Object::None) + }; + let setter = Object::Builtin(Rc::new(BuiltinFn { + name, + binds_instance: false, + call: Box::new(s), + call_kw: None, + })); + weavepy_vm::descr_registry::mark_native_descr_accessor(&setter); + setter + }; + Object::Property(Rc::new(PyProperty::new( + getter, + setter, + Object::None, + Object::None, + ))) +} + +/// Wrap a C integer of any width into the narrowest faithful `Object` +/// (`Int` when it fits in `i64`, else a big `Long`). +fn int_obj(v: i128) -> Object { + match i64::try_from(v) { + Ok(i) => Object::Int(i), + Err(_) => Object::Long(Rc::new(num_bigint::BigInt::from(v))), + } +} + +/// Read the `T_*` field of `body` at `offset` into a Python value +/// (RFC 0045, wave 3). `body` must be a faithful instance body, and +/// `offset` must name a field within its `tp_basicsize` block. +// Wildcard import of the local `T_*` constant module: this `match` dispatches +// over (almost) every member-type tag, so a wildcard is clearer than a +// 20-name list. +#[allow(clippy::wildcard_imports)] +unsafe fn read_member(body: *mut PyObject, ty: c_int, offset: PySsizeT) -> Object { + use member_types::*; + let field = unsafe { (body as *const u8).offset(offset as isize) }; + unsafe { + match ty { + T_BOOL => Object::Bool(std::ptr::read_unaligned(field as *const i8) != 0), + T_BYTE => int_obj(std::ptr::read_unaligned(field as *const i8) as i128), + T_UBYTE => int_obj(std::ptr::read_unaligned(field as *const u8) as i128), + T_SHORT => int_obj(std::ptr::read_unaligned(field as *const i16) as i128), + T_USHORT => int_obj(std::ptr::read_unaligned(field as *const u16) as i128), + T_INT => int_obj(std::ptr::read_unaligned(field as *const i32) as i128), + T_UINT => int_obj(std::ptr::read_unaligned(field as *const u32) as i128), + T_LONG => { + int_obj(std::ptr::read_unaligned(field as *const std::os::raw::c_long) as i128) + } + T_ULONG => { + int_obj(std::ptr::read_unaligned(field as *const std::os::raw::c_ulong) as i128) + } + T_LONGLONG => int_obj(std::ptr::read_unaligned(field as *const i64) as i128), + T_ULONGLONG => int_obj(std::ptr::read_unaligned(field as *const u64) as i128), + T_PYSSIZET => int_obj(std::ptr::read_unaligned(field as *const isize) as i128), + T_FLOAT => Object::Float(std::ptr::read_unaligned(field as *const f32) as f64), + T_DOUBLE => Object::Float(std::ptr::read_unaligned(field as *const f64)), + T_CHAR => { + let c = std::ptr::read_unaligned(field as *const u8); + Object::Str(Rc::from((c as char).to_string().as_str())) + } + T_STRING => { + let p = std::ptr::read_unaligned(field as *const *const c_char); + if p.is_null() { + Object::None + } else { + Object::Str(Rc::from(CStr::from_ptr(p).to_string_lossy().as_ref())) + } + } + T_OBJECT | T_OBJECT_EX => { + let p = std::ptr::read_unaligned(field as *const *mut PyObject); + if p.is_null() { + Object::None + } else { + crate::object::clone_object(p) + } + } + _ => Object::None, + } + } +} + +/// Coerce a Python value to `i64` for an integer member assignment. +fn obj_i64(v: &Object) -> Result { + match v { + Object::Int(i) => Ok(*i), + Object::Bool(b) => Ok(*b as i64), + Object::Long(big) => { + use num_traits::ToPrimitive; + big.to_i64() + .ok_or_else(|| type_error("integer too large for this member")) + } + _ => Err(type_error("member assignment expects an integer")), + } +} + +/// Coerce a Python value to `f64` for a floating-point member assignment. +fn obj_f64(v: &Object) -> Result { + match v { + Object::Float(f) => Ok(*f), + Object::Int(i) => Ok(*i as f64), + Object::Bool(b) => Ok(*b as i64 as f64), + _ => Err(type_error("member assignment expects a real number")), + } +} + +/// Write a Python value into the `T_*` field of `body` at `offset` +/// (RFC 0045, wave 3). Mirrors [`read_member`]; numeric and object members +/// are writable, while the borrowed-pointer members (`T_STRING`, `T_CHAR`) +/// are treated as read-only (assigning one would require owning C storage). +#[allow(clippy::wildcard_imports)] +unsafe fn write_member( + body: *mut PyObject, + ty: c_int, + offset: PySsizeT, + value: &Object, +) -> Result<(), RuntimeError> { + use member_types::*; + let field = unsafe { (body as *mut u8).offset(offset as isize) }; + unsafe { + match ty { + T_BOOL => std::ptr::write_unaligned(field as *mut i8, i8::from(obj_i64(value)? != 0)), + T_BYTE => std::ptr::write_unaligned(field as *mut i8, obj_i64(value)? as i8), + T_UBYTE => std::ptr::write_unaligned(field as *mut u8, obj_i64(value)? as u8), + T_SHORT => std::ptr::write_unaligned(field as *mut i16, obj_i64(value)? as i16), + T_USHORT => std::ptr::write_unaligned(field as *mut u16, obj_i64(value)? as u16), + T_INT => std::ptr::write_unaligned(field as *mut i32, obj_i64(value)? as i32), + T_UINT => std::ptr::write_unaligned(field as *mut u32, obj_i64(value)? as u32), + T_LONG => { + std::ptr::write_unaligned(field as *mut std::os::raw::c_long, obj_i64(value)? as _) + } + T_ULONG => { + std::ptr::write_unaligned(field as *mut std::os::raw::c_ulong, obj_i64(value)? as _) + } + T_LONGLONG => std::ptr::write_unaligned(field as *mut i64, obj_i64(value)?), + T_ULONGLONG => std::ptr::write_unaligned(field as *mut u64, obj_i64(value)? as u64), + T_PYSSIZET => std::ptr::write_unaligned(field as *mut isize, obj_i64(value)? as isize), + T_FLOAT => std::ptr::write_unaligned(field as *mut f32, obj_f64(value)? as f32), + T_DOUBLE => std::ptr::write_unaligned(field as *mut f64, obj_f64(value)?), + T_OBJECT | T_OBJECT_EX => { + // Own a reference for the field, release the previous one. + let new_p = crate::object::into_owned(value.clone()); + let old = std::ptr::read_unaligned(field as *const *mut PyObject); + std::ptr::write_unaligned(field as *mut *mut PyObject, new_p); + if !old.is_null() { + crate::object::Py_DecRef(old); + } + } + T_STRING | T_CHAR | T_STRING_INPLACE | T_NONE => { + return Err(type_error("readonly attribute")); + } + _ => return Err(type_error("unsupported member type")), + } + } + Ok(()) +} diff --git a/crates/weavepy-capi/src/inherit.rs b/crates/weavepy-capi/src/inherit.rs new file mode 100644 index 0000000..1537f19 --- /dev/null +++ b/crates/weavepy-capi/src/inherit.rs @@ -0,0 +1,202 @@ +//! RFC 0047 (wave 5): faithful `inherit_slots`. +//! +//! CPython's `PyType_Ready` finishes by running `inherit_slots` +//! (`Objects/typeobject.c`): every `tp_*` function slot and method-suite +//! entry the subtype leaves NULL is copied down from its base, so that an +//! inlined `Py_TYPE(self)->tp_repr(self)` on a *subclass* resolves to the +//! function the base defined. Waves 1-4 did **not** do this: a readied +//! subtype carried only the slots it spelled out itself, and inherited +//! behaviour was reached *only* through the bridged type's MRO (the +//! synthesised dunder shims). That is correct for Python-level dispatch +//! (`sub + other` finds `Base.__add__` via the MRO) but wrong for the +//! dominant Cython idiom: Cython-generated code reads +//! `Py_TYPE(obj)->tp_as_number->nb_add` (and friends) **directly off the +//! C struct**, with no MRO walk. On a subclass whose `tp_as_number` was +//! NULL that is a NULL-deref. RFC 0046 §2.7 shipped a per-call `tp_base` +//! walk as a stop-gap for the repr/str path and named the real fix as +//! wave-5 work; this module is that fix. +//! +//! ## What this bakes in, and where +//! +//! At `PyType_Ready` time, immediately after the bridged type is built +//! (so the type dict still carries only the subtype's *own* dunders — +//! the inherited ones are reached through the MRO, exactly as CPython), +//! [`inherit_slots`] copies, from the **immediate base only**: +//! +//! 1. **The decoded [`SlotTable`].** Every slot id the subtype left NULL +//! is filled from the base's table, so the direct-table-read dispatch +//! paths (the buffer protocol, vectorcall, `tp_descr_get`/`set`, the GC +//! bridge) and the `has_*_protocol` queries see the inherited slot. +//! 2. **The faithful `PyTypeObject` struct.** Every NULL direct function +//! slot and every NULL/partial method suite (`tp_as_number`, …) is +//! filled, so an extension's inlined `Py_TYPE(self)->tp_*` read lands +//! on the inherited function. +//! +//! Copying only from the *immediate* base is sufficient and complete: +//! `PyType_Ready` readies a type's base before the type itself +//! (`bridge_or_ready(tp_base)` during harvest), and each base was itself +//! run through `inherit_slots`, so the immediate base's table and struct +//! are already **fully flattened**. One level of copy therefore carries +//! the whole ancestor chain — the same invariant CPython relies on. + +use core::ffi::c_int; +use core::ffi::c_void; +use core::mem::size_of; + +use crate::layout; +use crate::slottable::{self, SlotTable}; +use crate::types::PyTypeObject; + +/// Bake every slot the base provides but the subtype `t` leaves NULL into +/// the subtype's decoded `table` and its faithful struct — the wave-5 +/// equivalent of CPython's `inherit_slots`. +/// +/// `base` is the subtype's `tp_base` (already readied + flattened). When +/// either pointer is null, or the base is a WeavePy-native built-in (whose +/// behaviour the VM provides through the MRO rather than C slots), this is +/// a no-op. +/// +/// # Safety +/// `t` must be a freshly-harvested, writable faithful `PyTypeObject`; +/// `base` must be null or a valid (readied or built-in) `PyTypeObject`. +pub unsafe fn inherit_slots(t: *mut PyTypeObject, table: &mut SlotTable, base: *mut PyTypeObject) { + if t.is_null() || base.is_null() { + return; + } + + // (A) Complete the decoded slot table from the base's flattened one. + if let Some(base_table) = unsafe { slottable::slot_table_for(base) } { + for id in 1..(slottable::SLOT_TABLE_SIZE as c_int) { + if table.get(id).is_null() { + let inherited = base_table.get(id); + if !inherited.is_null() { + table.install(id, inherited.as_void()); + } + } + } + } + + // (B) Bake the inherited direct slots + method suites into the struct. + unsafe { inherit_struct(t, base) }; +} + +/// Copy a single raw function slot when the destination is NULL. +fn copy_void(dst: &mut *mut c_void, src: *mut c_void) { + if dst.is_null() && !src.is_null() { + *dst = src; + } +} + +/// Fill the subtype's NULL direct slots + method-suite pointers from the +/// (already-flattened) base's faithful struct. +unsafe fn inherit_struct(t: *mut PyTypeObject, base: *mut PyTypeObject) { + let sub = unsafe { &mut *t }; + let b = unsafe { &*base }; + + // `tp_dealloc` is an `Option`, not a raw pointer. Inherit + // it so a subclass instance is finalised through the base's destructor + // (e.g. a base that frees a `malloc`'d buffer). The `PyType_Ready` + // default (`_PyWeavePy_Dealloc`) only applies if it is *still* NULL. + if sub.tp_dealloc.is_none() { + sub.tp_dealloc = b.tp_dealloc; + } + + copy_void(&mut sub.tp_repr, b.tp_repr); + copy_void(&mut sub.tp_str, b.tp_str); + copy_void(&mut sub.tp_hash, b.tp_hash); + copy_void(&mut sub.tp_call, b.tp_call); + copy_void(&mut sub.tp_getattr, b.tp_getattr); + copy_void(&mut sub.tp_setattr, b.tp_setattr); + copy_void(&mut sub.tp_getattro, b.tp_getattro); + copy_void(&mut sub.tp_setattro, b.tp_setattro); + copy_void(&mut sub.tp_iter, b.tp_iter); + copy_void(&mut sub.tp_iternext, b.tp_iternext); + copy_void(&mut sub.tp_richcompare, b.tp_richcompare); + copy_void(&mut sub.tp_descr_get, b.tp_descr_get); + copy_void(&mut sub.tp_descr_set, b.tp_descr_set); + copy_void(&mut sub.tp_init, b.tp_init); + copy_void(&mut sub.tp_new, b.tp_new); + copy_void(&mut sub.tp_alloc, b.tp_alloc); + copy_void(&mut sub.tp_free, b.tp_free); + copy_void(&mut sub.tp_is_gc, b.tp_is_gc); + copy_void(&mut sub.tp_del, b.tp_del); + copy_void(&mut sub.tp_finalize, b.tp_finalize); + copy_void(&mut sub.tp_traverse, b.tp_traverse); + copy_void(&mut sub.tp_clear, b.tp_clear); + copy_void(&mut sub.tp_vectorcall, b.tp_vectorcall); + + // The instance-layout offsets are inherited when the subtype adds no + // storage of its own (the common pure-behaviour subclass). + if sub.tp_dictoffset == 0 { + sub.tp_dictoffset = b.tp_dictoffset; + } + if sub.tp_weaklistoffset == 0 { + sub.tp_weaklistoffset = b.tp_weaklistoffset; + } + if sub.tp_vectorcall_offset == 0 { + sub.tp_vectorcall_offset = b.tp_vectorcall_offset; + } + + unsafe { + inherit_suite( + &mut sub.tp_as_number, + b.tp_as_number, + size_of::(), + ); + inherit_suite( + &mut sub.tp_as_sequence, + b.tp_as_sequence, + size_of::(), + ); + inherit_suite( + &mut sub.tp_as_mapping, + b.tp_as_mapping, + size_of::(), + ); + inherit_suite( + &mut sub.tp_as_async, + b.tp_as_async, + size_of::(), + ); + inherit_suite( + &mut sub.tp_as_buffer, + b.tp_as_buffer, + size_of::(), + ); + } +} + +/// Inherit one method suite. +/// +/// * Subtype has no suite → **share** the base's pointer. The base's +/// suite is already flattened and the entries are read-only function +/// pointers, so sharing is sound and matches the inlined-read effect of +/// `inherit_slots` (`Py_TYPE(self)->tp_as_number->nb_add` now resolves). +/// * Subtype has its own (possibly partial) suite → fill its NULL +/// word-slots from the base's, in place — matching CPython's per-slot +/// `COPYSLOT`. Every field of a method suite is pointer-width, so a +/// word-by-word merge covers them all (including the reserved holes, +/// which are copied harmlessly). +unsafe fn inherit_suite(sub: &mut *mut c_void, base: *mut c_void, size: usize) { + if base.is_null() { + return; + } + if sub.is_null() { + *sub = base; + return; + } + let words = size / size_of::<*mut c_void>(); + let sp = (*sub) as *mut *mut c_void; + let bp = base as *const *mut c_void; + for i in 0..words { + unsafe { + let s = sp.add(i); + if (*s).is_null() { + let inherited = *bp.add(i); + if !inherited.is_null() { + *s = inherited; + } + } + } + } +} diff --git a/crates/weavepy-capi/src/instance.rs b/crates/weavepy-capi/src/instance.rs new file mode 100644 index 0000000..7f0fafe --- /dev/null +++ b/crates/weavepy-capi/src/instance.rs @@ -0,0 +1,312 @@ +//! Faithful inline instance bodies (RFC 0045, wave 3). +//! +//! Wave 1 gave WeavePy's *built-in* values layout-faithful mirrors so a +//! stock extension's inlined field reads (`PyFloat_AS_DOUBLE`, …) land on +//! real CPython-shaped memory. Wave 2 readied stock *types*, but stored +//! their instance state in `__dict__` (the side-allocated `_core_addr` +//! pattern) because a C struct field read at a fixed `tp_basicsize` +//! offset (`((MyType *)self)->field`) was not yet stable across the +//! boundary — every crossing minted a fresh box. +//! +//! This module closes that gap. An instance of an **inline-storage +//! extension type** ([`crate::types::is_inline_instance_type`] — a +//! `PyType_FromSpec` / `PyType_Ready` type that declares +//! `tp_basicsize > sizeof(PyObject)`) is materialised **once** into a +//! `tp_basicsize`-sized faithful body — `[PyObject head | inline fields | +//! inline var-data]` — via [`crate::mirror::alloc_instance_body`]. The +//! body is **owned by the native [`PyInstance`]** (recorded in its +//! `c_body` cell) and presents the **same pointer** on every crossing, so +//! `self->field` written in one C call is still there in the next, and +//! the Python view (`obj.field` via `tp_members`) reads the same bytes. +//! +//! ## Lifetime +//! +//! Two halves reference each other; exactly one edge is strong, so there +//! is no cycle: +//! +//! * The **instance owns the body.** [`PyInstance`]'s `Drop` frees the +//! block (via the `register_instance_body_free` hook installed by +//! [`install`]) — running the type's custom `tp_dealloc` first for +//! faithful resource cleanup. +//! * The **body borrows the instance** through a `Weak` in its +//! [`MirrorPrefix`](crate::mirror::MirrorPrefix), so +//! [`crate::mirror::native_of`] resolves the pointer back to *the same* +//! instance without owning it. +//! * While **C holds at least one reference** (the body's `ob_refcnt` is +//! positive) the [`STRONG`] map pins the instance with a real `Rc`, so a +//! pointer handed to C never dangles even if the VM drops its last +//! reference first. When C's refcount reaches zero +//! ([`release_c_ownership`]) that pin is dropped; the block survives as +//! long as the VM still references the instance, and is reclaimed with +//! the instance otherwise. + +use std::cell::RefCell; +use std::collections::HashMap; + +use weavepy_vm::sync::Rc; +use weavepy_vm::types::PyInstance; + +use crate::object::{PyObject, PySsizeT}; +use crate::types::PyTypeObject; + +thread_local! { + /// C-side ownership of inline instances: `body pointer -> Rc`. + /// + /// An entry exists exactly while the body's C refcount is positive — + /// i.e. while a C extension holds a reference. It is the strong edge + /// that keeps the native instance (and therefore its faithful body) + /// alive for C even after the VM has dropped its last reference. The + /// [`MirrorPrefix`](crate::mirror::MirrorPrefix)'s back-reference is a + /// `Weak`, so this map is the *only* strong C→instance link and there + /// is no ownership cycle. + static STRONG: RefCell>> = + RefCell::new(HashMap::new()); +} + +/// Install the VM hook that frees an instance's faithful body when the +/// instance is collected (RFC 0045, wave 3). Idempotent; called from +/// [`crate::interp::ensure_initialised`]. +pub fn install() { + weavepy_vm::types::register_instance_body_free(free_instance_body_hook); +} + +/// Hand `inst` to C as its single, stable faithful body (RFC 0045). +/// +/// On the **first** crossing the body is allocated `tp_basicsize` bytes +/// wide (its `ob_refcnt` starts at 1, representing C's borrow) and +/// recorded in `inst.c_body`; subsequent crossings return that same +/// pointer. Either way C's borrow is pinned in [`STRONG`] for the +/// lifetime of the reference, and the returned pointer carries one C +/// reference the caller owns. +pub fn instance_body_out(inst: &Rc, ty: *mut PyTypeObject) -> *mut PyObject { + let existing = inst.c_body.get(); + if existing == 0 { + // First crossing: mint the faithful body. `alloc_instance_body` + // starts it at refcount 1 — that is C's borrow, so pin the + // instance for as long as the reference lives. + let basicsize = + unsafe { (*ty).tp_basicsize }.max(std::mem::size_of::() as PySsizeT) as usize; + let body = attach_body(inst, ty, basicsize); + // RFC 0029 (wave 5): a `datetime`/`date`/`time`/`timedelta` + // instance crossing into C is materialised into a byte-faithful + // body so the inlined `PyDateTime_GET_*` accessor macros (which + // pandas' tslibs read directly) see correct data. A no-op for + // every other inline type. + crate::datetime_api::maybe_pack_datetime_body(body, ty, inst); + return body; + } + + // Re-crossing: the body already exists and outlived any previous C + // reference (the instance owns it). Re-establish C's borrow. + let body = existing as *mut PyObject; + if crate::mirror::body_trace_enabled() { + // Verify the cached body still resolves back to *this* instance. + let resolved = unsafe { crate::mirror::native_of(body) }; + let matches = matches!(&resolved, weavepy_vm::object::Object::Instance(other) + if weavepy_vm::sync::Rc::ptr_eq(other, inst)); + if !matches { + let tn = unsafe { crate::object::debug_type_name(body) }; + eprintln!( + "[STALE-CBODY] inst=0x{:x} cls={} c_body=0x{:x} body-type={} resolved-to={}", + weavepy_vm::sync::Rc::as_ptr(inst) as usize, + inst.cls().name, + body as usize, + tn, + resolved.type_name_owned(), + ); + } + } + let head = unsafe { &mut *body }; + if head.ob_refcnt <= 0 { + head.ob_refcnt = 1; + strong_pin(body, inst); + } else { + head.ob_refcnt += 1; + } + body +} + +/// Allocate a faithful, zeroed inline instance for `ty` directly from C +/// (RFC 0045) — the `PyType_GenericAlloc` path for inline-storage types. +/// Mints a fresh [`PyInstance`] bound to `ty`'s bridged class, gives it a +/// `tp_basicsize + nitems * tp_itemsize`-wide body (refcount 1), and pins +/// C's ownership. Returns null if `ty` is not a bridged type. +pub fn make_inline_instance(ty: *mut PyTypeObject, nitems: PySsizeT) -> *mut PyObject { + let Some(cls) = (unsafe { crate::types::bridge_type(ty) }) else { + return std::ptr::null_mut(); + }; + let basicsize = + unsafe { (*ty).tp_basicsize }.max(std::mem::size_of::() as PySsizeT) as usize; + let itemsize = unsafe { (*ty).tp_itemsize }.max(0) as usize; + let body_bytes = basicsize + nitems.max(0) as usize * itemsize; + let inst = Rc::new(PyInstance::new(cls)); + attach_body(&inst, ty, body_bytes) +} + +/// Allocate the faithful body, record it on `inst`, and pin C's borrow. +/// Shared by [`instance_body_out`] (first crossing) and +/// [`make_inline_instance`] (C-side alloc). The body's refcount is 1. +fn attach_body(inst: &Rc, ty: *mut PyTypeObject, body_bytes: usize) -> *mut PyObject { + let weak = Rc::downgrade(inst); + let body = crate::mirror::alloc_instance_body(ty, body_bytes, weak); + inst.c_body.set(body as usize); + strong_pin(body, inst); + body +} + +/// Pin the instance in [`STRONG`] under `body`. The previous value (if +/// any) is dropped *after* the borrow is released — dropping an +/// `Rc` can run `PyInstance::drop` → the free hook → back +/// into [`STRONG`], which would otherwise re-borrow it mutably. +fn strong_pin(body: *mut PyObject, inst: &Rc) { + if crate::mirror::body_trace_enabled() { + let tn = unsafe { crate::object::debug_type_name(body) }; + if tn.contains("Engine") { + let rc = unsafe { (*body).ob_refcnt }; + eprintln!("[PIN] body=0x{:x} type={} refcnt={}", body as usize, tn, rc); + } + } + let previous = STRONG.with(|m| m.borrow_mut().insert(body as usize, inst.clone())); + drop(previous); +} + +/// End C's borrow of an inline instance body (RFC 0045): its C refcount +/// has reached zero. Drops the [`STRONG`] pin — the block itself is owned +/// by the instance and is freed when the instance is collected (which may +/// happen synchronously here, if the VM also holds no further reference). +/// +/// # Safety +/// `p` must be a faithful instance body +/// ([`crate::mirror::is_instance_body`]). +pub unsafe fn release_c_ownership(p: *mut PyObject) { + if crate::mirror::body_trace_enabled() { + let tn = unsafe { crate::object::debug_type_name(p) }; + if tn.contains("Engine") { + let rc = unsafe { (*p).ob_refcnt }; + eprintln!("[RELEASE-C] body=0x{:x} type={} refcnt={}", p as usize, tn, rc); + } + } + // Take the pin out *before* dropping it: dropping the last `Rc` runs + // `PyInstance::drop`, which calls the free hook, which touches + // `STRONG` again — so the borrow must already be released. + // + // `try_with`, not `with`: at thread/process teardown the `STRONG` + // thread-local may itself be mid-destruction, and a plain `.with` + // there panics (`AccessError`) — which, in a `Drop`, aborts the + // process (RFC 0046, wave 4). If the map is gone the pins are gone + // too; there is nothing to remove. + let pinned = STRONG + .try_with(|m| m.borrow_mut().remove(&(p as usize))) + .ok() + .flatten(); + drop(pinned); +} + +/// VM hook: free an instance's faithful body when the instance is +/// collected (registered by [`install`]). Runs the type's *custom* +/// `tp_dealloc` once for faithful resource cleanup (e.g. freeing a +/// `self->data` buffer), then releases the block. A stock dealloc's +/// `tp_free(self)` / `PyObject_Free(self)` / `PyObject_GC_Del(self)` on +/// this body is absorbed (see [`crate::memory::PyObject_Free`]). +fn free_instance_body_hook(body: usize) { + if body == 0 { + return; + } + let p = body as *mut PyObject; + // RFC 0046 (wave 4): a *non-inline* instance's `c_body` holds a plain + // identity `PyObjectBox`, not a faithful mirror body. That box is owned + // by C's refcount and reclaimed by `free_box` (which clears `c_body` + // first), so the box's strong payload pins the instance and this hook + // can only see it if some future refactor breaks that invariant. Guard + // defensively: routing a non-body through the faithful free path below + // would read a mirror prefix that does not exist. `free_box` frees it + // correctly instead. `is_instance_body` only reads `ob_type`, so it is + // sound on a live box. + if !unsafe { crate::mirror::is_instance_body(p) } { + unsafe { crate::object::free_box(p) }; + return; + } + // The instance only reaches `Drop` once its strong count is zero, and + // a live `STRONG` pin *is* a strong count — so no pin can remain here. + // + // `try_with`, not `with`: at thread/process teardown this hook fires + // *from within* the `STRONG` map's own destructor (dropping its + // pinned `Rc`s runs `PyInstance::drop` → here). The map is + // then mid-destruction, so a plain `.with` panics with `AccessError` + // — and panicking in a TLS destructor aborts the process (the exit + // 133 / "thread local panicked on drop" abort, RFC 0046 wave 4). When + // the TLS is gone the process is exiting; the OS reclaims the block, + // so bail without freeing rather than touch more (possibly destroyed) + // capi thread-locals (`unregister_minted`, the mirror registry). + match STRONG.try_with(|m| m.borrow_mut().remove(&body)) { + Ok(stale) => { + debug_assert!( + stale.is_none(), + "RFC 0045: instance collected while C still owned its body" + ); + drop(stale); + } + Err(_) => return, + } + + unsafe { + let ty = (*p).ob_type; + if !ty.is_null() { + if let Some(dealloc) = (*ty).tp_dealloc { + // Skip our own default dealloc (it would recurse into + // `free_box`); run only a genuine extension `tp_dealloc` + // for faithful resource cleanup. + let default_dealloc: unsafe extern "C" fn(*mut PyObject) = + crate::object::_PyWeavePy_Dealloc; + if dealloc as usize != default_dealloc as usize { + // RFC 0045 (wave 5): neutralise a Cython `@cython.freelist` + // dealloc for the duration of this call. A `@cython.freelist` + // `cdef class` — pandas' `BlockManager`, `Block`, + // `BlockPlacement`, … — ends its `tp_dealloc` with + // + // if (freecount < N & Py_TYPE(o)->tp_basicsize == sizeof) + // freelist[freecount++] = o; // stash raw pointer + // else + // Py_TYPE(o)->tp_free(o); // release + // + // (verified by disassembling the pandas 2.3 wheel: the stash + // is gated *only* on `freecount < N` and the exact + // `tp_basicsize` — the `!HasFeature(IS_ABSTRACT | HEAPTYPE)` + // guard some Cython versions add is absent here, so flag + // manipulation does not divert it). + // + // The stash keeps a **raw** pointer to `o` past refcount + // zero, but WeavePy is about to `free_instance_body(p)` — + // returning that block to the allocator. The dangling + // freelist entry is then handed back by a later `tp_new` + // (`o = freelist[--freecount]; memset(o,…); PyObject_INIT`) + // *after* the block has been re-minted as an unrelated + // object, aliasing e.g. a `slice` onto a `BlockManager` + // (`'slice' object is not iterable`) or an `ndarray` onto an + // `IndexEngine` (`'ndarray' has no attribute 'is_unique'`). + // Faithful instance bodies are owned by the VM instance, not + // a C freelist. + // + // Perturbing `tp_basicsize` for the duration of the call + // fails the `tp_basicsize == sizeof` term, so the dealloc + // takes the `tp_free(o)` branch instead. Readied types wire + // `tp_free = PyObject_Free`, which *absorbs* the free of a + // body (`crate::memory::PyObject_Free`) because `ob_type` + // is untouched — the body is still recognised as an instance + // body. No entry is stashed, so `freecount` stays 0 and the + // matching `tp_new` reuse branch (`freecount > 0`) never + // fires either: every instance is minted afresh through + // `tp_alloc` (`PyType_GenericAlloc`), exactly as WeavePy's + // ownership model requires. `tp_basicsize` is restored + // immediately (before `free_instance_body` and before any + // subsequent allocation reads it). + let orig_basicsize = (*ty).tp_basicsize; + (*ty).tp_basicsize = orig_basicsize.wrapping_add(8); + dealloc(p); + (*ty).tp_basicsize = orig_basicsize; + } + } + } + crate::mirror::free_instance_body(p); + } +} diff --git a/crates/weavepy-capi/src/interp.rs b/crates/weavepy-capi/src/interp.rs index f3ed7e1..3e3c122 100644 --- a/crates/weavepy-capi/src/interp.rs +++ b/crates/weavepy-capi/src/interp.rs @@ -121,8 +121,24 @@ pub fn effective_interpreter_mut() -> Option<*mut Interpreter> { /// (best-effort, may be stale). pub fn ensure_active(body: impl FnOnce() -> R) -> R { if current_interpreter_mut().is_some() { + if std::env::var_os("WEAVEPY_TRACE_EA").is_some() { + eprintln!("[EA] nested (skip flush)"); + } + // Context is already live here — a safe point to publish the static + // builtins' `tp_bases`/`tp_mro` (run-once; needs the allocator). + crate::types::publish_static_type_hierarchy(); return body(); } + if std::env::var_os("WEAVEPY_TRACE_EA").is_some() { + eprintln!("[EA] outermost (flush)"); + } + // Outermost VM→C transition. Re-publish any VM-mutated faithful list + // mirrors into their `ob_item` buffers first, so a stock extension's + // inlined `PyList_GET_ITEM` macro reads the VM's latest mutations rather + // than the buffer that was current when the list was last seeded. This is + // the single choke point through which *every* bridged C call (foreign + // hooks, tp_call, dunder shims, descriptors) passes (RFC 0047, wave 5). + unsafe { crate::mirror::flush_seeded_lists() }; let interp = if let Some(p) = weavepy_vm::vm_singletons::current_interpreter_ptr() { p } else { @@ -137,7 +153,13 @@ pub fn ensure_active(body: impl FnOnce() -> R) -> R { globals: None, current_module: None, }; - with_active(ctx, body) + with_active(ctx, || { + // First VM→C transition with a fresh context: publish the static + // builtins' `tp_bases`/`tp_mro` (run-once) now that the allocator is + // reachable, before any extension C code can read those slots. + crate::types::publish_static_type_hierarchy(); + body() + }) } static INIT: Once = Once::new(); @@ -147,6 +169,14 @@ static INIT: Once = Once::new(); /// statically-initialised exception pointers. pub fn ensure_initialised() { INIT.call_once(|| { + // RFC 0046 (wave 4): optional native crash backtrace for debugging + // faults inside a freshly-loaded extension's initialiser. + if std::env::var_os("WEAVEPY_CRASH_BT").is_some() { + extern "C" { + fn weavepy_install_crash_handler(); + } + unsafe { weavepy_install_crash_handler() }; + } crate::types::init_static_types(); crate::singletons::init_singleton_types( crate::types::_PyNone_Type.as_ptr(), @@ -155,6 +185,19 @@ pub fn ensure_initialised() { crate::types::PyEllipsis_Type.as_ptr(), ); crate::errors::init_static_exceptions(); + // Bridge C `tp_traverse` / `tp_clear` into the tracing collector + // (RFC 0044, WS4) before any extension GC type can be created. + crate::gc_bridge::install(); + // Install the hook that frees an inline instance's faithful body + // when its native instance is collected (RFC 0045, WS1). + crate::instance::install(); + // Install the hook that releases a capsule's retained box when its + // VM-side soul drops (RFC 0045, WS5 — the `import_array()` idiom). + crate::capsule::install(); + // Install the foreign-object proxy bridge so a `PyObject` the + // extension minted itself (numpy ndarray/descr/ufunc) round-trips + // through the VM opaquely (RFC 0046, wave 4). + crate::foreign::install(); }); } diff --git a/crates/weavepy-capi/src/layout.rs b/crates/weavepy-capi/src/layout.rs new file mode 100644 index 0000000..b4a0a4a --- /dev/null +++ b/crates/weavepy-capi/src/layout.rs @@ -0,0 +1,835 @@ +//! Byte-faithful CPython 3.13 object layouts (RFC 0043, wave 1, WS1). +//! +//! Every struct here is `#[repr(C)]` and pinned, with compile-time +//! `const _: () = assert!(...)` guards, to the *exact* sizes and field +//! offsets that the host's stock CPython 3.13 headers produce on a +//! 64-bit little-endian build (the `LP64` / arm64 + x86-64 macOS/Linux +//! ABI). The numbers were read out of the installed headers with an +//! `offsetof`/`sizeof` probe; see `docs/rfcs/0043-cpython-binary-abi.md`. +//! +//! The point of this module is twofold: +//! +//! 1. It is the **authoritative, machine-checked description** of the +//! binary ABI WeavePy must present so that a *stock* C extension +//! (compiled against CPython's real headers, carrying *inlined* +//! field-access macros like `PyFloat_AS_DOUBLE`, `PyList_GET_ITEM`, +//! `Py_SIZE`) reads correct memory when it pokes a WeavePy object. +//! If a CPython point release shifts a field, the `assert!`s here +//! fail the build loudly rather than silently corrupting memory. +//! 2. It provides the concrete Rust types the mirror bridge +//! ([`crate::mirror`]) allocates and fills. +//! +//! Only the non-debug, non-free-threaded (`!Py_GIL_DISABLED`, +//! `!Py_TRACE_REFS`) build is modelled; those are explicit non-goals in +//! RFC 0043. + +#![allow(non_camel_case_types)] + +use std::os::raw::{c_char, c_int, c_void}; + +use crate::object::{PyHashT, PyObject, PySsizeT}; + +/// CPython's `digit` (a 30-bit limb stored in a `uint32_t`). See +/// `Include/cpython/longintrepr.h` and `PyLong_SHIFT == 30`. +pub type digit = u32; + +/// Base-2^30 limb shift, matching CPython 3.13's `PyLong_SHIFT`. +pub const PYLONG_SHIFT: u32 = 30; +/// Mask of a single 30-bit limb. +pub const PYLONG_MASK: u32 = (1u32 << PYLONG_SHIFT) - 1; + +// `_PyLongValue.lv_tag` packing (Include/cpython/longintrepr.h): +// lv_tag = (digit_count << NON_SIZE_BITS) | sign +// where the low 2 bits encode the sign. +/// Number of low tag bits reserved for the sign (`lv_tag >> 3` is the +/// digit count). +pub const PYLONG_NON_SIZE_BITS: usize = 3; +/// Sign field: positive (`> 0`). +pub const PYLONG_SIGN_POSITIVE: usize = 0; +/// Sign field: the value is exactly zero. +pub const PYLONG_SIGN_ZERO: usize = 1; +/// Sign field: negative (`< 0`). +pub const PYLONG_SIGN_NEGATIVE: usize = 2; + +// --------------------------------------------------------------------------- +// Variable-size object head. +// --------------------------------------------------------------------------- + +/// `PyVarObject` — `PyObject` plus an `ob_size` element count. The head +/// of every variable-length built-in (tuple, list, bytes, int, ...). +#[repr(C)] +#[derive(Debug)] +pub struct PyVarObject { + pub ob_base: PyObject, + pub ob_size: PySsizeT, +} + +const _: () = { + assert!(std::mem::size_of::() == 16); + assert!(std::mem::offset_of!(PyObject, ob_refcnt) == 0); + assert!(std::mem::offset_of!(PyObject, ob_type) == 8); + assert!(std::mem::size_of::() == 24); + assert!(std::mem::offset_of!(PyVarObject, ob_size) == 16); +}; + +// --------------------------------------------------------------------------- +// Numeric scalars. +// --------------------------------------------------------------------------- + +/// `PyFloatObject { PyObject_HEAD; double ob_fval; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyFloatObject { + pub ob_base: PyObject, + pub ob_fval: f64, +} + +const _: () = { + assert!(std::mem::size_of::() == 24); + assert!(std::mem::offset_of!(PyFloatObject, ob_fval) == 16); +}; + +/// CPython's `Py_complex { double real; double imag; }`. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct PyComplexValue { + pub real: f64, + pub imag: f64, +} + +/// `PyComplexObject { PyObject_HEAD; Py_complex cval; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyComplexObject { + pub ob_base: PyObject, + pub cval: PyComplexValue, +} + +const _: () = { + assert!(std::mem::size_of::() == 32); + assert!(std::mem::offset_of!(PyComplexObject, cval) == 16); +}; + +/// CPython 3.12+ `_PyLongValue { uintptr_t lv_tag; digit ob_digit[1]; }`. +/// +/// `lv_tag` packs the limb count (`>> 3`) and the sign (low 2 bits); the +/// limbs follow inline. Declared with a 1-element `ob_digit` to match +/// CPython's `[1]` flexible-array convention (so `size_of` agrees); +/// real instances over-allocate the tail. +#[repr(C)] +#[derive(Debug)] +pub struct PyLongValue { + pub lv_tag: usize, + pub ob_digit: [digit; 1], +} + +/// `PyLongObject { PyObject_HEAD; _PyLongValue long_value; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyLongObject { + pub ob_base: PyObject, + pub long_value: PyLongValue, +} + +const _: () = { + assert!(std::mem::size_of::() == 32); + assert!(std::mem::offset_of!(PyLongObject, long_value) == 16); + // lv_tag is the first field of long_value, hence also at +16. + assert!(std::mem::offset_of!(PyLongValue, lv_tag) == 0); + assert!(std::mem::offset_of!(PyLongValue, ob_digit) == 8); + assert!(std::mem::size_of::() == 4); + assert!(PYLONG_SHIFT == 30); +}; + +// --------------------------------------------------------------------------- +// Byte strings. +// --------------------------------------------------------------------------- + +/// `PyBytesObject { PyObject_VAR_HEAD; Py_hash_t ob_shash; char ob_sval[1]; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyBytesObject { + pub ob_base: PyVarObject, + pub ob_shash: PyHashT, + pub ob_sval: [c_char; 1], +} + +const _: () = { + assert!(std::mem::size_of::() == 40); + assert!(std::mem::offset_of!(PyBytesObject, ob_shash) == 24); + assert!(std::mem::offset_of!(PyBytesObject, ob_sval) == 32); +}; + +/// `PyByteArrayObject` — `Include/cpython/bytearrayobject.h`. +#[repr(C)] +#[derive(Debug)] +pub struct PyByteArrayObject { + pub ob_base: PyVarObject, + pub ob_alloc: PySsizeT, + pub ob_bytes: *mut c_char, + pub ob_start: *mut c_char, + pub ob_exports: PySsizeT, +} + +const _: () = { + assert!(std::mem::size_of::() == 56); + assert!(std::mem::offset_of!(PyByteArrayObject, ob_alloc) == 24); + assert!(std::mem::offset_of!(PyByteArrayObject, ob_bytes) == 32); + assert!(std::mem::offset_of!(PyByteArrayObject, ob_start) == 40); + assert!(std::mem::offset_of!(PyByteArrayObject, ob_exports) == 48); +}; + +// --------------------------------------------------------------------------- +// Sequence containers. +// --------------------------------------------------------------------------- + +/// `PyTupleObject { PyObject_VAR_HEAD; PyObject *ob_item[1]; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyTupleObject { + pub ob_base: PyVarObject, + pub ob_item: [*mut PyObject; 1], +} + +const _: () = { + assert!(std::mem::size_of::() == 32); + assert!(std::mem::offset_of!(PyTupleObject, ob_item) == 24); +}; + +/// `PyListObject { PyObject_VAR_HEAD; PyObject **ob_item; Py_ssize_t allocated; }`. +#[repr(C)] +#[derive(Debug)] +pub struct PyListObject { + pub ob_base: PyVarObject, + pub ob_item: *mut *mut PyObject, + pub allocated: PySsizeT, +} + +const _: () = { + assert!(std::mem::size_of::() == 40); + assert!(std::mem::offset_of!(PyListObject, ob_item) == 24); + assert!(std::mem::offset_of!(PyListObject, allocated) == 32); +}; + +/// `PyDictObject` (CPython 3.13): +/// ```c +/// typedef struct { +/// PyObject_HEAD +/// Py_ssize_t ma_used; +/// uint64_t ma_version_tag; +/// PyDictKeysObject *ma_keys; +/// PyDictValues *ma_values; +/// } PyDictObject; +/// ``` +/// RFC 0047 (wave 5): macro-heavy Cython reads `ma_used` *directly* off +/// this struct — `PyDict_GET_SIZE(d)` is `((PyDictObject*)d)->ma_used`, +/// and `__Pyx_PyVectorcall_FastCallDict_kw` reads it to pre-size the +/// kwnames allocation when a Cython method is called with keyword +/// arguments (`rng.integers(0, 100, size=4)`). A non-faithful dict body +/// returns garbage there and the over-sized cleanup loop dereferences +/// uninitialised slots. The entries themselves are reached only through +/// the C-API functions (`PyDict_Next`, `PyDict_GetItem`), which WeavePy +/// services from the mirror prefix's native dict, so `ma_keys` / +/// `ma_values` stay NULL. +#[repr(C)] +#[derive(Debug)] +pub struct PyDictObject { + pub ob_base: PyObject, + pub ma_used: PySsizeT, + pub ma_version_tag: u64, + pub ma_keys: *mut core::ffi::c_void, + pub ma_values: *mut core::ffi::c_void, +} + +const _: () = { + assert!(std::mem::size_of::() == 48); + assert!(std::mem::offset_of!(PyDictObject, ma_used) == 16); + assert!(std::mem::offset_of!(PyDictObject, ma_version_tag) == 24); + assert!(std::mem::offset_of!(PyDictObject, ma_keys) == 32); + assert!(std::mem::offset_of!(PyDictObject, ma_values) == 40); +}; + +/// `PySet_MINSIZE` — the fixed size of a set's inline `smalltable` +/// (CPython `Objects/setobject.c`). A freshly created empty set points +/// `table` at its own `smalltable` with `mask == PySet_MINSIZE - 1`. +pub const PYSET_MINSIZE: usize = 8; + +/// `PySetObject` (CPython 3.13, `Include/cpython/setobject.h`): +/// ```c +/// typedef struct { +/// PyObject *key; +/// Py_hash_t hash; +/// } setentry; +/// typedef struct { +/// PyObject_HEAD +/// Py_ssize_t fill; /* # Active + # Dummy */ +/// Py_ssize_t used; /* # Active */ +/// Py_ssize_t mask; +/// setentry *table; +/// Py_hash_t hash; /* only used by frozenset objects */ +/// Py_ssize_t finger; /* search finger for pop() */ +/// setentry smalltable[PySet_MINSIZE]; +/// PyObject *weakreflist; +/// } PySetObject; +/// ``` +/// RFC 0047 (wave 5): macro-heavy Cython reads `used` *directly* off this +/// struct — `PySet_GET_SIZE(so)` / `PyFrozenSet_GET_SIZE(so)` expand to +/// `((PySetObject*)so)->used`, and Cython lowers both `len(s)` and the +/// truthiness test `if s:` on a set-typed value to that macro. pandas' +/// `Timedelta.__new__` guards its keyword parsing with +/// `if set(kwargs).difference_update(...) ... : raise`, so a non-faithful +/// set body (a bare 16-byte `PyObject` head) returns a stray heap word for +/// `used` and the guard fires spuriously. The entries themselves are only +/// ever reached through the C-API (`PySet_Size`, `tp_iter`), served from +/// the prefix's native set, so `table` points at the (empty) inline +/// `smalltable` and `hash`/`finger` stay zero/-1 — exactly the shape of a +/// freshly-initialised CPython set, but with `fill`/`used` published to +/// match the real element count. +#[repr(C)] +#[derive(Debug)] +pub struct PySetObject { + pub ob_base: PyObject, // 0 + pub fill: PySsizeT, // 16 + pub used: PySsizeT, // 24 + pub mask: PySsizeT, // 32 + pub table: *mut c_void, // 40 (setentry *) + pub hash: PyHashT, // 48 + pub finger: PySsizeT, // 56 + pub smalltable: [PySsizeT; 16], // 64 (PySet_MINSIZE setentries = 128 bytes) + pub weakreflist: *mut PyObject, // 192 +} + +const _: () = { + assert!(std::mem::size_of::() == 200); + assert!(std::mem::offset_of!(PySetObject, fill) == 16); + assert!(std::mem::offset_of!(PySetObject, used) == 24); + assert!(std::mem::offset_of!(PySetObject, mask) == 32); + assert!(std::mem::offset_of!(PySetObject, table) == 40); + assert!(std::mem::offset_of!(PySetObject, hash) == 48); + assert!(std::mem::offset_of!(PySetObject, finger) == 56); + assert!(std::mem::offset_of!(PySetObject, smalltable) == 64); + assert!(std::mem::offset_of!(PySetObject, weakreflist) == 192); +}; + +// --------------------------------------------------------------------------- +// Built-in function objects. +// --------------------------------------------------------------------------- +// +// `builtin_function_or_method` (`PyCFunctionObject`) and the +// `PyMethodDef` it points at. RFC 0046 (wave 4): numpy's `add_docstring` +// reaches *through* a function object — `((PyCFunctionObject *)f)->m_ml->ml_doc` +// — to install (and dedupe) docstrings, a direct struct walk the host +// cannot interpose. A WeavePy `Object::Builtin` therefore crosses into C +// as a faithful `PyCFunctionObject` whose `m_ml` points at a real, +// writable `PyMethodDef` (carried inline, just past the object) so the +// read of `ml_doc` and the subsequent `ml_doc = docstr` write both land +// on valid memory. + +/// `PyMethodDef { const char *ml_name; PyCFunction ml_meth; int ml_flags; +/// const char *ml_doc; }` — `Include/methodobject.h`. +#[repr(C)] +#[derive(Debug)] +pub struct PyMethodDef { + pub ml_name: *const c_char, // 0 + pub ml_meth: *mut c_void, // 8 (PyCFunction) + pub ml_flags: c_int, // 16 (+4 pad) + _flags_pad: u32, + pub ml_doc: *const c_char, // 24 +} + +const _: () = { + assert!(std::mem::size_of::() == 32); + assert!(std::mem::offset_of!(PyMethodDef, ml_name) == 0); + assert!(std::mem::offset_of!(PyMethodDef, ml_meth) == 8); + assert!(std::mem::offset_of!(PyMethodDef, ml_flags) == 16); + assert!(std::mem::offset_of!(PyMethodDef, ml_doc) == 24); +}; + +/// `PyCFunctionObject` — `Include/cpython/methodobject.h`. +#[repr(C)] +#[derive(Debug)] +pub struct PyCFunctionObject { + pub ob_base: PyObject, // 0 + pub m_ml: *mut PyMethodDef, // 16 + pub m_self: *mut PyObject, // 24 + pub m_module: *mut PyObject, // 32 + pub m_weakreflist: *mut PyObject, // 40 + pub vectorcall: *mut c_void, // 48 (vectorcallfunc) +} + +const _: () = { + assert!(std::mem::size_of::() == 56); + assert!(std::mem::offset_of!(PyCFunctionObject, m_ml) == 16); + assert!(std::mem::offset_of!(PyCFunctionObject, m_self) == 24); + assert!(std::mem::offset_of!(PyCFunctionObject, m_module) == 32); + assert!(std::mem::offset_of!(PyCFunctionObject, m_weakreflist) == 40); + assert!(std::mem::offset_of!(PyCFunctionObject, vectorcall) == 48); +}; + +/// `PyMethodObject` — `Include/cpython/classobject.h`. A *bound method* +/// (`instance.meth`). RFC 0047 (wave 5): macro-heavy Cython unpacks a +/// bound method by reading `im_func` / `im_self` **directly off the C +/// struct** via the `PyMethod_GET_FUNCTION` / `PyMethod_GET_SELF` macros +/// — its `with` / `for` / call fast paths all do +/// `if (PyMethod_Check(m)) { self = m->im_self; func = m->im_func; … }` +/// (numpy.random's `RandomState` acquires `self.lock` exactly this way). +/// A WeavePy `Object::BoundMethod` therefore must cross into C as this +/// faithful layout, with `im_func`/`im_self` populated, rather than as an +/// opaque box (whose Rust payload bytes would be misread as those +/// pointers and crash the subsequent indirect call). +#[repr(C)] +#[derive(Debug)] +pub struct PyMethodObject { + pub ob_base: PyObject, // 0 + pub im_func: *mut PyObject, // 16 + pub im_self: *mut PyObject, // 24 + pub im_weakreflist: *mut PyObject, // 32 + pub vectorcall: *mut c_void, // 40 (vectorcallfunc) +} + +const _: () = { + assert!(std::mem::size_of::() == 48); + assert!(std::mem::offset_of!(PyMethodObject, im_func) == 16); + assert!(std::mem::offset_of!(PyMethodObject, im_self) == 24); + assert!(std::mem::offset_of!(PyMethodObject, im_weakreflist) == 32); + assert!(std::mem::offset_of!(PyMethodObject, vectorcall) == 40); +}; + +/// `PySliceObject { PyObject_HEAD; PyObject *start, *stop, *step; }`. +/// +/// RFC 0047 (wave 5): macro-heavy Cython reads these three fields *directly* +/// off the struct rather than through `PySlice_Unpack`. pandas' +/// `internals.slice_canonize(slice s)` does `s.step` etc., which +/// compiles to `((PySliceObject*)s)->step` plus an inline incref/decref — so +/// a slice crossing into C must be a faithful `PySliceObject`, not a +/// `PyObjectBox` whose Rust payload sits where C expects `start`. +#[repr(C)] +#[derive(Debug)] +pub struct PySliceObject { + pub ob_base: PyObject, // 0 + pub start: *mut PyObject, // 16 + pub stop: *mut PyObject, // 24 + pub step: *mut PyObject, // 32 +} + +const _: () = { + assert!(std::mem::size_of::() == 40); + assert!(std::mem::offset_of!(PySliceObject, start) == 16); + assert!(std::mem::offset_of!(PySliceObject, stop) == 24); + assert!(std::mem::offset_of!(PySliceObject, step) == 32); +}; + +/// `PyMemoryViewObject` (CPython 3.13, `Include/cpython/memoryobject.h`): +/// +/// ```c +/// typedef struct { +/// PyObject_VAR_HEAD +/// _PyManagedBufferObject *mbuf; +/// Py_hash_t hash; +/// int flags; +/// Py_ssize_t exports; +/// Py_buffer view; +/// PyObject *weakreflist; +/// Py_ssize_t ob_array[1]; +/// } PyMemoryViewObject; +/// ``` +/// +/// RFC 0047 (wave 5): `PyMemoryView_GET_BUFFER(op)` is a **macro** — +/// `&((PyMemoryViewObject *)(op))->view` — and Cython's fused-type +/// dispatch reads the embedded buffer's `ndim`/`itemsize`/`format` +/// straight off this struct (`__Pyx_PyMemoryView_Get_itemsize`, +/// `__Pyx_PyMemoryView_Get_ndim`) without calling `bf_getbuffer`. pandas' +/// `lib.map_infer_mask` resolves its `ndarray[object]` specialization only +/// when `memoryview(arr)->view.itemsize == 8` and `->view.format == "O"`, +/// so a memoryview crossing into C must be a faithful `PyMemoryViewObject` +/// with a populated inline `view`, not a `PyObjectBox`. The flexible +/// `ob_array` tail (CPython points `view.shape`/`strides`/`suboffsets` +/// into it) is omitted: the mirror points those at its out-of-line aux +/// buffer instead, and no stock reader walks `ob_array` directly. +#[repr(C)] +pub struct PyMemoryViewObject { + pub ob_base: PyVarObject, // 0 (24) + pub mbuf: *mut c_void, // 24 + pub hash: PyHashT, // 32 + pub flags: c_int, // 40 + pub exports: PySsizeT, // 48 (4 bytes pad after `flags`) + pub view: crate::buffer::Py_buffer, // 56 (80) + pub weakreflist: *mut PyObject, // 136 +} + +const _: () = { + assert!(std::mem::offset_of!(PyMemoryViewObject, mbuf) == 24); + assert!(std::mem::offset_of!(PyMemoryViewObject, hash) == 32); + assert!(std::mem::offset_of!(PyMemoryViewObject, flags) == 40); + assert!(std::mem::offset_of!(PyMemoryViewObject, exports) == 48); + assert!(std::mem::offset_of!(PyMemoryViewObject, view) == 56); + assert!(std::mem::offset_of!(PyMemoryViewObject, weakreflist) == 136); + // The embedded `Py_buffer`'s hot fields land where the macros read + // them: `view.itemsize` at 56+24=80, `view.ndim` at 56+36=92, + // `view.format` at 56+40=96. + assert!( + std::mem::offset_of!(PyMemoryViewObject, view) + + std::mem::offset_of!(crate::buffer::Py_buffer, ndim) + == 92 + ); + assert!( + std::mem::offset_of!(PyMemoryViewObject, view) + + std::mem::offset_of!(crate::buffer::Py_buffer, itemsize) + == 80 + ); +}; + +// --------------------------------------------------------------------------- +// PEP 393 flexible unicode representation. +// --------------------------------------------------------------------------- + +/// The PEP 393 `state` bit-field, packed into a `u32`. CPython declares: +/// +/// ```c +/// struct { +/// unsigned int interned:2; // bits 0..1 +/// unsigned int kind:3; // bits 2..4 +/// unsigned int compact:1; // bit 5 +/// unsigned int ascii:1; // bit 6 +/// unsigned int statically_allocated:1; // bit 7 +/// unsigned int :24; // padding +/// } state; +/// ``` +/// +/// On a little-endian LP64 target clang allocates `unsigned int` +/// bit-fields from the least-significant bit, so the packing above is +/// stable; the inlined `PyUnicode_KIND`/`PyUnicode_IS_ASCII` macros read +/// exactly these bits. +pub mod ustate { + pub const KIND_1BYTE: u32 = 1; + pub const KIND_2BYTE: u32 = 2; + pub const KIND_4BYTE: u32 = 4; + + pub const INTERNED_SHIFT: u32 = 0; + pub const KIND_SHIFT: u32 = 2; + pub const COMPACT_SHIFT: u32 = 5; + pub const ASCII_SHIFT: u32 = 6; + pub const STATIC_SHIFT: u32 = 7; + + /// Build a faithful `state` word. + #[inline] + pub fn pack(interned: u32, kind: u32, compact: bool, ascii: bool, statically: bool) -> u32 { + (interned & 0x3) << INTERNED_SHIFT + | (kind & 0x7) << KIND_SHIFT + | (compact as u32) << COMPACT_SHIFT + | (ascii as u32) << ASCII_SHIFT + | (statically as u32) << STATIC_SHIFT + } +} + +/// `PyASCIIObject` — the compact-ASCII string head (PEP 393). The +/// character data for a compact-ASCII string follows the struct inline. +#[repr(C)] +#[derive(Debug)] +pub struct PyASCIIObject { + pub ob_base: PyObject, + pub length: PySsizeT, + pub hash: PyHashT, + /// PEP 393 `state` bit-field; see [`ustate`]. + pub state: u32, + _state_pad: u32, +} + +const _: () = { + assert!(std::mem::size_of::() == 40); + assert!(std::mem::offset_of!(PyASCIIObject, length) == 16); + assert!(std::mem::offset_of!(PyASCIIObject, hash) == 24); + assert!(std::mem::offset_of!(PyASCIIObject, state) == 32); +}; + +/// `PyCompactUnicodeObject` — adds the lazily-filled UTF-8 cache. +#[repr(C)] +#[derive(Debug)] +pub struct PyCompactUnicodeObject { + pub _base: PyASCIIObject, + pub utf8_length: PySsizeT, + pub utf8: *mut c_char, +} + +const _: () = { + assert!(std::mem::size_of::() == 56); + assert!(std::mem::offset_of!(PyCompactUnicodeObject, utf8_length) == 40); + assert!(std::mem::offset_of!(PyCompactUnicodeObject, utf8) == 48); +}; + +/// `PyUnicodeObject` — the non-compact form with an out-of-line buffer. +#[repr(C)] +#[derive(Debug)] +pub struct PyUnicodeObject { + pub _base: PyCompactUnicodeObject, + pub data: *mut c_void, +} + +const _: () = { + assert!(std::mem::size_of::() == 64); + assert!(std::mem::offset_of!(PyUnicodeObject, data) == 56); +}; + +// --------------------------------------------------------------------------- +// Method-suite structs (RFC 0044, wave 2, WS1). +// --------------------------------------------------------------------------- +// +// Spelled out field-by-field, byte-faithful to CPython 3.13. Wave 2 +// *dispatches* through these: a stock extension defines a type with +// `tp_as_number = &my_number`, and `PyType_Ready` reads `nb_add` at +// offset 0, `nb_multiply` at 16, … to populate the `SlotTable`. +// +// Every slot is typed `*mut c_void` (the ABI is pointer-width and the +// harvest stores the raw pointer in the `SlotTable`, casting to the +// concrete `unsafe extern "C" fn` only at the call site). This matches +// how `crate::types::PyTypeObject` types its `tp_*` slots. The +// canonical C signature for each slot is given in the doc comment. The +// reserved holes CPython keeps (`nb_reserved`, `was_sq_slice`, +// `was_sq_ass_slice`) are named so the offset asserts cover the whole +// struct. + +/// `PyNumberMethods` — the numeric protocol suite (`tp_as_number`). +#[repr(C)] +#[derive(Debug)] +pub struct PyNumberMethods { + pub nb_add: *mut c_void, // 0 binaryfunc + pub nb_subtract: *mut c_void, // 8 binaryfunc + pub nb_multiply: *mut c_void, // 16 binaryfunc + pub nb_remainder: *mut c_void, // 24 binaryfunc + pub nb_divmod: *mut c_void, // 32 binaryfunc + pub nb_power: *mut c_void, // 40 ternaryfunc + pub nb_negative: *mut c_void, // 48 unaryfunc + pub nb_positive: *mut c_void, // 56 unaryfunc + pub nb_absolute: *mut c_void, // 64 unaryfunc + pub nb_bool: *mut c_void, // 72 inquiry + pub nb_invert: *mut c_void, // 80 unaryfunc + pub nb_lshift: *mut c_void, // 88 binaryfunc + pub nb_rshift: *mut c_void, // 96 binaryfunc + pub nb_and: *mut c_void, // 104 binaryfunc + pub nb_xor: *mut c_void, // 112 binaryfunc + pub nb_or: *mut c_void, // 120 binaryfunc + pub nb_int: *mut c_void, // 128 unaryfunc + /// Reserved (was `nb_long`); always null. + pub nb_reserved: *mut c_void, // 136 + pub nb_float: *mut c_void, // 144 unaryfunc + pub nb_inplace_add: *mut c_void, // 152 binaryfunc + pub nb_inplace_subtract: *mut c_void, // 160 binaryfunc + pub nb_inplace_multiply: *mut c_void, // 168 binaryfunc + pub nb_inplace_remainder: *mut c_void, // 176 binaryfunc + pub nb_inplace_power: *mut c_void, // 184 ternaryfunc + pub nb_inplace_lshift: *mut c_void, // 192 binaryfunc + pub nb_inplace_rshift: *mut c_void, // 200 binaryfunc + pub nb_inplace_and: *mut c_void, // 208 binaryfunc + pub nb_inplace_xor: *mut c_void, // 216 binaryfunc + pub nb_inplace_or: *mut c_void, // 224 binaryfunc + pub nb_floor_divide: *mut c_void, // 232 binaryfunc + pub nb_true_divide: *mut c_void, // 240 binaryfunc + pub nb_inplace_floor_divide: *mut c_void, // 248 binaryfunc + pub nb_inplace_true_divide: *mut c_void, // 256 binaryfunc + pub nb_index: *mut c_void, // 264 unaryfunc + pub nb_matrix_multiply: *mut c_void, // 272 binaryfunc + pub nb_inplace_matrix_multiply: *mut c_void, // 280 binaryfunc +} + +/// `PySequenceMethods` — the sequence protocol suite (`tp_as_sequence`). +#[repr(C)] +#[derive(Debug)] +pub struct PySequenceMethods { + pub sq_length: *mut c_void, // 0 lenfunc + pub sq_concat: *mut c_void, // 8 binaryfunc + pub sq_repeat: *mut c_void, // 16 ssizeargfunc + pub sq_item: *mut c_void, // 24 ssizeargfunc + /// Reserved (was `sq_slice`); always null. + pub was_sq_slice: *mut c_void, // 32 + pub sq_ass_item: *mut c_void, // 40 ssizeobjargproc + /// Reserved (was `sq_ass_slice`); always null. + pub was_sq_ass_slice: *mut c_void, // 48 + pub sq_contains: *mut c_void, // 56 objobjproc + pub sq_inplace_concat: *mut c_void, // 64 binaryfunc + pub sq_inplace_repeat: *mut c_void, // 72 ssizeargfunc +} + +/// `PyMappingMethods` — the mapping protocol suite (`tp_as_mapping`). +#[repr(C)] +#[derive(Debug)] +pub struct PyMappingMethods { + pub mp_length: *mut c_void, // 0 lenfunc + pub mp_subscript: *mut c_void, // 8 binaryfunc + pub mp_ass_subscript: *mut c_void, // 16 objobjargproc +} + +/// `PyAsyncMethods` — the async protocol suite (`tp_as_async`). +#[repr(C)] +#[derive(Debug)] +pub struct PyAsyncMethods { + pub am_await: *mut c_void, // 0 unaryfunc + pub am_aiter: *mut c_void, // 8 unaryfunc + pub am_anext: *mut c_void, // 16 unaryfunc + pub am_send: *mut c_void, // 24 sendfunc +} + +/// `PyBufferProcs` — the PEP 3118 buffer suite (`tp_as_buffer`). +#[repr(C)] +#[derive(Debug)] +pub struct PyBufferProcs { + pub bf_getbuffer: *mut c_void, // 0 getbufferproc + pub bf_releasebuffer: *mut c_void, // 8 releasebufferproc +} + +const _: () = { + assert!(std::mem::size_of::() == 288); + assert!(std::mem::offset_of!(PyNumberMethods, nb_add) == 0); + assert!(std::mem::offset_of!(PyNumberMethods, nb_multiply) == 16); + assert!(std::mem::offset_of!(PyNumberMethods, nb_power) == 40); + assert!(std::mem::offset_of!(PyNumberMethods, nb_bool) == 72); + assert!(std::mem::offset_of!(PyNumberMethods, nb_int) == 128); + assert!(std::mem::offset_of!(PyNumberMethods, nb_reserved) == 136); + assert!(std::mem::offset_of!(PyNumberMethods, nb_float) == 144); + assert!(std::mem::offset_of!(PyNumberMethods, nb_inplace_add) == 152); + assert!(std::mem::offset_of!(PyNumberMethods, nb_floor_divide) == 232); + assert!(std::mem::offset_of!(PyNumberMethods, nb_true_divide) == 240); + assert!(std::mem::offset_of!(PyNumberMethods, nb_index) == 264); + assert!(std::mem::offset_of!(PyNumberMethods, nb_matrix_multiply) == 272); + assert!(std::mem::offset_of!(PyNumberMethods, nb_inplace_matrix_multiply) == 280); + + assert!(std::mem::size_of::() == 80); + assert!(std::mem::offset_of!(PySequenceMethods, sq_length) == 0); + assert!(std::mem::offset_of!(PySequenceMethods, sq_item) == 24); + assert!(std::mem::offset_of!(PySequenceMethods, sq_ass_item) == 40); + assert!(std::mem::offset_of!(PySequenceMethods, sq_contains) == 56); + assert!(std::mem::offset_of!(PySequenceMethods, sq_inplace_repeat) == 72); + + assert!(std::mem::size_of::() == 24); + assert!(std::mem::offset_of!(PyMappingMethods, mp_length) == 0); + assert!(std::mem::offset_of!(PyMappingMethods, mp_subscript) == 8); + assert!(std::mem::offset_of!(PyMappingMethods, mp_ass_subscript) == 16); + + assert!(std::mem::size_of::() == 32); + assert!(std::mem::offset_of!(PyAsyncMethods, am_await) == 0); + assert!(std::mem::offset_of!(PyAsyncMethods, am_aiter) == 8); + assert!(std::mem::offset_of!(PyAsyncMethods, am_anext) == 16); + assert!(std::mem::offset_of!(PyAsyncMethods, am_send) == 24); + + assert!(std::mem::size_of::() == 16); + assert!(std::mem::offset_of!(PyBufferProcs, bf_getbuffer) == 0); + assert!(std::mem::offset_of!(PyBufferProcs, bf_releasebuffer) == 8); +}; + +// --------------------------------------------------------------------------- +// The full, byte-faithful PyTypeObject. +// --------------------------------------------------------------------------- + +/// C function-pointer slot types. We type them as raw `*mut c_void` / +/// `Option` where wave 1 reads or writes them, and as +/// opaque `*mut c_void` where it only needs the byte to be present at the +/// right offset. +pub type destructor = unsafe extern "C" fn(*mut PyObject); +pub type freefunc = unsafe extern "C" fn(*mut c_void); +pub type allocfunc = unsafe extern "C" fn(*mut PyTypeObjectFull, PySsizeT) -> *mut PyObject; +pub type newfunc = + unsafe extern "C" fn(*mut PyTypeObjectFull, *mut PyObject, *mut PyObject) -> *mut PyObject; + +/// The full CPython 3.13 `PyTypeObject`, byte-for-byte. Field order and +/// offsets are pinned below. Slots wave 1 does not dispatch through are +/// typed as `*mut c_void` (a pointer-sized hole at the correct offset); +/// the ones it reads/writes (`tp_basicsize`, `tp_itemsize`, `tp_flags`, +/// `tp_dealloc`, `tp_alloc`, `tp_new`, `tp_free`, `tp_name`) are typed. +#[repr(C)] +pub struct PyTypeObjectFull { + pub ob_base: PyVarObject, // 0 + pub tp_name: *const c_char, // 24 + pub tp_basicsize: PySsizeT, // 32 + pub tp_itemsize: PySsizeT, // 40 + pub tp_dealloc: Option, // 48 + pub tp_vectorcall_offset: PySsizeT, // 56 + pub tp_getattr: *mut c_void, // 64 + pub tp_setattr: *mut c_void, // 72 + pub tp_as_async: *mut PyAsyncMethods, // 80 + pub tp_repr: *mut c_void, // 88 + pub tp_as_number: *mut PyNumberMethods, // 96 + pub tp_as_sequence: *mut PySequenceMethods, // 104 + pub tp_as_mapping: *mut PyMappingMethods, // 112 + pub tp_hash: *mut c_void, // 120 + pub tp_call: *mut c_void, // 128 + pub tp_str: *mut c_void, // 136 + pub tp_getattro: *mut c_void, // 144 + pub tp_setattro: *mut c_void, // 152 + pub tp_as_buffer: *mut PyBufferProcs, // 160 + pub tp_flags: u64, // 168 (unsigned long) + pub tp_doc: *const c_char, // 176 + pub tp_traverse: *mut c_void, // 184 + pub tp_clear: *mut c_void, // 192 + pub tp_richcompare: *mut c_void, // 200 + pub tp_weaklistoffset: PySsizeT, // 208 + pub tp_iter: *mut c_void, // 216 + pub tp_iternext: *mut c_void, // 224 + pub tp_methods: *mut c_void, // 232 + pub tp_members: *mut c_void, // 240 + pub tp_getset: *mut c_void, // 248 + pub tp_base: *mut PyTypeObjectFull, // 256 + pub tp_dict: *mut PyObject, // 264 + pub tp_descr_get: *mut c_void, // 272 + pub tp_descr_set: *mut c_void, // 280 + pub tp_dictoffset: PySsizeT, // 288 + pub tp_init: *mut c_void, // 296 + pub tp_alloc: Option, // 304 + pub tp_new: Option, // 312 + pub tp_free: Option, // 320 + pub tp_is_gc: *mut c_void, // 328 + pub tp_bases: *mut PyObject, // 336 + pub tp_mro: *mut PyObject, // 344 + pub tp_cache: *mut PyObject, // 352 + pub tp_subclasses: *mut c_void, // 360 + pub tp_weaklist: *mut PyObject, // 368 + pub tp_del: *mut c_void, // 376 + pub tp_version_tag: c_uint_pad, // 384 (unsigned int + 4 pad) + pub tp_finalize: *mut c_void, // 392 + pub tp_vectorcall: *mut c_void, // 400 + /// `unsigned char tp_watched` + `uint16_t tp_versions_used` + pad. + pub tp_tail: [u8; 8], // 408 +} + +/// `unsigned int tp_version_tag` widened to its 8-byte aligned slot. +pub type c_uint_pad = u64; + +const _: () = { + assert!(std::mem::size_of::() == 416); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_name) == 24); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_basicsize) == 32); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_itemsize) == 40); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_dealloc) == 48); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_as_number) == 96); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_as_sequence) == 104); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_as_mapping) == 112); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_as_buffer) == 160); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_flags) == 168); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_doc) == 176); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_base) == 256); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_dictoffset) == 288); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_alloc) == 304); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_new) == 312); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_free) == 320); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_finalize) == 392); + assert!(std::mem::offset_of!(PyTypeObjectFull, tp_vectorcall) == 400); +}; + +/// A handful of the `tp_flags` feature bits wave 1 sets so a stock +/// `PyType_HasFeature(t, Py_TPFLAGS_…)` read returns the truth. Values +/// from `Include/object.h`. +pub mod tpflags { + pub const LONG_SUBCLASS: u64 = 1 << 24; + pub const LIST_SUBCLASS: u64 = 1 << 25; + pub const TUPLE_SUBCLASS: u64 = 1 << 26; + pub const BYTES_SUBCLASS: u64 = 1 << 27; + pub const UNICODE_SUBCLASS: u64 = 1 << 28; + pub const DICT_SUBCLASS: u64 = 1 << 29; + pub const BASE_EXC_SUBCLASS: u64 = 1 << 30; + pub const TYPE_SUBCLASS: u64 = 1 << 31; + pub const DEFAULT: u64 = 0; + pub const BASETYPE: u64 = 1 << 10; + pub const READY: u64 = 1 << 12; + pub const IMMUTABLETYPE: u64 = 1 << 8; +} + +/// Sanity: `c_int` is 4 bytes on every target we model. +const _: () = assert!(std::mem::size_of::() == 4); diff --git a/crates/weavepy-capi/src/lib.rs b/crates/weavepy-capi/src/lib.rs index df3092f..6cde9b9 100644 --- a/crates/weavepy-capi/src/lib.rs +++ b/crates/weavepy-capi/src/lib.rs @@ -96,30 +96,44 @@ pub mod abstract_; pub mod argparse; pub mod buffer; pub mod buffer_format; +pub mod builtin_new; +pub mod builtin_slots; pub mod capsule; +pub mod code_obj; pub mod containers; pub mod datetime_api; pub mod dunder_shim; pub mod errors; pub mod ffi; pub mod force_link_table; +pub mod foreign; +pub mod gc_bridge; pub mod genericalloc; pub mod getset; +pub mod inherit; +pub mod instance; pub mod interp; +pub mod layout; pub mod lifecycle; pub mod loader; pub mod memory; pub mod memoryview; +pub mod mirror; pub mod module; +pub mod monitoring; pub mod numbers; pub mod numbers_format; pub mod object; +pub mod pystate; pub mod singletons; pub mod slice; pub mod slottable; pub mod strings; pub mod types; pub mod vectorcall; +pub mod wave4; +pub mod wave5; +pub mod wave5_pandas; pub use interp::{enter_extension_call, with_active, ActiveContext}; pub use loader::{load_extension_module, LoadError}; diff --git a/crates/weavepy-capi/src/lifecycle.rs b/crates/weavepy-capi/src/lifecycle.rs index 771cddb..a2ffce0 100644 --- a/crates/weavepy-capi/src/lifecycle.rs +++ b/crates/weavepy-capi/src/lifecycle.rs @@ -130,10 +130,12 @@ pub struct PyThreadState { #[no_mangle] pub unsafe extern "C" fn PyThreadState_Get() -> *mut PyThreadState { - // We don't yet have per-thread state objects; return a - // sentinel. The interpreter doesn't dereference this. - static mut DUMMY: u8 = 0; - &raw mut DUMMY as *mut PyThreadState + // RFC 0047 (wave 5): real Cython output dereferences the returned + // thread state (`tstate->interp`, and via the fast-thread-state error + // path `tstate->current_exception`). Hand back the faithful per-thread + // store from `crate::pystate` so those field accesses are sound and the + // pending-exception slot is shared with `crate::errors`. + crate::pystate::current_threadstate() } #[no_mangle] diff --git a/crates/weavepy-capi/src/loader.rs b/crates/weavepy-capi/src/loader.rs index 9403a96..ed44386 100644 --- a/crates/weavepy-capi/src/loader.rs +++ b/crates/weavepy-capi/src/loader.rs @@ -61,6 +61,10 @@ pub fn load_extension_module( ) -> Result { crate::interp::ensure_initialised(); + let trace_loader = std::env::var_os("WEAVEPY_TRACE_LOADER").is_some(); + if trace_loader { + eprintln!("[LOADER] dlopen path={path:?} module={module_name}"); + } let lib = unsafe { Library::new(path) }.map_err(|e| LoadError::Dlopen(format!("{path:?}: {e}")))?; @@ -88,17 +92,60 @@ pub fn load_extension_module( current_module: Some(placeholder.clone()), }; - let raw = crate::interp::enter_extension_call(ctx, || unsafe { init_fn() }); + let raw = crate::interp::enter_extension_call(ctx, || { + let r = unsafe { init_fn() }; + if r.is_null() { + return r; + } + // PEP 489: a tagged `PyModuleDef` means multi-phase init — run + // the create/exec slots ourselves to get the populated module. + if unsafe { crate::module::is_module_def(r) } { + match unsafe { + crate::module::run_multiphase_init( + r as *mut crate::module::PyModuleDef, + module_name, + ) + } { + Ok(m) => m, + Err(e) => { + crate::errors::set_runtime_error(format!("multi-phase init failed: {e}")); + std::ptr::null_mut() + } + } + } else { + r + } + }); if raw.is_null() { - let pending = crate::errors::take_pending().map(|p| { - format!( - "{}: {:?}", - p.ty.as_ref() + if trace_loader { + let peek = crate::errors::take_pending().map(|p| { + let ty = p + .ty + .as_ref() .map(|t| t.name.clone()) - .unwrap_or_else(|| "Exception".to_owned()), - p.value - ) + .unwrap_or_else(|| "Exception".to_owned()); + let msg = crate::errors::message_for(&p.value); + let s = format!("{ty}: {msg}"); + crate::errors::set_pending(p.ty, p.value); + s + }); + eprintln!( + "[LOADER] FAILED (null init) module={module_name} pending={peek:?}" + ); + } + let pending = crate::errors::take_pending().map(|p| { + let ty = p + .ty + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "Exception".to_owned()); + let msg = crate::errors::message_for(&p.value); + if msg.is_empty() { + ty + } else { + format!("{ty}: {msg}") + } }); return Err(LoadError::NullInit { pending }); } @@ -125,6 +172,9 @@ pub fn load_extension_module( // process; otherwise its symbols (and therefore the module's // function pointers) would dangle. Leaking is correct here. let _: &'static Library = Box::leak(Box::new(lib)); + if trace_loader { + eprintln!("[LOADER] OK (lib leaked) module={module_name}"); + } Ok(result) } @@ -173,9 +223,14 @@ pub fn extension_suffixes() -> &'static [&'static str] { if cfg!(target_os = "macos") { &[".cpython-313-darwin.so", ".abi3.so", ".so", ".dylib"] } else if cfg!(target_os = "linux") { + // glibc (manylinux) and musl (musllinux, RFC 0047 wave 5) carry + // distinct SOABI suffixes; the loader probes both so a wheel from + // either Linux ABI resolves to its `.so`. &[ ".cpython-313-x86_64-linux-gnu.so", ".cpython-313-aarch64-linux-gnu.so", + ".cpython-313-x86_64-linux-musl.so", + ".cpython-313-aarch64-linux-musl.so", ".abi3.so", ".so", ] diff --git a/crates/weavepy-capi/src/memory.rs b/crates/weavepy-capi/src/memory.rs index 6f35052..8a5d50a 100644 --- a/crates/weavepy-capi/src/memory.rs +++ b/crates/weavepy-capi/src/memory.rs @@ -141,6 +141,17 @@ pub unsafe extern "C" fn PyObject_Realloc( #[no_mangle] pub unsafe extern "C" fn PyObject_Free(p: *mut std::ffi::c_void) { + // RFC 0045 (wave 3): a faithful inline *instance body* is owned by its + // native instance, not by C's allocator. A stock `tp_dealloc` that + // ends with `tp_free(self)` / `PyObject_Free(self)` must be absorbed — + // the block is reclaimed when the owning instance is collected, and + // freeing it here (it has no `PyMem` allocation header) would corrupt + // the heap. The check is strict (mirror magic + `Weak` back-ref), so + // it never mistakes a genuine `PyObject_Free` scratch buffer for one. + if !p.is_null() && unsafe { crate::mirror::is_instance_body(p as *mut crate::object::PyObject) } + { + return; + } unsafe { PyMem_Free(p) }; } diff --git a/crates/weavepy-capi/src/memoryview.rs b/crates/weavepy-capi/src/memoryview.rs index 977fb21..fa69089 100644 --- a/crates/weavepy-capi/src/memoryview.rs +++ b/crates/weavepy-capi/src/memoryview.rs @@ -58,37 +58,152 @@ pub unsafe extern "C" fn PyMemoryView_FromObject(exporter: *mut PyObject) -> *mu Object::ByteArray(b) => PyMemoryView::from_bytearray(b.clone()), Object::MemoryView(other) => clone_memoryview(other), _ => { - // Generic path: drive the buffer protocol on the - // exporter and build a memoryview from the results. + // Generic path: drive the buffer protocol on the exporter and + // build a faithful memoryview from the results. Request the + // full read-only buffer (format + ndim + strides) exactly like + // CPython's `PyMemoryView_FromObject`, so an exporter such as + // numpy reports its real `format`/`itemsize` — `'O'`/8 for an + // `dtype=object` array, `'l'`/8 for `int64`, etc. A bare + // `PyBUF_SIMPLE` (flags=0) request loses the format string and + // collapses every view to bytes, which breaks Cython + // fused-type dispatch: `map_fused_type` resolves `ndarray[object]` + // only when `memoryview(arr)` reports `itemsize == sizeof(void*)` + // and a parseable `'O'` format (pandas' `lib.map_infer_mask`). + const PYBUF_FULL_RO: c_int = 0x011C; // INDIRECT | STRIDES | ND | FORMAT let mut view = Py_buffer::zeroed(); - let rc = unsafe { crate::buffer::PyObject_GetBuffer(exporter, &raw mut view, 0) }; + let rc = + unsafe { crate::buffer::PyObject_GetBuffer(exporter, &raw mut view, PYBUF_FULL_RO) }; if rc != 0 { return ptr::null_mut(); } - let bytes_data = if view.buf.is_null() || view.len <= 0 { - Vec::new() - } else { - unsafe { std::slice::from_raw_parts(view.buf as *const u8, view.len as usize) } - .to_vec() - }; let readonly = view.readonly != 0; // Snapshot the scalar fields before [`PyBuffer_Release`] // tears down `view`'s exporter-owned arrays. let view_len = view.len.max(0) as usize; let view_itemsize = view.itemsize.max(1) as usize; + let format = if view.format.is_null() { + "B".to_owned() + } else { + unsafe { core::ffi::CStr::from_ptr(view.format) } + .to_string_lossy() + .into_owned() + }; + + // Capture the exporter's multi-dimensional geometry before + // `PyBuffer_Release` frees the exporter-owned shape/stride + // arrays. CPython's memoryview references the exporter's memory + // and keeps its `ndim`/`shape`/`strides`; WeavePy copies the + // bytes, so we snapshot the geometry and re-materialise the data + // in C order to stay self-consistent. Collapsing every view to + // 1-D (the old behaviour) breaks Cython typed-memoryview + // dispatch, which validates `buf.ndim` — e.g. pandas' + // `group_last(int64_t[:, ::1] out, ndarray[int64_t, ndim=2] + // values, ...)` rejects a flattened `values` with "Buffer has + // wrong number of dimensions (expected 2, got 1)". + let ndim = view.ndim.max(0) as usize; + let shape: Vec = if ndim >= 1 && !view.shape.is_null() { + (0..ndim) + .map(|i| unsafe { *view.shape.add(i) }.max(0) as usize) + .collect() + } else { + Vec::new() + }; + let strides: Vec = if ndim >= 1 && !view.strides.is_null() { + (0..ndim) + .map(|i| unsafe { *view.strides.add(i) } as isize) + .collect() + } else { + Vec::new() + }; + + // Materialise the bytes in C-contiguous order. A C-contiguous + // source (numpy's usual case, and any array whose only gaps come + // from size-1 axes) is a straight `view.len`-byte copy; a + // strided/transposed/reversed source is gathered element by + // element following `strides`, matching `memoryview.tobytes()`. + let bytes_data = if view.buf.is_null() || view_len == 0 { + Vec::new() + } else if shape.len() >= 2 && !is_c_contiguous_dims(&shape, &strides, view_itemsize) { + gather_c_order(view.buf as *const u8, &shape, &strides, view_itemsize) + } else { + unsafe { std::slice::from_raw_parts(view.buf as *const u8, view_len) }.to_vec() + }; + unsafe { crate::buffer::PyBuffer_Release(&raw mut view) }; - PyMemoryView::contiguous_1d( + if std::env::var_os("WEAVEPY_TRACE_BUF").is_some() { + eprintln!( + "[WEAVEPY_TRACE_BUF] FromObject built mv format={format:?} itemsize={view_itemsize} ndim={ndim} len={view_len}" + ); + } + let built = PyMemoryView::contiguous_1d( MemoryViewBuffer::Bytes(bytes_data.into()), view_len, readonly, - "B".to_owned(), + format, view_itemsize, - ) + ); + // Store the ≥2-D shape so `ndim`/`shape` survive the copy; leave + // `strides` empty so the VM derives C-contiguous strides that + // match the C-order bytes we just materialised. (A 1-D view's + // derived `[len / itemsize]` shape already matches, so there is + // nothing extra to record.) + if shape.len() >= 2 { + *built.shape.borrow_mut() = shape; + } + built } }; crate::object::into_owned(Object::MemoryView(weavepy_vm::sync::Rc::new(mv))) } +/// C-contiguity test that mirrors numpy's: axes of length 0 or 1 impose +/// no layout constraint, so an `(n, 1)` view (a transposed row, common in +/// pandas' `_call_cython_op`) still counts as contiguous and takes the +/// fast linear-copy path. +fn is_c_contiguous_dims(shape: &[usize], strides: &[isize], itemsize: usize) -> bool { + if strides.is_empty() { + return true; + } + let mut expected = itemsize as isize; + for i in (0..shape.len()).rev() { + if shape[i] > 1 && strides[i] != expected { + return false; + } + expected *= shape[i] as isize; + } + true +} + +/// Gather a strided buffer into a fresh C-order byte vector, walking the +/// multi-index last-axis-fastest exactly like `memoryview.tobytes()`. +/// Handles negative strides (`arr[::-1]`), where `buf` points at the first +/// logical element and offsets run backwards through the allocation. +fn gather_c_order(buf: *const u8, shape: &[usize], strides: &[isize], itemsize: usize) -> Vec { + let total: usize = shape.iter().product(); + let mut out = Vec::with_capacity(total * itemsize); + if total == 0 { + return out; + } + let ndim = shape.len(); + let mut index = vec![0usize; ndim]; + for _ in 0..total { + let mut off: isize = 0; + for d in 0..ndim { + off += index[d] as isize * strides[d]; + } + let src = unsafe { buf.offset(off) }; + out.extend_from_slice(unsafe { std::slice::from_raw_parts(src, itemsize) }); + for d in (0..ndim).rev() { + index[d] += 1; + if index[d] < shape[d] { + break; + } + index[d] = 0; + } + } + out +} + fn clone_memoryview(other: &PyMemoryView) -> PyMemoryView { PyMemoryView { buffer: match &other.buffer { @@ -268,23 +383,59 @@ pub unsafe extern "C" fn PyMemoryView_GET_BUFFER(mv: *mut PyObject) -> *mut Py_b let buf_ptr = buf_box.as_mut_ptr() as *mut std::ffi::c_void; let format = view.format.borrow().clone() + "\0"; let format_storage: Box<[u8]> = format.into_bytes().into_boxed_slice(); + let itemsize = view.itemsize.get().max(1); + + // Element shape/stride: honour an explicit shape, else derive a 1-D + // `[len / itemsize]` C-contiguous layout so `shape`/`itemsize`/`len` + // stay self-consistent (`shape[0] == len / itemsize`, not `len`). + let stored_shape = view.shape.borrow(); + let (shape_box, strides_box): (Box<[PySsizeT]>, Box<[PySsizeT]>) = if stored_shape.is_empty() { + let n = if itemsize > 0 { len / itemsize } else { 0 }; + ( + Box::new([n as PySsizeT]), + Box::new([itemsize as PySsizeT]), + ) + } else { + let shape: Vec = stored_shape.iter().map(|&s| s as PySsizeT).collect(); + let stored_strides = view.strides.borrow(); + let strides: Vec = if stored_strides.is_empty() { + let mut st = vec![0 as PySsizeT; shape.len()]; + let mut acc = itemsize as PySsizeT; + for i in (0..shape.len()).rev() { + st[i] = acc; + acc *= shape[i]; + } + st + } else { + stored_strides.iter().map(|&s| s as PySsizeT).collect() + }; + (shape.into_boxed_slice(), strides.into_boxed_slice()) + }; + let ndim = shape_box.len() as c_int; let internal = Box::new(crate::buffer::BufferInternal { owned_buf: Some(buf_box), - shape: Box::new([len as PySsizeT]), - strides: Box::new([view.itemsize.get() as PySsizeT]), + keepalive: None, + shape: shape_box, + strides: strides_box, suboffsets: Box::new([]), format: format_storage, }); let internal_ptr = Box::into_raw(internal); let internal_ref = unsafe { &mut *internal_ptr }; + if std::env::var_os("WEAVEPY_TRACE_BUF").is_some() { + eprintln!( + "[WEAVEPY_TRACE_BUF] GET_BUFFER mv format={:?} itemsize={itemsize} ndim={ndim} len={len}", + view.format.borrow() + ); + } let pyb = Py_buffer { buf: buf_ptr, obj: mv, len: len as PySsizeT, - itemsize: view.itemsize.get() as PySsizeT, + itemsize: itemsize as PySsizeT, readonly: c_int::from(view.readonly.get()), - ndim: 1, + ndim, format: internal_ref.format.as_ptr() as *mut c_char, shape: internal_ref.shape.as_mut_ptr(), strides: internal_ref.strides.as_mut_ptr(), diff --git a/crates/weavepy-capi/src/mirror.rs b/crates/weavepy-capi/src/mirror.rs new file mode 100644 index 0000000..d1654b0 --- /dev/null +++ b/crates/weavepy-capi/src/mirror.rs @@ -0,0 +1,3262 @@ +//! The object mirror bridge (RFC 0043, wave 1, WS2). +//! +//! CPython extensions are not merely *callers* of an API; the stock +//! headers *inline* the hot path, so a compiled wheel reads object +//! fields at fixed byte offsets (`PyFloat_AS_DOUBLE` → `*(double*)(op+16)`, +//! `Py_SIZE` → `*(Py_ssize_t*)(op+16)`, `PyTuple_GET_ITEM` → +//! `((PyTupleObject*)op)->ob_item[i]`). WeavePy's native value is a Rust +//! [`Object`] enum with none of those fields at those offsets, so we +//! cannot satisfy a stock reader by interposing a function. +//! +//! Following PyPy's `cpyext` and GraalPy's C-API layer, this module +//! maintains a **layout-faithful mirror**: when a native value crosses +//! into C it is materialised into a heap block whose bytes match the +//! corresponding CPython 3.13 struct ([`crate::layout`]) exactly. The +//! public `*mut PyObject` points at that faithful body; immediately +//! *before* it (a negative offset, invisible to C) sits a +//! [`MirrorPrefix`] holding the owning native [`Object`] — so a pointer +//! WeavePy minted resolves back to its native object in O(1) without a +//! global lookup, while the public pointer stays byte-faithful. +//! +//! Wave 1 fills faithful bodies for the immutable high-frequency types +//! whose internals get inlined (`float`, `int`, `complex`, `bytes`, +//! compact `str`, `tuple`); other types get a head-only "generic" body +//! whose native value still lives in the prefix (so the function-call +//! C-API and `clone_object` work, only stock *inlined field reads* are a +//! later wave). Either way the prefix is uniform, so resolution and +//! freeing are representation-independent. + +use std::alloc::{alloc, dealloc, Layout}; +use std::os::raw::c_void; +use std::ptr; + +use num_bigint::BigInt; +use weavepy_vm::object::Object; + +use crate::layout::{self, ustate}; +use crate::object::{PyObject, PySsizeT}; +use crate::types::{self, PyTypeObject}; + +/// Diagnostic: gate faithful instance-body alloc/free tracing on +/// `WEAVEPY_BODY_TRACE` (RFC 0045 debugging of body-address reuse). +pub fn body_trace_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_BODY_TRACE").is_some()) +} + +fn body_trace_interesting(tn: &str) -> bool { + tn.contains("Engine") + || tn.contains("ndarray") + || tn.contains("Index") + || tn.contains("BlockManager") + || tn.contains("Block") + || tn.contains("internals") +} + +thread_local! { + /// Diagnostic (WEAVEPY_BODY_TRACE): the type name most recently freed + /// at each instance-body address, so a subsequent allocation reusing + /// that address can flag a body-address reuse across types. + static FREED_BODY_TYPES: RefCell> = + RefCell::new(HashMap::new()); + /// Diagnostic (WEAVEPY_WATCH_BLOCKS): addresses of blocks tuples to + /// trace refcount ops on, to find a premature-free / over-decref. + static WATCHED: RefCell> = + RefCell::new(std::collections::HashSet::new()); + /// Diagnostic (WEAVEPY_WATCH_BLOCKS): free-site history (type + short + /// backtrace) for each mirror address, so a later stale read can print + /// the full reuse chain that led to the confusion. + static FREE_BT: RefCell>> = + RefCell::new(HashMap::new()); +} + +/// Record the free-site of a mirror at `p` (WEAVEPY_WATCH_BLOCKS), keyed +/// by address, so a later stale read of the same address can report who +/// freed it. +pub unsafe fn record_mirror_free(p: *mut PyObject) { + if !watch_enabled() { + return; + } + // Only faithful tuples/lists — the shapes a `blocks` field points at — + // to keep backtrace capture rare enough not to perturb timing. + if !unsafe { is_faithful_tuple(p) } && !unsafe { is_faithful_list(p) } { + return; + } + let tn = unsafe { crate::object::debug_type_name(p) }; + // Keep only the last ~4 interior frames to make the chain readable. + let full = std::backtrace::Backtrace::force_capture().to_string(); + let short: String = full + .lines() + .filter(|l| { + l.contains("free_mirror") + || l.contains("free_box") + || l.contains("DecRef") + || l.contains("Dealloc") + || l.contains("install_new") + || l.contains("VectorcallMethod") + || l.contains("reap") + || l.contains("tp_clear") + || l.contains("GC_") + || l.contains("clear") + }) + .take(8) + .collect::>() + .join(" | "); + FREE_BT.with(|m| { + m.borrow_mut() + .entry(p as usize) + .or_default() + .push((tn, short)); + }); +} + +/// Look up the free-site history recorded for `addr` (WEAVEPY_WATCH_BLOCKS). +pub fn lookup_free_bt(addr: usize) -> Option> { + if !watch_enabled() { + return None; + } + FREE_BT.with(|m| m.borrow().get(&addr).cloned()) +} + +use std::cell::RefCell; + +pub fn watch_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_WATCH_BLOCKS").is_some()) +} + +pub fn watch_ptr(p: usize) { + if watch_enabled() { + WATCHED.with(|s| s.borrow_mut().insert(p)); + } +} + +pub fn is_watched(p: usize) -> bool { + watch_enabled() && WATCHED.with(|s| s.borrow().contains(&p)) +} + +pub fn unwatch_ptr(p: usize) { + if watch_enabled() { + WATCHED.with(|s| s.borrow_mut().remove(&p)); + } +} + +fn note_body_freed(addr: usize, tyname: String) { + if !body_trace_enabled() { + return; + } + FREED_BODY_TYPES.with(|m| { + m.borrow_mut().insert(addr, tyname); + }); +} + +fn check_body_reuse(addr: usize, new_ty: &str) { + if !body_trace_enabled() { + return; + } + let prev = FREED_BODY_TYPES.with(|m| m.borrow_mut().remove(&addr)); + if let Some(old) = prev { + if body_trace_interesting(&old) || body_trace_interesting(new_ty) { + eprintln!( + "[BODY-REUSE] addr=0x{:x} old_type={} new_type={}", + addr, old, new_ty + ); + } + } +} + +/// WeavePy bookkeeping placed immediately before the faithful body. The +/// public `*mut PyObject` is `prefix as *mut u8 + PREFIX_SIZE`, so the +/// prefix is recovered by subtracting [`PREFIX_SIZE`]. +#[repr(C)] +pub struct MirrorPrefix { + /// The owning native object. Holding it here pins the value (its + /// `Rc`s) for as long as C holds a reference; dropped when the + /// mirror's refcount reaches zero. For a wave-3 **instance body** + /// (see [`inst`](Self::inst)) this is [`Object::None`] — the body + /// only *borrows* its instance, so it must not own a strong `Rc`. + pub obj: Object, + /// For a faithful **instance body** (RFC 0045, wave 3) this is a + /// `Weak` back-reference to the owning native [`PyInstance`]; `None` + /// for every built-in mirror (which carries its value in + /// [`obj`](Self::obj)). A `Weak` rather than the strong + /// `Object::Instance` is what breaks the body↔instance ownership + /// cycle: the *instance* owns the body (and frees it on drop, via the + /// `register_instance_body_free` hook), while the body only borrows + /// back so [`native_of`] can resolve the pointer to its instance. + pub inst: Option>, + /// Extra C-side state (capsule pointer, module-state, …). Mirrors + /// do not use this today but the slot keeps parity with the legacy + /// box so shared accessors are uniform. + pub user_data: *mut c_void, + /// Optional destructor, run before the block is freed. + pub destructor: Option, + /// Total bytes of the body allocation (`PREFIX_SIZE + body`), for + /// [`dealloc`]. + pub alloc_size: usize, + /// Out-of-line buffer owned by this mirror (a list's `ob_item` + /// array), or null. + pub aux_ptr: *mut u8, + /// Byte length of [`aux_ptr`]'s allocation. + pub aux_size: usize, + /// True iff this mirror is a faithful, **buffer-authoritative** unicode + /// string built by [`new_unicode_mirror`] (the target of + /// `PyUnicode_New`/`PyUnicode_Resize`, RFC 0047, wave 5). A stock + /// extension writes such a string's character buffer *directly* — the + /// inlined `PyUnicode_WRITE` macro after `PyUnicode_New`, or + /// `PyUnicode_CopyCharacters` after `PyUnicode_Resize` — so the C body, + /// not the prefix's staged [`obj`](Self::obj), is authoritative on + /// read-back ([`native_of`] reconstructs via [`read_str`]). A normal + /// str mirror (minted by [`mirror_out`]) leaves this `false` and stays + /// prefix-authoritative: its bytes are never mutated in place. + pub str_buffer: bool, + /// True once a faithful **list** mirror's prefix [`obj`](Self::obj) has + /// been seeded from the authoritative inline `ob_item` buffer (RFC 0047, + /// wave 5). A list mints with `false`; the first [`native_of`] read-back + /// reconstructs the prefix list from `ob_item` — capturing a C-built list + /// (`PyList_New` + the `PyList_SET_ITEM` macro, e.g. numpy's + /// `__cpu_dispatch__`) — and flips this `true`. Thereafter the prefix list + /// is the shared, identity-stable source of truth, so a Python-side + /// mutation of a C-resident `cdef public list` (pandas' + /// `BlockManager.axes[0] = new_axis`) persists across crossings instead of + /// landing on a throwaway per-read reconstruction. (Always `false` for + /// non-list mirrors.) + pub list_synced: bool, + /// A small magic so debugging tools (and assertions) can recognise + /// a mirror prefix. + pub magic: u64, +} + +/// Sentinel stamped into every [`MirrorPrefix::magic`]. +pub const MIRROR_MAGIC: u64 = 0x5742_504d_5252_5230; // "WBPMRR0" + +/// Body alignment. 16 is ≥ the alignment of every faithful struct +/// (`f64`, pointers, `Py_complex`) and keeps SIMD-friendly buffers sane. +const BODY_ALIGN: usize = 16; + +/// Bytes reserved for the prefix, rounded so the body that follows is +/// [`BODY_ALIGN`]-aligned. +pub const PREFIX_SIZE: usize = { + let s = std::mem::size_of::(); + // round up to BODY_ALIGN + (s + (BODY_ALIGN - 1)) & !(BODY_ALIGN - 1) +}; + +const _: () = { + // The prefix must not be larger than the reserved region, and the + // reserved region must be a multiple of the body alignment. + assert!(std::mem::align_of::() <= BODY_ALIGN); + assert!(PREFIX_SIZE.is_multiple_of(BODY_ALIGN)); + assert!(PREFIX_SIZE >= std::mem::size_of::()); +}; + +/// Recover the prefix pointer from a public body pointer. +/// +/// # Safety +/// `p` must be a body pointer previously returned by [`mirror_out`] / +/// [`mirror_out_with_type`] (i.e. [`is_mirror`] is true). +#[inline] +pub unsafe fn prefix_of(p: *mut PyObject) -> *mut MirrorPrefix { + unsafe { (p as *mut u8).sub(PREFIX_SIZE) as *mut MirrorPrefix } +} + +/// True if `p` is a faithful mirror (as opposed to a legacy +/// `PyObjectBox` or a static singleton/type). Decided by the object's +/// type: every value of a faithful built-in type is minted as a mirror, +/// and (RFC 0045, wave 3) every instance of an inline-storage extension +/// type is minted as a faithful instance body — so the type pointer is a +/// sound, deref-free discriminator for both. +/// +/// # Safety +/// `p` must be non-null and point at a valid object head (`ob_type` +/// readable). Callers must have already excluded the static singletons +/// and static type objects (which are not mirrors). +#[inline] +pub unsafe fn is_mirror(p: *mut PyObject) -> bool { + if p.is_null() { + return false; + } + let ty = unsafe { (*p).ob_type }; + type_is_faithful(ty) || types::is_inline_instance_type(ty) +} + +/// The set of built-in types whose instances are minted as faithful +/// mirrors. Mirrors `crate::types::type_for_object` for these variants. +pub fn type_is_faithful(ty: *mut PyTypeObject) -> bool { + if ty.is_null() { + return false; + } + ty == types::PyFloat_Type.as_ptr() + || ty == types::PyLong_Type.as_ptr() + || ty == types::PyBool_Type.as_ptr() + || ty == types::PyComplex_Type.as_ptr() + || ty == types::PyBytes_Type.as_ptr() + || ty == types::PyByteArray_Type.as_ptr() + || ty == types::PyUnicode_Type.as_ptr() + || ty == types::PyTuple_Type.as_ptr() + || ty == types::PyList_Type.as_ptr() + // RFC 0047 (wave 5): `dict`. Macro-heavy Cython reads + // `((PyDictObject*)d)->ma_used` straight off the struct (the + // `PyDict_GET_SIZE` macro and the keyword-argument fast path + // `__Pyx_PyVectorcall_FastCallDict_kw`), so a dict crossing into C + // must be a faithful `PyDictObject` header. WeavePy mints *every* + // `Object::Dict` through this path (`type_for_object(Dict)` is the + // sole writer of `PyDict_Type`), so the type-keyed discriminator is + // sound. + || ty == types::PyDict_Type.as_ptr() + // RFC 0047 (wave 5): `set` / `frozenset`. Macro-heavy Cython reads + // `((PySetObject*)s)->used` straight off the struct — `PySet_GET_SIZE` + // / `PyFrozenSet_GET_SIZE`, which Cython emits for both `len(s)` and + // the truthiness test `if s:` on a set-typed value (pandas' + // `Timedelta.__new__` keyword guard). WeavePy mints *every* + // `Object::Set`/`FrozenSet` through `type_for_object` (the sole writer + // of these two type pointers), so the type-keyed discriminator is + // sound: no foreign object carries `PySet_Type`/`PyFrozenSet_Type`. + || ty == types::PySet_Type.as_ptr() + || ty == types::PyFrozenSet_Type.as_ptr() + // RFC 0046 (wave 4): `builtin_function_or_method`. WeavePy mints + // *every* `PyCFunction` (we expose no `PyCFunction_NewEx`, and + // `type_for_object(Builtin)` is the sole writer of this type), so a + // type-keyed discriminator is sound: no foreign object ever carries + // `PyCFunction_Type`. + || ty == types::PyCFunction_Type.as_ptr() + // RFC 0047 (wave 5): `method` (a bound method). WeavePy mints *every* + // `PyMethod_Type` object — `PyMethod_New` routes through the VM and + // `type_for_object(BoundMethod)` is the sole writer — so the + // type-keyed discriminator is sound: no foreign object carries + // `PyMethod_Type`. A faithful body is mandatory because Cython's + // `with`/`for`/call fast paths unpack a bound method by reading + // `im_func`/`im_self` straight off the C struct (see + // `layout::PyMethodObject`). + || ty == types::PyMethod_Type.as_ptr() + // RFC 0047 (wave 5): `slice`. WeavePy mints *every* `Object::Slice` + // through `type_for_object(Slice)` (the sole writer of `PySlice_Type`), + // so the type-keyed discriminator is sound. A faithful body is + // mandatory because Cython reads `start`/`stop`/`step` straight off the + // `PySliceObject` struct (pandas' `internals.slice_canonize`; see + // `layout::PySliceObject`). + || ty == types::PySlice_Type.as_ptr() + // RFC 0047 (wave 5): `memoryview`. WeavePy mints *every* + // `Object::MemoryView` through `type_for_object(MemoryView)` (the sole + // writer of `PyMemoryView_Type`; `PyMemoryView_FromObject` and friends + // all route through it), so the type-keyed discriminator is sound — and + // the `is_weavepy_owned` guard in `free_box`/`clone_object` runs first, + // so a (hypothetical) foreign object carrying `PyMemoryView_Type` is + // never mis-claimed. A faithful `PyMemoryViewObject` body is mandatory + // because `PyMemoryView_GET_BUFFER` is a macro (`&mv->view`) that + // Cython's fused-type dispatch reads straight off the struct (pandas' + // `lib.map_infer_mask`; see `layout::PyMemoryViewObject`). Without this + // entry `is_mirror` is false for a memoryview mirror, so `free_box` + // drops its prefix-offset body as a `PyObjectBox` + // (`POINTER_BEING_FREED_WAS_NOT_ALLOCATED`). + || ty == types::PyMemoryView_Type.as_ptr() +} + +/// True if a native [`Object`] is mirrored with a faithful body (rather +/// than routed through the legacy `PyObjectBox`). +pub fn obj_is_faithful(obj: &Object) -> bool { + matches!( + obj, + Object::Float(_) + | Object::Int(_) + | Object::Long(_) + | Object::Bool(_) + | Object::Complex(_) + | Object::Bytes(_) + | Object::ByteArray(_) + | Object::Str(_) + | Object::Tuple(_) + | Object::List(_) + | Object::Dict(_) + | Object::Set(_) + | Object::FrozenSet(_) + | Object::Builtin(_) + | Object::BoundMethod(_) + | Object::Slice(_) + | Object::MemoryView(_) + ) +} + +/// Materialise `obj` into a faithful mirror, choosing the type pointer +/// from the value. Caller owns one reference. +pub fn mirror_out(obj: Object) -> *mut PyObject { + let ty = types::type_for_object(&obj); + mirror_out_with_type(obj, ty) +} + +/// Materialise `obj` into a faithful mirror with an explicit type +/// pointer. Used for the tuple-staging case (`PyTuple_New` advertises +/// `PyTuple_Type` while staging a mutable `List`). +pub fn mirror_out_with_type(obj: Object, ty: *mut PyTypeObject) -> *mut PyObject { + // A bool crosses as the immortal, layout-faithful `Py_True`/`Py_False` + // singleton — never a freshly-minted box. CPython hands out exactly these + // two `PyLongObject`s, and C code relies both on pointer identity + // (`x == Py_True`, `Py_RETURN_TRUE`) and on the inline digit/sign decode + // (`maybe_convert_objects`'s `bools[i] = val`). The generic-body fallback + // would have produced a 16-byte `PyObject` with no `_PyLongValue`. + if let Object::Bool(b) = &obj { + return if *b { + crate::singletons::true_ptr() + } else { + crate::singletons::false_ptr() + }; + } + // RFC 0047 (wave 5): a `set`/`frozenset` crosses as a single canonical + // box (see [`SET_BOX_CACHE`]). Reuse the live one whenever the same + // native set is already mirrored so a C-cached `PyObject*` stays + // coherent across a VM-routed mutation (`difference_update`, `|=`, …). + if let Some(key) = set_rc_key(&obj) { + if let Some(p) = cached_set_box(key) { + return p; + } + let p = mirror_out_fresh(obj, ty); + register_set_box(key, p); + return p; + } + // A `builtin_function_or_method` crosses as a single canonical box (see + // [`BUILTIN_BOX_CACHE`]) so pointer-identity tests (`op is operator.eq`, + // as in `pandas._libs.ops.vec_compare`) hold across the boundary. Reuse + // the live one whenever the same native builtin is already mirrored. + if let Some(key) = builtin_rc_key(&obj) { + if let Some(p) = cached_builtin_box(key) { + return p; + } + let p = mirror_out_fresh(obj, ty); + register_builtin_box(key, p); + return p; + } + mirror_out_fresh(obj, ty) +} + +/// Mint a fresh faithful mirror block for `obj` (no canonical-box cache +/// consultation). Every mirror is born here; [`mirror_out_with_type`] +/// layers the set cache on top. +fn mirror_out_fresh(obj: Object, ty: *mut PyTypeObject) -> *mut PyObject { + let plan = BodyPlan::for_object(&obj); + let total = PREFIX_SIZE + plan.body_size; + let layout = Layout::from_size_align(total, BODY_ALIGN).expect("mirror layout"); + let raw = unsafe { alloc(layout) }; + assert!(!raw.is_null(), "mirror allocation failed"); + unsafe { ptr::write_bytes(raw, 0, total) }; + + let body = unsafe { raw.add(PREFIX_SIZE) } as *mut PyObject; + + // Allocate any out-of-line buffer (list `ob_item`) before we move + // `obj` into the prefix, so we can still read it. + let mut aux_ptr: *mut u8 = ptr::null_mut(); + let mut aux_size: usize = 0; + unsafe { + fill_body(body, ty, &obj, &plan, &mut aux_ptr, &mut aux_size); + } + + // Head. + unsafe { + (*body).ob_refcnt = 1; + (*body).ob_type = ty; + } + + // Prefix (owns the native object). + let pre = raw as *mut MirrorPrefix; + unsafe { + ptr::write( + pre, + MirrorPrefix { + obj, + inst: None, + user_data: ptr::null_mut(), + destructor: None, + alloc_size: total, + aux_ptr, + aux_size, + str_buffer: false, + list_synced: false, + magic: MIRROR_MAGIC, + }, + ); + } + crate::object::register_minted(body); + body +} + +/// Allocate a faithful, zeroed **instance body** (RFC 0045, wave 3): a +/// `[MirrorPrefix | tp_basicsize (+ var-data)]` block whose body begins +/// with `PyObject_HEAD` so a stock reader pokes the extension's inline +/// fields at their declared offsets (`((MyType *)self)->field`). +/// +/// `body_bytes` is the full body size (`tp_basicsize + nitems * +/// tp_itemsize`, clamped to at least `sizeof(PyObject)`); the head's +/// refcount starts at 1 and `ob_type` is `ty`. The prefix *borrows* the +/// owning instance through `weak` (no strong `Rc`, so there is no +/// ownership cycle); the instance frees the block on drop via +/// [`free_instance_body`]. +pub fn alloc_instance_body( + ty: *mut PyTypeObject, + body_bytes: usize, + weak: weavepy_vm::sync::Weak, +) -> *mut PyObject { + let body_bytes = body_bytes.max(std::mem::size_of::()); + let total = PREFIX_SIZE + body_bytes; + let layout = Layout::from_size_align(total, BODY_ALIGN).expect("instance body layout"); + let raw = unsafe { alloc(layout) }; + assert!(!raw.is_null(), "instance body allocation failed"); + unsafe { ptr::write_bytes(raw, 0, total) }; + + let body = unsafe { raw.add(PREFIX_SIZE) } as *mut PyObject; + if body_trace_enabled() && crate::object::is_weavepy_owned(body) { + let tn = unsafe { crate::object::debug_type_name(body) }; + eprintln!( + "[DOUBLE-ALLOC] alloc returned live minted body=0x{:x} prev-type={}", + body as usize, tn + ); + } + unsafe { + (*body).ob_refcnt = 1; + (*body).ob_type = ty; + } + let pre = raw as *mut MirrorPrefix; + unsafe { + ptr::write( + pre, + MirrorPrefix { + obj: Object::None, + inst: Some(weak), + user_data: ptr::null_mut(), + destructor: None, + alloc_size: total, + aux_ptr: ptr::null_mut(), + aux_size: 0, + str_buffer: false, + list_synced: false, + magic: MIRROR_MAGIC, + }, + ); + } + crate::object::register_minted(body); + if body_trace_enabled() { + let tn = unsafe { crate::object::debug_type_name(body) }; + check_body_reuse(body as usize, &tn); + if body_trace_interesting(&tn) { + let inst_ptr = unsafe { (*pre).inst.as_ref() } + .and_then(|w| w.upgrade()) + .map(|rc| weavepy_vm::sync::Rc::as_ptr(&rc) as usize) + .unwrap_or(0); + eprintln!( + "[BALLOC] body=0x{:x} inst=0x{:x} type={}", + body as usize, inst_ptr, tn + ); + } + } + body +} + +/// True iff `p` is a faithful **instance body** (RFC 0045, wave 3) — a +/// mirror whose prefix carries the [`MIRROR_MAGIC`] sentinel *and* a +/// `Weak` back-reference. Used by +/// [`crate::object::free_box`] to route a C refcount-zero through "end +/// C's borrow" rather than the immediate deallocate path, and by +/// [`crate::memory::PyObject_Free`] to *absorb* a stock `tp_dealloc`'s +/// `tp_free(self)` on a body the owning instance still owns. +/// +/// The magic check is what makes this sound to call on an *arbitrary* +/// pointer (e.g. a scratch buffer handed to `PyObject_Free`): a +/// non-mirror's bytes would have to both name a registered inline type +/// at `ob_type` *and* carry the 8-byte sentinel at the prefix offset, +/// which does not happen in practice. +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 8]`. +pub unsafe fn is_instance_body(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let pre = unsafe { prefix_of(p) }; + unsafe { (*pre).magic == MIRROR_MAGIC && (*pre).inst.is_some() } +} + +/// Free a faithful instance body's allocation (RFC 0045, wave 3). Called +/// from the `register_instance_body_free` hook when the owning native +/// instance is collected — never from the C refcount path. Drops the +/// prefix (its `Weak` back-reference) and releases the block. +/// +/// # Safety +/// `p` must be an instance body ([`is_instance_body`]) that the owning +/// instance is releasing; it must not be used afterwards. +pub unsafe fn free_instance_body(p: *mut PyObject) { + if body_trace_enabled() { + let tn = unsafe { crate::object::debug_type_name(p) }; + note_body_freed(p as usize, tn.clone()); + if body_trace_interesting(&tn) { + let rc = unsafe { (*p).ob_refcnt }; + eprintln!("[BFREE] body=0x{:x} type={} refcnt={}", p as usize, tn, rc); + if tn.contains("Engine") || tn.contains("BlockManager") { + eprintln!("{}", std::backtrace::Backtrace::force_capture()); + } + } + } + crate::object::unregister_minted(p); + let pre = unsafe { prefix_of(p) }; + if let Some(d) = unsafe { (*pre).destructor } { + unsafe { d(p) }; + } + let alloc_size = unsafe { (*pre).alloc_size }; + // Drop the prefix in place (`obj` is None; the `Weak` back-reference + // decrements the instance's weak count) before releasing the block. + unsafe { ptr::drop_in_place(pre) }; + let layout = Layout::from_size_align(alloc_size, BODY_ALIGN).expect("instance body layout"); + unsafe { dealloc(pre as *mut u8, layout) }; +} + +/// Clone the native object out of a mirror without touching the C-side +/// refcount. +/// +/// # Safety +/// `p` must satisfy [`is_mirror`]. +pub unsafe fn native_of(p: *mut PyObject) -> Object { + let pre = unsafe { prefix_of(p) }; + // RFC 0045 (wave 3): a faithful instance body resolves through its + // `Weak` back-reference to the owning native instance, so every + // crossing of the same pointer yields the *same* `PyInstance` (and + // thus the same `__dict__`, identity, and inline body). The `Weak` + // still upgrades here — the body is alive, so the instance is too. + if let Some(weak) = unsafe { (*pre).inst.as_ref() } { + // RFC 0046 (wave 5): a faithful **str-subtype** body (numpy's + // `str_`, built by `builtin_new::str_new`) carries no VM-native + // string on its `PyInstance`, so the VM's string operations (`+`, + // f-strings, comparison, hashing) cannot read it and a bare + // `Object::Instance` reports "unsupported operand". Reconstruct its + // value so it behaves as a `str` — a numpy scalar is interchangeable + // with its Python counterpart. Gated on the cheap `tp_base`-chain + // subtype test, so an ordinary faithful instance (numpy `ndarray`, + // pandas block, …) is unaffected. + let head = unsafe { &*p }; + if !head.ob_type.is_null() + && !std::ptr::eq(head.ob_type, types::PyUnicode_Type.as_ptr()) + && unsafe { + crate::types::PyType_IsSubtype(head.ob_type, types::PyUnicode_Type.as_ptr()) + } != 0 + { + if let Some(s) = unsafe { read_unicode_value(p) } { + return Object::from_str(s); + } + } + // RFC 0046 (wave 5): a faithful **bytes-subtype** body (numpy's + // `bytes_`, built by `builtin_new::bytes_new`) carries its value in + // the inline `ob_sval` array, not on its `PyInstance`. Reconstruct it + // so the VM's `bytes` operations (comparison, hashing, `bytes(x)`, + // indexing) see the real value — a numpy scalar is interchangeable + // with its Python counterpart. Same cheap subtype guard as unicode. + if !head.ob_type.is_null() + && !std::ptr::eq(head.ob_type, types::PyBytes_Type.as_ptr()) + && unsafe { + crate::types::PyType_IsSubtype(head.ob_type, types::PyBytes_Type.as_ptr()) + } != 0 + { + if let Some(b) = unsafe { read_bytes_value(p) } { + let rc: weavepy_vm::sync::Rc<[u8]> = b.into(); + return Object::Bytes(rc); + } + } + return match weak.upgrade() { + Some(inst) => Object::Instance(inst), + None => Object::None, + }; + } + // RFC 0046 (wave 4): a faithful tuple's inline `ob_item` is the source + // of truth (a stock `PyTuple_SET_ITEM` writes it directly, bypassing + // our functions), so reconstruct from the C body rather than the + // staged prefix object. + if unsafe { is_faithful_tuple(p) } { + return unsafe { read_tuple(p) }; + } + // RFC 0047 (wave 5): a faithful list is **seed-once, then prefix- + // authoritative**. A stock `PyList_New` + `PyList_SET_ITEM` build writes + // the inline `ob_item` directly (numpy's `__cpu_dispatch__`), so the first + // read-back reconstructs the prefix list from that buffer. Thereafter the + // prefix's `Object::List` is the shared, identity-stable source of truth: + // every crossing of the same mirror yields the *same* `Rc`, so a Python + // mutation of a C-resident `cdef public list` persists. pandas' + // `BlockManager.insert` does `self.axes[0] = new_axis` on the list its + // Cython getter returns; reconstruct-on-*every*-read handed each crossing + // a throwaway copy, so the store vanished and `df["c"] = …` silently + // dropped the column (`KeyError: 'c'`). + if unsafe { is_faithful_list(p) } { + let pre = unsafe { prefix_of(p) }; + if !unsafe { (*pre).list_synced } { + let seeded = unsafe { read_list(p) }; + unsafe { + (*pre).obj = seeded; + (*pre).list_synced = true; + } + // Now VM-shared: a Python-side mutation of this list must be + // re-published to `ob_item` before C reads it back through the + // `PyList_GET_ITEM` macro (see [`flush_seeded_lists`]). + register_seeded_list(p); + } else { + // Adopt any *direct* C-side macro write to `ob_item` (RFC 0047, + // wave 5) — e.g. Cython's `__Pyx_ListComp_Append` building + // `memoryview.shape` — back into the shared prefix `Rc` before + // handing it to the VM. A VM-only mutation is left untouched. + unsafe { reconcile_list_from_c(p) }; + } + return unsafe { (*pre).obj.clone() }; + } + // RFC 0047 (wave 5): a **buffer-authoritative** unicode mirror (the + // result of `PyUnicode_New`/`PyUnicode_Resize`) has its character data + // written directly by the extension (the inlined `PyUnicode_WRITE` + // macro, `PyUnicode_CopyCharacters`), so reconstruct from the C buffer + // rather than the staged prefix object, which would be stale. A normal + // str mirror (`str_buffer == false`) is never mutated in place, so its + // prefix object stays authoritative (and avoids a per-crossing rebuild). + if unsafe { (*pre).str_buffer } { + return unsafe { read_str(p) }; + } + unsafe { (*pre).obj.clone() } +} + +/// True iff `p` is a faithful **tuple** mirror — a mirror whose advertised +/// type is `PyTuple_Type` and whose inline `ob_item` array holds the +/// elements (RFC 0046, wave 4). A stock extension fills such a tuple with +/// the `PyTuple_SET_ITEM` macro and reads it with `PyTuple_GET_ITEM`, both +/// of which touch the inline array directly, so the C body — not the +/// prefix's staged [`Object`] — is authoritative on every read. +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 16]`. +pub unsafe fn is_faithful_tuple(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyTuple_Type.as_ptr()) +} + +/// True if `p` is a faithful `dict` mirror. +/// +/// # Safety +/// `p` must be non-null with a readable `ob_type`. +pub unsafe fn is_faithful_dict(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyDict_Type.as_ptr()) +} + +/// Refresh a faithful dict mirror's `ma_used` from its prefix's native +/// dict after a C-side mutation changed the entry count. CPython exposes +/// the live count straight off the struct (`PyDict_GET_SIZE`), so every +/// WeavePy dict mutator that crosses the C boundary must re-publish it +/// here. No-op for any pointer that isn't a faithful dict mirror. +/// +/// # Safety +/// `p` must be non-null with a readable `ob_type`. +pub unsafe fn sync_dict_ma_used(p: *mut PyObject) { + if !unsafe { is_faithful_dict(p) } { + return; + } + let pre = unsafe { prefix_of(p) }; + if let Object::Dict(rc) = unsafe { &(*pre).obj } { + let used = rc.borrow().len() as PySsizeT; + let d = p as *mut layout::PyDictObject; + unsafe { + (*d).ma_used = used; + } + } +} + +/// True if `p` is a faithful `set` **or** `frozenset` mirror. +/// +/// # Safety +/// `p` must be non-null with a readable `ob_type`. +pub unsafe fn is_faithful_set(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + if head.ob_type.is_null() { + return false; + } + std::ptr::eq(head.ob_type, crate::types::PySet_Type.as_ptr()) + || std::ptr::eq(head.ob_type, crate::types::PyFrozenSet_Type.as_ptr()) +} + +/// Refresh a faithful set mirror's `fill`/`used` from its prefix's native +/// set after an in-place mutation changed the element count. CPython +/// exposes the live count straight off the struct (`PySet_GET_SIZE` is +/// `((PySetObject*)so)->used`), and Cython lowers `len(s)` / `if s:` on a +/// set-typed value to that macro — so every mutation that reaches the set +/// through the C boundary (a `PySet_Add`, or an unbound-method call like +/// `set.difference_update(s, other)` routed through `PyObject_Call`) must +/// re-publish the size here. No-op for any pointer that isn't a faithful +/// set mirror. +/// +/// # Safety +/// `p` must be non-null with a readable `ob_type`. +pub unsafe fn sync_set_used(p: *mut PyObject) { + if !unsafe { is_faithful_set(p) } { + return; + } + let pre = unsafe { prefix_of(p) }; + let n = match unsafe { &(*pre).obj } { + Object::Set(rc) => rc.borrow().len() as PySsizeT, + Object::FrozenSet(fs) => fs.len() as PySsizeT, + _ => return, + }; + let so = p as *mut layout::PySetObject; + if std::env::var_os("WEAVEPY_TRACE_SETSEED").is_some() { + eprintln!( + "[SYNC_SET_USED] p={:p} old_used={} new={}", + p, + unsafe { (*so).used }, + n + ); + } + unsafe { + (*so).fill = n; + (*so).used = n; + } +} + +/// Re-publish the macro-visible size of a dict/set mirror after it may +/// have been mutated in place through the C boundary. A cheap no-op for +/// any pointer that isn't one of those two faithful mirrors (the +/// [`is_mirror`] magic check gates the type comparison), so it is safe to +/// sprinkle over the generic call path. +/// +/// # Safety +/// `p` may be null; if non-null it must have a readable `ob_type`. +pub unsafe fn sync_container_size(p: *mut PyObject) { + if p.is_null() || !unsafe { is_mirror(p) } { + return; + } + if unsafe { is_faithful_dict(p) } { + unsafe { sync_dict_ma_used(p) }; + } else if unsafe { is_faithful_set(p) } { + unsafe { sync_set_used(p) }; + } +} + +/// Reconstruct an [`Object::Tuple`] by reading a faithful tuple mirror's +/// inline `ob_item` array (`ob_size` entries). Each non-NULL slot is +/// resolved with [`crate::object::clone_object`] so a foreign element +/// round-trips opaquely and a DType class resolves to its bridged type. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_tuple`]. +pub unsafe fn read_tuple(p: *mut PyObject) -> Object { + let vo = p as *const layout::PyVarObject; + let n = unsafe { (*vo).ob_size }; + let n = if n < 0 { 0 } else { n as usize }; + let to = p as *const layout::PyTupleObject; + let base = ptr::addr_of!((*to).ob_item) as *const *mut PyObject; + let mut out = Vec::with_capacity(n); + for i in 0..n { + let slot = unsafe { *base.add(i) }; + out.push(if slot.is_null() { + Object::None + } else { + unsafe { crate::object::clone_object(slot) } + }); + } + if std::env::var_os("WEAVEPY_DEBUG_TUPLE").is_some() && n == 2 { + let s0 = unsafe { *base.add(0) }; + let s1 = unsafe { *base.add(1) }; + let k1 = match out.get(1) { + Some(Object::Foreign(_)) => "Foreign", + Some(Object::None) => "None", + Some(Object::Type(_)) => "Type", + Some(Object::Tuple(_)) => "Tuple", + Some(_) => "other", + None => "MISSING", + }; + eprintln!("[read_tuple n=2] slot0={s0:p} slot1={s1:p} out1_kind={k1}"); + } + Object::new_tuple(out) +} + +/// True iff `p` is a faithful **list** mirror — a mirror whose advertised +/// type is `PyList_Type` and whose `ob_item` buffer holds the elements +/// (RFC 0046, wave 4). A stock extension fills such a list with the +/// `PyList_SET_ITEM` macro (numpy builds `__cpu_dispatch__` this way: +/// `PyList_New(n)` then `PyList_SET_ITEM(list, i, str)`), which writes the +/// `ob_item` array directly — so the C body, not the prefix's staged +/// [`Object`], is authoritative on every read-back. +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 16]`. +pub unsafe fn is_faithful_list(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyList_Type.as_ptr()) +} + +/// True iff `p` is a faithful **bound method** mirror — a mirror whose +/// advertised type is `PyMethod_Type` and whose `im_func`/`im_self` +/// fields are owned references (RFC 0047, wave 5). Unlike a tuple/list, +/// a method body is never mutated through a `SET` macro, so the prefix's +/// staged [`Object::BoundMethod`] stays authoritative for read-back +/// ([`native_of`]); this predicate is used only to release the two extra +/// owned refs in [`free_mirror`]. +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 16]`. +pub unsafe fn is_faithful_method(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyMethod_Type.as_ptr()) +} + +/// True iff `p` is a faithful **slice** mirror — a mirror whose advertised +/// type is `PySlice_Type` and whose `start`/`stop`/`step` fields hold owned +/// `PyObject*`s (RFC 0047, wave 5). Cython reads those fields straight off +/// the `PySliceObject` struct (pandas' `internals.slice_canonize`). +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 16]`. +pub unsafe fn is_faithful_slice(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PySlice_Type.as_ptr()) +} + +/// Reconstruct an [`Object::List`] by reading a faithful list mirror's +/// `ob_item` buffer (`ob_size` entries). Each non-NULL slot is resolved +/// with [`crate::object::clone_object`]; a NULL slot (a `PyList_New(n)` +/// placeholder a stock extension never filled) reads as `None`, matching +/// CPython, where such a slot is the `NULL` that `PyList_SET_ITEM` expects +/// to overwrite. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +pub unsafe fn read_list(p: *mut PyObject) -> Object { + Object::new_list(unsafe { read_list_vec(p) }) +} + +/// Read a faithful list mirror's `ob_item` buffer into a plain `Vec` +/// (the element resolution used by [`read_list`], without the +/// `Object::List` wrapper). Used by the write-through path to refill an +/// existing prefix `Rc` in place, preserving its identity. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn read_list_vec(p: *mut PyObject) -> Vec { + let vo = p as *const layout::PyVarObject; + let n = unsafe { (*vo).ob_size }; + let n = if n < 0 { 0 } else { n as usize }; + let lo = p as *const layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + let mut out = Vec::with_capacity(n); + if !base.is_null() { + for i in 0..n { + let slot = unsafe { *base.add(i) }; + out.push(if slot.is_null() { + Object::None + } else { + unsafe { crate::object::clone_object(slot) } + }); + } + } + out +} + +// --------------------------------------------------------------------------- +// Faithful-list write-through coherence (RFC 0047, wave 5). +// +// A faithful list is *seed-once, then prefix-authoritative*: after the first +// read-back its prefix `Object::List` (a shared, identity-stable `Rc`) is the +// source of truth, so a Python-side mutation of a C-resident `cdef public +// list` persists. But a stock extension reads such a list back through the +// `PyList_GET_ITEM` **macro** — `((PyListObject*)op)->ob_item[i]`, compiled +// inline into the extension, which WeavePy cannot interpose. The macro reads +// the C `ob_item` buffer, *not* the prefix `Rc`, so a VM mutation leaves the +// two divergent: pandas' `BlockManager.insert` does `self.axes[0] = new_axis` +// (a VM `list.__setitem__`) and then `internals.pyx`'s `get_slice` reads +// `self.axes[0]` via the macro — seeing the stale pre-insert column and so +// `df.head()` / `iloc[:n]` silently drop the inserted column. +// +// There is no WeavePy code on the path between the VM store and the inlined +// macro read, so the buffer must be re-published *before* control re-enters +// C. Every seeded list mirror is registered here; [`flush_seeded_lists`] +// (called at the VM→C boundary) re-syncs each one's `ob_item` from its prefix +// `Rc`. The atomic gate keeps the common case (no list ever crossed to C) at +// a single relaxed load. +// --------------------------------------------------------------------------- + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +/// Per-seeded-list coherence state, keyed by `PyObject*`. +/// +/// A faithful list has *two* authorities that must be reconciled: the VM's +/// shared prefix `Rc` and the C `ob_item` buffer. `rc_fp` lets the VM→C +/// flush ([`sync_list_ob_item`]) skip an unmutated list; `c_ptrs` lets the +/// C→VM read-back ([`native_of`] → [`reconcile_list_from_c`]) detect a +/// *direct* C-side macro write — `PyList_SET_ITEM` + `__Pyx_SET_SIZE`, taken +/// by Cython's `__Pyx_ListComp_Append` fast path (e.g. building +/// `memoryview.shape`) and numpy's list builders — that never passed through +/// a WeavePy mutator, so the buffer must be adopted back into the `Rc`. +#[derive(Default)] +struct ListSync { + /// FNV fingerprints of the prefix `Rc` elements last published to + /// `ob_item` (empty until the first flush). See [`sync_list_ob_item`]. + rc_fp: Vec, + /// Raw `ob_item` pointer snapshot at the last agreement point (seed, + /// publish, write-through, or adopt). A later read that finds a different + /// buffer knows C wrote it directly. See [`reconcile_list_from_c`]. + c_ptrs: Vec, +} + +/// Seeded faithful list mirrors keyed by `PyObject*`. +static SEEDED_LISTS: Mutex>> = Mutex::new(None); +static SEEDED_LIST_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Canonical faithful `set`/`frozenset` boxes keyed by native `Rc` +/// identity (RFC 0047, wave 5): `Rc-payload-pointer → PyObject*`. +/// +/// A stock/Cython extension caches a `PyObject*` and later reads the +/// element count straight off the struct — `PySet_GET_SIZE(so)` is +/// `((PySetObject*)so)->used`, which Cython emits for *both* `len(s)` and +/// the truthiness test `if s:` on a set-typed value. If every crossing +/// minted a *fresh* mirror, that cached box would be a stale snapshot: an +/// unbound-method mutation like `set.difference_update(s, other)` routed +/// through `PyObject_Call` empties the shared native store but the count +/// re-publish ([`sync_set_used`]) lands on the ephemeral *argument* box, +/// never the one the extension cached. pandas' `Timedelta.__new__` +/// keyword guard (`set(kwargs)` → `difference_update(_req_kwargs)` → +/// `if unsupported_kwargs:`) then reads the pre-mutation `used` and raises +/// a spurious `ValueError`. Handing out **one** canonical box per native +/// set makes the cached pointer and the mutated/synced pointer the *same* +/// memory, so the guard sees the emptied set. +static SET_BOX_CACHE: Mutex>> = Mutex::new(None); +static SET_BOX_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Native `Rc` identity key for a `set`/`frozenset` (its `Arc` payload +/// pointer), or `None` for any other object. Two `Object` clones of the +/// same set share one `Rc`, so this is a stable per-set identity for as +/// long as any clone (e.g. a live mirror's prefix) keeps it alive. +fn set_rc_key(obj: &Object) -> Option { + match obj { + Object::Set(rc) => Some(weavepy_vm::sync::Rc::as_ptr(rc) as usize), + Object::FrozenSet(rc) => Some(weavepy_vm::sync::Rc::as_ptr(rc) as usize), + _ => None, + } +} + +/// Return the live canonical box for native-set identity `key`, handing +/// back a *fresh* C reference (matching `into_owned`'s "+1" contract). +/// `None` if no box is currently cached. +fn cached_set_box(key: usize) -> Option<*mut PyObject> { + let g = SET_BOX_CACHE.lock().ok()?; + let map = g.as_ref()?; + let bp = *map.get(&key)?; + let p = bp as *mut PyObject; + unsafe { crate::object::Py_IncRef(p) }; + Some(p) +} + +/// Record `p` as the canonical box for native-set identity `key`. +fn register_set_box(key: usize, p: *mut PyObject) { + if let Ok(mut g) = SET_BOX_CACHE.lock() { + if g + .get_or_insert_with(HashMap::new) + .insert(key, p as usize) + .is_none() + { + SET_BOX_COUNT.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Evict a faithful set mirror from the canonical cache when its storage +/// is released — called from [`free_mirror`] *before* the prefix's native +/// `Object` (and thus its `Rc`) is dropped. Only removes the entry when it +/// still points at `p`, so a stale box that lost a cache race can never +/// clobber the live canonical one. +/// +/// # Safety +/// `p` must be a faithful set mirror ([`is_faithful_set`]) whose prefix is +/// still intact. +pub unsafe fn unregister_set_box(p: *mut PyObject) { + let pre = unsafe { prefix_of(p) }; + let key = match set_rc_key(unsafe { &(*pre).obj }) { + Some(k) => k, + None => return, + }; + if let Ok(mut g) = SET_BOX_CACHE.lock() { + if let Some(map) = g.as_mut() { + if map.get(&key) == Some(&(p as usize)) { + map.remove(&key); + SET_BOX_COUNT.fetch_sub(1, Ordering::Relaxed); + } + } + } +} + +/// Canonical-box cache for `Object::Builtin` (`builtin_function_or_method`), +/// keyed by the native `Rc` payload pointer. +/// +/// A builtin such as `operator.eq` is a faithful mirror (see +/// [`obj_is_faithful`]), so absent a cache every crossing mints a *fresh* +/// `PyCFunction` box via [`mirror_out_fresh`] and hands C a different +/// pointer each time. That breaks the pointer-identity contract stock +/// Cython relies on: pandas' `pandas._libs.ops.vec_compare` / +/// `scalar_compare` select the comparison with a chain of `op is +/// operator.lt` / `elif op is operator.eq: …` tests (Cython lowers `is` to +/// a raw C `==`), and the analogous `Timedelta` / `Timestamp` reductions do +/// the same. When the argument box (`op`, marshaled at the call) and the box +/// Cython fetches with `PyObject_GetAttr(operator, "eq")` differ, *every* +/// branch is false and the function raises `ValueError("Unrecognized +/// operator")`. Handing out **one** canonical box per native builtin makes +/// the marshaled argument and the module-attribute lookup the *same* memory, +/// so the identity chain resolves exactly as under CPython. +static BUILTIN_BOX_CACHE: Mutex>> = Mutex::new(None); +static BUILTIN_BOX_COUNT: AtomicUsize = AtomicUsize::new(0); + +/// Native `Rc` identity key for an `Object::Builtin` (its `BuiltinFn` +/// payload pointer), or `None` for any other object. Two `Object` clones of +/// the same builtin share one `Rc`, so this is a stable per-builtin identity +/// for as long as any clone (e.g. a live mirror's prefix, or the value held +/// in the owning module dict) keeps it alive. +fn builtin_rc_key(obj: &Object) -> Option { + match obj { + Object::Builtin(rc) => Some(weavepy_vm::sync::Rc::as_ptr(rc) as usize), + _ => None, + } +} + +/// True iff `p` is a faithful `builtin_function_or_method` mirror — a mirror +/// whose advertised type is `PyCFunction_Type`. Used only as a cheap guard in +/// [`free_mirror`] before evicting from [`BUILTIN_BOX_CACHE`]. +/// +/// # Safety +/// `p` must be non-null and readable for `[prefix .. head + 16]`. +pub unsafe fn is_faithful_builtin(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyCFunction_Type.as_ptr()) +} + +/// Return the live canonical box for native-builtin identity `key`, handing +/// back a *fresh* C reference (matching the mint path's "+1" contract). +/// `None` if no box is currently cached. +fn cached_builtin_box(key: usize) -> Option<*mut PyObject> { + let g = BUILTIN_BOX_CACHE.lock().ok()?; + let map = g.as_ref()?; + let bp = *map.get(&key)?; + let p = bp as *mut PyObject; + unsafe { crate::object::Py_IncRef(p) }; + if std::env::var_os("WEAVEPY_BOXDBG").is_some() { + eprintln!("[BOXDBG] builtin cache HIT key=0x{key:x} -> box=0x{:x}", p as usize); + } + Some(p) +} + +/// Record `p` as the canonical box for native-builtin identity `key`. +fn register_builtin_box(key: usize, p: *mut PyObject) { + if std::env::var_os("WEAVEPY_BOXDBG").is_some() { + eprintln!("[BOXDBG] builtin cache MISS key=0x{key:x} minted box=0x{:x}", p as usize); + } + if let Ok(mut g) = BUILTIN_BOX_CACHE.lock() { + if g + .get_or_insert_with(HashMap::new) + .insert(key, p as usize) + .is_none() + { + BUILTIN_BOX_COUNT.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Evict a faithful builtin mirror from the canonical cache when its storage +/// is released — called from [`free_mirror`] *before* the prefix's native +/// `Object` (and thus its `Rc`) is dropped. Only removes the entry when it +/// still points at `p`, so a stale box that lost a cache race can never +/// clobber the live canonical one. +/// +/// # Safety +/// `p` must be a faithful builtin mirror ([`is_faithful_builtin`]) whose +/// prefix is still intact. +pub unsafe fn unregister_builtin_box(p: *mut PyObject) { + let pre = unsafe { prefix_of(p) }; + let key = match builtin_rc_key(unsafe { &(*pre).obj }) { + Some(k) => k, + None => return, + }; + if let Ok(mut g) = BUILTIN_BOX_CACHE.lock() { + if let Some(map) = g.as_mut() { + if map.get(&key) == Some(&(p as usize)) { + map.remove(&key); + BUILTIN_BOX_COUNT.fetch_sub(1, Ordering::Relaxed); + } + } + } +} + +/// Snapshot a faithful list mirror's raw `ob_item` pointers (as `usize`). +/// Cheap — no minting, no refcount change — so a read can tell whether C +/// wrote the buffer since the last agreement without reconstructing objects. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn list_ptr_snapshot(p: *mut PyObject) -> Vec { + let n = unsafe { list_size(p) }.max(0) as usize; + let lo = p as *const layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + let mut out = Vec::with_capacity(n); + if !base.is_null() { + for i in 0..n { + out.push(unsafe { *base.add(i) } as usize); + } + } + out +} + +/// Record the current `ob_item` as the agreed C state for a seeded list, so +/// a subsequent read does not mistake a WeavePy write-through for a foreign +/// C macro write (which would needlessly rebuild, or — after a further VM +/// mutation — clobber it). Called by the write-through mutators; a no-op for +/// a list that was never seeded/registered. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn note_c_agreement(p: *mut PyObject) { + if SEEDED_LIST_COUNT.load(Ordering::Relaxed) == 0 { + return; + } + let cur = unsafe { list_ptr_snapshot(p) }; + if let Ok(mut g) = SEEDED_LISTS.lock() { + if let Some(map) = g.as_mut() { + if let Some(slot) = map.get_mut(&(p as usize)) { + slot.c_ptrs = cur; + } + } + } +} + +/// The C→VM half of faithful-list coherence (RFC 0047, wave 5): adopt a +/// *direct* C-side write to a seeded list's `ob_item` back into the shared +/// prefix `Rc`. +/// +/// A stock extension can grow or overwrite a seeded list through the +/// `PyList_SET_ITEM` + `__Pyx_SET_SIZE` macros — Cython's +/// `__Pyx_ListComp_Append` fast path takes exactly this route when it builds +/// `tuple([length for length in self.view.shape[:self.view.ndim]])` for +/// `memoryview.shape`, so a 2-D buffer's shape read back as a 1-tuple and +/// pandas' groupby allocated 1-D internals (`Buffer has wrong number of +/// dimensions`). Such a write never passes through a WeavePy mutator, so the +/// prefix `Rc` is left stale. When the current `ob_item` differs from the +/// snapshot taken at the last agreement, the buffer is authoritative: +/// refill the `Rc` in place (identity preserved). A VM-only mutation leaves +/// `ob_item` untouched (snapshot matches) and so is never clobbered. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn reconcile_list_from_c(p: *mut PyObject) { + if SEEDED_LIST_COUNT.load(Ordering::Relaxed) == 0 { + return; + } + let cur = unsafe { list_ptr_snapshot(p) }; + // Cheap gate: an unchanged buffer means C wrote nothing; the `Rc` + // (possibly ahead with un-flushed VM mutations) stays authoritative. + // A missing entry ⇒ leave the `Rc` alone (never clobber VM state). + let changed = match SEEDED_LISTS.lock() { + Ok(g) => g + .as_ref() + .and_then(|m| m.get(&(p as usize))) + .map(|st| st.c_ptrs != cur) + .unwrap_or(false), + Err(_) => return, + }; + if !changed { + return; + } + let pre = unsafe { prefix_of(p) }; + let rc = match unsafe { &(*pre).obj } { + Object::List(rc) => rc.clone(), + _ => return, + }; + let adopted = unsafe { read_list_vec(p) }; + let fp: Vec = adopted.iter().map(fingerprint).collect(); + let n = cur.len(); + *rc.borrow_mut() = adopted; + if let Ok(mut g) = SEEDED_LISTS.lock() { + if let Some(map) = g.as_mut() { + if let Some(slot) = map.get_mut(&(p as usize)) { + slot.rc_fp = fp; + slot.c_ptrs = cur; + } + } + } + if std::env::var_os("WEAVEPY_TRACE_LISTSYNC").is_some() { + eprintln!("[LISTSYNC] adopt {p:p} ob_size={n}"); + } +} + +/// Allocation-free identity for an `Rc`/`Arc` (sized or unsized): the data +/// pointer, stable for the lifetime of the allocation. +#[inline] +fn rc_id(rc: &weavepy_vm::sync::Rc) -> u64 { + weavepy_vm::sync::Rc::as_ptr(rc) as *const () as u64 +} + +/// A 64-bit fingerprint of a list element that changes iff the element's +/// *identity or value* changes, computed without minting any C object. For +/// an `Rc`-backed value the stable allocation pointer is used; for an inline +/// scalar the value itself. This lets [`sync_list_ob_item`] detect an +/// unmutated list and leave its `ob_item` untouched (no refcount churn, no +/// dangling of a pointer C may still borrow), which is what makes flushing +/// at *every* VM→C boundary affordable. +fn fingerprint(o: &Object) -> u64 { + #[inline] + fn mix(tag: u8, payload: u64) -> u64 { + // FNV-1a over the tag byte then the eight payload bytes. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + h ^= tag as u64; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + let mut p = payload; + for _ in 0..8 { + h ^= p & 0xff; + h = h.wrapping_mul(0x0000_0100_0000_01b3); + p >>= 8; + } + h + } + use Object::*; + match o { + None => mix(0, 0), + Unbound => mix(1, 0), + Bool(b) => mix(2, *b as u64), + Int(i) => mix(3, *i as u64), + Float(f) => mix(4, f.to_bits()), + Long(rc) => mix(5, rc_id(rc)), + Complex(rc) => mix(6, rc_id(rc)), + Str(rc) => mix(7, rc_id(rc)), + WStr(rc) => mix(8, rc_id(rc)), + Tuple(rc) => mix(9, rc_id(rc)), + List(rc) => mix(10, rc_id(rc)), + Dict(rc) => mix(11, rc_id(rc)), + Range(rc) => mix(12, rc_id(rc)), + Function(rc) => mix(13, rc_id(rc)), + Builtin(rc) => mix(14, rc_id(rc)), + BoundMethod(rc) => mix(15, rc_id(rc)), + Code(rc) => mix(16, rc_id(rc)), + Cell(rc) => mix(17, rc_id(rc)), + Iter(rc) => mix(18, rc_id(rc)), + Slice(rc) => mix(19, rc_id(rc)), + Type(rc) => mix(20, rc_id(rc)), + Instance(rc) => mix(21, rc_id(rc)), + Module(rc) => mix(22, rc_id(rc)), + Generator(rc) => mix(23, rc_id(rc)), + Coroutine(rc) => mix(24, rc_id(rc)), + AsyncGenerator(rc) => mix(25, rc_id(rc)), + AsyncGenAwait(rc) => mix(26, rc_id(rc)), + Bytes(rc) => mix(27, rc_id(rc)), + ByteArray(rc) => mix(28, rc_id(rc)), + Set(rc) => mix(29, rc_id(rc)), + FrozenSet(rc) => mix(30, rc_id(rc)), + File(rc) => mix(31, rc_id(rc)), + Property(rc) => mix(32, rc_id(rc)), + StaticMethod(rc) => mix(33, rc_id(rc)), + ClassMethod(rc) => mix(34, rc_id(rc)), + SlotDescriptor(rc) => mix(35, rc_id(rc)), + Frame(rc) => mix(36, rc_id(rc)), + Traceback(rc) => mix(37, rc_id(rc)), + MemoryView(rc) => mix(38, rc_id(rc)), + MappingProxy(rc) => mix(39, rc_id(rc)), + DictView(rc) => mix(40, rc_id(rc)), + SimpleNamespace(rc) => mix(41, rc_id(rc)), + LazyIter(rc) => mix(42, rc_id(rc)), + Capsule(rc) => mix(43, rc_id(rc)), + Foreign(rc) => mix(44, rc_id(rc)), + } +} + +thread_local! { + /// Set while [`flush_seeded_lists`] is running. A slot decref during a + /// sync can free an object whose drop re-enters the VM→C boundary (and + /// thus `ensure_active` → `flush_seeded_lists`); the guard makes that + /// nested call a no-op so the outer flush keeps a consistent snapshot. + static FLUSHING: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +struct FlushGuard; +impl Drop for FlushGuard { + fn drop(&mut self) { + FLUSHING.with(|f| f.set(false)); + } +} + +/// Record a faithful list mirror as VM-shared (seeded) so its `ob_item` +/// is re-synced from the prefix `Rc` at the next VM→C boundary. +pub fn register_seeded_list(p: *mut PyObject) { + if p.is_null() { + return; + } + // The mirror was just seeded (its prefix `Rc` == `ob_item`), so capture + // the buffer snapshot now; a later read only adopts a *genuine* C write. + let c_ptrs = unsafe { list_ptr_snapshot(p) }; + if let Ok(mut g) = SEEDED_LISTS.lock() { + // An empty `rc_fp` forces the first flush to do a real sync (it can + // never equal a non-empty list's fingerprints). + if g.get_or_insert_with(HashMap::new) + .insert( + p as usize, + ListSync { + rc_fp: Vec::new(), + c_ptrs, + }, + ) + .is_none() + { + SEEDED_LIST_COUNT.fetch_add(1, Ordering::Relaxed); + if std::env::var_os("WEAVEPY_TRACE_LISTSYNC").is_some() { + let n = unsafe { list_size(p) }; + eprintln!("[LISTSYNC] register {p:p} ob_size={n}"); + } + } + } +} + +/// Drop a faithful list mirror from the seeded set (its storage is being +/// released). +pub fn unregister_seeded_list(p: *mut PyObject) { + if p.is_null() { + return; + } + if let Ok(mut g) = SEEDED_LISTS.lock() { + if let Some(map) = g.as_mut() { + if map.remove(&(p as usize)).is_some() { + SEEDED_LIST_COUNT.fetch_sub(1, Ordering::Relaxed); + } + } + } +} + +/// Re-publish a seeded faithful list mirror's `ob_item` buffer from its +/// prefix `Object::List` so a stock `PyList_GET_ITEM` macro sees the VM's +/// latest mutations. A slot whose desired occupant already lives there +/// (a stable identity — a cached instance box, a foreign pointer, a +/// singleton) is left untouched, so an unchanged list churns no refcounts +/// and never dangles a pointer C may still hold. +/// +/// # Safety +/// `p` must be a live pointer. +pub unsafe fn sync_list_ob_item(p: *mut PyObject) { + if !unsafe { is_faithful_list(p) } { + return; + } + let pre = unsafe { prefix_of(p) }; + // Never seeded ⇒ the C buffer is authoritative (a `PyList_New` + + // `PyList_SET_ITEM` build the VM has not yet read back); leave it. + if !unsafe { (*pre).list_synced } { + return; + } + // Adopt any *direct* C-side write first (RFC 0047, wave 5). Cython's + // `__Pyx_ListComp_Append` fast path grows a seeded list straight through + // the `PyList_SET_ITEM` + `__Pyx_SET_SIZE` macros (e.g. `[np.dtype(x) + // for x in ...]` building `TextReader.dtype_cast_order`), so the inline + // `ob_item`/`ob_size` can be *ahead* of the prefix `Rc` without any + // read-back having reconciled it. Publishing the stale `Rc` here would + // clobber those elements — pandas' C parser saw `dtype_cast_order` + // shrink to `[int64]` and gave up after the first (failed) cast, so + // every float/str/bool column read as an un-upcast `NoneType` na_count. + // Reconciling C→VM before the VM→C publish makes the flush symmetric: + // a genuine C write is adopted (the fingerprint then matches and the + // publish is skipped), a VM mutation is untouched and still published. + unsafe { reconcile_list_from_c(p) }; + let rc = match unsafe { &(*pre).obj } { + Object::List(rc) => rc, + _ => return, + }; + // Fingerprint the VM-shared list (allocation-free). If it matches what we + // last published to `ob_item`, the list is unmutated since the previous + // flush and the buffer is already coherent — leave it untouched (no + // allocation, no refcount churn). This is what keeps a flush at *every* + // VM→C boundary affordable; only a genuinely mutated list pays to rebuild. + let fp: Vec = rc.borrow().iter().map(fingerprint).collect(); + if let Ok(g) = SEEDED_LISTS.lock() { + if let Some(map) = g.as_ref() { + if let Some(st) = map.get(&(p as usize)) { + if st.rc_fp == fp { + return; + } + } + } + } + let items: Vec = rc.borrow().clone(); + let n = items.len(); + let old_n = unsafe { list_size(p) }.max(0) as usize; + if std::env::var_os("WEAVEPY_TRACE_LISTSYNC").is_some() { + eprintln!("[LISTSYNC] sync {p:p} prefix_len={n} old_ob_size={old_n}"); + } + if n > 0 { + unsafe { list_reserve(p, n) }; + } + let lo = p as *mut layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + if base.is_null() && n > 0 { + return; + } + for (i, it) in items.iter().enumerate() { + let slot = unsafe { base.add(i) }; + let old = unsafe { *slot }; + let new = crate::object::into_owned(it.clone()); + if std::env::var_os("WEAVEPY_TRACE_LISTSYNC").is_some() && n <= 3 { + eprintln!( + "[LISTSYNC] slot {i}: old={old:p} new={new:p} {}", + if new == old { "SKIP" } else { "REPLACE" } + ); + } + if new == old { + // Stable identity: `into_owned` handed back a fresh reference + // to the very pointer already in the slot. Release it and keep + // the slot as-is (no churn, no dangling pointer). + if !new.is_null() { + unsafe { crate::object::Py_DecRef(new) }; + } + continue; + } + unsafe { *slot = new }; + if !old.is_null() { + unsafe { crate::object::Py_DecRef(old) }; + } + } + // A shrink (pop/remove/slice-delete) leaves stale tail occupants; drop + // their references and clear the slots. + if old_n > n && !base.is_null() { + for i in n..old_n { + let slot = unsafe { base.add(i) }; + let old = unsafe { *slot }; + unsafe { *slot = ptr::null_mut() }; + if !old.is_null() { + unsafe { crate::object::Py_DecRef(old) }; + } + } + } + let vo = p as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = n as PySsizeT }; + // Record the published fingerprint (so the next flush can skip an + // unmutated list) and the resulting `ob_item` snapshot (so a read-back + // does not mistake this publish for a foreign C write). `get_mut` (not + // `insert`) avoids resurrecting an entry an interleaved + // decref→`unregister_seeded_list` may have removed. + let c_ptrs = unsafe { list_ptr_snapshot(p) }; + if let Ok(mut g) = SEEDED_LISTS.lock() { + if let Some(map) = g.as_mut() { + if let Some(slot) = map.get_mut(&(p as usize)) { + slot.rc_fp = fp; + slot.c_ptrs = c_ptrs; + } + } + } +} + +/// Re-sync every seeded faithful list mirror's `ob_item` from its prefix +/// `Rc`. Called at the VM→C boundary so a stock extension's inlined +/// `PyList_GET_ITEM` macro reads the VM's latest list mutations. +/// +/// # Safety +/// May only be called when no C code is mid-read of a seeded list's +/// `ob_item` (i.e. at a VM→C transition). +pub unsafe fn flush_seeded_lists() { + if std::env::var_os("WEAVEPY_NO_LISTSYNC").is_some() { + return; + } + let c = SEEDED_LIST_COUNT.load(Ordering::Relaxed); + if c == 0 { + return; + } + // A decref inside a sync can free an object whose drop re-enters here; + // skip the nested call rather than re-snapshotting mid-flush. + if FLUSHING.with(|f| f.replace(true)) { + return; + } + let _guard = FlushGuard; + if std::env::var_os("WEAVEPY_TRACE_LISTSYNC").is_some() { + eprintln!("[LISTSYNC] flush count={c}"); + } + // Snapshot under the lock, then sync without holding it (a slot decref + // may free an object and re-enter this module). + let ptrs: Vec = match SEEDED_LISTS.lock() { + Ok(g) => g + .as_ref() + .map(|m| m.keys().copied().collect()) + .unwrap_or_default(), + Err(_) => return, + }; + for pu in ptrs { + unsafe { sync_list_ob_item(pu as *mut PyObject) }; + } +} + +// --------------------------------------------------------------------------- +// Faithful mutable unicode (RFC 0047, wave 5). +// +// WeavePy's native string is an immutable `Rc`, but macro-heavy +// Cython mutates a string's character buffer *in place*: the f-string / +// `repr` codegen builds a result by `PyUnicode_New(n, maxchar)` followed by +// the inlined `PyUnicode_WRITE` macro (a direct store at `PyUnicode_DATA(o) +// + i*kind`), and concatenation takes an in-place fast path — +// `PyUnicode_Resize(&left, left_len+right_len)` then +// `PyUnicode_CopyCharacters(left, left_len, right, 0, right_len)` — when +// `left` is uniquely owned and not interned. To satisfy a stock reader the +// buffer must be a real, writable PEP 393 body, and any in-place mutation +// must be visible when the string crosses back. We therefore mint such +// strings as **buffer-authoritative** mirrors ([`MirrorPrefix::str_buffer`]) +// whose C body — not the staged prefix object — is read by [`native_of`]. +// --------------------------------------------------------------------------- + +/// The PEP 393 compact form for a string whose largest code point is +/// `maxchar`: `(kind, ascii, data_offset, char_width)`. The data offset is +/// where the inlined `PyUnicode_DATA` macro looks: just past +/// `PyASCIIObject` for a compact-ASCII string, else past +/// `PyCompactUnicodeObject` (which carries the UTF-8 cache fields). +fn unicode_form(maxchar: u32) -> (u32, bool, usize, usize) { + let ascii_head = std::mem::size_of::(); + let compact_head = std::mem::size_of::(); + if maxchar < 0x80 { + (ustate::KIND_1BYTE, true, ascii_head, 1) + } else if maxchar < 0x100 { + (ustate::KIND_1BYTE, false, compact_head, 1) + } else if maxchar < 0x1_0000 { + (ustate::KIND_2BYTE, false, compact_head, 2) + } else { + (ustate::KIND_4BYTE, false, compact_head, 4) + } +} + +/// The maximum code point a `kind`/`ascii` body may hold. A compact-ASCII +/// body is capped at `0x7F` (CPython's `PyUnicode_MAX_CHAR_VALUE`), so +/// writing a Latin-1 char into it is rejected, matching CPython. +#[inline] +fn kind_maxchar(kind: u32, ascii: bool) -> u32 { + match kind { + 1 => { + if ascii { + 0x7f + } else { + 0xff + } + } + 2 => 0xffff, + _ => 0x10_ffff, + } +} + +/// Store one code point into a PEP 393 buffer of the given `kind`. +/// +/// # Safety +/// `data` must point at a writable buffer with room for `i + 1` units of +/// `kind` bytes each. +#[inline] +unsafe fn write_codepoint(data: *mut u8, kind: u32, i: usize, cp: u32) { + match kind { + 1 => unsafe { *data.add(i) = cp as u8 }, + 2 => unsafe { *(data as *mut u16).add(i) = cp as u16 }, + _ => unsafe { *(data as *mut u32).add(i) = cp }, + } +} + +/// Load one code point from a PEP 393 buffer of the given `kind`. +/// +/// # Safety +/// `data` must point at a readable buffer with at least `i + 1` units. +#[inline] +unsafe fn read_codepoint(data: *const u8, kind: u32, i: usize) -> u32 { + match kind { + 1 => unsafe { *data.add(i) as u32 }, + 2 => unsafe { *(data as *const u16).add(i) as u32 }, + _ => unsafe { *(data as *const u32).add(i) }, + } +} + +/// True iff `p` is a **buffer-authoritative** unicode mirror — a string +/// built by [`new_unicode_mirror`] whose C buffer is the source of truth +/// and is safe to mutate through [`unicode_write_char`] / +/// [`unicode_copy_characters`]. A normal str mirror or a foreign string +/// returns `false`. +/// +/// # Safety +/// `p` must be non-null and point at a valid object head. +pub unsafe fn is_str_buffer(p: *mut PyObject) -> bool { + if !unsafe { is_mirror(p) } { + return false; + } + let head = unsafe { &*p }; + if head.ob_type.is_null() || !std::ptr::eq(head.ob_type, types::PyUnicode_Type.as_ptr()) { + return false; + } + unsafe { (*prefix_of(p)).str_buffer } +} + +/// `(kind, ascii, length, data)` for a unicode mirror that carries a +/// faithful PEP 393 body (a buffer-authoritative string, or a normal +/// `fill_str` mirror). `data` points at the writable character buffer. +/// +/// # Safety +/// `p` must be a unicode mirror with a faithful body (its allocation is at +/// least `size_of::()`). +unsafe fn str_buffer_info(p: *mut PyObject) -> (u32, bool, usize, *mut u8) { + let ao = p as *mut layout::PyASCIIObject; + let len = { + let l = unsafe { (*ao).length }; + if l < 0 { + 0 + } else { + l as usize + } + }; + let state = unsafe { (*ao).state }; + let kind = (state >> ustate::KIND_SHIFT) & 0x7; + let ascii = (state >> ustate::ASCII_SHIFT) & 0x1 != 0; + let off = if ascii { + std::mem::size_of::() + } else { + std::mem::size_of::() + }; + let data = unsafe { (p as *mut u8).add(off) }; + (kind, ascii, len, data) +} + +/// The largest code point representable by a unicode mirror's body +/// (`0x7F`/`0xFF`/`0xFFFF`/`0x10FFFF`), or `None` if `p` is not a unicode +/// mirror with a faithful body. Used by [`resize_unicode`] to preserve the +/// source string's kind across a resize (CPython never narrows the kind). +/// +/// # Safety +/// `p` must be non-null and point at a valid object head. +unsafe fn mirror_str_maxchar(p: *mut PyObject) -> Option { + if !unsafe { is_mirror(p) } { + return None; + } + let head = unsafe { &*p }; + if head.ob_type.is_null() || !std::ptr::eq(head.ob_type, types::PyUnicode_Type.as_ptr()) { + return None; + } + let pre = unsafe { prefix_of(p) }; + let body_size = unsafe { (*pre).alloc_size }.saturating_sub(PREFIX_SIZE); + if body_size < std::mem::size_of::() { + // Defensive: any WeavePy-minted string now carries a faithful PEP 393 + // body (all kinds), so this only guards a degenerate head-only body; + // its value would live in the prefix, so fall back to a content scan. + return None; + } + let (kind, ascii, _len, _data) = unsafe { str_buffer_info(p) }; + Some(kind_maxchar(kind, ascii)) +} + +/// Reconstruct an [`Object::Str`] from a unicode mirror's faithful PEP 393 +/// buffer (length, `kind`, and character data). Used by [`native_of`] for a +/// buffer-authoritative string so a direct `PyUnicode_WRITE` / +/// `PyUnicode_CopyCharacters` mutation is visible on read-back. +/// +/// # Safety +/// `p` must be a unicode mirror with a faithful body +/// ([`is_str_buffer`], or a normal `fill_str` mirror). +pub unsafe fn read_str(p: *mut PyObject) -> Object { + let (kind, _ascii, len, data) = unsafe { str_buffer_info(p) }; + if kind == 0 { + // No PEP 393 kind: not a faithful buffer — defer to the prefix. + return unsafe { (*prefix_of(p)).obj.clone() }; + } + let mut s = String::with_capacity(len); + for i in 0..len { + let cp = unsafe { read_codepoint(data, kind, i) }; + s.push(char::from_u32(cp).unwrap_or('\u{fffd}')); + } + Object::from_str(s) +} + +/// Decode any faithful `PyUnicodeObject` body — **compact** (inline data, +/// the `PyUnicode_New` form) or **legacy / non-compact** (out-of-line +/// `data.any`, the `unicode_subtype_new` form numpy's `str_` uses) — into a +/// Rust [`String`]. Returns `None` if the body has no valid PEP 393 kind +/// (so the caller can fall back). Mirrors the inlined `PyUnicode_KIND` / +/// `PyUnicode_DATA` reader macros. +/// +/// # Safety +/// `p` must point at a readable object head whose body is at least +/// `size_of::()` (compact) or `size_of::()` +/// (non-compact) bytes. +pub unsafe fn read_unicode_value(p: *mut PyObject) -> Option { + let ao = p as *const layout::PyASCIIObject; + let length = { + let l = unsafe { (*ao).length }; + if l < 0 { + return None; + } + l as usize + }; + let state = unsafe { (*ao).state }; + let kind = (state >> ustate::KIND_SHIFT) & 0x7; + if kind == 0 { + return None; + } + let ascii = (state >> ustate::ASCII_SHIFT) & 0x1 != 0; + let compact = (state >> ustate::COMPACT_SHIFT) & 0x1 != 0; + let data: *const u8 = if compact { + let off = if ascii { + std::mem::size_of::() + } else { + std::mem::size_of::() + }; + unsafe { (p as *const u8).add(off) } + } else { + let uo = p as *const layout::PyUnicodeObject; + unsafe { (*uo).data as *const u8 } + }; + if data.is_null() { + return None; + } + let mut s = String::with_capacity(length); + for i in 0..length { + let cp = unsafe { read_codepoint(data, kind, i) }; + s.push(char::from_u32(cp).unwrap_or('\u{fffd}')); + } + Some(s) +} + +/// Read the value of a faithful **bytes-subtype** body (numpy's `bytes_`) +/// from its inline `PyBytesObject` fields: `ob_size` (offset 16) and the +/// inline `ob_sval` char array (offset 32). Returns `None` for a negative +/// (uninitialised) size. Mirror of [`read_unicode_value`] for `bytes`. +/// +/// # Safety +/// `p` must be a faithful instance body whose type is a `bytes` subtype. +pub unsafe fn read_bytes_value(p: *mut PyObject) -> Option> { + let bo = p as *const layout::PyBytesObject; + let n = unsafe { (*bo).ob_base.ob_size }; + if n < 0 { + return None; + } + let data = unsafe { (*bo).ob_sval.as_ptr() as *const u8 }; + if data.is_null() { + return None; + } + Some(unsafe { std::slice::from_raw_parts(data, n as usize).to_vec() }) +} + +/// Mint a faithful, writable unicode mirror of `len` code points at the +/// PEP 393 kind implied by `maxchar`, with a zero-filled buffer (and a NUL +/// terminator unit). The caller owns one reference. This is the body of +/// `PyUnicode_New`: a stock extension fills it with the inlined +/// `PyUnicode_WRITE` macro and reads it with `PyUnicode_READ`, both of +/// which address `PyUnicode_DATA(o) + i*kind` — so the body must be a real +/// compact string at the exact offsets [`unicode_form`] computes. +pub fn new_unicode_mirror(len: usize, maxchar: u32) -> *mut PyObject { + let (kind, ascii, data_off, width) = unicode_form(maxchar); + // Overflow-safe size computation. A stock extension (e.g. Cython's + // inlined `str.join`, which sizes the result by summing + // `PyUnicode_GET_LENGTH` over the parts) can hand us a bogus or huge + // length; CPython's `PyUnicode_New` returns NULL + raises MemoryError in + // that case rather than aborting, so we must not panic here. + let raw_body = match len + .checked_add(1) + .and_then(|n| n.checked_mul(width)) + .and_then(|n| n.checked_add(data_off)) + { + Some(n) if n <= isize::MAX as usize => n, + _ => { + if std::env::var_os("WEAVEPY_USTR_DBG").is_some() { + eprintln!( + "[USTR] new_unicode_mirror oversize len={len} maxchar={maxchar:#x} width={width}\n{}", + std::backtrace::Backtrace::force_capture() + ); + } + return ptr::null_mut(); + } + }; + let body_size = round_up(raw_body, 8); + let total = match body_size.checked_add(PREFIX_SIZE) { + Some(t) if t <= isize::MAX as usize => t, + _ => return ptr::null_mut(), + }; + let layout = match Layout::from_size_align(total, BODY_ALIGN) { + Ok(l) => l, + Err(_) => return ptr::null_mut(), + }; + let raw = unsafe { alloc(layout) }; + if raw.is_null() { + return ptr::null_mut(); + } + unsafe { ptr::write_bytes(raw, 0, total) }; + + let body = unsafe { raw.add(PREFIX_SIZE) } as *mut PyObject; + let ty = types::PyUnicode_Type.as_ptr(); + unsafe { + (*body).ob_refcnt = 1; + (*body).ob_type = ty; + let ao = body as *mut layout::PyASCIIObject; + (*ao).length = len as PySsizeT; + (*ao).hash = -1; + (*ao).state = ustate::pack(0, kind, true, ascii, false); + // utf8/utf8_length (compact non-ASCII) stay zeroed by the wipe. + } + + let pre = raw as *mut MirrorPrefix; + unsafe { + ptr::write( + pre, + MirrorPrefix { + obj: Object::None, + inst: None, + user_data: ptr::null_mut(), + destructor: None, + alloc_size: total, + aux_ptr: ptr::null_mut(), + aux_size: 0, + str_buffer: true, + list_synced: false, + magic: MIRROR_MAGIC, + }, + ); + } + crate::object::register_minted(body); + body +} + +/// Resize the buffer-authoritative (or normal) unicode mirror `p` to +/// `newlen` code points, preserving the leading `min(oldlen, newlen)` +/// characters and the source kind. Returns a freshly minted mirror (the +/// caller publishes it and releases the old reference); the result's tail +/// `[oldlen, newlen)` is zero-filled, ready for `PyUnicode_CopyCharacters`. +/// Returns null if `p` is not a unicode object. +/// +/// # Safety +/// `p` must be non-null and point at a valid object head. +pub unsafe fn resize_unicode(p: *mut PyObject, newlen: usize) -> *mut PyObject { + // Snapshot the existing content (works for a buffer-authoritative body, + // a normal `fill_str` mirror, or a head-only non-Latin-1 string). + let content = unsafe { native_of(p) }; + let s = match content { + Object::Str(s) => s, + // PyUnicode_Resize only targets strings under construction; if `p` + // is not a str, refuse rather than corrupt memory. + _ => return ptr::null_mut(), + }; + let maxchar = unsafe { mirror_str_maxchar(p) } + .unwrap_or_else(|| s.chars().map(|c| c as u32).max().unwrap_or(0)); + let np = new_unicode_mirror(newlen, maxchar); + if np.is_null() { + return ptr::null_mut(); + } + let (kind, _ascii, _nlen, data) = unsafe { str_buffer_info(np) }; + for (i, ch) in s.chars().take(newlen).enumerate() { + unsafe { write_codepoint(data, kind, i, ch as u32) }; + } + np +} + +/// Write one code point into a buffer-authoritative unicode mirror at +/// `idx` (the body of `PyUnicode_WriteChar`). Returns an error string for +/// an out-of-range index, a code point too wide for the body's kind, or a +/// non-writable target. +/// +/// # Safety +/// `o` must be non-null and point at a valid object head. +pub unsafe fn unicode_write_char(o: *mut PyObject, idx: usize, ch: u32) -> Result<(), String> { + if !unsafe { is_str_buffer(o) } { + return Err("PyUnicode_WriteChar: target is not a writable unicode buffer".to_owned()); + } + let (kind, ascii, len, data) = unsafe { str_buffer_info(o) }; + if idx >= len { + return Err("string index out of range".to_owned()); + } + if ch > kind_maxchar(kind, ascii) { + return Err("character does not fit in the string's storage".to_owned()); + } + unsafe { write_codepoint(data, kind, idx, ch) }; + Ok(()) +} + +/// Copy `how_many` code points from `from[from_start..]` into the +/// buffer-authoritative mirror `to` at `to_start` (the body of +/// `PyUnicode_CopyCharacters`). `from` may be any string (read through +/// [`native_of`]); the source is snapshotted first, so an overlapping +/// `from == to` copy is well-defined. Returns the number copied, or an +/// error string. +/// +/// # Safety +/// `to` and `from` must be non-null and point at valid object heads. +pub unsafe fn unicode_copy_characters( + to: *mut PyObject, + to_start: usize, + from: *mut PyObject, + from_start: usize, + how_many: usize, +) -> Result { + if !unsafe { is_str_buffer(to) } { + return Err("PyUnicode_CopyCharacters: target is not a writable unicode buffer".to_owned()); + } + let (to_kind, to_ascii, to_len, to_data) = unsafe { str_buffer_info(to) }; + if to_start > to_len || how_many > to_len - to_start { + return Err("PyUnicode_CopyCharacters: target index out of range".to_owned()); + } + let from_obj = unsafe { native_of(from) }; + let from_s = match from_obj { + Object::Str(s) => s, + _ => return Err("PyUnicode_CopyCharacters: source is not a str".to_owned()), + }; + let from_chars: Vec = from_s.chars().map(|c| c as u32).collect(); + if from_start > from_chars.len() || how_many > from_chars.len() - from_start { + return Err("PyUnicode_CopyCharacters: source index out of range".to_owned()); + } + let cap = kind_maxchar(to_kind, to_ascii); + for k in 0..how_many { + let cp = from_chars[from_start + k]; + if cp > cap { + return Err( + "PyUnicode_CopyCharacters: character does not fit in target storage".to_owned(), + ); + } + unsafe { write_codepoint(to_data, to_kind, to_start + k, cp) }; + } + Ok(how_many) +} + +/// Read one code point from a buffer-authoritative unicode mirror at +/// `idx`, or `None` for an out-of-range index / non-buffer target. +/// +/// # Safety +/// `o` must be non-null and point at a valid object head. +pub unsafe fn unicode_read_char(o: *mut PyObject, idx: usize) -> Option { + if !unsafe { is_str_buffer(o) } { + return None; + } + let (kind, _ascii, len, data) = unsafe { str_buffer_info(o) }; + if idx >= len { + return None; + } + Some(unsafe { read_codepoint(data, kind, idx) }) +} + +/// Borrow the `pos`-th inline `ob_item` slot of a faithful tuple mirror +/// (RFC 0046, wave 4). Returns a *borrowed* pointer (no incref), matching +/// `PyTuple_GetItem`'s contract; `None` for an out-of-range index. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_tuple`]. +pub unsafe fn tuple_slot(p: *mut PyObject, pos: PySsizeT) -> Option<*mut PyObject> { + let vo = p as *const layout::PyVarObject; + let n = unsafe { (*vo).ob_size }; + if pos < 0 || pos >= n { + return None; + } + let to = p as *const layout::PyTupleObject; + let base = ptr::addr_of!((*to).ob_item) as *const *mut PyObject; + Some(unsafe { *base.add(pos as usize) }) +} + +/// Overwrite the `pos`-th inline `ob_item` slot of a faithful tuple mirror, +/// stealing `item` (CPython's `PyTuple_SetItem` semantics) and releasing +/// the slot's previous occupant. Returns `false` for an out-of-range index +/// (the caller then disposes of `item`). +/// +/// # Safety +/// `p` must satisfy [`is_faithful_tuple`]; `item` is a strong reference +/// whose ownership transfers to the tuple. +pub unsafe fn tuple_store(p: *mut PyObject, pos: PySsizeT, item: *mut PyObject) -> bool { + let vo = p as *const layout::PyVarObject; + let n = unsafe { (*vo).ob_size }; + if pos < 0 || pos >= n { + return false; + } + let to = p as *mut layout::PyTupleObject; + let base = ptr::addr_of_mut!((*to).ob_item) as *mut *mut PyObject; + let slot = unsafe { base.add(pos as usize) }; + let prev = unsafe { *slot }; + unsafe { *slot = item }; + if !prev.is_null() { + unsafe { crate::object::Py_DecRef(prev) }; + } + true +} + +/// Number of live elements in a faithful list mirror (its `ob_size`). +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +pub unsafe fn list_size(p: *mut PyObject) -> PySsizeT { + let vo = p as *const layout::PyVarObject; + unsafe { (*vo).ob_size }.max(0) +} + +/// Borrow the `pos`-th `ob_item` slot of a faithful list mirror (no +/// incref, matching `PyList_GetItem`); `None` for an out-of-range index. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +pub unsafe fn list_slot(p: *mut PyObject, pos: PySsizeT) -> Option<*mut PyObject> { + let n = unsafe { list_size(p) }; + if pos < 0 || pos >= n { + return None; + } + let lo = p as *const layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + if base.is_null() { + return None; + } + Some(unsafe { *base.add(pos as usize) }) +} + +/// Ensure the faithful list `p` can hold at least `min_cap` slots, +/// (re)allocating its out-of-line `ob_item` buffer and syncing both the +/// `PyListObject` (`ob_item` / `allocated`) and the mirror prefix's aux +/// tracking (`aux_ptr` / `aux_size`, which [`free_mirror`] uses to +/// release the buffer and decref its occupants). New slots are NULL. +/// Returns the (possibly new) base pointer. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn list_reserve(p: *mut PyObject, min_cap: usize) -> *mut *mut PyObject { + let lo = p as *mut layout::PyListObject; + let cur_alloc = unsafe { (*lo).allocated }.max(0) as usize; + let cur_base = unsafe { (*lo).ob_item }; + if min_cap <= cur_alloc && !cur_base.is_null() { + return cur_base; + } + // CPython-style over-allocation (`list_resize`) keeps amortised O(1) + // append: grow to `min_cap + (min_cap >> 3) + 6`, never below double + // the current capacity. + let grow = min_cap + (min_cap >> 3) + 6; + let new_cap = grow.max(cur_alloc.saturating_mul(2)).max(4); + let new_bytes = new_cap * std::mem::size_of::<*mut PyObject>(); + let layout = Layout::from_size_align(new_bytes, BODY_ALIGN).expect("ob_item layout"); + let new_buf = unsafe { alloc(layout) } as *mut *mut PyObject; + assert!(!new_buf.is_null(), "ob_item allocation failed"); + unsafe { ptr::write_bytes(new_buf as *mut u8, 0, new_bytes) }; + let n = unsafe { list_size(p) } as usize; + if !cur_base.is_null() { + for i in 0..n { + unsafe { *new_buf.add(i) = *cur_base.add(i) }; + } + } + let pre = unsafe { prefix_of(p) }; + let old_aux = unsafe { (*pre).aux_ptr }; + let old_aux_size = unsafe { (*pre).aux_size }; + if !old_aux.is_null() && old_aux_size > 0 { + let old_layout = Layout::from_size_align(old_aux_size, BODY_ALIGN).expect("aux layout"); + unsafe { dealloc(old_aux, old_layout) }; + } + unsafe { + (*lo).ob_item = new_buf; + (*lo).allocated = new_cap as PySsizeT; + (*pre).aux_ptr = new_buf as *mut u8; + (*pre).aux_size = new_bytes; + } + new_buf +} + +/// Bring a faithful list mirror's shared prefix `Object::List` *contents* +/// into line with its current C `ob_item` buffer — once, **in place** so +/// the `Rc` identity (and any VM alias that observes it, e.g. a +/// `defaultdict[k]` entry) is preserved — then mark the mirror +/// prefix-authoritative and register it for VM→C re-sync. A no-op once +/// already synced. +/// +/// This is the C→VM half of faithful-list coherence (RFC 0047, wave 5): +/// a stock `PyList_Append`/`PyList_SetItem` writes only the inline +/// `ob_item`, but Cython routinely holds the *same* list in the VM (a +/// dict entry, a `cdef` attribute) and reads it back there. Without this +/// the mutation vanished — a `cdef defaultdict group_dict` built with +/// `group_dict[k].append(...)` (pandas' `internals.get_blkno_indexers`) +/// yielded empty lists. +/// +/// For a VM-originated list the prefix `Rc` and `ob_item` already agree, +/// so the one-time refill is a cheap no-op copy; for a C-built list +/// (`PyList_New` + `PyList_SET_ITEM` macro) it captures the +/// macro-written elements before the targeted mutation is applied. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +unsafe fn list_prefix_seed_once(p: *mut PyObject) { + let pre = unsafe { prefix_of(p) }; + if unsafe { (*pre).list_synced } { + return; + } + let rc = match unsafe { &(*pre).obj } { + Object::List(rc) => rc.clone(), + _ => return, + }; + let cur = unsafe { read_list_vec(p) }; + *rc.borrow_mut() = cur; + unsafe { (*pre).list_synced = true }; + register_seeded_list(p); +} + +/// Append `item` to a faithful list mirror, taking a new strong +/// reference (CPython `PyList_Append` semantics — the caller keeps its +/// own reference). Writes the inline `ob_item` buffer *and* the shared +/// prefix `Object::List` `Rc` (the VM-visible view), keeping the two +/// coherent so a VM holder of the same list sees the append. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]; `item` must be a live, +/// non-null `PyObject*`. +pub unsafe fn list_append(p: *mut PyObject, item: *mut PyObject) { + unsafe { list_prefix_seed_once(p) }; + let n = unsafe { list_size(p) } as usize; + let base = unsafe { list_reserve(p, n + 1) }; + unsafe { crate::object::Py_IncRef(item) }; + unsafe { *base.add(n) = item }; + let vo = p as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = (n + 1) as PySsizeT }; + // Write-through to the shared prefix `Rc` (identity preserved) so a VM + // alias — a `defaultdict[k]` list a Cython `.append(...)` mutated — + // observes the append (RFC 0047, wave 5). + let pre = unsafe { prefix_of(p) }; + if let Object::List(rc) = unsafe { &(*pre).obj } { + rc.borrow_mut() + .push(unsafe { crate::object::clone_object(item) }); + } + unsafe { note_c_agreement(p) }; +} + +/// Insert `item` before `pos` (clamped to `[0, len]`) in a faithful list +/// mirror, taking a new strong reference. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]; `item` must be a live, +/// non-null `PyObject*`. +pub unsafe fn list_insert(p: *mut PyObject, pos: PySsizeT, item: *mut PyObject) { + unsafe { list_prefix_seed_once(p) }; + let n = unsafe { list_size(p) } as usize; + let base = unsafe { list_reserve(p, n + 1) }; + let at = pos.clamp(0, n as PySsizeT) as usize; + for i in (at..n).rev() { + unsafe { *base.add(i + 1) = *base.add(i) }; + } + unsafe { crate::object::Py_IncRef(item) }; + unsafe { *base.add(at) = item }; + let vo = p as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = (n + 1) as PySsizeT }; + // Mirror the insert into the shared prefix `Rc` (RFC 0047, wave 5). + let pre = unsafe { prefix_of(p) }; + if let Object::List(rc) = unsafe { &(*pre).obj } { + let mut v = rc.borrow_mut(); + let at = at.min(v.len()); + v.insert(at, unsafe { crate::object::clone_object(item) }); + } + unsafe { note_c_agreement(p) }; +} + +/// Overwrite the `pos`-th slot of a faithful list mirror, **stealing** +/// `item` (CPython `PyList_SetItem`) and releasing the prior occupant. +/// Returns `false` for an out-of-range index (the caller then disposes +/// of `item`). +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]; `item` is a strong reference +/// whose ownership transfers to the list. +pub unsafe fn list_store(p: *mut PyObject, pos: PySsizeT, item: *mut PyObject) -> bool { + let n = unsafe { list_size(p) }; + if pos < 0 || pos >= n { + return false; + } + unsafe { list_prefix_seed_once(p) }; + let lo = p as *mut layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + let slot = unsafe { base.add(pos as usize) }; + let prev = unsafe { *slot }; + unsafe { *slot = item }; + if !prev.is_null() { + unsafe { crate::object::Py_DecRef(prev) }; + } + // Mirror the store into the shared prefix `Rc` (RFC 0047, wave 5). + let pre = unsafe { prefix_of(p) }; + if let Object::List(rc) = unsafe { &(*pre).obj } { + let mut v = rc.borrow_mut(); + let idx = pos as usize; + if idx < v.len() { + v[idx] = unsafe { crate::object::clone_object(item) }; + } + } + unsafe { note_c_agreement(p) }; + true +} + +/// Snapshot the `ob_item` pointers of a faithful list mirror (borrowed; +/// no refcount change). Used by in-place permutations (reverse / sort). +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`]. +pub unsafe fn list_ptrs(p: *mut PyObject) -> Vec<*mut PyObject> { + let n = unsafe { list_size(p) } as usize; + let lo = p as *const layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + let mut out = Vec::with_capacity(n); + if !base.is_null() { + for i in 0..n { + out.push(unsafe { *base.add(i) }); + } + } + out +} + +/// Write back a permutation of the list's own pointers (same multiset, +/// same length — a pure reordering, so no refcount change). Used by +/// reverse / sort after [`list_ptrs`]. +/// +/// # Safety +/// `p` must satisfy [`is_faithful_list`] and `ptrs.len() == list_size(p)`. +pub unsafe fn list_permute(p: *mut PyObject, ptrs: &[*mut PyObject]) { + let lo = p as *mut layout::PyListObject; + let base = unsafe { (*lo).ob_item }; + if base.is_null() { + return; + } + for (i, &pp) in ptrs.iter().enumerate() { + unsafe { *base.add(i) = pp }; + } + // Re-publish the reordering into the shared prefix `Rc`, in place so a + // VM alias observes it (RFC 0047, wave 5). + let pre = unsafe { prefix_of(p) }; + if let Object::List(rc) = unsafe { &(*pre).obj } { + let cur = unsafe { read_list_vec(p) }; + *rc.borrow_mut() = cur; + if !unsafe { (*pre).list_synced } { + unsafe { (*pre).list_synced = true }; + register_seeded_list(p); + } + } + unsafe { note_c_agreement(p) }; +} + +/// Borrow the C-side state pointer stored in the prefix. +/// +/// # Safety +/// `p` must satisfy [`is_mirror`]. +pub unsafe fn user_data(p: *mut PyObject) -> *mut c_void { + let pre = unsafe { prefix_of(p) }; + unsafe { (*pre).user_data } +} + +/// Free a mirror: run its destructor, drop the owning native object and +/// any out-of-line buffer, then release the block. +/// +/// # Safety +/// `p` must satisfy [`is_mirror`] and have a zero (or about-to-be-zero) +/// refcount; it must not be used afterwards. +pub unsafe fn free_mirror(p: *mut PyObject) { + unsafe { record_mirror_free(p) }; + crate::object::unregister_minted(p); + // Drop a seeded list mirror from the write-through set. Gated on the + // atomic so an ordinary mirror free (float/int/…) never takes the lock. + if SEEDED_LIST_COUNT.load(Ordering::Relaxed) > 0 && unsafe { is_faithful_list(p) } { + unregister_seeded_list(p); + } + // RFC 0047 (wave 5): drop this box from the canonical set cache before + // its prefix (and the native `Rc` the key is derived from) is dropped. + if SET_BOX_COUNT.load(Ordering::Relaxed) > 0 && unsafe { is_faithful_set(p) } { + unsafe { unregister_set_box(p) }; + } + // Likewise drop this box from the canonical builtin cache (`operator.eq` + // &c.) before its prefix `Rc` is dropped, so the next crossing of the + // same native builtin mints (and re-registers) a fresh canonical box. + if BUILTIN_BOX_COUNT.load(Ordering::Relaxed) > 0 && unsafe { is_faithful_builtin(p) } { + unsafe { unregister_builtin_box(p) }; + } + let pre = unsafe { prefix_of(p) }; + let destructor = unsafe { (*pre).destructor }; + if let Some(d) = destructor { + unsafe { d(p) }; + } + let alloc_size = unsafe { (*pre).alloc_size }; + let aux_ptr = unsafe { (*pre).aux_ptr }; + let aux_size = unsafe { (*pre).aux_size }; + + // A list's out-of-line `ob_item` (RFC 0046, wave 4) holds one owned + // reference per element (including any a stock `PyList_SET_ITEM` stored + // directly), so release them before freeing the buffer. Immortal + // singletons (None/bool) no-op. Gated on `is_faithful_list`: a faithful + // **memoryview** mirror (RFC 0047, wave 5) also carries an aux buffer, + // but its bytes are packed `shape`/`strides`/data/format — *not* + // `PyObject*` slots — and must never be decref'd here. + if !aux_ptr.is_null() && aux_size > 0 && unsafe { is_faithful_list(p) } { + let n = (aux_size / std::mem::size_of::<*mut PyObject>()) as isize; + let slots = aux_ptr as *mut *mut PyObject; + for i in 0..n { + let elem = unsafe { *slots.offset(i) }; + if !elem.is_null() { + unsafe { crate::object::Py_DecRef(elem) }; + } + } + } + + // RFC 0046 (wave 4): a faithful tuple owns one reference to each inline + // `ob_item` element (materialised on creation or stored by a stock + // `PyTuple_SET_ITEM`), so release them before the block goes away. + // Immortal singletons (None/bool placeholders) no-op. + if unsafe { is_faithful_tuple(p) } { + let vo = p as *const layout::PyVarObject; + let n = unsafe { (*vo).ob_size }; + if n > 0 { + let to = p as *mut layout::PyTupleObject; + let base = ptr::addr_of_mut!((*to).ob_item) as *mut *mut PyObject; + for i in 0..n as usize { + let elem = unsafe { *base.add(i) }; + if !elem.is_null() { + unsafe { crate::object::Py_DecRef(elem) }; + } + } + } + } + + // RFC 0047 (wave 5): a faithful bound method owns one reference to each + // of `im_func` and `im_self` (materialised in `fill_body`), so release + // them before the block goes away. Immortal singletons no-op. + if unsafe { is_faithful_method(p) } { + let mo = p as *mut layout::PyMethodObject; + let func = unsafe { (*mo).im_func }; + let recv = unsafe { (*mo).im_self }; + if !func.is_null() { + unsafe { crate::object::Py_DecRef(func) }; + } + if !recv.is_null() { + unsafe { crate::object::Py_DecRef(recv) }; + } + } + + // RFC 0047 (wave 5): a faithful slice owns one reference to each of + // `start`/`stop`/`step` (materialised in `fill_body`), so release them + // before the block goes away. Immortal singletons (None/bool) no-op. + if unsafe { is_faithful_slice(p) } { + let so = p as *mut layout::PySliceObject; + for field in [ + unsafe { (*so).start }, + unsafe { (*so).stop }, + unsafe { (*so).step }, + ] { + if !field.is_null() { + unsafe { crate::object::Py_DecRef(field) }; + } + } + } + + // Drop the owning native object (releasing its Rc clones). + unsafe { ptr::drop_in_place(ptr::addr_of_mut!((*pre).obj)) }; + + if !aux_ptr.is_null() && aux_size > 0 { + let aux_layout = Layout::from_size_align(aux_size, BODY_ALIGN).expect("aux layout"); + unsafe { dealloc(aux_ptr, aux_layout) }; + } + + let layout = Layout::from_size_align(alloc_size, BODY_ALIGN).expect("mirror layout"); + unsafe { dealloc(pre as *mut u8, layout) }; +} + +// --------------------------------------------------------------------------- +// Body layout planning + filling. +// --------------------------------------------------------------------------- + +/// What kind of faithful body a value gets, and how big it is. +struct BodyPlan { + kind: BodyKind, + /// Size in bytes of the body (head + faithful tail). Always ≥ 16. + body_size: usize, +} + +#[derive(Clone, Copy)] +enum BodyKind { + Float, + Long, + Complex, + Bytes, + Str, + Tuple, + /// Faithful `PyListObject` with an out-of-line `ob_item` buffer + /// (RFC 0046, wave 4). numpy builds module lists by `PyList_New(n)` + /// then writing `ob_item[i]` directly (the `PyList_SET_ITEM` macro), + /// so the buffer must be a real, writable `PyObject*` array. + List, + /// Faithful `PyCFunctionObject` with an inline, writable `PyMethodDef` + /// (RFC 0046, wave 4). numpy's `add_docstring` walks + /// `((PyCFunctionObject *)f)->m_ml->ml_doc` directly to read and then + /// write a function's docstring, so `m_ml` must point at a real, + /// writable `PyMethodDef` (carried just past the object body). + CFunction, + /// Faithful `PyMethodObject` (a bound method) with `im_func`/`im_self` + /// populated (RFC 0047, wave 5). Macro-heavy Cython unpacks a bound + /// method by reading those two fields straight off the struct + /// (`PyMethod_GET_FUNCTION` / `PyMethod_GET_SELF`) before calling — so + /// they must hold real, owned `PyObject*`s, not opaque box bytes. + Method, + /// Faithful `PyDictObject` header (RFC 0047, wave 5): `ma_used` holds + /// the item count so a stock `PyDict_GET_SIZE` / the Cython keyword + /// fast path reads the right size. The entries live in the prefix's + /// native dict (reached via the C-API functions), so `ma_keys` / + /// `ma_values` stay NULL. + Dict, + /// Faithful `PySetObject` header (RFC 0047, wave 5): `fill`/`used` hold + /// the element count so a stock `PySet_GET_SIZE` / `PyFrozenSet_GET_SIZE` + /// macro — which Cython emits for both `len(s)` and the truthiness test + /// `if s:` on a set-typed value — reads the right size. `table` points at + /// the inline (empty) `smalltable`; the entries live in the prefix's + /// native set (reached via `PySet_Size` / `tp_iter`). + Set, + /// Faithful `PySliceObject` (RFC 0047, wave 5) with `start`/`stop`/`step` + /// populated as owned references. Macro-heavy Cython reads those three + /// fields straight off the struct (`((PySliceObject*)s)->step`), so they + /// must hold real `PyObject*`s. A slice is immutable, so the prefix's + /// staged `Object` stays authoritative on read-back; these owned refs are + /// released in `free_mirror`. + Slice, + /// Faithful `PyMemoryViewObject` (RFC 0047, wave 5) with a populated + /// inline `Py_buffer view`. `PyMemoryView_GET_BUFFER` is a macro + /// (`&mv->view`), so Cython's fused-type dispatch reads `view.ndim`, + /// `view.itemsize` and `view.format` straight off the struct — pandas' + /// `lib.map_infer_mask` keys its `ndarray[object]` specialization on + /// `itemsize == 8`/`format == "O"`. `view.buf`/`format`/`shape`/`strides` + /// point into the mirror's out-of-line aux buffer (freed in + /// `free_mirror`); the prefix's staged `Object::MemoryView` stays + /// authoritative on read-back. + MemoryView, + /// Head-only body; the native value lives only in the prefix. + Generic, +} + +impl BodyPlan { + fn for_object(obj: &Object) -> BodyPlan { + match obj { + Object::Float(_) => BodyPlan { + kind: BodyKind::Float, + body_size: std::mem::size_of::(), + }, + Object::Complex(_) => BodyPlan { + kind: BodyKind::Complex, + body_size: std::mem::size_of::(), + }, + Object::Int(_) | Object::Long(_) => { + let ndigits = long_digit_count(obj).max(1); + // head(16) + lv_tag(8) + ndigits * 4, rounded to 8. + let raw = 16 + 8 + ndigits * 4; + BodyPlan { + kind: BodyKind::Long, + body_size: round_up(raw, 8), + } + } + Object::Bytes(b) => BodyPlan { + kind: BodyKind::Bytes, + // varhead(24) + ob_shash(8) + (len+1) NUL-terminated. + body_size: round_up(24 + 8 + b.len() + 1, 8), + }, + Object::Str(s) => { + // Every string — 1-, 2-, or 4-byte kind — gets a faithful + // PEP 393 compact body so a stock extension's inlined + // `PyUnicode_DATA`/`PyUnicode_KIND`/`PyUnicode_GET_LENGTH` + // macros (and Cython's f-string / `str.join` fast paths, + // which read the parts' buffers directly) address real + // memory. A compact-ASCII string carries its 1-byte data + // just past `PyASCIIObject`; a compact non-ASCII string + // (Latin-1, UCS-2, or UCS-4) carries it past + // `PyCompactUnicodeObject`, where the inlined `PyUnicode_DATA` + // macro reads it (keyed off the `ascii`/`kind` state bits). + // Size the body for whichever kind `fill_str` will write. + let n = s.chars().count(); + let (_kind, _ascii, data_off, width) = unicode_form(str_maxchar(s)); + BodyPlan { + kind: BodyKind::Str, + body_size: round_up(data_off + (n + 1) * width, 8), + } + } + Object::Tuple(t) => BodyPlan { + kind: BodyKind::Tuple, + // varhead(24) + n pointers. + body_size: round_up(24 + t.len() * 8, 8).max(24), + }, + Object::List(_) => BodyPlan { + kind: BodyKind::List, + // The list's `ob_item` is out-of-line (a separate aux + // buffer); the body is exactly `PyListObject`. + body_size: std::mem::size_of::(), + }, + Object::Builtin(_) => BodyPlan { + kind: BodyKind::CFunction, + // `PyCFunctionObject` followed by an inline `PyMethodDef` + // (pointed at by `m_ml`); both live in the one block so a + // stock `f->m_ml->ml_doc` read/write stays in bounds and the + // method def is released with the object. + body_size: std::mem::size_of::() + + std::mem::size_of::(), + }, + Object::BoundMethod(_) => BodyPlan { + kind: BodyKind::Method, + // Exactly `PyMethodObject`; `im_func`/`im_self` are owned + // refs filled in `fill_body` and released in `free_mirror`. + body_size: std::mem::size_of::(), + }, + Object::Dict(_) => BodyPlan { + kind: BodyKind::Dict, + // Exactly `PyDictObject`; only `ma_used` is populated. + body_size: std::mem::size_of::(), + }, + Object::Set(_) | Object::FrozenSet(_) => BodyPlan { + kind: BodyKind::Set, + // Exactly `PySetObject`; `fill`/`used` carry the count and + // `table` points at the inline (empty) `smalltable`. + body_size: std::mem::size_of::(), + }, + Object::Slice(_) => BodyPlan { + kind: BodyKind::Slice, + // Exactly `PySliceObject`; `start`/`stop`/`step` are owned + // refs filled in `fill_body` and released in `free_mirror`. + body_size: std::mem::size_of::(), + }, + Object::MemoryView(_) => BodyPlan { + kind: BodyKind::MemoryView, + // Exactly `PyMemoryViewObject` (up to `weakreflist`); the + // inline `view`'s `buf`/`format`/`shape`/`strides` point at a + // packed out-of-line aux buffer filled in `fill_body`. + body_size: std::mem::size_of::(), + }, + _ => BodyPlan { + kind: BodyKind::Generic, + body_size: std::mem::size_of::(), + }, + } + } +} + +/// Fill the faithful fields of `body` from `obj`. The head is written by +/// the caller afterward (so `fill_body` must not depend on it). +/// +/// # Safety +/// `body` points at a zeroed block of at least `plan.body_size` bytes. +unsafe fn fill_body( + body: *mut PyObject, + _ty: *mut PyTypeObject, + obj: &Object, + plan: &BodyPlan, + aux_ptr: &mut *mut u8, + aux_size: &mut usize, +) { + match plan.kind { + BodyKind::Float => { + if let Object::Float(f) = obj { + let fo = body as *mut layout::PyFloatObject; + unsafe { (*fo).ob_fval = *f }; + } + } + BodyKind::Complex => { + if let Object::Complex(c) = obj { + let co = body as *mut layout::PyComplexObject; + unsafe { + (*co).cval = layout::PyComplexValue { + real: c.real, + imag: c.imag, + }; + } + } + } + BodyKind::Long => unsafe { fill_long(body, obj) }, + BodyKind::Bytes => { + if let Object::Bytes(b) = obj { + let vo = body as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = b.len() as PySsizeT }; + let bo = body as *mut layout::PyBytesObject; + unsafe { + (*bo).ob_shash = -1; + let dst = ptr::addr_of_mut!((*bo).ob_sval) as *mut u8; + ptr::copy_nonoverlapping(b.as_ptr(), dst, b.len()); + *dst.add(b.len()) = 0; // NUL terminator + } + } + } + BodyKind::Str => unsafe { fill_str(body, obj) }, + BodyKind::Tuple => { + if let Object::Tuple(t) = obj { + let vo = body as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = t.len() as PySsizeT }; + let to = body as *mut layout::PyTupleObject; + let base = ptr::addr_of_mut!((*to).ob_item) as *mut *mut PyObject; + for (i, elem) in t.iter().enumerate() { + // RFC 0046 (wave 4): the inline `ob_item` array is the + // tuple's *source of truth* — a stock `PyTuple_GET_ITEM` + // reads it directly and `PyTuple_SET_ITEM` writes it, so + // each element is an owned reference materialised here + // (and released in `free_mirror`). `into_owned` round- + // trips a foreign proxy to its original pointer and a + // type object to its own `PyTypeObject*`. None/bool reuse + // their immortal singletons so a `PyTuple_SET_ITEM` + // overwrite (which does not decref the prior slot) of a + // staged placeholder cannot leak. + let ep = match elem { + Object::None => crate::singletons::none_ptr(), + Object::Bool(true) => crate::singletons::true_ptr(), + Object::Bool(false) => crate::singletons::false_ptr(), + _ => crate::object::into_owned(elem.clone()), + }; + if std::env::var_os("WEAVEPY_DEBUG_TUPLE").is_some() && t.len() == 2 { + let k = match elem { + Object::Foreign(_) => "Foreign", + Object::None => "None", + Object::Type(_) => "Type", + Object::Tuple(_) => "Tuple", + _ => "other", + }; + eprintln!("[fill_body tuple n=2] i={i} kind={k} ep={ep:p}"); + } + unsafe { *base.add(i) = ep }; + } + } + } + BodyKind::List => { + if let Object::List(l) = obj { + let items = l.borrow(); + let n = items.len(); + let vo = body as *mut layout::PyVarObject; + unsafe { (*vo).ob_size = n as PySsizeT }; + let lo = body as *mut layout::PyListObject; + if n == 0 { + // CPython's empty list has `ob_item == NULL`. + unsafe { + (*lo).ob_item = ptr::null_mut(); + (*lo).allocated = 0; + } + } else { + let bytes = n * std::mem::size_of::<*mut PyObject>(); + let buf_layout = + Layout::from_size_align(bytes, BODY_ALIGN).expect("ob_item layout"); + let buf = unsafe { alloc(buf_layout) }; + assert!(!buf.is_null(), "ob_item allocation failed"); + unsafe { ptr::write_bytes(buf, 0, bytes) }; + let slots = buf as *mut *mut PyObject; + for (i, elem) in items.iter().enumerate() { + // Each element is materialised as an owned reference + // held by the list. None/bool reuse their immortal + // singletons so a stock `PyList_SET_ITEM` overwrite + // (which does *not* decref the prior slot) of a + // `PyList_New(n)` placeholder cannot leak. + let ep = match elem { + Object::None => crate::singletons::none_ptr(), + Object::Bool(true) => crate::singletons::true_ptr(), + Object::Bool(false) => crate::singletons::false_ptr(), + _ => crate::object::into_owned(elem.clone()), + }; + unsafe { *slots.add(i) = ep }; + } + unsafe { + (*lo).ob_item = slots; + (*lo).allocated = n as PySsizeT; + } + *aux_ptr = buf; + *aux_size = bytes; + } + } + } + BodyKind::CFunction => { + // Lay a faithful `PyCFunctionObject` over the body and point its + // `m_ml` at the inline `PyMethodDef` that follows. The def is + // left zeroed (`ml_doc == NULL`), so numpy's `add_docstring` + // takes the "first docstring" branch and *writes* `ml_doc` in + // place rather than `strcmp`-ing a garbage pointer. `m_self` / + // `m_module` / `vectorcall` stay NULL — calls and `__module__` + // are served by the VM through the prefix, never through these + // fields. `ml_name` is NULL for the same reason (`f.__name__` + // resolves in the VM); it is read by `add_docstring` only on the + // never-taken mismatch path. + let cf = body as *mut layout::PyCFunctionObject; + let def = + unsafe { (body as *mut u8).add(std::mem::size_of::()) } + as *mut layout::PyMethodDef; + unsafe { + (*cf).m_ml = def; + (*cf).m_self = ptr::null_mut(); + (*cf).m_module = ptr::null_mut(); + (*cf).m_weakreflist = ptr::null_mut(); + (*cf).vectorcall = ptr::null_mut(); + } + let _ = (aux_ptr, aux_size); + } + BodyKind::Method => { + // Lay a faithful `PyMethodObject` over the body and populate + // `im_func`/`im_self` with owned references, so a stock + // `PyMethod_GET_FUNCTION(m)` / `PyMethod_GET_SELF(m)` (the + // macros Cython's `with`/`for`/call fast paths inline) read a + // real function and receiver rather than Rust enum bytes. The + // calling convention WeavePy applies when the *method* is + // invoked (prepend `receiver`, call `function`) matches what + // Cython does after unpacking (prepend `im_self`, call + // `im_func`), so both routes reach the same callee with the + // same `self`. `im_weakreflist`/`vectorcall` stay NULL — the + // method is never invoked through its own vectorcall slot (its + // `tp_call` is unset, so a stock `PyObject_Call` routes through + // the VM via the prefix's `BoundMethod`). The owning + // `BoundMethod` also lives in the prefix, so these two extra + // owned refs are released in `free_mirror`. + if let Object::BoundMethod(bm) = obj { + let mo = body as *mut layout::PyMethodObject; + let func = crate::object::into_owned(bm.function.clone()); + let recv = crate::object::into_owned(bm.receiver.clone()); + unsafe { + (*mo).im_func = func; + (*mo).im_self = recv; + (*mo).im_weakreflist = ptr::null_mut(); + (*mo).vectorcall = ptr::null_mut(); + } + } + let _ = (aux_ptr, aux_size); + } + BodyKind::Dict => { + // Faithful `PyDictObject` header. Only `ma_used` (the item + // count a stock `PyDict_GET_SIZE` reads directly) is meaningful; + // the entries are served from the prefix's native dict through + // the C-API, so `ma_keys` / `ma_values` stay NULL. + if let Object::Dict(rc) = obj { + let d = body as *mut layout::PyDictObject; + unsafe { + (*d).ma_used = rc.borrow().len() as PySsizeT; + (*d).ma_version_tag = 0; + (*d).ma_keys = ptr::null_mut(); + (*d).ma_values = ptr::null_mut(); + } + } + let _ = (aux_ptr, aux_size); + } + BodyKind::Set => { + // Faithful `PySetObject` header. `fill`/`used` are the element + // count a stock `PySet_GET_SIZE` reads directly; the entries are + // served from the prefix's native set via the C-API, so `table` + // just points at the (zeroed) inline `smalltable` and the set + // looks like a freshly-initialised — if under-populated — CPython + // set (`mask == PySet_MINSIZE - 1`, `hash == -1`, `finger == 0`). + let n = match obj { + Object::Set(rc) => rc.borrow().len() as PySsizeT, + Object::FrozenSet(fs) => fs.len() as PySsizeT, + _ => 0, + }; + let so = body as *mut layout::PySetObject; + unsafe { + (*so).fill = n; + (*so).used = n; + (*so).mask = (layout::PYSET_MINSIZE - 1) as PySsizeT; + (*so).table = ptr::addr_of_mut!((*so).smalltable) as *mut core::ffi::c_void; + (*so).hash = -1; + (*so).finger = 0; + (*so).weakreflist = ptr::null_mut(); + } + let _ = (aux_ptr, aux_size); + } + BodyKind::Slice => { + // Lay a faithful `PySliceObject` over the body and populate + // `start`/`stop`/`step` with owned references, so a stock + // `((PySliceObject*)s)->step` read (and the inline incref/decref + // Cython brackets it with) hits real `PyObject*`s. A `None` + // component reuses the immortal singleton so the incref/decref is a + // no-op. The three owned refs are released in `free_mirror`. + if let Object::Slice(s) = obj { + let so = body as *mut layout::PySliceObject; + let materialise = |o: &Object| -> *mut PyObject { + match o { + Object::None => crate::singletons::none_ptr(), + Object::Bool(true) => crate::singletons::true_ptr(), + Object::Bool(false) => crate::singletons::false_ptr(), + _ => crate::object::into_owned(o.clone()), + } + }; + unsafe { + (*so).start = materialise(&s.start); + (*so).stop = materialise(&s.stop); + (*so).step = materialise(&s.step); + } + } + let _ = (aux_ptr, aux_size); + } + BodyKind::MemoryView => { + // Lay a faithful `PyMemoryViewObject` over the body and populate + // its inline `Py_buffer view`, so a stock `PyMemoryView_GET_BUFFER` + // macro (`&mv->view`) and the `__Pyx_PyMemoryView_Get_*` reads it + // feeds hit real `ndim`/`itemsize`/`format`/`shape`/`strides`. The + // window bytes, NUL-terminated format and the `shape`/`strides` + // `Py_ssize_t` arrays are packed into one out-of-line aux block + // (`view` points into it); the prefix's staged `Object::MemoryView` + // stays authoritative on read-back ([`native_of`]). The aux block + // is freed in [`free_mirror`] (gated off the list path, so its + // bytes are never mistaken for `PyObject*` slots). + if let Object::MemoryView(mv) = obj { + let mo = body as *mut layout::PyMemoryViewObject; + let itemsize = mv.itemsize.get().max(1); + let nbytes = mv.len.get(); + let shape = mv.shape_dims(); + let strides = mv.stride_bytes(); + let ndim = shape.len(); + let data = if mv.released.get() { + Vec::new() + } else { + mv.to_bytes() + }; + let fmt = mv.format.borrow(); + let fmt_bytes = fmt.as_bytes(); + + // Pack: [shape: ndim·8][strides: ndim·8][data][format+NUL], + // 8-aligned arrays first so `view.shape`/`strides` are aligned. + let ssz = std::mem::size_of::(); + let shape_off = 0usize; + let strides_off = shape_off + ndim * ssz; + let data_off = strides_off + ndim * ssz; + let fmt_off = data_off + data.len(); + let total_aux = round_up(fmt_off + fmt_bytes.len() + 1, 8).max(8); + let aux_layout = + Layout::from_size_align(total_aux, BODY_ALIGN).expect("mv aux layout"); + let aux = unsafe { alloc(aux_layout) }; + assert!(!aux.is_null(), "mv aux allocation failed"); + unsafe { ptr::write_bytes(aux, 0, total_aux) }; + + let shape_ptr = unsafe { aux.add(shape_off) } as *mut PySsizeT; + let strides_ptr = unsafe { aux.add(strides_off) } as *mut PySsizeT; + let data_ptr = unsafe { aux.add(data_off) }; + let fmt_ptr = unsafe { aux.add(fmt_off) } as *mut core::ffi::c_char; + for i in 0..ndim { + unsafe { + *shape_ptr.add(i) = shape[i] as PySsizeT; + *strides_ptr.add(i) = strides[i] as PySsizeT; + } + } + if !data.is_empty() { + unsafe { + ptr::copy_nonoverlapping(data.as_ptr(), data_ptr, data.len()); + } + } + unsafe { + ptr::copy_nonoverlapping(fmt_bytes.as_ptr(), aux.add(fmt_off), fmt_bytes.len()); + } + + // `_Py_MEMORYVIEW_C`(1) | `_Py_MEMORYVIEW_FORTRAN`(2): a + // contiguous view advertises both for 1-D, matching CPython's + // `init_flags`. A released view advertises `_RELEASED`(16). + let mut flags: core::ffi::c_int = 0; + if mv.released.get() { + flags |= 0x10; + } else if mv.is_c_contiguous() { + flags |= 0x1; + if ndim <= 1 { + flags |= 0x2; + } + } + + unsafe { + // `PyObject_VAR_HEAD` `ob_size` is `ndim` (CPython sizes + // the `ob_array` tail off it); harmless to a reader that + // uses `view.ndim`. + (*mo).ob_base.ob_size = ndim as PySsizeT; + (*mo).mbuf = ptr::null_mut(); + (*mo).hash = -1; + (*mo).flags = flags; + (*mo).exports = 0; + (*mo).weakreflist = ptr::null_mut(); + // `view.obj` stays NULL: a stray `PyBuffer_Release` on the + // macro-fetched view is then a no-op (no spurious decref of + // the memoryview). The real buffer protocol path + // (`PyObject_GetBuffer(mv, …)`) is serviced separately by + // `fill_native_buffer`'s `MemoryView` branch. + (*mo).view.buf = data_ptr as *mut std::ffi::c_void; + (*mo).view.obj = ptr::null_mut(); + (*mo).view.len = nbytes as PySsizeT; + (*mo).view.itemsize = itemsize as PySsizeT; + (*mo).view.readonly = core::ffi::c_int::from(mv.readonly.get()); + (*mo).view.ndim = ndim as core::ffi::c_int; + (*mo).view.format = fmt_ptr; + (*mo).view.shape = if ndim > 0 { shape_ptr } else { ptr::null_mut() }; + (*mo).view.strides = if ndim > 0 { strides_ptr } else { ptr::null_mut() }; + (*mo).view.suboffsets = ptr::null_mut(); + (*mo).view.internal = ptr::null_mut(); + } + *aux_ptr = aux; + *aux_size = total_aux; + } + } + BodyKind::Generic => { + // Head-only: nothing to fill. Suppress "unused" on a list's + // would-be aux buffer. + let _ = (aux_ptr, aux_size); + } + } +} + +/// Encode an integer's faithful `PyLongObject` body. +unsafe fn fill_long(body: *mut PyObject, obj: &Object) { + let (sign, mag) = int_sign_magnitude(obj); + let digits = to_base_2_30(mag); + let ndigits = digits.len().max(1); + let lo = body as *mut layout::PyLongObject; + let sign_field = if sign == 0 { + layout::PYLONG_SIGN_ZERO + } else if sign < 0 { + layout::PYLONG_SIGN_NEGATIVE + } else { + layout::PYLONG_SIGN_POSITIVE + }; + unsafe { + (*lo).long_value.lv_tag = (ndigits << layout::PYLONG_NON_SIZE_BITS) | sign_field; + let base = ptr::addr_of_mut!((*lo).long_value.ob_digit) as *mut layout::digit; + if digits.is_empty() { + *base = 0; + } else { + for (i, d) in digits.iter().enumerate() { + *base.add(i) = *d; + } + } + } +} + +/// Fill a compact PEP 393 unicode body of the kind implied by the string's +/// largest code point: 1-byte (compact-ASCII or Latin-1), 2-byte (UCS-2), or +/// 4-byte (UCS-4). The data offset (and the `ascii` state bit) differ +/// between the compact-ASCII form (data past `PyASCIIObject`) and the +/// compact non-ASCII forms (data past `PyCompactUnicodeObject`, where the +/// inlined `PyUnicode_DATA` macro reads it). Each code point is stored at +/// its kind's width and the buffer is NUL-terminated with one trailing unit, +/// so a stock reader's `PyUnicode_READ`/`PyUnicode_DATA` and Cython's +/// `str.join`/f-string fast paths address a real, correctly-sized buffer. +unsafe fn fill_str(body: *mut PyObject, obj: &Object) { + let Object::Str(s) = obj else { return }; + let (kind, ascii, data_off, width) = unicode_form(str_maxchar(s)); + let n = s.chars().count(); + let ao = body as *mut layout::PyASCIIObject; + unsafe { + (*ao).length = n as PySsizeT; + // RFC 0047 (wave 5): publish the real hash, not CPython's + // "uncomputed" sentinel (-1). Macro-heavy Cython matches keyword + // arguments by reading `((PyASCIIObject*)key)->hash` *directly* + // off the struct and comparing it to each interned argname's hash + // (`__Pyx_MatchKeywordArg_str`); both sides are WeavePy-minted + // strings, so a `py_str_hash`-consistent value makes the compare + // agree. Leaving -1 made every Cython keyword call fail with a + // spurious "unexpected keyword argument". + (*ao).hash = weavepy_vm::object::py_str_hash(s) as crate::object::PyHashT; + (*ao).state = ustate::pack( + 0, // not interned + kind, + true, // compact + ascii, // ascii + false, // not statically allocated + ); + let data = (body as *mut u8).add(data_off); + for (i, ch) in s.chars().enumerate() { + write_codepoint(data, kind, i, ch as u32); + } + // NUL-terminate with one trailing code unit of the body's width. + match width { + 1 => *data.add(n) = 0, + 2 => *(data as *mut u16).add(n) = 0, + _ => *(data as *mut u32).add(n) = 0, + } + } +} + +/// The largest code point in `s` (0 for the empty string), for +/// [`unicode_form`]. +fn str_maxchar(s: &str) -> u32 { + s.chars().map(|c| c as u32).max().unwrap_or(0) +} + +// --------------------------------------------------------------------------- +// Integer helpers. +// --------------------------------------------------------------------------- + +fn long_digit_count(obj: &Object) -> usize { + let (_, mag) = int_sign_magnitude(obj); + to_base_2_30(mag).len() +} + +/// Returns `(sign, magnitude)` where `sign ∈ {-1, 0, 1}`. +fn int_sign_magnitude(obj: &Object) -> (i32, u128) { + match obj { + Object::Int(v) => { + if *v == 0 { + (0, 0) + } else if *v < 0 { + (-1, (*v as i128).unsigned_abs()) + } else { + (1, *v as u128) + } + } + Object::Bool(b) => { + if *b { + (1, 1) + } else { + (0, 0) + } + } + Object::Long(big) => big_sign_magnitude(big), + _ => (0, 0), + } +} + +/// Big integers wider than `u128` are clamped to their low 128 bits for +/// the faithful body; WeavePy itself always reads the exact value from +/// the prefix, and stock extensions read big ints through the function +/// API (`PyLong_AsLong`), so the inlined-digit path matters only for +/// values that fit. (Full-width digit encoding is a wave-2 refinement.) +fn big_sign_magnitude(big: &BigInt) -> (i32, u128) { + use num_bigint::Sign; + let (sign, bytes) = big.to_bytes_le(); + let mut mag: u128 = 0; + for (i, b) in bytes.iter().take(16).enumerate() { + mag |= (*b as u128) << (i * 8); + } + let s = match sign { + Sign::NoSign => 0, + Sign::Plus => 1, + Sign::Minus => -1, + }; + (s, mag) +} + +/// Decompose a magnitude into base-2^30 little-endian limbs. +fn to_base_2_30(mut mag: u128) -> Vec { + let mut out = Vec::new(); + if mag == 0 { + return out; + } + while mag > 0 { + out.push((mag & (layout::PYLONG_MASK as u128)) as layout::digit); + mag >>= layout::PYLONG_SHIFT; + } + out +} +const fn round_up(n: usize, align: usize) -> usize { + (n + (align - 1)) & !(align - 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interp::ensure_initialised; + use weavepy_vm::sync::Rc as VmRc; + + /// Read a `T` at byte offset `off` from a body pointer, the way a + /// stock inlined macro would. + unsafe fn read_at(p: *mut PyObject, off: usize) -> T { + unsafe { ptr::read_unaligned((p as *const u8).add(off) as *const T) } + } + + fn as_float(o: &Object) -> f64 { + match o { + Object::Float(f) => *f, + _ => panic!("expected float"), + } + } + fn as_int(o: &Object) -> i64 { + match o { + Object::Int(v) => *v, + _ => panic!("expected int"), + } + } + + #[test] + fn float_body_is_faithful() { + ensure_initialised(); + let p = mirror_out(Object::Float(2.5)); + unsafe { + assert!(is_mirror(p)); + // ob_fval lives at offset 16 (where PyFloat_AS_DOUBLE reads). + assert_eq!(read_at::(p, 16), 2.5); + // refcount starts at 1, type is float. + assert_eq!((*p).ob_refcnt, 1); + assert_eq!((*p).ob_type, types::PyFloat_Type.as_ptr()); + // The native object resolves back. + assert_eq!(as_float(&native_of(p)), 2.5); + free_mirror(p); + } + } + + #[test] + fn long_body_encodes_small_int() { + ensure_initialised(); + let p = mirror_out(Object::Int(5)); + unsafe { + // lv_tag at +16: ndigits=1, sign positive → (1<<3)|0 = 8. + assert_eq!(read_at::(p, 16), 8); + // first digit at +24 == 5. + assert_eq!(read_at::(p, 24), 5); + assert_eq!(as_int(&native_of(p)), 5); + free_mirror(p); + } + } + + #[test] + fn long_body_encodes_negative() { + ensure_initialised(); + let p = mirror_out(Object::Int(-1)); + unsafe { + // sign negative = 2, ndigits 1 → (1<<3)|2 = 10. + assert_eq!(read_at::(p, 16), 10); + assert_eq!(read_at::(p, 24), 1); + free_mirror(p); + } + } + + #[test] + fn bytes_body_is_faithful() { + ensure_initialised(); + let p = mirror_out(Object::Bytes(VmRc::from(&b"hi"[..]))); + unsafe { + // ob_size at +16. + assert_eq!(read_at::(p, 16), 2); + // ob_sval at +32 holds the bytes + NUL. + assert_eq!(read_at::(p, 32), b'h'); + assert_eq!(read_at::(p, 33), b'i'); + assert_eq!(read_at::(p, 34), 0); + free_mirror(p); + } + } + + #[test] + fn str_ascii_body_is_faithful() { + ensure_initialised(); + let p = mirror_out(Object::Str(VmRc::from("abc"))); + unsafe { + // length at +16. + assert_eq!(read_at::(p, 16), 3); + // state at +32: kind=1byte, compact, ascii. + let state = read_at::(p, 32); + assert_eq!( + state, + ustate::pack(0, ustate::KIND_1BYTE, true, true, false) + ); + // compact data follows PyASCIIObject (offset 40). + assert_eq!(read_at::(p, 40), b'a'); + assert_eq!(read_at::(p, 42), b'c'); + free_mirror(p); + } + } + + #[test] + fn tuple_body_holds_element_mirrors() { + ensure_initialised(); + let t = Object::new_tuple(vec![Object::Float(1.0), Object::Int(2)]); + let p = mirror_out(t); + unsafe { + // ob_size at +16. + assert_eq!(read_at::(p, 16), 2); + // ob_item[0] at +24 is a float mirror with ob_fval 1.0. + let e0 = read_at::<*mut PyObject>(p, 24); + assert_eq!(read_at::(e0, 16), 1.0); + free_mirror(p); + } + } + + #[test] + fn generic_body_keeps_native_in_prefix() { + ensure_initialised(); + // A dict is not a faithful body; it gets a generic head-only body + // but still resolves through the prefix. + let p = mirror_out(Object::Float(9.0)); + unsafe { + assert!(is_mirror(p)); + free_mirror(p); + } + } +} diff --git a/crates/weavepy-capi/src/module.rs b/crates/weavepy-capi/src/module.rs index be5f7c6..4dffadf 100644 --- a/crates/weavepy-capi/src/module.rs +++ b/crates/weavepy-capi/src/module.rs @@ -8,7 +8,7 @@ use weavepy_vm::sync::Rc; use weavepy_vm::sync::RefCell; use weavepy_vm::error::{type_error, RuntimeError}; -use weavepy_vm::object::{BuiltinFn, DictData, DictKey, Object, PyModule}; +use weavepy_vm::object::{BuiltinFn, DictData, DictKey, MethodWrapper, Object, PyModule}; use crate::object::PyObject; @@ -133,6 +133,97 @@ pub unsafe fn collect_methods(mut defs: *mut PyMethodDef) -> Vec { out } +/// Invoke a `METH_FASTCALL` (optionally `| METH_KEYWORDS`) C function +/// (RFC 0046, wave 4). The vectorcall convention hands the callee a bare +/// `PyObject *const *` array plus an explicit `Py_ssize_t nargs` rather +/// than an args tuple — numpy's `add_docstring`, `arr_add_docstring`, and +/// the `_ArrayFunctionDispatcher` machinery are all fastcall — so the +/// stock `func(self, tuple)` path fed a fastcall callee a garbage `nargs` +/// (it read whatever was in the third register, typically 0, hence +/// "missing required positional argument 0"). +/// +/// Positional args become a contiguous owned array; the +/// `| METH_KEYWORDS` variant packs the keyword *values* immediately +/// after the positionals in that same array and rides their names in a +/// trailing `kwnames` tuple (the CPython vectorcall convention — see +/// `PyObject_Vectorcall`). `nargs` reports only the positional count, so +/// the callee's `npy_parse_arguments` reads `args[nargs + i]` for the +/// `i`-th `kwnames` entry. `numpy`'s `empty`/`zeros`/`array` are all +/// `METH_FASTCALL | METH_KEYWORDS`, so `np.empty(2, dtype=float32)` +/// reaches the C core through here. +unsafe fn call_fastcall( + func: unsafe extern "C" fn(*mut PyObject, *mut PyObject) -> *mut PyObject, + self_ptr: *mut PyObject, + args: &[Object], + kwargs: &[(String, Object)], + flags: c_int, +) -> *mut PyObject { + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() && !kwargs.is_empty() { + let keys: Vec<&str> = kwargs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!("[TRACE_FASTCALL] nargs={} kwargs={:?}", args.len(), keys); + } + let mut argv: Vec<*mut PyObject> = Vec::with_capacity(args.len() + kwargs.len()); + for a in args { + argv.push(crate::object::into_owned(a.clone())); + } + for (_, v) in kwargs { + argv.push(crate::object::into_owned(v.clone())); + } + let nargs = args.len() as crate::object::PySsizeT; + let argv_ptr = argv.as_ptr(); + let kwnames: *mut PyObject = if kwargs.is_empty() { + ptr::null_mut() + } else { + let names: Vec = kwargs + .iter() + .map(|(k, _)| Object::from_str(k.as_str())) + .collect(); + crate::object::into_owned(Object::new_tuple(names)) + }; + let result = if (flags & METH_KEYWORDS) != 0 { + #[allow(clippy::missing_transmute_annotations)] + let fast_kw: unsafe extern "C" fn( + *mut PyObject, + *const *mut PyObject, + crate::object::PySsizeT, + *mut PyObject, + ) -> *mut PyObject = unsafe { std::mem::transmute(func) }; + crate::interp::ensure_active(|| unsafe { fast_kw(self_ptr, argv_ptr, nargs, kwnames) }) + } else { + #[allow(clippy::missing_transmute_annotations)] + let fast: unsafe extern "C" fn( + *mut PyObject, + *const *mut PyObject, + crate::object::PySsizeT, + ) -> *mut PyObject = unsafe { std::mem::transmute(func) }; + crate::interp::ensure_active(|| unsafe { fast(self_ptr, argv_ptr, nargs) }) + }; + for &a in &argv { + unsafe { crate::object::Py_DecRef(a) }; + } + if !kwnames.is_null() { + unsafe { crate::object::Py_DecRef(kwnames) }; + } + result +} + +/// Build the owned `kwds` pointer for the `METH_VARARGS | METH_KEYWORDS` +/// bridge (the legacy `func(self, args, kwds)` convention). Returns +/// **NULL** for a keyword-less call — CPython passes `tp_call` a NULL +/// `kwds` then, and an empty WeavePy dict mirror reads as garbage size +/// through `PyDict_GET_SIZE`. Caller owns the result (NULL-safe to +/// `Py_DecRef`). +fn build_kwargs_dict(kwargs: &[(String, Object)]) -> *mut PyObject { + if kwargs.is_empty() { + return ptr::null_mut(); + } + let mut d = DictData::new(); + for (k, v) in kwargs { + d.insert(DictKey(Object::from_str(k.as_str())), v.clone()); + } + crate::object::into_owned(Object::Dict(Rc::new(RefCell::new(d)))) +} + /// Wrap a C function pointer in a [`BuiltinFn`] backed by a Rust /// closure that performs the Python → C bridge: /// @@ -149,86 +240,127 @@ fn wrap_c_function( self_obj: Option, ) -> Object { let static_name: &'static str = Box::leak(name.into_boxed_str()); - let f = move |args: &[Object]| -> Result { - let Some(func) = func else { - return Err(type_error(format!("'{static_name}' is null"))); - }; - crate::interp::ensure_initialised(); - crate::errors::clear_thread_local(); - - let self_ptr = match &self_obj { - Some(o) => crate::object::into_owned(o.clone()), - None => crate::singletons::none_ptr(), - }; + let self_call = self_obj.clone(); + let call = move |args: &[Object]| -> Result { + bridge_invoke(func, static_name, self_call.as_ref(), flags, args, &[]) + }; + // Only `METH_KEYWORDS` functions get a kwargs-aware entry point; for + // everyone else the VM keeps rejecting keyword arguments. + let call_kw: Option< + Box Result + Send + Sync>, + > = if (flags & METH_KEYWORDS) != 0 { + let self_kw = self_obj; + Some(Box::new( + move |args: &[Object], kwargs: &[(String, Object)]| { + bridge_invoke(func, static_name, self_kw.as_ref(), flags, args, kwargs) + }, + )) + } else { + None + }; + Object::Builtin(Rc::new(BuiltinFn { + name: static_name, + binds_instance: false, + call: Box::new(call), + call_kw, + })) +} - // Build the args object based on the calling convention. - let result = if (flags & METH_NOARGS) != 0 { - if !args.is_empty() { - return Err(type_error(format!( - "{static_name}() takes no arguments ({} given)", - args.len() - ))); - } - crate::interp::ensure_active(|| unsafe { - func(self_ptr, crate::singletons::none_ptr()) - }) - } else if (flags & METH_O) != 0 { - if args.len() != 1 { - return Err(type_error(format!( - "{static_name}() takes exactly one argument ({} given)", - args.len() - ))); - } - let arg = crate::object::into_owned(args[0].clone()); - let r = crate::interp::ensure_active(|| unsafe { func(self_ptr, arg) }); - unsafe { crate::object::Py_DecRef(arg) }; - r - } else { - let tuple = crate::object::into_owned(Object::new_tuple(args.to_vec())); - let r = if (flags & METH_KEYWORDS) != 0 { - #[allow(clippy::missing_transmute_annotations)] - let with_kw: unsafe extern "C" fn( - *mut PyObject, - *mut PyObject, - *mut PyObject, - ) - -> *mut PyObject = unsafe { std::mem::transmute(func) }; - let kw = crate::object::into_owned(Object::new_dict()); - let r = crate::interp::ensure_active(|| unsafe { with_kw(self_ptr, tuple, kw) }); - unsafe { crate::object::Py_DecRef(kw) }; - r - } else { - crate::interp::ensure_active(|| unsafe { func(self_ptr, tuple) }) - }; - unsafe { crate::object::Py_DecRef(tuple) }; - r - }; +/// Shared module-function bridge body used by both the positional-only +/// (`call`) and kwargs-aware (`call_kw`) entry points produced by +/// [`wrap_c_function`]. `kwargs` is always empty on the `call` path. +fn bridge_invoke( + func: Option *mut PyObject>, + static_name: &'static str, + self_obj: Option<&Object>, + flags: c_int, + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { + let Some(func) = func else { + return Err(type_error(format!("'{static_name}' is null"))); + }; + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + let keys: Vec<&str> = kwargs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!( + "[TRACE_BRIDGE] {static_name}() flags={flags:#x} nargs={} kwargs={:?}", + args.len(), + keys + ); + } + crate::interp::ensure_initialised(); + crate::errors::clear_thread_local(); + let self_ptr = match self_obj { + Some(o) => crate::object::into_owned(o.clone()), + None => crate::singletons::none_ptr(), + }; + let drop_self = |self_ptr: *mut PyObject| { if !std::ptr::eq(self_ptr, crate::singletons::none_ptr()) { unsafe { crate::object::Py_DecRef(self_ptr) }; } + }; - // Translate the result. - if result.is_null() { - // The C function indicated failure. Pull the pending - // error and convert. - if let Some(p) = crate::errors::take_pending() { - return Err(crate::errors::to_runtime_error(p)); - } + // Build the args object based on the calling convention. + let result = if (flags & METH_NOARGS) != 0 { + if !args.is_empty() { + drop_self(self_ptr); return Err(type_error(format!( - "{static_name}() returned NULL without setting an exception" + "{static_name}() takes no arguments ({} given)", + args.len() ))); } - let out = unsafe { crate::object::clone_object(result) }; - unsafe { crate::object::Py_DecRef(result) }; - Ok(out) + crate::interp::ensure_active(|| unsafe { func(self_ptr, crate::singletons::none_ptr()) }) + } else if (flags & METH_O) != 0 { + if args.len() != 1 { + drop_self(self_ptr); + return Err(type_error(format!( + "{static_name}() takes exactly one argument ({} given)", + args.len() + ))); + } + let arg = crate::object::into_owned(args[0].clone()); + let r = crate::interp::ensure_active(|| unsafe { func(self_ptr, arg) }); + unsafe { crate::object::Py_DecRef(arg) }; + r + } else if (flags & METH_FASTCALL) != 0 { + unsafe { call_fastcall(func, self_ptr, args, kwargs, flags) } + } else { + let tuple = crate::object::into_owned(Object::new_tuple(args.to_vec())); + let r = if (flags & METH_KEYWORDS) != 0 { + #[allow(clippy::missing_transmute_annotations)] + let with_kw: unsafe extern "C" fn( + *mut PyObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = unsafe { std::mem::transmute(func) }; + let kw = build_kwargs_dict(kwargs); + let r = crate::interp::ensure_active(|| unsafe { with_kw(self_ptr, tuple, kw) }); + unsafe { crate::object::Py_DecRef(kw) }; + r + } else { + crate::interp::ensure_active(|| unsafe { func(self_ptr, tuple) }) + }; + unsafe { crate::object::Py_DecRef(tuple) }; + r }; - Object::Builtin(Rc::new(BuiltinFn { - name: static_name, - binds_instance: false, - call: Box::new(f), - call_kw: None, - })) + + drop_self(self_ptr); + + // Translate the result. + if result.is_null() { + // The C function indicated failure. Pull the pending + // error and convert. + if let Some(p) = crate::errors::take_pending() { + return Err(crate::errors::to_runtime_error(p)); + } + return Err(type_error(format!( + "{static_name}() returned NULL without setting an exception" + ))); + } + let out = unsafe { crate::object::clone_object(result) }; + unsafe { crate::object::Py_DecRef(result) }; + Ok(out) } /// Wrap a `tp_methods` entry as a class-bound method. The first @@ -249,86 +381,109 @@ fn wrap_c_method_function( let qualified = format!("_capi:{name}"); let static_name: &'static str = Box::leak(qualified.into_boxed_str()); let display_name: &'static str = Box::leak(name.into_boxed_str()); - let f = move |args: &[Object]| -> Result { - let Some(func) = func else { - return Err(type_error(format!("'{display_name}' is null"))); - }; - crate::interp::ensure_initialised(); - crate::errors::clear_thread_local(); + let call = move |args: &[Object]| -> Result { + bridge_invoke_method(func, display_name, flags, args, &[]) + }; + let call_kw: Option< + Box Result + Send + Sync>, + > = if (flags & METH_KEYWORDS) != 0 { + Some(Box::new( + move |args: &[Object], kwargs: &[(String, Object)]| { + bridge_invoke_method(func, display_name, flags, args, kwargs) + }, + )) + } else { + None + }; + Object::Builtin(Rc::new(BuiltinFn { + name: static_name, + // C-type method defs are method descriptors: they bind. + binds_instance: true, + call: Box::new(call), + call_kw, + })) +} + +/// Shared `tp_methods` bridge body; the receiver is `args[0]` (routed to +/// the C function's `self`) and everything after is forwarded +/// positionally (with keyword args carried per the calling convention). +fn bridge_invoke_method( + func: Option *mut PyObject>, + display_name: &'static str, + flags: c_int, + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { + let Some(func) = func else { + return Err(type_error(format!("'{display_name}' is null"))); + }; + crate::interp::ensure_initialised(); + crate::errors::clear_thread_local(); - if args.is_empty() { + if args.is_empty() { + return Err(type_error(format!( + "{display_name}() takes at least 1 argument (self) (0 given)" + ))); + } + let self_ptr = crate::object::into_owned(args[0].clone()); + let rest = &args[1..]; + + let result = if (flags & METH_NOARGS) != 0 { + if !rest.is_empty() { + unsafe { crate::object::Py_DecRef(self_ptr) }; return Err(type_error(format!( - "{display_name}() takes at least 1 argument (self) (0 given)" + "{display_name}() takes no arguments ({} given)", + rest.len() ))); } - let self_ptr = crate::object::into_owned(args[0].clone()); - let rest = &args[1..]; - - let result = if (flags & METH_NOARGS) != 0 { - if !rest.is_empty() { - unsafe { crate::object::Py_DecRef(self_ptr) }; - return Err(type_error(format!( - "{display_name}() takes no arguments ({} given)", - rest.len() - ))); - } - crate::interp::ensure_active(|| unsafe { - func(self_ptr, crate::singletons::none_ptr()) - }) - } else if (flags & METH_O) != 0 { - if rest.len() != 1 { - unsafe { crate::object::Py_DecRef(self_ptr) }; - return Err(type_error(format!( - "{display_name}() takes exactly one argument ({} given)", - rest.len() - ))); - } - let arg = crate::object::into_owned(rest[0].clone()); - let r = crate::interp::ensure_active(|| unsafe { func(self_ptr, arg) }); - unsafe { crate::object::Py_DecRef(arg) }; + crate::interp::ensure_active(|| unsafe { func(self_ptr, crate::singletons::none_ptr()) }) + } else if (flags & METH_O) != 0 { + if rest.len() != 1 { + unsafe { crate::object::Py_DecRef(self_ptr) }; + return Err(type_error(format!( + "{display_name}() takes exactly one argument ({} given)", + rest.len() + ))); + } + let arg = crate::object::into_owned(rest[0].clone()); + let r = crate::interp::ensure_active(|| unsafe { func(self_ptr, arg) }); + unsafe { crate::object::Py_DecRef(arg) }; + r + } else if (flags & METH_FASTCALL) != 0 { + unsafe { call_fastcall(func, self_ptr, rest, kwargs, flags) } + } else { + let tuple = crate::object::into_owned(Object::new_tuple(rest.to_vec())); + let r = if (flags & METH_KEYWORDS) != 0 { + #[allow(clippy::missing_transmute_annotations)] + let with_kw: unsafe extern "C" fn( + *mut PyObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = unsafe { std::mem::transmute(func) }; + let kw = build_kwargs_dict(kwargs); + let r = crate::interp::ensure_active(|| unsafe { with_kw(self_ptr, tuple, kw) }); + unsafe { crate::object::Py_DecRef(kw) }; r } else { - let tuple = crate::object::into_owned(Object::new_tuple(rest.to_vec())); - let r = if (flags & METH_KEYWORDS) != 0 { - #[allow(clippy::missing_transmute_annotations)] - let with_kw: unsafe extern "C" fn( - *mut PyObject, - *mut PyObject, - *mut PyObject, - ) - -> *mut PyObject = unsafe { std::mem::transmute(func) }; - let kw = crate::object::into_owned(Object::new_dict()); - let r = crate::interp::ensure_active(|| unsafe { with_kw(self_ptr, tuple, kw) }); - unsafe { crate::object::Py_DecRef(kw) }; - r - } else { - crate::interp::ensure_active(|| unsafe { func(self_ptr, tuple) }) - }; - unsafe { crate::object::Py_DecRef(tuple) }; - r + crate::interp::ensure_active(|| unsafe { func(self_ptr, tuple) }) }; + unsafe { crate::object::Py_DecRef(tuple) }; + r + }; - unsafe { crate::object::Py_DecRef(self_ptr) }; + unsafe { crate::object::Py_DecRef(self_ptr) }; - if result.is_null() { - if let Some(p) = crate::errors::take_pending() { - return Err(crate::errors::to_runtime_error(p)); - } - return Err(type_error(format!( - "{display_name}() returned NULL without setting an exception" - ))); + if result.is_null() { + if let Some(p) = crate::errors::take_pending() { + return Err(crate::errors::to_runtime_error(p)); } - let out = unsafe { crate::object::clone_object(result) }; - unsafe { crate::object::Py_DecRef(result) }; - Ok(out) - }; - Object::Builtin(Rc::new(BuiltinFn { - name: static_name, - // C-type method defs are method descriptors: they bind. - binds_instance: true, - call: Box::new(f), - call_kw: None, - })) + return Err(type_error(format!( + "{display_name}() returned NULL without setting an exception" + ))); + } + let out = unsafe { crate::object::clone_object(result) }; + unsafe { crate::object::Py_DecRef(result) }; + Ok(out) } /// `PyModule_Create2(def, api)` — extension entry point. Returns a @@ -383,14 +538,251 @@ pub unsafe extern "C" fn PyModule_Create2(def: *mut PyModuleDef, _api: c_int) -> filename: None, dict, }); + // PEP 3121: a single-phase extension that declares `m_size > 0` (pandas' + // vendored ujson) allocates per-module state here and writes into it via + // `PyModule_GetState`. Key the block by the module's native `Rc` identity + // (stable across the fresh per-crossing C boxes) before the `Rc` is moved + // into the crossing. + if def_ref.m_size > 0 { + let key = Rc::as_ptr(&module) as usize; + crate::wave5_pandas::ensure_module_state(key, def_ref.m_size as usize); + } + // CPython adds a single-phase module with per-interpreter state + // (`m_size >= 0`) to `interp->modules_by_index`, so the extension can later + // re-fetch it via `PyState_FindModule(def)`. pandas' vendored ujson relies + // on this to reach its cached `Series`/`DataFrame`/`Index` types. + if def_ref.m_size >= 0 { + crate::wave5_pandas::register_find_module( + def as *mut core::ffi::c_void, + Object::Module(module.clone()), + ); + } crate::object::into_owned(Object::Module(module)) } +/// PEP 489 module slot ids (mirror the header). +pub const PY_MOD_CREATE: c_int = 1; +pub const PY_MOD_EXEC: c_int = 2; + +/// `PyModuleDef_Init(def)` — entry point for a multi-phase (PEP 489) +/// extension. Unlike single-phase `PyModule_Create2`, the def is *not* +/// turned into a module here: it is tagged as a module-def object and +/// returned, so the loader (mirroring CPython's import machinery) can +/// run the `Py_mod_create`/`Py_mod_exec` slots itself. #[no_mangle] pub unsafe extern "C" fn PyModuleDef_Init(def: *mut PyModuleDef) -> *mut PyObject { - // Multi-phase init isn't fully supported; we treat this the - // same as PyModule_Create. - unsafe { PyModule_Create2(def, 1013) } + crate::interp::ensure_initialised(); + if def.is_null() { + return ptr::null_mut(); + } + unsafe { + let base = &mut (*def).m_base; + base.ob_base.ob_refcnt = crate::object::IMMORTAL_REFCNT; + base.ob_base.ob_type = crate::types::PyModuleDef_Type.as_ptr(); + } + def as *mut PyObject +} + +/// Does `raw` point at a `PyModuleDef` tagged by [`PyModuleDef_Init`] +/// (i.e. a multi-phase extension's return value)? +pub unsafe fn is_module_def(raw: *mut PyObject) -> bool { + if raw.is_null() { + return false; + } + unsafe { (*raw).ob_type == crate::types::PyModuleDef_Type.as_ptr() } +} + +/// Walk a null-terminated `PyModuleDef_Slot[]` array. +unsafe fn slots_of(def: *mut PyModuleDef) -> Vec { + let mut out = Vec::new(); + let mut p = unsafe { (*def).m_slots }; + if p.is_null() { + return out; + } + loop { + let s = unsafe { &*p }; + if s.slot == 0 { + break; + } + out.push(PyModuleDef_Slot { + slot: s.slot, + value: s.value, + }); + p = unsafe { p.add(1) }; + } + out +} + +/// Run a multi-phase extension's full init: create the module (default +/// or via a `Py_mod_create` slot) and execute every `Py_mod_exec` slot. +/// Returns the populated module, or an error string on failure. +/// +/// SAFETY: `def` must be a live `PyModuleDef` tagged by +/// `PyModuleDef_Init`. Must run inside an active extension context. +pub unsafe fn run_multiphase_init( + def: *mut PyModuleDef, + full_name: &str, +) -> Result<*mut PyObject, String> { + let slots = unsafe { slots_of(def) }; + let trace = std::env::var_os("WEAVEPY_TRACE_MPI").is_some(); + if trace { + eprintln!( + "[mpi] enter run_multiphase_init name={full_name} nslots={}", + slots.len() + ); + } + + // Phase 1: create the module object. + let create_slot = slots.iter().find(|s| s.slot == PY_MOD_CREATE); + let module: *mut PyObject = if let Some(slot) = create_slot { + if trace { + eprintln!("[mpi] {full_name}: create slot -> calling"); + } + let create: unsafe extern "C" fn(*mut PyObject, *mut PyModuleDef) -> *mut PyObject = + unsafe { std::mem::transmute(slot.value) }; + let spec = unsafe { build_module_spec(full_name) }; + if trace { + eprintln!("[mpi] {full_name}: build_module_spec -> {}", if spec.is_null() { "NULL" } else { "ok" }); + } + let m = unsafe { create(spec, def) }; + if !spec.is_null() { + unsafe { crate::object::Py_DecRef(spec) }; + } + if trace { + eprintln!("[mpi] {full_name}: create slot -> {}", if m.is_null() { "NULL" } else { "ok" }); + } + m + } else { + // Default creation: a fresh module preloaded with m_methods. + unsafe { PyModule_Create2(def, 1013) } + }; + if module.is_null() { + let pending = crate::errors::take_pending() + .map(|p| { + let ty = p + .ty + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "Exception".to_owned()); + let msg = crate::errors::message_for(&p.value); + if msg.is_empty() { + format!("module create slot raised {ty}") + } else { + format!("module create slot raised {ty}: {msg}") + } + }) + .unwrap_or_else(|| "module creation slot returned NULL".to_owned()); + return Err(pending); + } + + // Make the module discoverable under its full dotted name while the + // exec slots run (numpy reads `__name__` and re-imports siblings). + // CPython's import machinery also sets `__package__` (the parent + // package) before running a module's body; an extension's relative + // imports (`from ._pcg64 cimport …` in numpy.random._generator) resolve + // against it, so derive it from the dotted name here. A leaf module's + // package is its name minus the last component; a top-level module's is + // empty. + unsafe { + if let Object::Module(m) = crate::object::clone_object(module) { + let mut d = m.dict.borrow_mut(); + d.insert( + DictKey(Object::from_static("__name__")), + Object::from_str(full_name.to_owned()), + ); + let package = match full_name.rsplit_once('.') { + Some((head, _)) => head.to_owned(), + None => String::new(), + }; + d.insert( + DictKey(Object::from_static("__package__")), + Object::from_str(package), + ); + } + } + + // Phase 2: run every Py_mod_exec slot in order. + for (i, slot) in slots.iter().filter(|s| s.slot == PY_MOD_EXEC).enumerate() { + if trace { + eprintln!("[mpi] {full_name}: exec slot {i} -> calling"); + } + let exec: unsafe extern "C" fn(*mut PyObject) -> c_int = + unsafe { std::mem::transmute(slot.value) }; + let rc = unsafe { exec(module) }; + if trace { + eprintln!("[mpi] {full_name}: exec slot {i} -> rc={rc}"); + } + if rc != 0 { + let pending = crate::errors::take_pending() + .map(|p| { + let ty = + p.ty.as_ref() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "?".to_owned()); + format!("{ty}: {}", p.value.to_str()) + }) + .unwrap_or_else(|| format!("Py_mod_exec slot returned {rc}")); + if trace { + eprintln!("[mpi] {full_name}: exec slot {i} FAILED -> {pending}"); + } + unsafe { crate::object::Py_DecRef(module) }; + return Err(pending); + } + if let Some(p) = crate::errors::take_pending() { + let ty = p + .ty + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_else(|| "Exception".to_owned()); + let msg = crate::errors::message_for(&p.value); + unsafe { crate::object::Py_DecRef(module) }; + return Err(if msg.is_empty() { + format!("exec slot {i} left pending {ty}") + } else { + format!("exec slot {i} left pending {ty}: {msg}") + }); + } + } + Ok(module) +} + +/// Build a minimal `importlib.machinery.ModuleSpec(name, None)` for a +/// `Py_mod_create` slot. Returns NULL (and clears the error) if the +/// spec can't be constructed; numpy never uses a create slot. +unsafe fn build_module_spec(full_name: &str) -> *mut PyObject { + let machinery = + unsafe { PyImport_ImportModule(b"importlib.machinery\0".as_ptr() as *const c_char) }; + if machinery.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + let cls = unsafe { + crate::abstract_::PyObject_GetAttrString( + machinery, + b"ModuleSpec\0".as_ptr() as *const c_char, + ) + }; + unsafe { crate::object::Py_DecRef(machinery) }; + if cls.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + let name_obj = crate::object::into_owned(Object::from_str(full_name.to_owned())); + let args = unsafe { crate::containers::PyTuple_New(2) }; + unsafe { + crate::containers::PyTuple_SetItem(args, 0, name_obj); + crate::object::Py_IncRef(crate::singletons::none_ptr()); + crate::containers::PyTuple_SetItem(args, 1, crate::singletons::none_ptr()); + } + let spec = unsafe { crate::abstract_::PyObject_CallObject(cls, args) }; + unsafe { + crate::object::Py_DecRef(cls); + crate::object::Py_DecRef(args); + } + if spec.is_null() { + crate::errors::clear_thread_local(); + } + spec } /// Add `(name, value)` to `m`'s dict, taking ownership of `value`. @@ -616,6 +1008,66 @@ pub unsafe extern "C" fn PyImport_AddModule(name: *const c_char) -> *mut PyObjec } } +/// `PyImport_AddModuleRef(name)` (3.13) — like `PyImport_AddModule` but +/// returns a **new** reference. WeavePy's `PyImport_AddModule` already +/// hands back an owned reference, so this is the same call. Cython's +/// `__Pyx_PyImport_AddModuleRef` is `#define`d to this on 3.13. +#[no_mangle] +pub unsafe extern "C" fn PyImport_AddModuleRef(name: *const c_char) -> *mut PyObject { + unsafe { PyImport_AddModule(name) } +} + +/// `PyImport_GetModuleDict()` — the genuine `sys.modules` dict (a borrowed +/// reference in CPython). WeavePy's `sys.modules` *is* a real dict backed +/// by the interpreter's [`ModuleCache`], so registrations Cython performs +/// here (`PyDict_SetItemString(modules, "cyreal", m)`) flow into the live +/// module table. We hand back an owned reference to that same dict; the +/// underlying storage is interpreter-lived, so treating it as borrowed +/// (the caller never decrefs) does not free it. +#[no_mangle] +pub unsafe extern "C" fn PyImport_GetModuleDict() -> *mut PyObject { + crate::interp::ensure_initialised(); + crate::interp::with_current(|ctx| { + let interp = unsafe { &*ctx.interp }; + let modules = interp.module_cache().modules.clone(); + crate::object::into_owned(Object::Dict(modules)) + }) + .unwrap_or(ptr::null_mut()) +} + +/// `PyModule_NewObject(name)` — create a fresh module object named `name` +/// (a unicode object) **without** registering it in `sys.modules`, matching +/// CPython. Cython's PEP 489 create slot (`__pyx_pymod_create`) calls this. +#[no_mangle] +pub unsafe extern "C" fn PyModule_NewObject(name: *mut PyObject) -> *mut PyObject { + crate::interp::ensure_initialised(); + let s = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + other => { + crate::errors::set_type_error(format!( + "PyModule_NewObject: name must be a string, not {}", + other.type_name() + )); + return ptr::null_mut(); + } + }; + let dict = Rc::new(RefCell::new(DictData::new())); + dict.borrow_mut().insert( + DictKey(Object::from_static("__name__")), + Object::from_str(s.clone()), + ); + dict.borrow_mut().insert( + DictKey(Object::from_static("__doc__")), + Object::None, + ); + let module = Object::Module(Rc::new(PyModule { + name: s, + filename: None, + dict, + })); + crate::object::into_owned(module) +} + #[no_mangle] pub unsafe extern "C" fn PyImport_GetModule(name: *mut PyObject) -> *mut PyObject { let name_str = match unsafe { crate::object::clone_object(name) } { @@ -630,6 +1082,80 @@ pub unsafe extern "C" fn PyImport_GetModule(name: *mut PyObject) -> *mut PyObjec .map_or(ptr::null_mut(), |m| crate::object::into_owned(m)) } +/// `PyClassMethod_New(callable)` — wrap `callable` in a `classmethod` +/// descriptor, returning a new reference (Python `classmethod(callable)`). +/// Cython emits this for a `@classmethod` assigned in a class body — e.g. +/// frozenlist's `__class_getitem__ = classmethod(types.GenericAlias)`. +#[no_mangle] +pub unsafe extern "C" fn PyClassMethod_New(callable: *mut PyObject) -> *mut PyObject { + if callable.is_null() { + crate::errors::set_type_error("PyClassMethod_New: callable is NULL"); + return ptr::null_mut(); + } + let func = unsafe { crate::object::clone_object(callable) }; + crate::object::into_owned(Object::ClassMethod(MethodWrapper::new(func))) +} + +/// `PyDescr_NewClassMethod(type, method)` — build a `classmethod_descriptor` +/// from a single C `PyMethodDef` for installation in `type`'s dict. Read +/// later as `Type.method`, the descriptor binds `Type` as the call's first +/// argument (the classmethod protocol). WeavePy bridges the C function into +/// a builtin and wraps it in [`Object::ClassMethod`], whose MRO-lookup path +/// (`crate::abstract_`) binds the owning class — so a subsequent +/// `Type.method(...)` reaches the C function with the type as `self`. +#[no_mangle] +pub unsafe extern "C" fn PyDescr_NewClassMethod( + _type: *mut crate::types::PyTypeObject, + method: *mut PyMethodDef, +) -> *mut PyObject { + if method.is_null() { + crate::errors::set_type_error("PyDescr_NewClassMethod: method is NULL"); + return ptr::null_mut(); + } + let entry = unsafe { *method }; + if entry.ml_name.is_null() { + crate::errors::set_type_error("PyDescr_NewClassMethod: method has no name"); + return ptr::null_mut(); + } + let name = unsafe { CStr::from_ptr(entry.ml_name) } + .to_string_lossy() + .into_owned(); + let callable = wrap_c_function(name, entry.ml_meth, entry.ml_flags, None); + crate::object::into_owned(Object::ClassMethod(MethodWrapper::new(callable))) +} + +/// `PyCFunction_NewEx(ml, self, module)` — build a builtin function/method +/// object from a single `PyMethodDef`, binding `self` (NULL → unbound). The +/// `module` owner is informational under WeavePy's model. Used by +/// [`crate::wave5_pandas::PyCMethod_New`] and any method-table install that +/// reaches for the public constructor. +#[no_mangle] +pub unsafe extern "C" fn PyCFunction_NewEx( + ml: *mut PyMethodDef, + self_: *mut PyObject, + _module: *mut PyObject, +) -> *mut PyObject { + if ml.is_null() { + crate::errors::set_type_error("PyCFunction_NewEx: method def is NULL"); + return ptr::null_mut(); + } + let entry = unsafe { *ml }; + if entry.ml_name.is_null() { + crate::errors::set_type_error("PyCFunction_NewEx: method has no name"); + return ptr::null_mut(); + } + let name = unsafe { CStr::from_ptr(entry.ml_name) } + .to_string_lossy() + .into_owned(); + let self_obj = if self_.is_null() || std::ptr::eq(self_, crate::singletons::none_ptr()) { + None + } else { + Some(unsafe { crate::object::clone_object(self_) }) + }; + let callable = wrap_c_function(name, entry.ml_meth, entry.ml_flags, self_obj); + crate::object::into_owned(callable) +} + fn install_runtime_error(err: RuntimeError) { match err { RuntimeError::PyException(pe) => { diff --git a/crates/weavepy-capi/src/monitoring.rs b/crates/weavepy-capi/src/monitoring.rs new file mode 100644 index 0000000..fc5a68c --- /dev/null +++ b/crates/weavepy-capi/src/monitoring.rs @@ -0,0 +1,231 @@ +//! RFC 0047 (wave 5): the PEP 669 `sys.monitoring` C-API. +//! +//! Cython compiles every function — including a module's top-level +//! `__pyx_pymod_exec_*` body — with profiling/line-tracing hooks when the +//! wheel is built with `linetrace=True` / `-DCYTHON_TRACE(_NOGIL)=1`. This +//! is *not* an exotic configuration: `frozenlist`, `aiohttp`'s `_helpers`, +//! and many other published wheels enable it in their default source build +//! (it is inert at runtime unless a tracer is installed). +//! +//! On CPython 3.13 those hooks lower onto the `sys.monitoring` C-API +//! (`CYTHON_USE_SYS_MONITORING`): `Cython/Utility/Profile.c` emits, at every +//! function entry, +//! +//! ```c +//! memset(state_array, 0, sizeof(state_array)); +//! if (!tstate->tracing) { +//! code = __Pyx_createFrameCodeObject(...); // PyCode_NewEmpty +//! ret = __Pyx__TraceStartFunc(state_array, code, ...); +//! } +//! // __Pyx__TraceStartFunc: +//! // PyMonitoring_EnterScope(state_array, &version, event_types, n); +//! // PyMonitoring_FirePyStartEvent(&state_array[PY_START], code, offset); +//! ``` +//! +//! `PyMonitoring_FirePyStartEvent` (and the other `Fire*` helpers) are +//! `static inline` in `cpython/monitoring.h` and short-circuit on +//! `state->active`, so once the state array reports "inactive" they never +//! reach the out-of-line `_PyMonitoring_Fire*Event` body. **But +//! `PyMonitoring_EnterScope` / `PyMonitoring_ExitScope` are real exported +//! functions** that Cython calls unconditionally. Without them the macOS +//! dynamic loader (`-undefined dynamic_lookup`) lets the `.so` load and then +//! binds the first `PyMonitoring_EnterScope` call to address 0 → a NULL-call +//! segfault inside `__Pyx__TraceStartFunc`, before the first line of the +//! extension's own code runs. +//! +//! WeavePy does not implement bytecode instrumentation, so **no monitoring +//! event is ever active**. The faithful behaviour for such an +//! un-instrumented interpreter is exactly what CPython does when nothing is +//! being monitored: `EnterScope` reports every requested event as inactive +//! (and records the current monitoring version so a repeat call fast-paths), +//! `ExitScope` is a no-op, and the out-of-line `Fire*` bodies — reached only +//! when a state is active, which never happens — return 0 ("no error, not +//! handled"). With the state array left inactive, Cython's inline +//! `Fire*Event` shims return 0 and the whole trace path collapses to a +//! couple of branches, just as it does on stock CPython with no tracer set. + +#![allow(clippy::missing_safety_doc)] + +use core::ffi::c_int; + +use crate::object::PyObject; + +/// `PyMonitoringState` — `cpython/monitoring.h`. A two-byte per-event cell +/// the interpreter keeps in sync with the active tool set; `active` gates the +/// inline `PyMonitoring_Fire*Event` fast path. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct PyMonitoringState { + pub active: u8, + pub opaque: u8, +} + +/// The monitoring version WeavePy reports. Monitoring is never enabled, so +/// the active configuration never changes and a single stable version is +/// faithful: a caller that cached this value short-circuits its next +/// `EnterScope`, and one that didn't gets the (already inactive) states +/// recomputed. +const WEAVEPY_MONITORING_VERSION: u64 = 0; + +/// `PyMonitoring_EnterScope(state_array, version, event_types, length)` — +/// synchronise a function's local monitoring-state array with the +/// interpreter's active tool set on scope entry. +/// +/// WeavePy monitors nothing, so every requested event is inactive. We clear +/// the `length` cells the caller asked about and stamp the stable monitoring +/// version into `*version`. Returns 0 (success); CPython only returns -1 on +/// an allocation failure that cannot occur here. +#[no_mangle] +pub unsafe extern "C" fn PyMonitoring_EnterScope( + state_array: *mut PyMonitoringState, + version: *mut u64, + _event_types: *const u8, + length: isize, +) -> c_int { + if !state_array.is_null() && length > 0 { + for i in 0..length { + let cell = unsafe { &mut *state_array.offset(i) }; + cell.active = 0; + cell.opaque = 0; + } + } + if !version.is_null() { + unsafe { *version = WEAVEPY_MONITORING_VERSION }; + } + 0 +} + +/// `PyMonitoring_ExitScope()` — leave the current monitoring scope. With no +/// active monitoring there is no per-scope state to unwind. +#[no_mangle] +pub unsafe extern "C" fn PyMonitoring_ExitScope() -> c_int { + 0 +} + +// --------------------------------------------------------------------------- +// Out-of-line `_PyMonitoring_Fire*Event` bodies. +// +// Reached only through the inline `PyMonitoring_Fire*Event` shims in +// `cpython/monitoring.h`, each of which returns early unless its +// `PyMonitoringState::active` byte is set. Because `EnterScope` always +// leaves every state inactive under WeavePy, these are never actually +// invoked — but Cython's generated `.so` references their symbols, so they +// must resolve to a real address. Each is the correct "nothing happened" +// answer: 0 (no error; the event was not handled). +// --------------------------------------------------------------------------- + +macro_rules! fire_event_noop { + ($($name:ident),* $(,)?) => { + $( + #[no_mangle] + pub unsafe extern "C" fn $name( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + ) -> c_int { + 0 + } + )* + }; +} + +// Events whose ABI is (state, codelike, offset). +fire_event_noop!( + _PyMonitoring_FirePyStartEvent, + _PyMonitoring_FirePyResumeEvent, + _PyMonitoring_FirePyThrowEvent, + _PyMonitoring_FireRaiseEvent, + _PyMonitoring_FireReraiseEvent, + _PyMonitoring_FireExceptionHandledEvent, + _PyMonitoring_FireCRaiseEvent, + _PyMonitoring_FirePyUnwindEvent, +); + +/// `(state, codelike, offset, retval)` — Python return. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FirePyReturnEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _retval: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, retval)` — generator yield. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FirePyYieldEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _retval: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, retval)` — C function return. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireCReturnEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _retval: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, callable, arg0)` — call event. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireCallEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _callable: *mut PyObject, + _arg0: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, lineno)` — line event. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireLineEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _lineno: c_int, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, target_offset)` — jump event. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireJumpEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _target_offset: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, target_offset)` — branch event. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireBranchEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _target_offset: *mut PyObject, +) -> c_int { + 0 +} + +/// `(state, codelike, offset, value)` — `StopIteration`. +#[no_mangle] +pub unsafe extern "C" fn _PyMonitoring_FireStopIterationEvent( + _state: *mut PyMonitoringState, + _codelike: *mut PyObject, + _offset: i32, + _value: *mut PyObject, +) -> c_int { + 0 +} diff --git a/crates/weavepy-capi/src/numbers.rs b/crates/weavepy-capi/src/numbers.rs index 0524585..953535b 100644 --- a/crates/weavepy-capi/src/numbers.rs +++ b/crates/weavepy-capi/src/numbers.rs @@ -11,6 +11,87 @@ use weavepy_vm::object::{Object, PyComplex}; use crate::object::PyObject; +/// Read a `*const c_char` `tp_name` for diagnostics — best-effort, returns +/// `"?"` for a NULL chain. +unsafe fn debug_type_name(o: *mut PyObject) -> String { + if o.is_null() { + return "".to_owned(); + } + let ty = unsafe { (*o).ob_type }; + if ty.is_null() { + return "".to_owned(); + } + let name = unsafe { (*ty).tp_name }; + if name.is_null() { + return "".to_owned(); + } + unsafe { CStr::from_ptr(name) } + .to_string_lossy() + .into_owned() +} + +/// RFC 0046 (wave 4): invoke a no-arg numeric dunder (`__float__`, +/// `__index__`, `__complex__`) on `o` and coerce the result to an +/// `Object`. Returns `None` if `o` has no such attribute (the caller then +/// tries the next protocol or raises); `Some(None)` if the call or +/// conversion failed with an exception already set. +/// +/// CPython's `PyFloat_AsDouble` / `PyComplex_AsCComplex` consult the +/// number-protocol slots (`nb_float`, `nb_index`); a stock extension +/// exposes those as the matching dunder, so a `PyObject_GetAttrString` +/// reaches them through the type's `tp_getattro` (numpy scalars included). +unsafe fn call_number_dunder(o: *mut PyObject, name: &str) -> Option> { + let cname = match std::ffi::CString::new(name) { + Ok(c) => c, + Err(_) => return None, + }; + let meth = unsafe { crate::abstract_::PyObject_GetAttrString(o, cname.as_ptr()) }; + if meth.is_null() { + // No such attribute — clear the AttributeError and let the caller + // fall through to the next protocol. + let _ = crate::errors::take_pending(); + return None; + } + let res = unsafe { crate::abstract_::PyObject_CallNoArgs(meth) }; + unsafe { crate::object::Py_DecRef(meth) }; + if res.is_null() { + return Some(None); + } + let obj = unsafe { crate::object::clone_object(res) }; + unsafe { crate::object::Py_DecRef(res) }; + Some(Some(obj)) +} + +/// CPython's `PyLong_As*` extractors coerce a non-`int` argument through +/// `__index__` (`_PyNumber_Index`) before failing with a `TypeError` +/// (see `Objects/longobject.c`). A numpy integer scalar (`np.int64`) is a +/// *foreign* object carrying `__index__` in its C `nb_index` slot, so +/// routing through [`crate::abstract_::PyNumber_Index`] reaches it exactly +/// as CPython does — unblocking numpy's `timedelta64(np.int64(...), unit)` +/// constructor and any Cython code feeding numpy scalars to `PyLong_As*`. +/// +/// On success returns a *builtin* integer `Object` (`Int`/`Long`/`Bool`), +/// which callers convert without re-entering this fallback. Returns `None` +/// with a `TypeError` (or the `nb_index` slot's own exception) left pending +/// when `o` cannot be interpreted as an integer. +pub(crate) unsafe fn index_to_builtin_int(o: *mut PyObject) -> Option { + let idx = unsafe { crate::abstract_::PyNumber_Index(o) }; + if idx.is_null() { + return None; + } + let obj = unsafe { crate::object::clone_object(idx) }; + unsafe { crate::object::Py_DecRef(idx) }; + match obj { + Object::Int(_) | Object::Long(_) | Object::Bool(_) => Some(obj), + _ => { + crate::errors::set_type_error( + "__index__ returned non-int (the object cannot be interpreted as an integer)", + ); + None + } + } +} + // ---------- PyLong (Python `int`) ---------- #[no_mangle] @@ -60,6 +141,35 @@ pub unsafe extern "C" fn PyLong_FromDouble(v: f64) -> *mut PyObject { crate::object::into_owned(Object::Int(v.trunc() as i64)) } +/// True if ASCII byte `c` is a valid digit in `radix` (2..=36). +fn digit_in_radix(c: u8, radix: u32) -> bool { + let v = match c { + b'0'..=b'9' => u32::from(c - b'0'), + b'a'..=b'z' => u32::from(c - b'a') + 10, + b'A'..=b'Z' => u32::from(c - b'A') + 10, + _ => return false, + }; + v < radix +} + +/// `PyLong_FromString(str, pend, base)` — parse an integer from a C string, +/// faithful to CPython semantics: +/// * leading/trailing ASCII whitespace is skipped; +/// * an optional leading `+`/`-` sign; +/// * `base == 0` auto-detects the base from a `0x`/`0o`/`0b` prefix +/// (else decimal); a bare leading `0` (Python 3) requires the value to +/// be all zeros; +/// * for base 2/8/16 the matching `0b`/`0o`/`0x` prefix is optional and +/// stripped; +/// * single underscores are permitted between digits (PEP 515); +/// * `*pend` (when non-NULL) is set to the first unconsumed character; +/// when `pend` is NULL any trailing non-whitespace is an error. +/// +/// The previous implementation mapped `base == 0` to radix 10 and never +/// stripped a `0x`/`0o`/`0b` prefix, so a stock extension doing +/// `PyLong_FromString("0x1a2b", NULL, 0)` — pandas' `tslibs.offsets` init +/// parses a pointer address exactly this way — failed with +/// "invalid literal for int() with base 10". #[no_mangle] pub unsafe extern "C" fn PyLong_FromString( s: *const c_char, @@ -70,16 +180,119 @@ pub unsafe extern "C" fn PyLong_FromString( crate::errors::set_value_error("PyLong_FromString: NULL pointer"); return ptr::null_mut(); } + if base != 0 && (base < 2 || base > 36) { + crate::errors::set_value_error("int() base must be >= 2 and <= 36, or 0"); + return ptr::null_mut(); + } let s_bytes = unsafe { CStr::from_ptr(s) }.to_bytes(); let s_str = std::str::from_utf8(s_bytes).unwrap_or(""); - let trimmed = s_str.trim(); - let radix = if base == 0 { 10 } else { base as u32 }; - match BigInt::parse_bytes(trimmed.as_bytes(), radix) { - Some(big) => { - if !end.is_null() { - unsafe { - *end = s.add(s_bytes.len()).cast_mut(); - } + let bytes = s_str.as_bytes(); + let n = bytes.len(); + let orig = s_str; + + let mut i = 0usize; + while i < n && bytes[i].is_ascii_whitespace() { + i += 1; + } + let mut negative = false; + if i < n && (bytes[i] == b'+' || bytes[i] == b'-') { + negative = bytes[i] == b'-'; + i += 1; + } + + let has_prefix = |i: usize, lo: u8, hi: u8| -> bool { + i + 1 < n && bytes[i] == b'0' && (bytes[i + 1] == lo || bytes[i + 1] == hi) + }; + + let mut radix = base as u32; + let mut only_zeros_required = false; + if base == 0 { + if has_prefix(i, b'x', b'X') { + radix = 16; + i += 2; + } else if has_prefix(i, b'o', b'O') { + radix = 8; + i += 2; + } else if has_prefix(i, b'b', b'B') { + radix = 2; + i += 2; + } else { + radix = 10; + // Python 3: a bare leading zero (e.g. "0123") is invalid for + // base 0; only "0", "00", "0_0" … (all zeros) are accepted. + if i < n && bytes[i] == b'0' { + only_zeros_required = true; + } + } + } else if base == 16 && has_prefix(i, b'x', b'X') { + i += 2; + } else if base == 8 && has_prefix(i, b'o', b'O') { + i += 2; + } else if base == 2 && has_prefix(i, b'b', b'B') { + i += 2; + } + + // Collect digits, allowing a single underscore between two digits. + let mut digits: Vec = Vec::with_capacity(n - i); + let mut prev_was_digit = false; + let mut consumed = i; + while i < n { + let c = bytes[i]; + if c == b'_' { + // A separator is only valid immediately after a digit and + // immediately before another digit. + if !prev_was_digit || i + 1 >= n || !digit_in_radix(bytes[i + 1], radix) { + break; + } + prev_was_digit = false; + i += 1; + continue; + } + if digit_in_radix(c, radix) { + digits.push(c); + prev_was_digit = true; + i += 1; + consumed = i; + } else { + break; + } + } + + let fail = || -> *mut PyObject { + crate::errors::set_value_error(format!( + "invalid literal for int() with base {}: '{}'", + if base == 0 { 10 } else { base }, + orig.trim(), + )); + ptr::null_mut() + }; + + if digits.is_empty() { + return fail(); + } + if only_zeros_required && digits.iter().any(|&d| d != b'0') { + return fail(); + } + + // Trailing whitespace is allowed; anything else after it is garbage. + let mut j = consumed; + while j < n && bytes[j].is_ascii_whitespace() { + j += 1; + } + if end.is_null() { + if j != n { + return fail(); + } + } else { + unsafe { + *end = s.add(consumed).cast_mut(); + } + } + + match BigInt::parse_bytes(&digits, radix) { + Some(mut big) => { + if negative { + big = -big; } if let Some(small) = big.to_i64() { crate::object::into_owned(Object::Int(small)) @@ -87,13 +300,7 @@ pub unsafe extern "C" fn PyLong_FromString( crate::object::into_owned(Object::Long(Rc::new(big))) } } - None => { - crate::errors::set_value_error(format!( - "invalid literal for int() with base {}: {}", - radix, trimmed - )); - ptr::null_mut() - } + None => fail(), } } @@ -109,15 +316,27 @@ pub unsafe extern "C" fn PyLong_AsLong(o: *mut PyObject) -> i64 { Object::Long(big) => match big.to_i64() { Some(v) => v, None => { + if std::env::var_os("WEAVEPY_TRACE_OVERFLOW").is_some() { + eprintln!( + "[WEAVEPY_TRACE_OVERFLOW] PyLong_AsLong overflow on value with {} bits\n{}", + big.bits(), + std::backtrace::Backtrace::force_capture() + ); + } crate::errors::set_overflow_error("Python int too large to convert to C long"); -1 } }, Object::Float(f) => f.trunc() as i64, - _ => { - crate::errors::set_type_error("an integer is required"); - -1 - } + _ => match unsafe { index_to_builtin_int(o) } { + Some(Object::Int(i)) => i, + Some(Object::Bool(b)) => i64::from(b), + Some(Object::Long(big)) => big.to_i64().unwrap_or_else(|| { + crate::errors::set_overflow_error("Python int too large to convert to C long"); + -1 + }), + _ => -1, + }, } } @@ -126,16 +345,71 @@ pub unsafe extern "C" fn PyLong_AsLongLong(o: *mut PyObject) -> i64 { unsafe { PyLong_AsLong(o) } } +/// `PyLong_AsUnsignedLong(o)` — the full unsigned 64-bit range `[0, 2^64)` +/// on LP64/LLP64 (where `unsigned long` is 64-bit). Routing through the +/// *signed* [`PyLong_AsLong`] (as a prior version did) wrongly rejected +/// `[2^63, 2^64)` — exactly the 64-bit seed/state words numpy's +/// `numpy.random` feeds through `np.uint64(...)` during `mtrand` init. #[no_mangle] pub unsafe extern "C" fn PyLong_AsUnsignedLong(o: *mut PyObject) -> u64 { - let v = unsafe { PyLong_AsLong(o) }; - if v < 0 { - if crate::errors::pending().is_none() { - crate::errors::set_overflow_error("can't convert negative value to unsigned int"); - } + if o.is_null() { + crate::errors::set_type_error("PyLong_AsUnsignedLong: NULL"); return u64::MAX; } - v as u64 + match unsafe { crate::object::clone_object(o) } { + Object::Int(i) => { + if i < 0 { + crate::errors::set_overflow_error( + "can't convert negative value to unsigned int", + ); + u64::MAX + } else { + i as u64 + } + } + Object::Bool(b) => u64::from(b), + Object::Long(big) => match big.to_u64() { + Some(v) => v, + None => { + if big.sign() == num_bigint::Sign::Minus { + crate::errors::set_overflow_error( + "can't convert negative value to unsigned int", + ); + } else { + crate::errors::set_overflow_error( + "Python int too large to convert to C unsigned long", + ); + } + u64::MAX + } + }, + _ => match unsafe { index_to_builtin_int(o) } { + Some(Object::Int(i)) => { + if i < 0 { + crate::errors::set_overflow_error( + "can't convert negative value to unsigned int", + ); + u64::MAX + } else { + i as u64 + } + } + Some(Object::Bool(b)) => u64::from(b), + Some(Object::Long(big)) => big.to_u64().unwrap_or_else(|| { + if big.sign() == num_bigint::Sign::Minus { + crate::errors::set_overflow_error( + "can't convert negative value to unsigned int", + ); + } else { + crate::errors::set_overflow_error( + "Python int too large to convert to C unsigned long", + ); + } + u64::MAX + }), + _ => u64::MAX, + }, + } } #[no_mangle] @@ -211,10 +485,21 @@ pub unsafe extern "C" fn PyLong_AsLongAndOverflow(o: *mut PyObject, overflow: *m } }, Object::Float(f) => f.trunc() as i64, - _ => { - crate::errors::set_type_error("an integer is required"); - -1 - } + _ => match unsafe { index_to_builtin_int(o) } { + Some(Object::Int(i)) => i, + Some(Object::Bool(b)) => i64::from(b), + Some(Object::Long(big)) => big.to_i64().unwrap_or_else(|| { + if !overflow.is_null() { + let sign = match big.sign() { + num_bigint::Sign::Minus => -1, + _ => 1, + }; + unsafe { *overflow = sign }; + } + -1 + }), + _ => -1, + }, } } @@ -244,10 +529,12 @@ pub unsafe extern "C" fn _PyLong_AsByteArray( Object::Int(i) => BigInt::from(i), Object::Long(b) => (*b).clone(), Object::Bool(b) => BigInt::from(b as i64), - _ => { - crate::errors::set_type_error("an integer is required"); - return -1; - } + _ => match unsafe { index_to_builtin_int(o) } { + Some(Object::Int(i)) => BigInt::from(i), + Some(Object::Long(b)) => (*b).clone(), + Some(Object::Bool(b)) => BigInt::from(b as i64), + _ => return -1, + }, }; let mut buf: Vec = if is_signed != 0 { big.to_signed_bytes_le() @@ -333,6 +620,76 @@ pub unsafe extern "C" fn PyFloat_FromDouble(v: f64) -> *mut PyObject { crate::object::into_owned(Object::Float(v)) } +/// Outcome of running CPython's float number-protocol on an object. +pub(crate) enum FloatProtocol { + /// Converted to this double via `nb_float` / `__float__` (or the + /// `nb_index` / `__index__` fallback). + Value(f64), + /// A protocol slot ran and *raised*; the pending error is already set and + /// must be propagated verbatim (CPython never rewrites a slot's error). + Raised, + /// The object implements none of `nb_float`/`__float__`/`nb_index`/ + /// `__index__`; *no* error is set, so the caller emits its own + /// entry-point-specific `TypeError` (the wording differs between + /// `PyFloat_AsDouble` and `PyNumber_Float`, matching CPython). + NoProtocol, +} + +/// Run CPython's `nb_float` (→ `__float__`) then `nb_index` (→ `__index__`) +/// fallback on `o` (already cloned to `obj`). Shared by [`PyFloat_AsDouble`] +/// and [`crate::abstract_::PyNumber_Float`]; those two differ *only* in the +/// final no-protocol message and, for `PyNumber_Float`, an extra +/// `str`/`bytes` → `PyFloat_FromString` branch — exactly as in CPython. +pub(crate) unsafe fn float_number_protocol(o: *mut PyObject, obj: &Object) -> FloatProtocol { + // A *foreign* extension scalar (numpy `float64`/`float32`) carries + // `__float__` in its C `nb_float` slot, invisible to the getattro-based + // `__float__` lookup below — numpy's `tp_getattro` walks only its own dict + // and misses the dunder inherited from the mirror base (the same blind + // spot `complex128.__complex__` hit). CPython reads `nb_float` off the + // type directly, so try that first. + if matches!(obj, Object::Foreign(_)) { + let r = unsafe { crate::abstract_::foreign_nb_float(o) }; + if !r.is_null() { + let v = unsafe { crate::object::clone_object(r) }; + unsafe { crate::object::Py_DecRef(r) }; + match v { + Object::Float(f) => return FloatProtocol::Value(f), + Object::Int(i) => return FloatProtocol::Value(i as f64), + Object::Long(big) => { + return FloatProtocol::Value(big.to_f64().unwrap_or(f64::INFINITY)) + } + Object::Bool(b) => return FloatProtocol::Value(f64::from(b as i32)), + // A misbehaving `nb_float` returned a non-float; fall through + // to the `__float__`/`__index__` protocol. + _ => {} + } + } else if crate::errors::pending().is_some() { + return FloatProtocol::Raised; + } + } + // RFC 0046 (wave 4): consult `__float__` then `__index__` (CPython's + // `nb_float` / `nb_index` fallback) so a numpy scalar or user instance + // converts faithfully. + for attr in ["__float__", "__index__"] { + if let Some(result) = unsafe { call_number_dunder(o, attr) } { + return match result { + Some(Object::Float(f)) => FloatProtocol::Value(f), + Some(Object::Int(i)) => FloatProtocol::Value(i as f64), + Some(Object::Long(big)) => { + FloatProtocol::Value(big.to_f64().unwrap_or(f64::INFINITY)) + } + Some(Object::Bool(b)) => FloatProtocol::Value(f64::from(b as i32)), + Some(_) => { + crate::errors::set_type_error("__float__ returned non-float"); + FloatProtocol::Raised + } + None => FloatProtocol::Raised, + }; + } + } + FloatProtocol::NoProtocol +} + #[no_mangle] pub unsafe extern "C" fn PyFloat_AsDouble(o: *mut PyObject) -> f64 { if o.is_null() { @@ -344,10 +701,30 @@ pub unsafe extern "C" fn PyFloat_AsDouble(o: *mut PyObject) -> f64 { Object::Int(i) => i as f64, Object::Long(big) => big.to_f64().unwrap_or(f64::INFINITY), Object::Bool(b) => f64::from(b as i32), - _ => { - crate::errors::set_type_error("a float is required"); - -1.0 - } + other => match unsafe { float_number_protocol(o, &other) } { + FloatProtocol::Value(v) => v, + FloatProtocol::Raised => -1.0, + FloatProtocol::NoProtocol => { + if std::env::var_os("WEAVEPY_TRACE_CONV").is_some() { + let owned = crate::object::is_weavepy_owned(o); + let variant = format!("{other:?}"); + let short: String = variant.chars().take(80).collect(); + eprintln!( + "[conv] PyFloat_AsDouble: no float protocol on {} ptr={o:p} weavepy_owned={owned} clone={short}", + unsafe { debug_type_name(o) }, + ); + } + // CPython's `PyFloat_AsDouble`: `must be real number, not X` + // (`Py_TYPE(op)->tp_name`). This is a *different* message from + // the `float()` builtin (`PyNumber_Float`), which pandas' + // groupby-`corr` on an object column relies on matching. + crate::errors::set_type_error(format!( + "must be real number, not {}", + unsafe { debug_type_name(o) } + )); + -1.0 + } + }, } } @@ -490,8 +867,23 @@ pub unsafe extern "C" fn PyComplex_RealAsDouble(o: *mut PyObject) -> f64 { Object::Int(i) => i as f64, Object::Long(big) => big.to_f64().unwrap_or(f64::INFINITY), _ => { - crate::errors::set_type_error("a complex is required"); - -1.0 + // RFC 0046 (wave 4): CPython tries `__complex__` (real part), + // then falls back to the float protocol (`__float__` / + // `__index__`, via `PyFloat_AsDouble`). + if let Some(result) = unsafe { call_number_dunder(o, "__complex__") } { + return match result { + Some(Object::Complex(c)) => c.real, + Some(Object::Float(f)) => f, + Some(Object::Int(i)) => i as f64, + Some(Object::Long(big)) => big.to_f64().unwrap_or(f64::INFINITY), + Some(_) => { + crate::errors::set_type_error("__complex__ returned non-complex"); + -1.0 + } + None => -1.0, + }; + } + unsafe { PyFloat_AsDouble(o) } } } } @@ -503,7 +895,19 @@ pub unsafe extern "C" fn PyComplex_ImagAsDouble(o: *mut PyObject) -> f64 { } match unsafe { crate::object::clone_object(o) } { Object::Complex(c) => c.imag, - _ => 0.0, + Object::Float(_) | Object::Int(_) | Object::Long(_) | Object::Bool(_) => 0.0, + _ => { + // RFC 0046 (wave 4): `__complex__` carries the imaginary part; a + // real-only object (no `__complex__`) has imag 0. + if let Some(result) = unsafe { call_number_dunder(o, "__complex__") } { + return match result { + Some(Object::Complex(c)) => c.imag, + Some(_) => 0.0, + None => -1.0, + }; + } + 0.0 + } } } diff --git a/crates/weavepy-capi/src/object.rs b/crates/weavepy-capi/src/object.rs index ceee36d..359aa37 100644 --- a/crates/weavepy-capi/src/object.rs +++ b/crates/weavepy-capi/src/object.rs @@ -50,11 +50,36 @@ pub struct PyObject { pub type PySsizeT = isize; pub type PyHashT = isize; -/// Refcount value used to mark an object as immortal. Chosen to be -/// large enough that no realistic refcount churn ever decrements -/// the value to zero, matching CPython's -/// `_Py_IMMORTAL_REFCNT` sentinel. -pub const IMMORTAL_REFCNT: PySsizeT = (PySsizeT::MAX / 2) - 1; +/// Refcount value used to mark an object as immortal. +/// +/// This mirrors CPython 3.13's `_Py_IMMORTAL_REFCNT` **exactly**: on a +/// 64-bit build it is `UINT_MAX` (`0xFFFF_FFFF`), i.e. all of the *low* +/// 32 bits set. The precise value matters for binary-ABI compatibility +/// (RFC 0043): a stock CPython extension compiled against the real +/// headers carries an *inlined* `Py_INCREF`/`Py_DECREF` that the host +/// cannot intercept, and those inline forms decide immortality by +/// reading the low 32-bit half-word (`_Py_IsImmortal` tests +/// `(int32_t)ob_refcnt < 0`, true for `0xFFFF_FFFF`). With the old +/// `isize::MAX/2 - 1` sentinel the low half-word was `0xFFFF_FFFE`, so a +/// stock inlined refcount op would *not* recognise a WeavePy singleton / +/// static type as immortal and could mutate (and ultimately free) it. +/// +/// On 64-bit the high 32 bits are zero, so a `>= IMMORTAL_REFCNT` test +/// still cleanly separates the (immortal) statics from realistic mortal +/// counts, and [`is_immortal_refcnt`] additionally accepts any value +/// whose low-32 sign bit is set (matching `_Py_IsImmortal`). +pub const IMMORTAL_REFCNT: PySsizeT = 0xFFFF_FFFF; + +/// CPython-faithful immortality predicate (`_Py_IsImmortal`). +/// +/// On 64-bit, an object is immortal iff the low 32 bits, read as a +/// signed `i32`, are negative — i.e. bit 31 is set. This matches the +/// inline check stock extensions compile in, so the function-call and +/// inlined refcount paths agree on the same object. +#[inline] +pub fn is_immortal_refcnt(refcnt: PySsizeT) -> bool { + ((refcnt as u32) as i32) < 0 +} /// Heap-allocated extended box. /// @@ -67,6 +92,167 @@ pub struct PyObjectBox { pub payload: PayloadCell, } +// --------------------------------------------------------------------------- +// WeavePy-minted pointer registry (RFC 0046, wave 4). +// +// A real C extension (numpy) allocates many objects of its *own* — static +// `PyArray_Descr`s, builtin function objects, type objects — by paths that +// never touch WeavePy's allocator. Such a "foreign" `*mut PyObject` is not a +// `PyObjectBox`, a mirror, an instance body, or a capsule; interpreting its +// bytes as any of those corrupts memory ([`clone_object`] reading a bogus +// payload; [`free_box`] `Box::from_raw`-ing foreign storage). +// +// To tell ours from foreign *soundly* (no speculative reads at guessed +// offsets) we record every public pointer WeavePy hands to C in this set and +// remove it when the storage is released. A pointer that is **not** present +// (and is neither a static singleton nor a type object) is foreign, and is +// proxied into the VM as [`weavepy_vm::object::Object::Foreign`]. +// --------------------------------------------------------------------------- + +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Mutex; + +static MINTED: Mutex>> = Mutex::new(None); + +// ---- TEMP foreign-soul liveness tracker (WEAVEPY_TRACK_SOULS) ------------ +// Precise premature-free detector for foreign proxies. `fwd_incref` / +// `fwd_decref` (the ForeignHooks incref/decref) are called *exactly* when a +// `PyForeignSoul` is born / dies, so this map counts how many live souls +// reference each foreign pointer. If `free_box` frees a foreign box while its +// soul count is still > 0, some path over-decref'd the C refcount and the VM +// still holds a dangling proxy — the merge UAF. Gated on an env var. +static SOULS: Mutex>> = Mutex::new(None); + +pub fn track_souls_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_TRACK_SOULS").is_some()) +} + +pub fn soul_inc(p: usize) { + if !track_souls_enabled() || p == 0 { + return; + } + if let Ok(mut g) = SOULS.lock() { + *g.get_or_insert_with(HashMap::new).entry(p).or_insert(0) += 1; + } +} + +/// Decrement the live-soul count for `p`. Must be called *before* the +/// underlying `Py_DecRef`, so that the last soul's own decref (which frees +/// the box) sees a zero count and is not flagged. +pub fn soul_dec(p: usize) { + if !track_souls_enabled() || p == 0 { + return; + } + if let Ok(mut g) = SOULS.lock() { + if let Some(m) = g.as_mut() { + if let Some(c) = m.get_mut(&p) { + *c = c.saturating_sub(1); + if *c == 0 { + m.remove(&p); + } + } + } + } +} + +fn soul_count(p: usize) -> u32 { + if !track_souls_enabled() || p == 0 { + return 0; + } + SOULS + .lock() + .ok() + .and_then(|g| g.as_ref().and_then(|m| m.get(&p).copied())) + .unwrap_or(0) +} + +pub fn freebox_trace_enabled() -> bool { + use std::sync::OnceLock; + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("WEAVEPY_FREEBOX_TRACE").is_some()) +} + +thread_local! { + static FREEBOX_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +struct FreeBoxDepthGuard; +impl FreeBoxDepthGuard { + fn enter() -> Self { + FREEBOX_DEPTH.with(|c| c.set(c.get() + 1)); + FreeBoxDepthGuard + } +} +impl Drop for FreeBoxDepthGuard { + fn drop(&mut self) { + FREEBOX_DEPTH.with(|c| c.set(c.get().saturating_sub(1))); + } +} + +pub(crate) unsafe fn debug_type_name(p: *mut PyObject) -> String { + if p.is_null() { + return "".to_string(); + } + let ty = unsafe { (*p).ob_type }; + if ty.is_null() { + return "".to_string(); + } + let np = unsafe { (*(ty as *mut crate::layout::PyTypeObjectFull)).tp_name }; + if np.is_null() { + return "".to_string(); + } + unsafe { std::ffi::CStr::from_ptr(np) } + .to_string_lossy() + .into_owned() +} + +/// Record `p` as a WeavePy-minted public pointer. Called by every mint +/// site (box, mirror body, instance body, capsule) so [`is_weavepy_owned`] +/// can later distinguish it from a foreign extension object. +pub fn register_minted(p: *mut PyObject) { + if p.is_null() { + return; + } + if freebox_trace_enabled() { + let tyname = unsafe { debug_type_name(p) }; + if tyname.contains("Engine") { + eprintln!("[MINT] register p=0x{:x} type={}", p as usize, tyname); + } + } + if let Ok(mut g) = MINTED.lock() { + g.get_or_insert_with(HashSet::new).insert(p as usize); + } +} + +/// Drop `p` from the minted set when its storage is released. +pub fn unregister_minted(p: *mut PyObject) { + if p.is_null() { + return; + } + if let Ok(mut g) = MINTED.lock() { + if let Some(set) = g.as_mut() { + set.remove(&(p as usize)); + } + } +} + +/// True iff `p` is a live pointer WeavePy itself minted (box / mirror / +/// instance body / capsule). A non-owned, non-singleton, non-type +/// pointer is a *foreign* extension object. +pub fn is_weavepy_owned(p: *mut PyObject) -> bool { + if p.is_null() { + return false; + } + MINTED + .lock() + .ok() + .and_then(|g| g.as_ref().map(|s| s.contains(&(p as usize)))) + .unwrap_or(false) +} + impl std::fmt::Debug for PyObjectBox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PyObjectBox") @@ -110,7 +296,125 @@ impl PayloadCell { /// arranges its own decref). #[allow(clippy::missing_safety_doc)] pub fn into_owned(obj: Object) -> *mut PyObject { + // RFC 0046 (wave 4): `None` crosses into C as the canonical + // `&_Py_NoneStruct` singleton, never a fresh box. Stock extensions + // test for it by pointer identity — the header's `Py_None` macro is + // `(&_Py_NoneStruct)` and code writes `if (x == Py_None)` (numpy's + // `_ArrayFunctionDispatcher.__new__` does exactly this on its first + // argument). A minted box would compare unequal and silently take the + // wrong branch. The singleton is immortal, so it needs no refcount + // bump and is never freed. + if matches!(obj, Object::None) { + return crate::singletons::none_ptr(); + } + // RFC 0046 (wave 4): `Ellipsis` and `NotImplemented` are likewise + // pointer-identity singletons on the C side. numpy's index parser + // (`prepare_index` in `mapping.c`) recognises the ellipsis with a bare + // `op == Py_Ellipsis` test; a freshly-minted box would compare unequal, + // so `arr[-1, ...] = x` (numpy's own `linspace`) would raise "only + // integers, slices (`:`), ellipsis (`...`) … are valid indices". Hand C + // the static singletons (immortal, never freed) instead. + if weavepy_vm::vm_singletons::is_ellipsis(&obj) { + return crate::singletons::ellipsis_ptr(); + } + if weavepy_vm::vm_singletons::is_not_implemented(&obj) { + return crate::singletons::not_implemented_ptr(); + } + // RFC 0046 (wave 4): a foreign proxy round-trips back to the *same* + // `PyObject*` the extension first gave us (identity is load-bearing — + // numpy compares descrs/types by pointer). Hand C a fresh reference. + if let Object::Foreign(s) = &obj { + let p = s.ptr as *mut PyObject; + if p.is_null() && std::env::var_os("WEAVEPY_DEBUG_TUPLE").is_some() { + eprintln!("[into_owned] FOREIGN with NULL ptr!"); + } + unsafe { Py_IncRef(p) }; + return p; + } + // RFC 0046 (wave 4): a type object's canonical `PyObject*` is the + // `PyTypeObject` itself — numpy compares DType classes by pointer and + // validates them with `Py_IS_TYPE(cls, &PyArrayDTypeMeta_Type)` (a + // direct `cls->ob_type` read). Boxing an `Object::Type` would instead + // mint an *instance* whose `ob_type` is the class, so resolve it to the + // registered `PyTypeObject*` (static, heap, or readied) and hand C a + // fresh reference to that. + if let Object::Type(t) = &obj { + // A type's canonical `PyObject*` is always a `PyTypeObject`. If + // none is registered yet (a Python-defined class — e.g. a stdlib + // type like `enum.Enum` — crossing into C for the first time), + // mint one on demand rather than falling through to the generic + // instance-box path, which would hand C a box wearing bare `type` + // and lose the real metaclass (`Py_TYPE(cls)`). + // + // Use the *same* resolver order as `type_for_object` does for an + // instance — `find → synth → install` — so a protocol class (one + // whose instances drive a C-level `tp_iter`/`tp_iternext`/… read, + // e.g. `itertools.cycle`) registers its slot-bearing synth type, + // not a bare `install_user_type` shell. Otherwise the bare type + // pollutes the registry and a later instance crossing finds it + // first, losing `tp_iternext` ("cycle object is not an iterator"). + let p = crate::types::type_ptr_for_class(t) + .or_else(|| crate::types::synth_type_for_class(t)) + .unwrap_or_else(|| crate::types::install_user_type(t)); + let p = p as *mut PyObject; + unsafe { Py_IncRef(p) }; + return p; + } + // Faithful built-in types cross into C as layout-faithful mirrors + // (RFC 0043) so a stock extension's *inlined* field reads land on + // real CPython-shaped memory. Everything else keeps the legacy + // `PyObjectBox` (head + Rust payload) representation. + if crate::mirror::obj_is_faithful(&obj) { + return crate::mirror::mirror_out(obj); + } + // RFC 0045 (wave 3): a capsule round-trips as its original retained box + // (the same pointer C first saw), not a fresh per-crossing box. + if let Object::Capsule(rc) = &obj { + return crate::capsule::capsule_box_from_soul(rc); + } let ty = crate::types::type_for_object(&obj); + // RFC 0045 (wave 3): an instance of an inline-storage extension type + // crosses into C as its single, stable faithful body (so `self->field` + // reads the same bytes on every crossing), not a fresh per-crossing + // box. Every other object keeps the legacy `PyObjectBox`. + if let Object::Instance(inst) = &obj { + if std::env::var_os("WEAVEPY_TRACE_CTOR").is_some() { + let path = if crate::types::is_inline_instance_type(ty) { + "inline" + } else if inst.c_body.get() != 0 { + "cached_box" + } else { + "mint_box" + }; + eprintln!( + "[CTOR] into_owned name={} ty={:p} inline={} basicsize={} cls={} path={}", + crate::types::ctor_trace_name(ty), + ty, + crate::types::is_inline_instance_type(ty), + unsafe { (*ty).tp_basicsize }, + inst.cls().name, + path, + ); + } + if crate::types::is_inline_instance_type(ty) { + return crate::instance::instance_body_out(inst, ty); + } + // RFC 0046 (wave 4): a *non-inline* instance crosses as a single, + // stable identity box cached in `c_body`. Stock extensions cache an + // object by pointer and test it with `==`: numpy stashes + // `npy_static_pydata._NoValue` at import and a ufunc reduction + // detects "no initial value given" with `initial == _NoValue`. A + // fresh per-crossing box would compare unequal, so numpy would treat + // the `_NoValue` *sentinel* as a real initial value and try to coerce + // it to the output dtype (`float(_NoValue)` → "a float is required"). + // Returning the same pointer every time makes the identity test hold. + // The box still owns the instance strongly and is freed by C's + // refcount exactly like the legacy box — `free_box` clears the cache. + if let Some(p) = cached_instance_box(inst) { + return p; + } + return mint_instance_box(inst, ty); + } let boxed = Box::new(PyObjectBox { head: PyObject { ob_refcnt: 1, @@ -118,7 +422,81 @@ pub fn into_owned(obj: Object) -> *mut PyObject { }, payload: PayloadCell::from_object(obj), }); - Box::into_raw(boxed) as *mut PyObject + let raw = Box::into_raw(boxed) as *mut PyObject; + register_minted(raw); + raw +} + +/// Return the cached identity box for a non-inline `inst` if one already +/// exists, with a fresh C reference (RFC 0046, wave 4). The box outlives +/// any single C reference because it owns the instance strongly; it is +/// reclaimed only when C's refcount reaches zero (see [`free_box`]). +fn cached_instance_box( + inst: &weavepy_vm::sync::Rc, +) -> Option<*mut PyObject> { + let cached = inst.c_body.get(); + if cached == 0 { + return None; + } + let p = cached as *mut PyObject; + unsafe { Py_IncRef(p) }; + Some(p) +} + +/// Mint the single identity box for a non-inline `inst`, record it in +/// `inst.c_body`, and return it with one C reference (RFC 0046, wave 4). +/// The payload holds a strong clone of the instance, so the box pins the +/// instance for as long as C holds a reference. +fn mint_instance_box( + inst: &weavepy_vm::sync::Rc, + ty: *mut PyTypeObject, +) -> *mut PyObject { + let boxed = Box::new(PyObjectBox { + head: PyObject { + ob_refcnt: 1, + ob_type: ty, + }, + payload: PayloadCell::from_object(Object::Instance(inst.clone())), + }); + let raw = Box::into_raw(boxed) as *mut PyObject; + register_minted(raw); + inst.c_body.set(raw as usize); + raw +} + +/// Like [`into_owned_with_type`] but for a *non-inline* instance, never +/// consults or populates the identity cache (`c_body`): it always mints a +/// **fresh** box. +/// +/// RFC 0046 (wave 4): the cycle collector's `tp_traverse` / `tp_clear` +/// bridge must borrow an instance into C *without* perturbing the +/// refcount of the cached identity box a C-held cycle edge points at. A +/// stock GC type breaks a cycle by `Py_CLEAR`-ing the child it owns; that +/// stock, inlined `Py_DECREF` drives the child box to zero and runs the +/// extension's `tp_dealloc` (e.g. `Node_dealloc`, which decrements a live +/// counter and frees the node) via [`_Py_Dealloc`]. If the bridge handed +/// `tp_clear` the *cached* box (with the usual `+1`), that extra reference +/// would stop the cascade from reaching zero, so the node would instead be +/// reclaimed later through [`free_box`] — which is `tp_free`, not +/// `tp_dealloc`, and therefore skips the extension's cleanup, leaking the +/// node and desyncing its counter. A fresh, uncached box keeps the cached +/// edge at exactly the refcount the extension expects. +pub fn into_owned_with_type_uncached(obj: Object, ty: *mut PyTypeObject) -> *mut PyObject { + if let Object::Instance(inst) = &obj { + if !crate::types::is_inline_instance_type(ty) && !crate::mirror::type_is_faithful(ty) { + let boxed = Box::new(PyObjectBox { + head: PyObject { + ob_refcnt: 1, + ob_type: ty, + }, + payload: PayloadCell::from_object(Object::Instance(inst.clone())), + }); + let raw = Box::into_raw(boxed) as *mut PyObject; + register_minted(raw); + return raw; + } + } + into_owned_with_type(obj, ty) } /// Build a box that wraps `obj` and is associated with the given @@ -126,6 +504,49 @@ pub fn into_owned(obj: Object) -> *mut PyObject { /// alone isn't precise enough — e.g. when constructing an instance /// of a heap-allocated user type from `PyType_FromSpec`). pub fn into_owned_with_type(obj: Object, ty: *mut PyTypeObject) -> *mut PyObject { + // RFC 0046 (wave 4): a foreign proxy ignores the advertised type and + // round-trips to its original pointer (see [`into_owned`]). + if let Object::Foreign(s) = &obj { + let p = s.ptr as *mut PyObject; + unsafe { Py_IncRef(p) }; + return p; + } + // RFC 0046 (wave 4): a type object round-trips to its own + // `PyTypeObject*` (see [`into_owned`]); the advertised `ty` (the + // metaclass) is irrelevant to a class's canonical pointer. + if let Object::Type(t) = &obj { + let p = crate::types::type_ptr_for_class(t) + .or_else(|| crate::types::synth_type_for_class(t)) + .unwrap_or_else(|| crate::types::install_user_type(t)); + let p = p as *mut PyObject; + unsafe { Py_IncRef(p) }; + return p; + } + // If the *advertised* type is a faithful built-in (e.g. the + // tuple-staging case where `obj` is an `Object::List` but the type + // is `PyTuple_Type`), mint a mirror so the public pointer stays + // byte-faithful and resolves back through the prefix. + if crate::mirror::type_is_faithful(ty) { + return crate::mirror::mirror_out_with_type(obj, ty); + } + // RFC 0045 (wave 3): a capsule round-trips as its original retained box + // regardless of the advertised type (see [`into_owned`]). + if let Object::Capsule(rc) = &obj { + return crate::capsule::capsule_box_from_soul(rc); + } + // RFC 0045 (wave 3): inline-storage extension instances cross as their + // stable faithful body (see [`into_owned`]). + if let Object::Instance(inst) = &obj { + if crate::types::is_inline_instance_type(ty) { + return crate::instance::instance_body_out(inst, ty); + } + // RFC 0046 (wave 4): non-inline instances cross as their single, + // stable identity box (see [`into_owned`]). + if let Some(p) = cached_instance_box(inst) { + return p; + } + return mint_instance_box(inst, ty); + } let boxed = Box::new(PyObjectBox { head: PyObject { ob_refcnt: 1, @@ -133,7 +554,9 @@ pub fn into_owned_with_type(obj: Object, ty: *mut PyTypeObject) -> *mut PyObject }, payload: PayloadCell::from_object(obj), }); - Box::into_raw(boxed) as *mut PyObject + let raw = Box::into_raw(boxed) as *mut PyObject; + register_minted(raw); + raw } /// Clone the wrapped [`Object`] out of a box. The C-side reference @@ -160,23 +583,49 @@ pub unsafe fn clone_object(p: *mut PyObject) -> Object { return Object::Bool(false); } if std::ptr::eq(head, crate::singletons::_Py_NotImplementedStruct.as_ptr()) { - return Object::None; + return weavepy_vm::vm_singletons::not_implemented(); } if std::ptr::eq(head, crate::singletons::_Py_EllipsisObject.as_ptr()) { - return Object::None; + return weavepy_vm::vm_singletons::ellipsis(); } - // PyTypeObject extends PyObject; static type slots short-circuit - // here because their bridge field carries the native Rc. We - // *must* verify the metaclass FIRST — reading `(*ty).bridge` - // requires that `p` actually point at a `PyTypeObjectBox`. - if !head.ob_type.is_null() && std::ptr::eq(head.ob_type, crate::types::PyType_Type.as_ptr()) { - if let Some(t) = unsafe { crate::types::bridge_type(p as *mut crate::types::PyTypeObject) } - { - return Object::Type(t); - } + // PyTypeObject extends PyObject; resolve any WeavePy-owned type box + // (static, heap, or readied) back to its bridged `Object::Type`. + // + // `bridge_type` is pointer-identity-safe: it checks the readied side + // table and the static/heap registries *before* reading the private + // `bridge` field, so it never dereferences offset 424 of a foreign or + // non-type box. It must NOT be gated on `ob_type == PyType_Type`: a + // WeavePy class whose metaclass is not bare `type` (e.g. `enum.Enum`, + // whose `ob_type` is now its real `EnumType` mirror, or numpy's DType + // classes carrying `ob_type == &PyArrayDTypeMeta_Type`) would + // otherwise fail this check and be opaquely proxied as a foreign + // `'object'` — breaking `cls.__mro__`, `isinstance(x, cls)`, etc. + if let Some(t) = unsafe { crate::types::bridge_type(p as *mut crate::types::PyTypeObject) } { + return Object::Type(t); } - let bx = unsafe { &*(p as *const PyObjectBox) }; - let raw = bx.payload.obj.clone(); + // RFC 0046 (wave 4): a pointer WeavePy did not mint — a static numpy + // `PyArray_Descr`, an extension-built function object, an un-bridged + // type — is *foreign*. It is none of the shapes below, so interpreting + // it as one corrupts memory. Decided BEFORE the capsule/mirror checks + // because `is_mirror` is type-based and would mis-claim a foreign object + // whose (readied) type was registered for inline storage. Proxy it + // opaquely; it round-trips back to the same pointer via `into_owned`. + if !crate::object::is_weavepy_owned(p) { + return unsafe { crate::foreign::wrap_foreign(p) }; + } + // RFC 0045 (wave 3): a capsule carries its state in `user_data`, not in + // `payload.obj` (which is `None`) — without this it would collapse to + // `None` on crossing into the VM and break `import_array()`. Resolve it + // to its identity-stable soul, which round-trips back to the same box. + if unsafe { crate::capsule::is_capsule(p) } { + return unsafe { crate::capsule::capsule_soul(p) }; + } + let raw = if unsafe { crate::mirror::is_mirror(p) } { + unsafe { crate::mirror::native_of(p) } + } else { + let bx = unsafe { &*(p as *const PyObjectBox) }; + bx.payload.obj.clone() + }; // `PyTuple_New` allocates a mutable staging List but advertises // `PyTuple_Type` so it round-trips as a tuple. Freeze the list // into an immutable tuple on every external clone — this is the @@ -207,23 +656,259 @@ pub unsafe fn raw_payload(p: *mut PyObject) -> Option { { return None; } + if unsafe { crate::mirror::is_mirror(p) } { + return Some(unsafe { crate::mirror::native_of(p) }); + } let bx = unsafe { &*(p as *const PyObjectBox) }; Some(bx.payload.obj.clone()) } +/// Overwrite the native object backing `p` (its prefix for a mirror, or +/// its payload for a legacy box). Used by `PyTuple_SetItem` when it must +/// rewrite an already-frozen tuple in place. +/// +/// # Safety +/// `p` must be a heap object produced by [`into_owned`] / +/// [`into_owned_with_type`] (not a static singleton/type). +#[allow(clippy::missing_safety_doc)] +pub unsafe fn set_payload(p: *mut PyObject, obj: Object) { + if unsafe { crate::mirror::is_mirror(p) } { + let pre = unsafe { crate::mirror::prefix_of(p) }; + unsafe { (*pre).obj = obj }; + } else { + let bx = unsafe { &mut *(p as *mut PyObjectBox) }; + bx.payload.obj = obj; + } +} + +/// Default `tp_dealloc` for WeavePy's faithful built-in and heap types. +/// +/// Stock CPython's *inlined* `Py_DECREF` calls `_Py_Dealloc(op)` when an +/// object's refcount reaches zero, which reads `Py_TYPE(op)->tp_dealloc` +/// and invokes it. Because that path is compiled into the wheel and the +/// host cannot intercept it, every type WeavePy exposes installs this as +/// its `tp_dealloc` (at the CPython-faithful offset 48) so a stock +/// extension dropping the last reference to one of our objects releases +/// the storage correctly instead of jumping through a garbage slot. +/// +/// # Safety +/// `op` must be a live heap object (mirror or legacy box) with a zero +/// refcount, exactly as `_Py_Dealloc` guarantees. +#[no_mangle] +pub unsafe extern "C" fn _PyWeavePy_Dealloc(op: *mut PyObject) { + if op.is_null() { + return; + } + unsafe { free_box(op) }; +} + +/// `_Py_Dealloc(op)` — CPython's object-deallocation entry point. +/// +/// Stock release-build headers compile an *inlined* `Py_DECREF` that, on +/// reaching refcount zero, calls this external symbol; it must therefore +/// exist in the host. Faithfully, it dispatches to `Py_TYPE(op)->tp_dealloc` +/// (which WeavePy points at [`_PyWeavePy_Dealloc`] for every type it +/// exposes), falling back to the direct free path. +/// +/// # Safety +/// `op` must be a live heap object whose refcount has reached zero. +#[no_mangle] +pub unsafe extern "C" fn _Py_Dealloc(op: *mut PyObject) { + if op.is_null() { + return; + } + // RFC 0046 (wave 4): a faithful *instance body*'s lifetime is owned by + // its native `PyInstance`, not by C's refcount (RFC 0045). A stock + // extension compiles CPython's *inlined* `Py_DECREF`, which on + // reaching zero calls this symbol **directly** — bypassing + // [`Py_DecRef`]/[`free_box`]. Running the type's `tp_dealloc` here + // (e.g. numpy's `array_dealloc`) would free the live object's payload + // — its `data`/`dimensions`/`descr` — out from under the VM instance + // that still owns it: the block is absorbed by [`crate::memory:: + // PyObject_Free`] and survives, but every field is gone, so the next + // VM access reads a half-destroyed array (a NULL `descr` crashed + // numpy's `convert_ufunc_arguments`). This is the exact refcount cycle + // a temporary view drives: `v[:, ::-1]` incref's its base `v`, and the + // view's collection decref's `v` back through zero. Route through + // `free_box`, which ends *C's* borrow (drops the strong pin) and keeps + // the body intact; the real `tp_dealloc` runs only when the owning + // instance is collected (the `free_instance_body` hook). + // + // The `is_weavepy_owned` guard is load-bearing: `is_instance_body` is + // type-keyed and reads a `MirrorPrefix` at a *negative* offset, so on a + // foreign numpy pointer it would interpret numpy's bytes as our prefix. + // A foreign object is never one of our bodies; let its own `tp_dealloc` + // (below) run. + if unsafe { is_weavepy_owned(op) && crate::mirror::is_instance_body(op) } { + unsafe { free_box(op) }; + return; + } + let ty = unsafe { (*op).ob_type }; + if !ty.is_null() { + if let Some(dealloc) = unsafe { (*ty).tp_dealloc } { + unsafe { dealloc(op) }; + return; + } + } + unsafe { free_box(op) }; +} + /// Drop a box's storage, running its destructor (if any) first. /// /// SAFETY: `p` must be a heap-allocated box previously produced by /// [`into_owned`] / [`into_owned_with_type`] / capsule / module /// helpers. Static singletons short-circuit through the immortal /// check in [`Py_DecRef`]. -unsafe fn free_box(p: *mut PyObject) { +pub(crate) unsafe fn free_box(p: *mut PyObject) { + // Thread/process-teardown guard. At exit Rust destroys thread-local + // storage; a deep exception pinned in *another* thread-local (e.g. a + // `RecursionError` whose ~1000-frame traceback is retained to exit) is + // then dropped in that window and decref-s its C mirror pointers back + // through here. The caches and type registry `free_box` unconditionally + // consults (`BORROWED_ITEM_CACHE`, `DICT_BOX_CACHE`, `INLINE_TYPES`) may + // already be destroyed; touching a destroyed thread-local panics with + // `AccessError` → `abort()`, killing the process *after* it has already + // finished (and printed) its work — which, under the oracle sweep, turns + // an otherwise-complete run into an unparseable "crash". When any of them + // is gone we are unambiguously mid-teardown: leak `p` (the OS reclaims all + // memory at exit) rather than risk that abort — or, worse, a misclassified + // free against a half-destroyed `INLINE_TYPES` (which could `free_mirror` + // a plain box and corrupt the heap). Teardown is synchronous per thread, + // so once these are confirmed live at entry they stay live for the whole + // (single-threaded) call. + if !crate::containers::caches_alive() || !crate::types::inline_types_alive() { + return; + } + if crate::mirror::is_watched(p as usize) { + eprintln!( + "[WATCH] FREE-BOX 0x{:x} type={}", + p as usize, + unsafe { debug_type_name(p) } + ); + crate::mirror::unwatch_ptr(p as usize); + } // Invalidate any borrowed-item cache entries pinned to this // box's address so subsequent reuse of the slab doesn't return // stale items from the old container. crate::containers::invalidate_borrowed_cache(p); + if freebox_trace_enabled() { + let tyname = unsafe { debug_type_name(p) }; + if tyname.contains("Engine") || tyname.contains("index.") { + let owned = is_weavepy_owned(p); + let is_body = unsafe { crate::mirror::is_instance_body(p) }; + let is_mir = unsafe { crate::mirror::is_mirror(p) }; + eprintln!( + "[FREEBOX-ENTRY] p=0x{:x} type={} owned={} body={} mirror={} depth={}", + p as usize, + tyname, + owned, + is_body, + is_mir, + FREEBOX_DEPTH.with(|c| c.get()), + ); + } + } + + // RFC 0046 (wave 4): a *foreign* object (extension-minted, never in our + // registry) must never be `Box::from_raw`-d or `free_mirror`-d as one of + // our objects. This check MUST precede `is_instance_body`/`is_mirror`: + // those are *type-keyed* (a deref-free discriminator), so a foreign numpy + // object whose type WeavePy readied for inline storage (or a faithful + // built-in type) is mis-claimed as a mirror — and `free_mirror` then + // `dealloc`s a pointer numpy allocated, aborting the process + // (`POINTER_BEING_FREED_WAS_NOT_ALLOCATED`, seen dropping `numpy.eye`'s + // flatiter temporaries). `clone_object` decides foreign-ness first for + // exactly this reason. When a foreign proxy's last VM reference drops, + // dispatch to the extension's own `tp_dealloc` (numpy frees its array + // data, etc.); with no `tp_dealloc` we leak rather than corrupt. + if !is_weavepy_owned(p) { + let live = soul_count(p as usize); + if live > 0 { + let tyname = unsafe { debug_type_name(p) }; + eprintln!( + "[SOUL-UAF] freeing foreign p=0x{:x} type={} while {} soul(s) alive", + p as usize, tyname, live + ); + eprintln!("{}", std::backtrace::Backtrace::force_capture()); + } + if crate::object::freebox_trace_enabled() { + let tyname = unsafe { debug_type_name(p) }; + if tyname.contains("Engine") + || tyname.contains("Index") + || tyname.contains("ndarray") + || FREEBOX_DEPTH.with(|c| c.get()) > 0 + { + eprintln!( + "[FREEBOX] depth={} FOREIGN-FREE p=0x{:x} type={} souls={}", + FREEBOX_DEPTH.with(|c| c.get()), + p as usize, + tyname, + live, + ); + } + } + let ty = unsafe { (*p).ob_type }; + if !ty.is_null() { + if let Some(dealloc) = unsafe { (*ty).tp_dealloc } { + let _g = FreeBoxDepthGuard::enter(); + unsafe { dealloc(p) }; + } + } + return; + } + + // RFC 0045 (wave 3): a faithful *instance body*'s lifetime is owned by + // its native `PyInstance`, not by C's refcount. Reaching zero here + // only ends *C's* borrow (drops the strong pin); the block is freed + // when the instance is collected (via the free hook). Checked before + // `free_mirror`, since an instance body is also a mirror. + if unsafe { crate::mirror::is_instance_body(p) } { + unsafe { crate::instance::release_c_ownership(p) }; + return; + } + + // Faithful mirrors are raw-allocated with a negative-offset prefix; + // free them through the mirror bridge (which runs any destructor, + // drops the owning native object, and releases the block + any + // out-of-line buffer). + if unsafe { crate::mirror::is_mirror(p) } { + unsafe { crate::mirror::free_mirror(p) }; + return; + } + + // TEMP (RFC 0047 wave 5): detect freeing a capsule box that still has a + // live VM-side soul (`Object::Capsule.handle`). The soul is supposed to + // hold a lifelong retain, so this reaching zero is a refcount imbalance + // that leaves the soul's `handle` dangling — the pandas `_pandas_*_CAPI` + // capsules (stored on the top-level pandas VM module) hit exactly this, + // making `PyCapsule_Import` fail and crashing datetime code. + if crate::capsule::cap_trace_enabled() && unsafe { crate::capsule::is_capsule(p) } { + eprintln!( + "[CAP] FREE-CAPSULE-BOX p=0x{:x} refcnt={} soul_alive={}", + p as usize, + unsafe { (*p).ob_refcnt as i64 }, + crate::capsule::soul_alive(p), + ); + if crate::capsule::soul_alive(p) { + eprintln!( + "[CAP] *** UAF: freeing capsule box with live soul ***\n{}", + std::backtrace::Backtrace::force_capture() + ); + } + } + + unregister_minted(p); + let bx = unsafe { Box::from_raw(p as *mut PyObjectBox) }; + // RFC 0046 (wave 4): if this is an instance's cached identity box, drop + // the `c_body` cache so a subsequent crossing re-mints a fresh box + // rather than handing C this about-to-be-freed pointer (use-after-free). + if let Object::Instance(inst) = &bx.payload.obj { + if inst.c_body.get() == p as usize { + inst.c_body.set(0); + } + } if let Some(d) = bx.payload.destructor { let raw = Box::into_raw(bx); unsafe { d(raw as *mut PyObject) }; @@ -246,7 +931,7 @@ pub unsafe extern "C" fn Py_IncRef(op: *mut PyObject) { return; } let head = unsafe { &mut *op }; - if head.ob_refcnt >= IMMORTAL_REFCNT { + if is_immortal_refcnt(head.ob_refcnt) { return; } head.ob_refcnt += 1; @@ -264,10 +949,18 @@ pub unsafe extern "C" fn Py_DecRef(op: *mut PyObject) { return; } let head = unsafe { &mut *op }; - if head.ob_refcnt >= IMMORTAL_REFCNT { + if is_immortal_refcnt(head.ob_refcnt) { return; } head.ob_refcnt -= 1; + if head.ob_refcnt <= 0 && crate::mirror::is_watched(op as usize) { + eprintln!( + "[WATCH] FREE-AT-DEC 0x{:x} refcnt={}\n{}", + op as usize, + head.ob_refcnt, + std::backtrace::Backtrace::force_capture() + ); + } if head.ob_refcnt == 0 { unsafe { free_box(op) }; } @@ -296,5 +989,5 @@ pub fn is_heap_object(op: *mut PyObject) -> bool { return false; } let head = unsafe { &*op }; - head.ob_refcnt < IMMORTAL_REFCNT + !is_immortal_refcnt(head.ob_refcnt) } diff --git a/crates/weavepy-capi/src/pystate.rs b/crates/weavepy-capi/src/pystate.rs new file mode 100644 index 0000000..7edeb7b --- /dev/null +++ b/crates/weavepy-capi/src/pystate.rs @@ -0,0 +1,140 @@ +//! RFC 0047 (wave 5): a byte-faithful `PyThreadState` backing store. +//! +//! Genuine Cython output compiled against stock CPython 3.13 sets +//! `CYTHON_FAST_THREAD_STATE 1`, which makes its error machinery +//! (`__Pyx_ErrFetchInState` / `__Pyx_ErrRestoreInState` / +//! `__Pyx_PyErr_Occurred`) read and write **`tstate->current_exception` +//! directly** at the field's fixed struct offset, bypassing the +//! `PyErr_*` call surface entirely. It also reads `tstate->interp` +//! (`__Pyx_check_single_interpreter`). +//! +//! WeavePy previously handed out a one-byte sentinel from +//! `PyThreadState_Get`, which works only as long as nothing dereferences +//! it. To run real Cython we expose a thread-local store laid out like +//! CPython's `PyThreadState` — at minimum a readable `interp` slot and a +//! readable/writable `current_exception` slot at the correct offsets — +//! and we make [`crate::errors`] treat that `current_exception` slot as +//! the single source of truth for the pending exception. That unification +//! is what lets an exception raised by a WeavePy C-API call be *seen* by +//! Cython's inlined `current_exception` read, and an exception stashed by +//! Cython be seen by WeavePy. +//! +//! The store is intentionally over-sized and zeroed: every field Cython +//! might touch lands inside it, and a zeroed `interp`/`current_exception` +//! is the correct "no interpreter id / no error" initial state. + +#![allow(clippy::missing_safety_doc)] + +use core::cell::UnsafeCell; +use core::ffi::{c_int, c_void}; +use std::ptr; + +use crate::lifecycle::PyThreadState; +use crate::object::PyObject; + +// CPython 3.13 `struct _ts` field offsets (machine-checked against stock +// `cpython/pystate.h`; see the layout walk in the wave-5 work log). +const OFF_INTERP: usize = 16; // PyInterpreterState *interp +const OFF_CURRENT_EXCEPTION: usize = 112; // PyObject *current_exception +const OFF_EXC_INFO: usize = 120; // _PyErr_StackItem *exc_info + +/// Generously sized backing body. The real 3.13 `PyThreadState` is well +/// under this; the slack guarantees any in-struct field write Cython emits +/// stays in-bounds. +const TS_BYTES: usize = 1024; + +/// `_PyErr_StackItem { PyObject *exc_value; struct _err_stackitem *previous_item; }`. +/// `tstate->exc_info` must be non-NULL (CPython guarantees it), so we point +/// it at this per-thread item. WeavePy does not model the handled-exception +/// stack, so it stays empty. +#[repr(C)] +struct StackItem { + exc_value: *mut PyObject, + previous_item: *mut c_void, +} + +#[repr(C, align(16))] +struct TStateStore { + body: [u8; TS_BYTES], + exc_info: StackItem, + initialized: bool, +} + +thread_local! { + static TSTATE: UnsafeCell = const { + UnsafeCell::new(TStateStore { + body: [0u8; TS_BYTES], + exc_info: StackItem { + exc_value: ptr::null_mut(), + previous_item: ptr::null_mut(), + }, + initialized: false, + }) + }; +} + +/// Return the current thread's faithful `PyThreadState` body, wiring the +/// `exc_info` self-pointer on first touch. The returned pointer is stable +/// for the life of the thread. +fn store_ptr() -> *mut TStateStore { + TSTATE.with(|cell| { + let store = cell.get(); + unsafe { + if !(*store).initialized { + (*store).initialized = true; + // exc_info (offset 120) points at the embedded StackItem. + let exc_info_ptr = ptr::addr_of_mut!((*store).exc_info) as *mut c_void; + let body = (*store).body.as_mut_ptr(); + ptr::write_unaligned(body.add(OFF_EXC_INFO) as *mut *mut c_void, exc_info_ptr); + } + } + store + }) +} + +/// `*mut PyThreadState` for the current thread (the body pointer). +pub fn current_threadstate() -> *mut PyThreadState { + let store = store_ptr(); + unsafe { (*store).body.as_mut_ptr() as *mut PyThreadState } +} + +/// Pointer to this thread's `current_exception` field — the canonical +/// pending-exception cell shared with Cython's inlined access. +pub fn current_exception_slot() -> *mut *mut PyObject { + let store = store_ptr(); + unsafe { (*store).body.as_mut_ptr().add(OFF_CURRENT_EXCEPTION) as *mut *mut PyObject } +} + +// --------------------------------------------------------------------------- +// FFI +// --------------------------------------------------------------------------- + +/// `PyThreadState_GetUnchecked()` — the non-asserting current-thread-state +/// accessor (3.13). Cython's `__Pyx_PyThreadState_Current` resolves to this. +#[no_mangle] +pub unsafe extern "C" fn PyThreadState_GetUnchecked() -> *mut PyThreadState { + crate::interp::ensure_initialised(); + current_threadstate() +} + +/// `PyInterpreterState_GetID(interp)` — WeavePy is single-interpreter, so +/// the id is always 0. The argument (which Cython derives from +/// `tstate->interp`, currently a zeroed/NULL slot) is intentionally ignored. +#[no_mangle] +pub unsafe extern "C" fn PyInterpreterState_GetID(_interp: *mut c_void) -> i64 { + 0 +} + +/// `PyGC_Enable()` / `PyGC_Disable()` — return the *previous* enabled flag. +/// WeavePy's collector isn't toggled through this C entry; report "was +/// enabled" (1) so Cython's save/restore bookkeeping is internally +/// consistent, and otherwise no-op. +#[no_mangle] +pub unsafe extern "C" fn PyGC_Enable() -> c_int { + 1 +} + +#[no_mangle] +pub unsafe extern "C" fn PyGC_Disable() -> c_int { + 1 +} diff --git a/crates/weavepy-capi/src/singletons.rs b/crates/weavepy-capi/src/singletons.rs index 42cc290..9419e99 100644 --- a/crates/weavepy-capi/src/singletons.rs +++ b/crates/weavepy-capi/src/singletons.rs @@ -39,14 +39,61 @@ impl Singleton { } } +/// `Py_True`/`Py_False` are faithful `PyLongObject` singletons. +/// +/// CPython's `bool` is an `int` subclass and `_Py_TrueStruct` / +/// `_Py_FalseStruct` are `struct _longobject` (a `PyLongObject`), not bare +/// `PyObject`s. RFC 0043/0047: macro-heavy Cython converts a Python bool to a +/// C integer by reading the `PyLongObject` digits and sign tag *directly* off +/// the struct (`__Pyx_PyLong_IsCompact` / `__Pyx_PyLong_CompactValue` under +/// `CYTHON_USE_PYLONG_INTERNALS`) rather than calling `PyLong_AsLong`. pandas' +/// `lib.maybe_convert_objects` stores each bool into an `ndarray[uint8]` +/// (`bools[i] = val`), which compiles to exactly that inline read; a bare +/// 16-byte `PyObject` left the `lv_tag`/`ob_digit` slots reading past the +/// allocation, so `False` decoded as a "negative value" and the store raised +/// `OverflowError: can't convert negative value to npy_uint8`. Backing the +/// singletons with a real `_PyLongValue` makes the inline decode read `0`/`1`. +#[repr(transparent)] +pub struct BoolSingleton(UnsafeCell); + +unsafe impl Sync for BoolSingleton {} + +impl BoolSingleton { + /// `value` is `0` (False) or `1` (True). + pub const fn new(value: crate::layout::digit) -> Self { + // `lv_tag = (ndigits << NON_SIZE_BITS) | sign`. False is the canonical + // zero (0 digits, SIGN_ZERO); True is one positive digit. + let lv_tag = if value == 0 { + crate::layout::PYLONG_SIGN_ZERO + } else { + (1usize << crate::layout::PYLONG_NON_SIZE_BITS) | crate::layout::PYLONG_SIGN_POSITIVE + }; + Self(UnsafeCell::new(crate::layout::PyLongObject { + ob_base: PyObject { + ob_refcnt: IMMORTAL_REFCNT, + ob_type: std::ptr::null_mut(), + }, + long_value: crate::layout::PyLongValue { + lv_tag, + ob_digit: [value], + }, + })) + } + + /// Return a stable raw `PyObject*` (the embedded `ob_base`). + pub fn as_ptr(&self) -> *mut PyObject { + self.0.get() as *mut PyObject + } +} + #[no_mangle] pub static _Py_NoneStruct: Singleton = Singleton::new(); #[no_mangle] -pub static _Py_TrueStruct: Singleton = Singleton::new(); +pub static _Py_TrueStruct: BoolSingleton = BoolSingleton::new(1); #[no_mangle] -pub static _Py_FalseStruct: Singleton = Singleton::new(); +pub static _Py_FalseStruct: BoolSingleton = BoolSingleton::new(0); #[no_mangle] pub static _Py_NotImplementedStruct: Singleton = Singleton::new(); diff --git a/crates/weavepy-capi/src/slice.rs b/crates/weavepy-capi/src/slice.rs index 1804d26..7fe7c3b 100644 --- a/crates/weavepy-capi/src/slice.rs +++ b/crates/weavepy-capi/src/slice.rs @@ -73,7 +73,32 @@ pub unsafe extern "C" fn PySlice_Unpack( Object::Int(i) => Some(*i as PySsizeT), Object::Long(big) => big.to_isize(), Object::Bool(b) => Some(if *b { 1 } else { 0 }), - _ => None, + // CPython's `_PyEval_SliceIndex` accepts any object exposing + // `__index__` — a numpy `int64`/`intp` scalar, a pandas block + // placement — not just a native `int`. Coerce it through + // `PyNumber_Index`; on failure clear the error it set so the + // caller reports the slice-specific message. (pandas' `melt`, + // groupby `apply`, and MultiIndex `loc` all slice with np ints.) + other => { + let p = crate::object::into_owned(other.clone()); + if p.is_null() { + return None; + } + let idx = unsafe { crate::abstract_::PyNumber_Index(p) }; + unsafe { crate::object::Py_DecRef(p) }; + if idx.is_null() { + crate::errors::clear_thread_local(); + return None; + } + let v = match unsafe { crate::object::clone_object(idx) } { + Object::Int(i) => Some(i as PySsizeT), + Object::Long(big) => big.to_isize(), + Object::Bool(b) => Some(if b { 1 } else { 0 }), + _ => None, + }; + unsafe { crate::object::Py_DecRef(idx) }; + v + } } }; let step_v = match resolve(&s.step, 1) { diff --git a/crates/weavepy-capi/src/slottable.rs b/crates/weavepy-capi/src/slottable.rs index 795f4f2..643bf2b 100644 --- a/crates/weavepy-capi/src/slottable.rs +++ b/crates/weavepy-capi/src/slottable.rs @@ -163,8 +163,14 @@ pub unsafe fn slot_table_for(ty: *mut crate::types::PyTypeObject) -> Option<&'st if ty.is_null() { return None; } + // Readied stock types (RFC 0044) keep their decoded slots in a side + // registry — their 416-byte struct has no embedded `PyTypeObjectBox` + // and they don't carry `Py_TPFLAGS_HEAPTYPE`. Check that first. + if let Some(table) = crate::types::readied_slot_table(ty) { + return Some(table); + } let flags = unsafe { (*ty).tp_flags }; - if (flags & crate::types::PY_TPFLAGS_HEAPTYPE) == 0 { + if (flags & crate::types::PY_TPFLAGS_HEAPTYPE as u64) == 0 { return None; } let bx = ty as *const crate::types::PyTypeObjectBox; diff --git a/crates/weavepy-capi/src/strings.rs b/crates/weavepy-capi/src/strings.rs index 97cdda9..0448dcd 100644 --- a/crates/weavepy-capi/src/strings.rs +++ b/crates/weavepy-capi/src/strings.rs @@ -109,8 +109,17 @@ pub unsafe extern "C" fn PyUnicode_GetLength(o: *mut PyObject) -> PySsizeT { return -1; } match unsafe { crate::object::clone_object(o) } { - Object::Str(s) => s.chars().count() as PySsizeT, - _ => { + Object::Str(s) => { + let n = s.chars().count() as PySsizeT; + if std::env::var_os("WEAVEPY_TRACE_UCS4").is_some() { + eprintln!("[UCS4] GetLength({o:p}) = {n} value={s:?}"); + } + n + } + other => { + if std::env::var_os("WEAVEPY_TRACE_UCS4").is_some() { + eprintln!("[UCS4] GetLength({o:p}) NOT-STR kind={}", other.type_name()); + } crate::errors::set_type_error("expected str"); -1 } @@ -450,10 +459,34 @@ pub unsafe extern "C" fn PyUnicode_FromEncodedObject( unsafe { PyUnicode_Decode(s, buf.len() as PySsizeT, encoding, errors) } } _ => { - crate::errors::set_type_error( - "PyUnicode_FromEncodedObject: expected bytes-like object", - ); - ptr::null_mut() + // CPython falls back to the PEP 3118 buffer protocol for any + // bytes-like object that isn't an exact `bytes`/`bytearray` + // (memoryview, array.array, mmap, and — crucially for numpy — + // its `bytes_`/`str_` scalars and 0-d buffer exporters that + // datetime64 array formatting hands to us). Mirror that instead + // of rejecting everything non-native. + let mut view = crate::buffer::Py_buffer::zeroed(); + // PyBUF_SIMPLE == 0 + let rc = unsafe { crate::buffer::PyObject_GetBuffer(obj, &mut view, 0) }; + if rc != 0 { + // Replace the pending BufferError with the TypeError CPython + // raises here (clearer for "needs a bytes-like object"). + crate::errors::set_type_error( + "decoding to str: need a bytes-like object", + ); + return ptr::null_mut(); + } + let data = view.buf as *const c_char; + let len = view.len; + let out = if data.is_null() || len == 0 { + unsafe { + PyUnicode_Decode(b"".as_ptr() as *const c_char, 0, encoding, errors) + } + } else { + unsafe { PyUnicode_Decode(data, len, encoding, errors) } + }; + unsafe { crate::buffer::PyBuffer_Release(&mut view) }; + out } } } @@ -492,6 +525,13 @@ pub unsafe extern "C" fn PyUnicode_ReadChar(o: *mut PyObject, idx: PySsizeT) -> if o.is_null() { return u32::MAX; } + // Fast path: a buffer-authoritative string reads straight from its + // PEP 393 buffer (no per-call string rebuild). + if idx >= 0 { + if let Some(cp) = unsafe { crate::mirror::unicode_read_char(o, idx as usize) } { + return cp; + } + } match unsafe { crate::object::clone_object(o) } { Object::Str(s) => { let i = idx.max(0) as usize; @@ -600,32 +640,71 @@ pub unsafe extern "C" fn PyUnicode_InternInPlace(_p: *mut *mut PyObject) { // literals. } -/// `PyUnicode_New(size, maxchar)` — build a mutable preallocated -/// str. We approximate by allocating a fresh empty Str; user -/// code should write characters through -/// `PyUnicode_WriteChar` (which we treat as a no-op since the -/// underlying storage is immutable). +/// `PyUnicode_New(size, maxchar)` — mint a fresh, writable, zero-filled +/// unicode string of `size` code points at the PEP 393 kind implied by +/// `maxchar` (RFC 0047, wave 5). The result is a **buffer-authoritative** +/// mirror: a stock extension fills it with the inlined `PyUnicode_WRITE` +/// macro (a direct store at `PyUnicode_DATA(o) + i*kind`) and the bytes are +/// reconstructed when the string crosses back to the VM. This is the exact +/// idiom Cython's f-string / integer-format codegen uses. #[no_mangle] -pub unsafe extern "C" fn PyUnicode_New(_size: PySsizeT, _maxchar: u32) -> *mut PyObject { - crate::object::into_owned(Object::from_static("")) +pub unsafe extern "C" fn PyUnicode_New(size: PySsizeT, maxchar: u32) -> *mut PyObject { + let n = if size < 0 { 0 } else { size as usize }; + let r = crate::mirror::new_unicode_mirror(n, maxchar); + if r.is_null() { + // Match CPython: an oversize/failed allocation raises MemoryError + // rather than returning NULL with no exception set. + unsafe { crate::errors::PyErr_NoMemory() }; + } + r } +/// `PyUnicode_WriteChar(o, idx, ch)` — store one code point into a writable +/// string built by `PyUnicode_New`. Returns 0 on success, -1 (with an +/// exception set) for an out-of-range index, a too-wide code point, or a +/// non-writable target. #[no_mangle] -pub unsafe extern "C" fn PyUnicode_WriteChar(_o: *mut PyObject, _idx: PySsizeT, _ch: u32) -> c_int { - // Treated as a no-op; full unicode-buffer mutation will - // require a private rep we haven't introduced yet. - 0 +pub unsafe extern "C" fn PyUnicode_WriteChar(o: *mut PyObject, idx: PySsizeT, ch: u32) -> c_int { + if o.is_null() || idx < 0 { + crate::errors::set_value_error("PyUnicode_WriteChar: invalid argument"); + return -1; + } + match unsafe { crate::mirror::unicode_write_char(o, idx as usize, ch) } { + Ok(()) => 0, + Err(msg) => { + crate::errors::set_value_error(msg); + -1 + } + } } +/// `PyUnicode_CopyCharacters(to, to_start, from, from_start, how_many)` — +/// copy `how_many` code points from `from` into the writable string `to` +/// (RFC 0047, wave 5). Returns the number copied, or -1 with an exception +/// set. Cython's in-place concatenation fast path calls this straight after +/// `PyUnicode_Resize` to append the right operand. #[no_mangle] pub unsafe extern "C" fn PyUnicode_CopyCharacters( - _to: *mut PyObject, - _to_start: PySsizeT, - _from: *mut PyObject, - _from_start: PySsizeT, - _how_many: PySsizeT, + to: *mut PyObject, + to_start: PySsizeT, + from: *mut PyObject, + from_start: PySsizeT, + how_many: PySsizeT, ) -> PySsizeT { - -1 + if to.is_null() || from.is_null() { + crate::errors::set_type_error("PyUnicode_CopyCharacters: expected str"); + return -1; + } + let ts = to_start.max(0) as usize; + let fs = from_start.max(0) as usize; + let hm = how_many.max(0) as usize; + match unsafe { crate::mirror::unicode_copy_characters(to, ts, from, fs, hm) } { + Ok(n) => n as PySsizeT, + Err(msg) => { + crate::errors::set_value_error(msg); + -1 + } + } } #[no_mangle] @@ -860,27 +939,58 @@ pub unsafe extern "C" fn PyUnicode_Join( return ptr::null_mut(); } }; - let items: Vec = match unsafe { crate::object::clone_object(seq) } { - Object::List(rc) => rc - .borrow() - .iter() - .map(|o| match o { - Object::Str(s) => s.to_string(), - _ => String::new(), - }) - .collect(), - Object::Tuple(items) => items - .iter() - .map(|o| match o { - Object::Str(s) => s.to_string(), - _ => String::new(), - }) - .collect(), + + // CPython's `PyUnicode_Join` runs `seq` through `PySequence_Fast`, so any + // iterable (list, tuple, generator, set, dict-keys, list-subclass, …) is + // accepted — not just the two builtin sequence types. Collect the elements + // into a native `Vec`: use a direct borrow for the common + // list/tuple fast paths and fall back to the iterator protocol otherwise. + let elems: Vec = match unsafe { crate::object::clone_object(seq) } { + Object::List(rc) => rc.borrow().iter().cloned().collect(), + Object::Tuple(items) => items.iter().cloned().collect(), _ => { - crate::errors::set_type_error("seq must be iterable"); - return ptr::null_mut(); + let it = unsafe { crate::abstract_::PyObject_GetIter(seq) }; + if it.is_null() { + // Preserve CPython's message shape for non-iterables; the + // iterator protocol already set a TypeError, but be explicit. + if crate::errors::pending().is_none() { + crate::errors::set_type_error("can only join an iterable"); + } + return ptr::null_mut(); + } + let mut out: Vec = Vec::new(); + loop { + let item = unsafe { crate::abstract_::PyIter_Next(it) }; + if item.is_null() { + break; + } + out.push(unsafe { crate::object::clone_object(item) }); + unsafe { crate::object::Py_DecRef(item) }; + } + unsafe { crate::object::Py_DecRef(it) }; + if crate::errors::pending().is_some() { + return ptr::null_mut(); + } + out } }; + + // Every element must be a `str`; mirror CPython's + // "sequence item N: expected str instance, T found" TypeError otherwise. + let mut items: Vec = Vec::with_capacity(elems.len()); + for (i, o) in elems.iter().enumerate() { + match o { + Object::Str(s) => items.push(s.to_string()), + other => { + crate::errors::set_type_error(&format!( + "sequence item {}: expected str instance, {} found", + i, + other.type_name() + )); + return ptr::null_mut(); + } + } + } crate::object::into_owned(Object::from_str(items.join(&sep_str))) } @@ -927,17 +1037,47 @@ pub unsafe extern "C" fn PyUnicode_Fill( #[no_mangle] pub unsafe extern "C" fn PyUnicode_FromKindAndData( - _kind: c_int, + kind: c_int, buffer: *const std::ffi::c_void, size: PySsizeT, ) -> *mut PyObject { - // Treat all kinds (1, 2, 4-byte chars) as utf-8 input; the - // common kind in extension code is 1 (Latin-1-ish) or 4 - // (full UCS-4). We map both to UTF-8 by best effort. + // PEP 393: `data` is an array of `size` code units whose width is + // given by `kind` (1/2/4 bytes per code point). Each code unit is a + // raw code point — for the 1-byte kind that means Latin-1, NOT + // UTF-8. numpy's UNICODE_getitem reads array elements back via + // `PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, ucs4, len)`, so + // honoring `kind` is required to avoid truncating multi-char strings. let len = size.max(0) as usize; - let slice = unsafe { std::slice::from_raw_parts(buffer as *const u8, len) }; - let owned = String::from_utf8_lossy(slice).into_owned(); - crate::object::into_owned(Object::from_str(owned)) + if buffer.is_null() || len == 0 { + return crate::object::into_owned(Object::from_str(String::new())); + } + let mut s = String::with_capacity(len); + match kind { + 2 => { + let p = buffer as *const u16; + for i in 0..len { + let cp = unsafe { *p.add(i) } as u32; + s.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); + } + } + 4 => { + let p = buffer as *const u32; + for i in 0..len { + let cp = unsafe { *p.add(i) }; + s.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); + } + } + // kind == 1 (Latin-1); also the fallback for the deprecated + // wchar/0 kind. Each byte is a code point in 0..=255. + _ => { + let p = buffer as *const u8; + for i in 0..len { + let cp = unsafe { *p.add(i) } as u32; + s.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); + } + } + } + crate::object::into_owned(Object::from_str(s)) } /// `PyUnicode_DecodeFSDefault` / `PyUnicode_EncodeFSDefault` — diff --git a/crates/weavepy-capi/src/types.rs b/crates/weavepy-capi/src/types.rs index 93e57a4..3e4274a 100644 --- a/crates/weavepy-capi/src/types.rs +++ b/crates/weavepy-capi/src/types.rs @@ -27,7 +27,7 @@ use std::ptr; use std::sync::Mutex; use weavepy_vm::sync::Rc; -use weavepy_vm::object::{DictData, DictKey, Object}; +use weavepy_vm::object::{BoundMethod, DictData, DictKey, Object}; use weavepy_vm::types::TypeObject; use crate::object::{PyObject, PySsizeT, IMMORTAL_REFCNT}; @@ -35,38 +35,182 @@ use crate::slottable::SlotTable; /// Layout of a type object as the C side sees it. /// -/// The first field shadows [`PyObject`] exactly. Subsequent fields -/// are deliberately a *subset* of CPython's full `PyTypeObject` — -/// extensions that compile against `Py_LIMITED_API` only see the -/// header through opaque accessors, so we don't need to expose the -/// hundred-odd CPython slots verbatim. The `_bridge` slot at the end -/// stores the `Rc` we use for native dispatch. +/// As of RFC 0043 (wave 1) this is **byte-faithful to CPython 3.13's +/// full `PyTypeObject`** for the entire documented prefix (offsets `0` +/// through `416`, i.e. `ob_base` … `tp_vectorcall` + the +/// `tp_watched`/`tp_versions_used` tail). The exact field offsets are +/// pinned, and machine-checked against the host's stock headers, in +/// [`crate::layout::PyTypeObjectFull`]; `debug_assert_type_layout` +/// cross-checks this live struct against that spec. +/// +/// WeavePy's *private* bookkeeping (`tp_slots`, the native `bridge`) +/// lives **after** the 416-byte faithful region. Because CPython types +/// are variable-size (`tp_itemsize`) and extensions only ever read the +/// documented slots, those trailing fields are invisible to stock code +/// while letting native dispatch keep its `Rc`. #[repr(C)] pub struct PyTypeObject { - pub head: PyObject, + // --- byte-faithful CPython 3.13 prefix (offsets 0..416) --- + pub head: PyObject, // 0 + /// `PyVarObject.ob_size`. Types are var-objects; this is 0 for the + /// built-ins (CPython keeps it 0 too). + pub ob_size: PySsizeT, // 16 /// Type's qualified name (`module.Name` for heap types). - pub tp_name: *const c_char, - /// Reserved for future use; mirrors CPython's `tp_basicsize`. - pub tp_basicsize: PySsizeT, - pub tp_itemsize: PySsizeT, - pub tp_flags: u32, - /// Extension-supplied [`crate::ffi::PyType_Slot`] table, or - /// null if the type wasn't built from a spec. + pub tp_name: *const c_char, // 24 + pub tp_basicsize: PySsizeT, // 32 + pub tp_itemsize: PySsizeT, // 40 + /// Instance destructor. Stock inlined `Py_DECREF` → `_Py_Dealloc` + /// reads this slot, so it must sit at offset 48 and be valid: for + /// faithful built-ins it routes to the mirror/box free path. + pub tp_dealloc: Option, // 48 + pub tp_vectorcall_offset: PySsizeT, // 56 + pub tp_getattr: *mut c_void, // 64 + pub tp_setattr: *mut c_void, // 72 + pub tp_as_async: *mut c_void, // 80 + pub tp_repr: *mut c_void, // 88 + pub tp_as_number: *mut c_void, // 96 + pub tp_as_sequence: *mut c_void, // 104 + pub tp_as_mapping: *mut c_void, // 112 + pub tp_hash: *mut c_void, // 120 + pub tp_call: *mut c_void, // 128 + pub tp_str: *mut c_void, // 136 + pub tp_getattro: *mut c_void, // 144 + pub tp_setattro: *mut c_void, // 152 + pub tp_as_buffer: *mut c_void, // 160 + /// CPython `unsigned long tp_flags`. 64-bit, at offset 168. + pub tp_flags: u64, // 168 + pub tp_doc: *const c_char, // 176 + pub tp_traverse: *mut c_void, // 184 + pub tp_clear: *mut c_void, // 192 + pub tp_richcompare: *mut c_void, // 200 + pub tp_weaklistoffset: PySsizeT, // 208 + pub tp_iter: *mut c_void, // 216 + pub tp_iternext: *mut c_void, // 224 + pub tp_methods: *mut c_void, // 232 + pub tp_members: *mut c_void, // 240 + pub tp_getset: *mut c_void, // 248 + pub tp_base: *mut PyTypeObject, // 256 + pub tp_dict: *mut PyObject, // 264 + pub tp_descr_get: *mut c_void, // 272 + pub tp_descr_set: *mut c_void, // 280 + pub tp_dictoffset: PySsizeT, // 288 + pub tp_init: *mut c_void, // 296 + pub tp_alloc: *mut c_void, // 304 + pub tp_new: *mut c_void, // 312 + pub tp_free: *mut c_void, // 320 + pub tp_is_gc: *mut c_void, // 328 + pub tp_bases: *mut PyObject, // 336 + pub tp_mro: *mut PyObject, // 344 + pub tp_cache: *mut PyObject, // 352 + pub tp_subclasses: *mut c_void, // 360 + pub tp_weaklist: *mut PyObject, // 368 + pub tp_del: *mut c_void, // 376 + pub tp_version_tag: u64, // 384 (unsigned int + pad) + pub tp_finalize: *mut c_void, // 392 + pub tp_vectorcall: *mut c_void, // 400 + /// `unsigned char tp_watched` + `uint16_t tp_versions_used` + pad. + pub tp_tail: [u8; 8], // 408 + // --- WeavePy private fields (offset >= 416, invisible to C) --- + /// Extension-supplied [`crate::ffi::PyType_Slot`] table, or null. pub tp_slots: *mut PyType_Slot, - /// Bridge to the WeavePy native type. Boxed - /// `Rc` (Rc keeps refcount); the box is leaked - /// when the type is materialised. For heap types whose lifetime - /// is bound to an extension's scope we drop this box on - /// `tp_free`; static types have a sentinel that's never freed. + /// Bridge to the WeavePy native type. Boxed `Rc`. pub bridge: *mut Rc, - /// Static type vs. heap-allocated type marker. Set to - /// `IMMORTAL_REFCNT` for static types so the refcount machinery - /// is a no-op. - _filler: [usize; 4], + _filler: [usize; 2], } unsafe impl Sync for PyTypeObject {} +impl PyTypeObject { + /// A fully-zeroed faithful type with only the head set. Used as the + /// `..` base for the initialisers so each site spells out just the + /// fields it cares about. + pub const fn new_zeroed() -> Self { + PyTypeObject { + head: PyObject { + ob_refcnt: IMMORTAL_REFCNT, + ob_type: ptr::null_mut(), + }, + ob_size: 0, + tp_name: ptr::null(), + tp_basicsize: 0, + tp_itemsize: 0, + tp_dealloc: None, + tp_vectorcall_offset: 0, + tp_getattr: ptr::null_mut(), + tp_setattr: ptr::null_mut(), + tp_as_async: ptr::null_mut(), + tp_repr: ptr::null_mut(), + tp_as_number: ptr::null_mut(), + tp_as_sequence: ptr::null_mut(), + tp_as_mapping: ptr::null_mut(), + tp_hash: ptr::null_mut(), + tp_call: ptr::null_mut(), + tp_str: ptr::null_mut(), + tp_getattro: ptr::null_mut(), + tp_setattro: ptr::null_mut(), + tp_as_buffer: ptr::null_mut(), + tp_flags: 0, + tp_doc: ptr::null(), + tp_traverse: ptr::null_mut(), + tp_clear: ptr::null_mut(), + tp_richcompare: ptr::null_mut(), + tp_weaklistoffset: 0, + tp_iter: ptr::null_mut(), + tp_iternext: ptr::null_mut(), + tp_methods: ptr::null_mut(), + tp_members: ptr::null_mut(), + tp_getset: ptr::null_mut(), + tp_base: ptr::null_mut(), + tp_dict: ptr::null_mut(), + tp_descr_get: ptr::null_mut(), + tp_descr_set: ptr::null_mut(), + tp_dictoffset: 0, + tp_init: ptr::null_mut(), + tp_alloc: ptr::null_mut(), + tp_new: ptr::null_mut(), + tp_free: ptr::null_mut(), + tp_is_gc: ptr::null_mut(), + tp_bases: ptr::null_mut(), + tp_mro: ptr::null_mut(), + tp_cache: ptr::null_mut(), + tp_subclasses: ptr::null_mut(), + tp_weaklist: ptr::null_mut(), + tp_del: ptr::null_mut(), + tp_version_tag: 0, + tp_finalize: ptr::null_mut(), + tp_vectorcall: ptr::null_mut(), + tp_tail: [0; 8], + tp_slots: ptr::null_mut(), + bridge: ptr::null_mut(), + _filler: [0; 2], + } + } +} + +/// Cross-check the live [`PyTypeObject`] against the machine-checked +/// faithful spec in [`crate::layout`]. Compile-time; zero runtime cost. +const _: () = { + use crate::layout::PyTypeObjectFull as F; + assert!(std::mem::offset_of!(PyTypeObject, tp_name) == std::mem::offset_of!(F, tp_name)); + assert!( + std::mem::offset_of!(PyTypeObject, tp_basicsize) == std::mem::offset_of!(F, tp_basicsize) + ); + assert!( + std::mem::offset_of!(PyTypeObject, tp_itemsize) == std::mem::offset_of!(F, tp_itemsize) + ); + assert!(std::mem::offset_of!(PyTypeObject, tp_dealloc) == std::mem::offset_of!(F, tp_dealloc)); + assert!(std::mem::offset_of!(PyTypeObject, tp_flags) == std::mem::offset_of!(F, tp_flags)); + assert!(std::mem::offset_of!(PyTypeObject, tp_base) == std::mem::offset_of!(F, tp_base)); + assert!( + std::mem::offset_of!(PyTypeObject, tp_finalize) == std::mem::offset_of!(F, tp_finalize) + ); + assert!( + std::mem::offset_of!(PyTypeObject, tp_vectorcall) == std::mem::offset_of!(F, tp_vectorcall) + ); + // The private fields must begin at or after the faithful region. + assert!(std::mem::offset_of!(PyTypeObject, tp_slots) >= std::mem::size_of::()); +}; + /// Re-export of the C `PyType_Slot` shape. #[repr(C)] #[derive(Debug, Clone, Copy)] @@ -131,19 +275,7 @@ unsafe impl Sync for StaticType {} impl StaticType { pub const fn new() -> Self { - Self(UnsafeCell::new(PyTypeObject { - head: PyObject { - ob_refcnt: IMMORTAL_REFCNT, - ob_type: ptr::null_mut(), - }, - tp_name: ptr::null(), - tp_basicsize: 0, - tp_itemsize: 0, - tp_flags: 0, - tp_slots: ptr::null_mut(), - bridge: ptr::null_mut(), - _filler: [0; 4], - })) + Self(UnsafeCell::new(PyTypeObject::new_zeroed())) } pub fn as_ptr(&self) -> *mut PyTypeObject { @@ -189,6 +321,29 @@ decl_static_type! { pub PyGen_Type; pub PyCoro_Type; pub PyAsyncGen_Type; + // RFC 0047 (wave 5): the umbrella type for WeavePy's native iterators + // (`Object::Iter` — list/tuple/range/dict/set/... iterators). Macro-heavy + // Cython reads `Py_TYPE(it)->tp_iternext` *directly* in its `for` loop and + // `next()` codegen; without a non-NULL slot a compiled `for x in range(n)` + // (numpy.random's `SeedSequence.generate_state`) jumps to its error label + // with no exception set. The type carries `tp_iter` (return self) + + // `tp_iternext` (→ `PyIter_Next`). + pub PySeqIter_Type; + // RFC 0046 (wave 4): types numpy's `_multiarray_umath` references by + // address (`Py_TYPE(x) == &PyMemoryView_Type`, descriptor/proxy slots). + pub PyMemoryView_Type; + pub PyDictProxy_Type; + pub PyGetSetDescr_Type; + pub PyMemberDescr_Type; + pub PyMethodDescr_Type; + // RFC 0047 (wave 5): `wrapper_descriptor` (slot wrappers like + // `object.__init__`); numpy.random / pandas reference the type by + // address. A synthetic minimal type satisfies symbol + identity. + pub PyWrapperDescr_Type; + // RFC 0046 (wave 4): tags a `PyModuleDef` returned by + // `PyModuleDef_Init`, so the loader can recognise a multi-phase + // (PEP 489) extension and run its create/exec slots. + pub PyModuleDef_Type; } /// Initialise the static type table from the running interpreter's @@ -210,6 +365,12 @@ pub fn init_static_types() { ty.head.ob_refcnt = IMMORTAL_REFCNT; ty.head.ob_type = PyType_Type.as_ptr(); ty.tp_name = name.as_ptr() as *const c_char; + // `_Py_Dealloc` (stock inlined `Py_DECREF`) reads + // `Py_TYPE(inst)->tp_dealloc`; route to the host free path so + // a stock extension that drops the last ref to one of our + // objects releases the mirror/box instead of jumping through + // a garbage slot. + ty.tp_dealloc = Some(crate::object::_PyWeavePy_Dealloc); ty.bridge = Box::into_raw(Box::new(rc)); } } @@ -239,10 +400,18 @@ pub fn init_static_types() { bt.async_generator_.clone(), ); - // The complex/NotImplemented/Ellipsis/Capsule/CFunction/Slice/Method - // types don't correspond directly to BuiltinTypes entries; - // we synthesise minimal native types so `type(Py_None) is _PyNone_Type` - // (and friends) round-trips correctly. + // `complex` bridges to the VM's real builtin `complex` (like `float`), + // so `PyComplex_Type`'s bridged type is *identity-equal* to the VM + // `complex`. numpy's `complex128` dual-inherits from it + // (`tp_bases == (complexfloating, complex)`), and + // `isinstance(np.complex128(z), complex)` / `issubclass(...)` only hold + // when the MRO's `complex` entry *is* the builtin one. + install(&PyComplex_Type, b"complex\0", bt.complex_.clone()); + + // The NotImplemented/Ellipsis/Capsule/CFunction/Slice/Method types + // don't correspond directly to BuiltinTypes entries; we synthesise + // minimal native types so `type(Py_None) is _PyNone_Type` (and + // friends) round-trips correctly. fn install_synth(slot: &StaticType, name: &'static [u8], display: &str) { let rc = TypeObject::new_builtin( display, @@ -251,7 +420,6 @@ pub fn init_static_types() { .expect("synthetic builtin type must linearise"); install(slot, name, rc); } - install_synth(&PyComplex_Type, b"complex\0", "complex"); install_synth( &_PyNotImplemented_Type, b"NotImplementedType\0", @@ -266,6 +434,197 @@ pub fn init_static_types() { "builtin_function_or_method", ); install_synth(&PyMethod_Type, b"method\0", "method"); + // RFC 0046 (wave 4): numpy references these by address. Synthetic + // minimal types are enough for symbol resolution + pointer identity. + install_synth(&PyMemoryView_Type, b"memoryview\0", "memoryview"); + install_synth(&PyDictProxy_Type, b"mappingproxy\0", "mappingproxy"); + install_synth( + &PyGetSetDescr_Type, + b"getset_descriptor\0", + "getset_descriptor", + ); + install_synth( + &PyMemberDescr_Type, + b"member_descriptor\0", + "member_descriptor", + ); + install_synth( + &PyMethodDescr_Type, + b"method_descriptor\0", + "method_descriptor", + ); + install_synth( + &PyWrapperDescr_Type, + b"wrapper_descriptor\0", + "wrapper_descriptor", + ); + install_synth(&PyModuleDef_Type, b"moduledef\0", "moduledef"); + install_synth(&PySeqIter_Type, b"iterator\0", "iterator"); + + // RFC 0047 (wave 5): wire the iteration protocol (`tp_iter` → self, + // `tp_iternext` → `PyIter_Next`) onto the iterator umbrella type and the + // generator type. Stock Cython reads these slots off `Py_TYPE(it)` + // directly when compiling `for`/`next()`, so a WeavePy iterator handed to + // a C extension must advertise them or the loop silently errors. + unsafe { + crate::builtin_slots::install_iterator(&PySeqIter_Type); + crate::builtin_slots::install_iterator(&PyGen_Type); + } + + // RFC 0047 (wave 5): wire the descriptor `tp_descr_get` onto the + // callable types so a WeavePy function / instance-binding builtin method + // found via `_PyType_Lookup` *binds* to the instance. Stock Cython's + // special-method protocol (`with`, `for`, operator dunders) reads this + // slot directly off `Py_TYPE(descr)`; without it a bound special method + // (a `threading.Lock`'s `__exit__`) was used unbound and called with no + // `self`, failing inside extension module init. + unsafe { + (*PyFunction_Type.as_ptr()).tp_descr_get = callable_descr_get as *mut c_void; + (*PyCFunction_Type.as_ptr()).tp_descr_get = callable_descr_get as *mut c_void; + (*PyMethodDescr_Type.as_ptr()).tp_descr_get = callable_descr_get as *mut c_void; + } + + // Set the `Py_TPFLAGS_*_SUBCLASS` fast-subclass bits (and a + // baseline `Py_TPFLAGS_DEFAULT`) on the built-in static types. + // Stock CPython 3.13 headers inline the `PyX_Check` family as + // `PyType_FastSubclass(Py_TYPE(o), Py_TPFLAGS_X_SUBCLASS)`, reading + // `tp_flags` directly, and the *full*-API macros (`PyTuple_GET_ITEM` + // etc.) `assert(PyX_Check(o))` in non-`NDEBUG` builds. Without these + // bits a stock extension aborts the moment it touches one of our + // objects. (RFC 0043 WS3/WS4.) + use crate::layout::tpflags; + unsafe fn add_flags(slot: &StaticType, flags: u64) { + unsafe { + (*slot.as_ptr()).tp_flags |= tpflags::DEFAULT | flags; + } + } + unsafe { + add_flags(&PyType_Type, tpflags::TYPE_SUBCLASS | tpflags::BASETYPE); + add_flags(&PyBaseObject_Type, tpflags::BASETYPE); + add_flags(&PyLong_Type, tpflags::LONG_SUBCLASS | tpflags::BASETYPE); + // bool is an int subclass, so `PyLong_Check(True)` must hold. + add_flags(&PyBool_Type, tpflags::LONG_SUBCLASS); + add_flags(&PyList_Type, tpflags::LIST_SUBCLASS | tpflags::BASETYPE); + add_flags(&PyTuple_Type, tpflags::TUPLE_SUBCLASS | tpflags::BASETYPE); + add_flags(&PyBytes_Type, tpflags::BYTES_SUBCLASS | tpflags::BASETYPE); + add_flags( + &PyUnicode_Type, + tpflags::UNICODE_SUBCLASS | tpflags::BASETYPE, + ); + add_flags(&PyDict_Type, tpflags::DICT_SUBCLASS | tpflags::BASETYPE); + // Types that have no fast-subclass bit still want DEFAULT. + add_flags(&PyFloat_Type, tpflags::BASETYPE); + add_flags(&PyComplex_Type, tpflags::BASETYPE); + add_flags(&PyByteArray_Type, tpflags::BASETYPE); + add_flags(&PySet_Type, tpflags::BASETYPE); + add_flags(&PyFrozenSet_Type, 0); + // The iterator umbrella type needs the baseline DEFAULT feature bit so + // `PyType_HasFeature`/`PyIter_Check` behave; no fast-subclass bit. + add_flags(&PySeqIter_Type, 0); + } + + // RFC 0047 (wave 5): faithful `tp_basicsize` / `tp_itemsize` for the + // static built-ins. A Cython extension imports a builtin and validates + // it with `__Pyx_ImportType`, which reads `Py_TYPE(builtins.X)-> + // tp_basicsize` and raises `ValueError("X size changed, may indicate + // binary incompatibility. Expected N from C header, got M from + // PyObject")` when the live value is smaller than the `sizeof(...)` its + // stock CPython 3.13 headers baked in (numpy.random's `bit_generator` + // checks `builtins.type` == `sizeof(PyHeapTypeObject)` = 928, and a + // zero tripped it). WeavePy stores these objects as managed + // `PyObjectBox` mirrors, so the field is **reporting-only**: it never + // diverts allocation, which is gated by the separate `INLINE_TYPES` + // registry these are never entered into. The values mirror stock + // CPython 3.13 (`.__basicsize__` / `.__itemsize__`) byte-for-byte. + unsafe fn set_size(slot: &StaticType, basicsize: PySsizeT, itemsize: PySsizeT) { + unsafe { + let ty = &mut *slot.as_ptr(); + ty.tp_basicsize = basicsize; + ty.tp_itemsize = itemsize; + } + } + unsafe { + set_size(&PyType_Type, 928, 40); + set_size(&PyBaseObject_Type, 16, 0); + set_size(&PyLong_Type, 24, 4); + set_size(&PyFloat_Type, 24, 0); + set_size(&PyBool_Type, 24, 4); + set_size(&PyComplex_Type, 32, 0); + set_size(&PyUnicode_Type, 64, 0); + set_size(&PyBytes_Type, 33, 1); + set_size(&PyByteArray_Type, 56, 0); + set_size(&PyTuple_Type, 24, 8); + set_size(&PyList_Type, 40, 0); + set_size(&PyDict_Type, 48, 0); + set_size(&PySet_Type, 200, 0); + set_size(&PyFrozenSet_Type, 200, 0); + } + + // RFC 0046 (wave 4): give the exported value built-ins a faithful + // `tp_new`. A C type that subclasses one of them (numpy's + // `float64 ← float`, `str_ ← str`, `bytes_ ← bytes`) inherits and may + // directly call the base's `tp_new`; a NULL slot is a jump through + // address 0 (`np.float64(1.0)` and numpy's import self-checks crash). + crate::builtin_new::install_builtin_constructors(); + + // RFC 0047 (wave 5): populate the C-level protocol suites + // (`tp_as_sequence`/`tp_as_mapping`/`tp_iter`) on the built-in + // containers. Macro-heavy Cython reads these slots directly off the + // type struct (`__Pyx_PyObject_GetItem` → `tp_as_mapping->mp_subscript`), + // so without them a WeavePy list/tuple/dict appears "not subscriptable" + // to an extension even though the VM handles the operation. + crate::builtin_slots::install(); + + // RFC 0047 (wave 5): populate `tp_as_number` on the exported numeric + // built-ins (`int`/`float`/`bool`/`complex`). Macro-heavy Cython casts a + // scalar to a C integer/double by reading `Py_TYPE(x)->tp_as_number->nb_int` + // (`__Pyx_PyNumber_IntOrLong`) / `nb_float` directly; a NULL suite made + // `(some_float)` raise "an integer is required" — the exact break + // in pandas' `Timedelta("1 day")` string parser (`cast_from_unit`). + crate::builtin_slots::install_numbers(); + + // RFC 0047 (wave 5): wire `tp_repr` / `tp_str` / `tp_hash` on every + // exported built-in static type. Stock Cython/C code reaches the + // stringify + hash slots *directly* off `Py_TYPE(o)` — e.g. pandas' + // `lib.ensure_string_array` compiles `f"{val}"` on an `int`/`float` + // element to `Py_TYPE(val)->tp_repr(val)` (via `PyObject_Format` → + // `object.__format__` → `PyObject_Str` → `tp_str`/`tp_repr`), and dict + // /set keying reads `Py_TYPE(key)->tp_hash`. A NULL slot is a jump + // through address 0 (`s.astype(str)` on an int64 Series crashed with + // `pc=0x0` inside `ensure_string_array.cold`). The `synth_*` bridges + // forward to `PyObject_Repr` / `PyObject_Str` / `PyObject_Hash`, which + // dispatch on the runtime `Object` enum and never re-read these C slots, + // so the forward is recursion-safe. Only fill a slot left NULL so a + // faithful per-type slot (datetime, `PyType_FromSpec`) is untouched; + // `list`/`dict`/`set` get the hash bridge too, which raises + // `unhashable type` via the VM exactly as CPython's + // `PyObject_HashNotImplemented` does. + // `object.__hash__` / `type.__hash__` are CPython's identity hash + // (`_Py_HashPointer`), **not** the VM-forwarding `synth_tp_hash` bridge. + // A stock extension reads `PyBaseObject_Type->tp_hash` straight off the + // struct and calls it directly (numpy's `datetime`/`timedelta` scalar hash + // does this for a `NaT`); the forwarding bridge would ping-pong `object + // slot → PyObject_Hash → py_hash_value(Foreign) → fwd_hash → numpy slot → + // object slot` and overflow the stack. Set them before the NULL-fill loop + // so the loop leaves them untouched. See [`object_generic_hash`]. + unsafe { + (*PyBaseObject_Type.as_ptr()).tp_hash = object_generic_hash as *mut c_void; + (*PyType_Type.as_ptr()).tp_hash = object_generic_hash as *mut c_void; + } + unsafe { + for slot in STATIC_TYPE_TABLE { + let ty = &mut *slot.as_ptr(); + if ty.tp_repr.is_null() { + ty.tp_repr = synth_tp_repr as *mut c_void; + } + if ty.tp_str.is_null() { + ty.tp_str = synth_tp_str as *mut c_void; + } + if ty.tp_hash.is_null() { + ty.tp_hash = synth_tp_hash as *mut c_void; + } + } + } } /// Map a runtime [`Object`] to the static [`PyTypeObject`] pointer @@ -288,18 +647,36 @@ pub fn type_for_object(o: &Object) -> *mut PyTypeObject { O::Set(_) => PySet_Type.as_ptr(), O::FrozenSet(_) => PyFrozenSet_Type.as_ptr(), O::Range(_) => PyRange_Type.as_ptr(), + O::MemoryView(_) => PyMemoryView_Type.as_ptr(), O::Module(_) => PyModule_Type.as_ptr(), O::Function(_) => PyFunction_Type.as_ptr(), O::Builtin(_) => PyCFunction_Type.as_ptr(), O::BoundMethod(_) => PyMethod_Type.as_ptr(), O::Generator(_) => PyGen_Type.as_ptr(), + O::Iter(_) => PySeqIter_Type.as_ptr(), O::Coroutine(_) => PyCoro_Type.as_ptr(), O::AsyncGenerator(_) => PyAsyncGen_Type.as_ptr(), O::Slice(_) => PySlice_Type.as_ptr(), O::Type(t) => find_type_ptr(t).unwrap_or_else(|| PyType_Type.as_ptr()), O::Instance(inst) => { - find_type_ptr(&inst.cls()).unwrap_or_else(|| PyBaseObject_Type.as_ptr()) + let cls = inst.cls(); + // RFC 0029 (wave 5): an instance crosses wearing its *real* type, + // not a bare `object`. A pure-Python subclass of a faithful C base + // — pytz's `UTC ← BaseTzInfo ← datetime.tzinfo`, consumed by + // pandas' `cdef tzinfo utc_pytz = pytz.utc` (a Cython + // `__Pyx_TypeTest(obj, datetime.tzinfo)`) — only passes the C-side + // subtype check if `Py_TYPE(obj)`'s `tp_base` chain reaches the + // `tzinfo` shell. `install_user_type` builds that chain (minting + // intermediate bases), so use it as the final fallback rather than + // collapsing every unregistered instance to `object`. + find_type_ptr(&cls) + .or_else(|| synth_type_for_class(&cls)) + .unwrap_or_else(|| install_user_type(&cls)) } + // RFC 0045 (wave 3): capsules round-trip as their retained box in + // `into_owned`, but report the faithful `PyCapsule_Type` for any + // direct `Py_TYPE`-style query that reaches here. + O::Capsule(_) => PyCapsule_Type.as_ptr(), _ => PyBaseObject_Type.as_ptr(), } } @@ -309,6 +686,13 @@ pub fn type_for_object(o: &Object) -> *mut PyTypeObject { /// [`type_for_object`]. Linear in the number of registered types /// (small static set + however many `PyType_FromSpec` produced). fn find_type_ptr(t: &Rc) -> Option<*mut PyTypeObject> { + // RFC 0029 (wave 5): the `datetime` module's classes resolve to the + // faithful, size-correct C types (minted on first use). Authoritative + // and identity-checked, so it runs *before* the generic registry scan + // and never collides with a coincidentally-named user class. + if let Some(p) = crate::datetime_api::faithful_type_for_class(t) { + return Some(p); + } let target = Rc::as_ptr(t); for slot in STATIC_TYPE_TABLE { let p = slot.as_ptr(); @@ -319,8 +703,9 @@ fn find_type_ptr(t: &Rc) -> Option<*mut PyTypeObject> { } } } - HEAP_TYPES.with(|cell| { - for &p in cell.borrow().iter() { + let from_heap = HEAP_TYPES.lock().ok().and_then(|g| { + for &addr in g.iter() { + let p = addr as *mut PyTypeObject; unsafe { let bridge = (*p).bridge; if !bridge.is_null() && Rc::as_ptr(&*bridge) == target { @@ -329,22 +714,49 @@ fn find_type_ptr(t: &Rc) -> Option<*mut PyTypeObject> { } } None + }); + if from_heap.is_some() { + return from_heap; + } + // Readied stock types (RFC 0044): match on the bridge and return + // the extension's own pointer so instances carry its `ob_type`. + READIED_TYPES.with(|cell| { + for rt in cell.borrow().iter() { + if Rc::as_ptr(&rt.bridge) == target { + if std::env::var_os("WEAVEPY_TRACE_TYPEPTR").is_some() { + eprintln!( + "[FINDPTR] readied match name={:?} ext_ptr={:p} bridge={:p}", + rt.bridge.name, rt.ext_ptr, target + ); + } + return Some(rt.ext_ptr); + } + } + None }) } -thread_local! { - /// Registry of heap-allocated `PyTypeObject *` produced by - /// `PyType_FromSpec[WithBases]`. Looked up by [`find_type_ptr`] - /// when an `Object::Instance` is materialised back into a - /// boxed `*mut PyObject`, so the box's `ob_type` matches the - /// extension's declared type. - /// - /// Heap types live forever (`Box::leak`'d at construction), - /// so we store raw pointers — they're stable for the process - /// lifetime. - static HEAP_TYPES: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; -} +/// Registry of heap-allocated `PyTypeObject *` produced by +/// `PyType_FromSpec[WithBases]` (and the bridged `PyExc_*` statics, +/// installed via [`install_user_type`]). Looked up by [`find_type_ptr`] +/// when an `Object::Instance` is materialised back into a boxed +/// `*mut PyObject`, so the box's `ob_type` matches the extension's +/// declared type, and by [`is_weavepy_owned_type`] to decide whether +/// `bridge_type` may read the trailing `bridge` field. +/// +/// RFC 0046 (wave 4): process-global, **not** thread-local. A heap +/// type's identity is a property of the process, not of one OS thread: +/// the boxes are `Box::leak`'d (immortal, stable pointers) and their +/// bridge is an `Arc` (`Send + Sync`). The `PyExc_*` +/// statics in particular are published once (under a global lock) on +/// whatever thread first initialises the runtime, then read from +/// *every* thread — so a thread-local registry made +/// `clone_object(PyExc_ValueError)` resolve to a foreign proxy on any +/// other thread, collapsing `PyErr_SetString(PyExc_ValueError, …)` to a +/// bare `RuntimeError`. Stored as `usize` addresses so the `static` is +/// `Send` (mirrors [`crate::object`]'s `MINTED` set). The interpreter +/// runs single-threaded under the GIL, so the mutex is uncontended. +static HEAP_TYPES: Mutex> = Mutex::new(Vec::new()); /// Register a heap-allocated type pointer so subsequent /// `Object::Instance` boxes get the extension's declared @@ -353,7 +765,343 @@ pub fn register_heap_type(p: *mut PyTypeObject) { if p.is_null() { return; } - HEAP_TYPES.with(|cell| cell.borrow_mut().push(p)); + if let Ok(mut g) = HEAP_TYPES.lock() { + if !g.contains(&(p as usize)) { + g.push(p as usize); + } + } +} + +// --------------------------------------------------------------------------- +// RFC 0047 (wave 5): synthesize a faithful C `PyTypeObject` for a *Python* +// class that crosses into a C extension. +// +// WeavePy classes (incl. stdlib ones written in Python like +// `itertools.cycle`) have no `PyType_FromSpec`-registered C type, so a +// bare `Object::Instance` previously crossed into C wearing +// `PyBaseObject_Type`. Macro-heavy Cython then reads protocol slots +// (`Py_TYPE(it)->tp_iternext`, `->tp_call`, …) straight off that struct, +// finds them NULL, and silently errors (`numpy.random.SeedSequence. +// generate_state` iterates `cycle(self.pool)`). +// +// We mint one immortal type per class, populated from the class's dunders +// with bridges that forward to the recursion-safe abstract C-API (which +// dispatches on the Rust `Object`, never re-reading these slots). Scoped +// to iterables/iterators to keep the blast radius small: every other +// instance keeps the historic `PyBaseObject_Type` crossing. + +unsafe extern "C" fn synth_tp_iter(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_GetIter(o) } +} +unsafe extern "C" fn synth_tp_iternext(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyIter_Next(o) } +} +unsafe extern "C" fn synth_tp_call( + o: *mut PyObject, + a: *mut PyObject, + k: *mut PyObject, +) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_Call(o, a, k) } +} +unsafe extern "C" fn synth_tp_repr(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_Repr(o) } +} +unsafe extern "C" fn synth_tp_str(o: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_Str(o) } +} +unsafe extern "C" fn synth_tp_hash(o: *mut PyObject) -> crate::object::PyHashT { + unsafe { crate::abstract_::PyObject_Hash(o) } +} + +/// `object.__hash__` — CPython's `_Py_HashPointer` identity hash, used for +/// `PyBaseObject_Type.tp_hash`. +/// +/// This must be **self-contained** (no re-entry into the VM), because stock C +/// extensions read `PyBaseObject_Type->tp_hash` *directly off the struct* and +/// call it — numpy's `datetime_arrtype_hash`/`timedelta_arrtype_hash` do +/// exactly this for a `NaT` scalar (`Py_TYPE == object`'s slot, hash by +/// identity). Routing that through the VM-forwarding `synth_tp_hash` would +/// re-enter `PyObject_Hash → py_hash_value(Foreign) → fwd_hash → the numpy +/// slot → object.tp_hash` and recurse until the stack overflows. +/// +/// The rotate-right-by-4 of the object address matches both CPython's +/// `_Py_HashPointer` and WeavePy's VM-side [`weavepy_vm::object::identity_hash`] +/// for a foreign object (keyed on the same `PyObject*`), so a value hashed +/// through this C slot agrees bit-for-bit with the VM's identity hash of the +/// same object — dict/set keying stays consistent across the C↔VM boundary. +pub(crate) unsafe extern "C" fn object_generic_hash(o: *mut PyObject) -> crate::object::PyHashT { + let u = o as usize as u64; + let v = u.rotate_right(4) as crate::object::PyHashT; + if v == -1 { + -2 + } else { + v + } +} + +/// Address of the VM-forwarding `tp_hash` bridge. `hash_via_slot` uses it to +/// short-circuit the shim for a *foreign* object: such an object's VM hash +/// (`py_hash_value` → `foreign::hash` → `fwd_hash` → `hash_via_slot`) would +/// otherwise re-enter this bridge (`synth_tp_hash` → `PyObject_Hash` → +/// `hash_public` → `py_hash_value`) and ping-pong `VM → C → VM` until the +/// stack overflows. The bridge adds nothing over the VM's own dispatch for a +/// foreign value, so it must be treated as "no native slot". +pub(crate) fn synth_tp_hash_addr() -> *mut c_void { + synth_tp_hash as *mut c_void +} +unsafe extern "C" fn synth_length(o: *mut PyObject) -> PySsizeT { + unsafe { crate::abstract_::PyObject_Length(o) } +} +unsafe extern "C" fn synth_subscript(o: *mut PyObject, k: *mut PyObject) -> *mut PyObject { + unsafe { crate::abstract_::PyObject_GetItem(o, k) } +} +unsafe extern "C" fn synth_ass_subscript( + o: *mut PyObject, + k: *mut PyObject, + v: *mut PyObject, +) -> c_int { + unsafe { crate::abstract_::PyObject_SetItem(o, k, v) } +} + +/// `tp_descr_get` for WeavePy callables (plain functions, instance-binding +/// builtin methods, method descriptors) that cross into a C extension as a +/// type-dict entry. +/// +/// CPython's special-method protocol — the one Cython emits for `with`, +/// `for`, operators (`__Pyx_PyObject_LookupSpecial`) — does +/// `res = _PyType_Lookup(tp, name); f = Py_TYPE(res)->tp_descr_get; res = +/// f(res, obj, tp);` to **bind** the found descriptor to the instance. With +/// no `tp_descr_get` wired on the function/method types the descriptor was +/// taken *unbound*, so a bound special method (e.g. a lock's `__exit__`) was +/// then called with `self` missing — surfacing as `AttributeError`/`TypeError` +/// deep inside an extension's module init. This mirrors CPython's +/// `func_descr_get` / `method_get`: bind to `obj` (yielding a `method`), or +/// return the descriptor unchanged for class access (`obj == NULL`/`None`) and +/// for a non-instance-binding builtin (a static/module function). +unsafe extern "C" fn callable_descr_get( + descr: *mut PyObject, + obj: *mut PyObject, + _type: *mut PyObject, +) -> *mut PyObject { + if std::env::var_os("WEAVEPY_TRACE_CTOR").is_some() { + let dty = if descr.is_null() { + ptr::null_mut() + } else { + unsafe { (*descr).ob_type } + }; + eprintln!( + "[DESCRGET] descr={descr:p} descr.ob_type={dty:p} obj={obj:p} type={_type:p}" + ); + } + let trace = std::env::var_os("WEAVEPY_TRACE_CTOR").is_some(); + if descr.is_null() { + return ptr::null_mut(); + } + // Class access (`Type.method`) yields the descriptor unchanged. + if obj.is_null() { + unsafe { crate::object::Py_IncRef(descr) }; + return descr; + } + if trace { + eprintln!("[DESCRGET] step=clone_obj"); + } + let receiver = unsafe { crate::object::clone_object(obj) }; + if trace { + eprintln!("[DESCRGET] step=clone_obj_done recv={}", receiver.type_name()); + } + if matches!(receiver, Object::None) { + unsafe { crate::object::Py_IncRef(descr) }; + return descr; + } + if trace { + eprintln!("[DESCRGET] step=clone_descr"); + } + let d = unsafe { crate::object::clone_object(descr) }; + if trace { + eprintln!("[DESCRGET] step=clone_descr_done d={}", d.type_name()); + } + let bind = match &d { + Object::Function(_) => true, + Object::Builtin(b) => b.binds_instance, + _ => false, + }; + if !bind { + unsafe { crate::object::Py_IncRef(descr) }; + return descr; + } + if trace { + eprintln!("[DESCRGET] step=make_bound"); + } + let bound = Object::BoundMethod(Rc::new(BoundMethod::new(receiver, d))); + if trace { + eprintln!("[DESCRGET] step=into_owned"); + } + let r = crate::object::into_owned(bound); + if trace { + eprintln!("[DESCRGET] step=done r={r:p}"); + } + r +} + +/// Serialises synth-type creation so a class crossing concurrently mints +/// exactly one type. +static SYNTH_LOCK: Mutex<()> = Mutex::new(()); + +/// True when `cls` (or any base) defines `name` as a non-`None` attribute. +fn class_has_dunder(cls: &Rc, name: &str) -> bool { + !matches!(cls.lookup(name), None | Some(Object::None)) +} + +/// Populate a synthesised mirror's C-level protocol slots from `cls`'s +/// Python dunder methods. Macro-heavy extension code (Cython's +/// `__Pyx_PyObject_GetItem` → `Py_TYPE(o)->tp_as_mapping->mp_subscript`, +/// `__Pyx_PyObject_Call` → `tp_call`, the `for`/`with` slot reads) consults +/// these slots *directly* off `Py_TYPE(obj)`, bypassing the abstract API. A +/// mirror that leaves them NULL therefore looks e.g. "not subscriptable" / +/// "not callable" to an extension even though the VM implements the operation +/// (pandas' `lib.pyx` does `Literal[_NoDefault.no_default]` — a `__getitem__` +/// on the frozen `typing._SpecialForm` — during single-pass module exec). +/// Shared by [`synth_type_for_class`] and [`install_user_type`] so every +/// Python-class crossing exposes a faithful slot table regardless of which +/// mirror-minting path built it. Each `synth_*` bridge forwards back into the +/// VM's dispatch. +fn synth_protocol_slots(ty: &mut PyTypeObject, cls: &Rc) { + if class_has_dunder(cls, "__iter__") { + ty.tp_iter = synth_tp_iter as *mut c_void; + } + if class_has_dunder(cls, "__next__") { + ty.tp_iternext = synth_tp_iternext as *mut c_void; + // CPython iterators answer `iter(it) is it`; advertise tp_iter too. + if ty.tp_iter.is_null() { + ty.tp_iter = synth_tp_iter as *mut c_void; + } + } + if class_has_dunder(cls, "__call__") { + ty.tp_call = synth_tp_call as *mut c_void; + } + if class_has_dunder(cls, "__repr__") { + ty.tp_repr = synth_tp_repr as *mut c_void; + } + if class_has_dunder(cls, "__str__") { + ty.tp_str = synth_tp_str as *mut c_void; + } + if class_has_dunder(cls, "__hash__") { + ty.tp_hash = synth_tp_hash as *mut c_void; + } + let has_len = class_has_dunder(cls, "__len__"); + let has_getitem = class_has_dunder(cls, "__getitem__"); + let has_setitem = + class_has_dunder(cls, "__setitem__") || class_has_dunder(cls, "__delitem__"); + if has_len || has_getitem || has_setitem { + let mut mm: crate::layout::PyMappingMethods = unsafe { std::mem::zeroed() }; + if has_len { + mm.mp_length = synth_length as *mut c_void; + } + if has_getitem { + mm.mp_subscript = synth_subscript as *mut c_void; + } + if has_setitem { + mm.mp_ass_subscript = synth_ass_subscript as *mut c_void; + } + ty.tp_as_mapping = Box::into_raw(Box::new(mm)) as *mut c_void; + if has_len { + let mut sm: crate::layout::PySequenceMethods = unsafe { std::mem::zeroed() }; + sm.sq_length = synth_length as *mut c_void; + ty.tp_as_sequence = Box::into_raw(Box::new(sm)) as *mut c_void; + } + } +} + +/// Mint (or return the cached) faithful C type for a Python class whose +/// instances drive a C-level protocol Cython reads off `Py_TYPE(obj)`: +/// iteration (`__iter__`/`__next__`) or the context-manager protocol +/// (`__enter__`/`__exit__`, looked up via `_PyType_Lookup` for `with`). +/// Returns `None` for every other class, leaving the historic +/// `PyBaseObject_Type` crossing in place to keep the blast radius small. +pub(crate) fn synth_type_for_class(cls: &Rc) -> Option<*mut PyTypeObject> { + let is_iter = class_has_dunder(cls, "__iter__") || class_has_dunder(cls, "__next__"); + let is_ctx = class_has_dunder(cls, "__enter__") || class_has_dunder(cls, "__exit__"); + if !is_iter && !is_ctx { + return None; + } + let _guard = SYNTH_LOCK.lock().ok()?; + // Another thread may have minted + registered it between our caller's + // `find_type_ptr` miss and acquiring the lock. + if let Some(p) = find_type_ptr(cls) { + return Some(p); + } + + let meta = metaclass_ptr(cls); + // RFC 0045 (wave 5): a synthesised shell can itself subclass an inline C + // type — numpy's `class MaskedArray(ndarray)` is iterable, so it reaches + // *this* path (not `install_user_type`) yet must still get a faithful + // `tp_basicsize`-wide body. Without inheriting the base's inline layout + // its instances were plain 16-byte boxes; numpy's `PyArray_NewFromDescr` + // then wrote the `PyArrayObject` fields over the Rust `obj` payload and a + // later `clone_object` dereferenced the clobbered pointer (a SIGBUS in + // `numpy.ma.core`'s `array_view → __array_finalize__`). + let (inline_base_ptr, base_inline, basicsize) = inherit_inline_base_layout(cls, 16); + let mut ty = PyTypeObject::new_zeroed(); + ty.head.ob_refcnt = IMMORTAL_REFCNT; + ty.head.ob_type = meta; + let cname = std::ffi::CString::new(cls.name.clone()) + .unwrap_or_else(|_| std::ffi::CString::new("object").unwrap()); + ty.tp_name = cname.into_raw() as *const c_char; + ty.tp_basicsize = basicsize; + ty.tp_dealloc = Some(crate::object::_PyWeavePy_Dealloc); + // Mirror CPython's `PyType_Ready` `tp_alloc`/`tp_free` defaults so a + // foreign C `tp_new` subclassing this synthesised type can allocate + // through its `tp_alloc` slot (see `install_user_type`). + { + let alloc_fp: unsafe extern "C" fn(*mut PyTypeObject, PySsizeT) -> *mut PyObject = + crate::genericalloc::PyType_GenericAlloc; + let new_fp: unsafe extern "C" fn( + *mut PyTypeObject, + *mut PyObject, + *mut PyObject, + ) -> *mut PyObject = crate::genericalloc::PyType_GenericNew; + let free_fp: unsafe extern "C" fn(*mut c_void) = crate::memory::PyObject_Free; + ty.tp_alloc = alloc_fp as *mut c_void; + // Inherit the solid (best_base) inline base's faithful `tp_new` so + // its `cdef object` fields are initialised (see the matching note in + // `install_user_type`). Non-inline shells keep the generic allocator. + ty.tp_new = if base_inline { + let inherited = inline_base_ptr + .filter(|bp| !bp.is_null()) + .map(|bp| unsafe { (*bp).tp_new }) + .unwrap_or(ptr::null_mut()); + if inherited.is_null() { + new_fp as *mut c_void + } else { + inherited + } + } else { + new_fp as *mut c_void + }; + ty.tp_free = free_fp as *mut c_void; + } + ty.tp_flags = crate::layout::tpflags::DEFAULT | crate::layout::tpflags::BASETYPE; + // Point `tp_base` at the faithful inline base (so `PyType_IsSubtype` + // and numpy's `PyArray_Check` walk to `ndarray`); a non-inline shell + // keeps the historic bare-`object` base to bound the blast radius. + ty.tp_base = if base_inline { + inline_base_ptr.unwrap_or_else(|| PyBaseObject_Type.as_ptr()) + } else { + PyBaseObject_Type.as_ptr() + }; + ty.bridge = Box::into_raw(Box::new(cls.clone())); + + synth_protocol_slots(&mut ty, cls); + + let p = Box::into_raw(Box::new(ty)); + register_heap_type(p); + // Mirror the inline base: instances need the same faithful inline body + // (RFC 0045) so fixed-offset field reads/writes land on real + // CPython-shaped memory rather than the Rust `obj` payload. + if base_inline { + maybe_register_inline_type(p); + } + Some(p) } /// Static table used by [`find_type_ptr`]. Listed once so we don't @@ -386,6 +1134,7 @@ static STATIC_TYPE_TABLE: &[&StaticType] = &[ &PyGen_Type, &PyCoro_Type, &PyAsyncGen_Type, + &PySeqIter_Type, ]; /// Borrow the bridged native type from a [`PyTypeObject`]. @@ -397,6 +1146,21 @@ pub unsafe fn bridge_type(ty: *mut PyTypeObject) -> Option> { if ty.is_null() { return None; } + // Readied stock types (RFC 0044) keep their bridge in a side + // registry — their struct is only 416 bytes and has no `bridge` + // field to read. Check that first. + if let Some(rt) = readied_for(ty) { + return Some(rt.bridge.clone()); + } + // RFC 0046 (wave 4): the private `bridge` field sits at offset 424 — + // *past* the 416-byte stock `PyTypeObject`. A foreign extension type + // (numpy's metatypes, an un-readied static type) is exactly that stock + // size, so reading `.bridge` would be an out-of-bounds heap read (it + // surfaced as a misaligned-pointer fault on a numpy type). Only our own + // static/heap type boxes carry the field; decide by pointer identity. + if !is_weavepy_owned_type(ty) { + return None; + } let bridge = unsafe { (*ty).bridge }; if bridge.is_null() { return None; @@ -404,6 +1168,168 @@ pub unsafe fn bridge_type(ty: *mut PyTypeObject) -> Option> { Some(unsafe { (*bridge).clone() }) } +/// Resolve a `PyTypeObject*` to its bridged [`TypeObject`], readying it +/// on demand if it has not been bridged yet. +/// +/// CPython's `PyType_Ready` finalises a type's **bases before the type +/// itself** (`type_ready` → `type_ready_mro`, which `PyType_Ready`s each +/// base). A stock extension that readies a subtype before its base — +/// numpy registers `Float16DType` before `PyType_Ready(&PyArrayDescr_Type)` +/// has run in some import orders — would otherwise lose the base entirely: +/// `bridge_type` returns `None`, the bridged type silently falls back to +/// `object`, and the subtype's MRO collapses to `[Self, object]`. That +/// dropped every getset the base declares (e.g. `numpy.dtype.type`). +/// +/// SAFETY: `ty` must be null or a valid (own or stock) `PyTypeObject*`. +pub unsafe fn bridge_or_ready(ty: *mut PyTypeObject) -> Option> { + if ty.is_null() { + return None; + } + if let Some(t) = unsafe { bridge_type(ty) } { + return Some(t); + } + // Not yet bridged: ready it (idempotent) and retry. Foreign stock + // types get harvested; our own types short-circuit inside PyType_Ready. + unsafe { PyType_Ready(ty) }; + unsafe { bridge_type(ty) } +} + +/// Mirror CPython's `inherit_special` fast-subclass bit assignment +/// (`Objects/typeobject.c`): a finalised type carries exactly one +/// `Py_TPFLAGS_*_SUBCLASS` bit, for the most-derived builtin in its +/// ancestry, so a stock extension's inlined `PyX_Check` +/// (`Py_TYPE(o)->tp_flags & Py_TPFLAGS_X_SUBCLASS`) classifies it without +/// an MRO walk. The else-if order matches CPython exactly. In particular +/// Cython's `__Pyx_ImportType` rejects `numpy.dtype` ("is not a type +/// object") unless its metaclass `numpy._DTypeMeta` — a `type` subclass — +/// carries `Py_TPFLAGS_TYPE_SUBCLASS`, and `__Pyx_Raise` rejects a +/// `cdef`-raised exception unless its class carries +/// `Py_TPFLAGS_BASE_EXC_SUBCLASS`. +fn fast_subclass_flags(t: &Rc) -> u64 { + use crate::layout::tpflags; + let bt = weavepy_vm::builtin_types::builtin_types(); + if t.is_subclass_of(&bt.base_exception) { + tpflags::BASE_EXC_SUBCLASS + } else if t.is_subclass_of(&bt.type_) { + tpflags::TYPE_SUBCLASS + } else if t.is_subclass_of(&bt.int_) { + tpflags::LONG_SUBCLASS + } else if t.is_subclass_of(&bt.bytes_) { + tpflags::BYTES_SUBCLASS + } else if t.is_subclass_of(&bt.str_) { + tpflags::UNICODE_SUBCLASS + } else if t.is_subclass_of(&bt.tuple_) { + tpflags::TUPLE_SUBCLASS + } else if t.is_subclass_of(&bt.list_) { + tpflags::LIST_SUBCLASS + } else if t.is_subclass_of(&bt.dict_) { + tpflags::DICT_SUBCLASS + } else { + 0 + } +} + +/// The canonical C `PyTypeObject*` for `t`'s **metaclass**, used as a +/// type mirror's `ob_type`. +/// +/// CPython code (and Cython-generated code in particular) reads a class's +/// metaclass straight off `Py_TYPE(cls)`: `type(Enum)` must be `EnumType`, +/// `type(np.dtype)` must be `numpy._DTypeMeta`, and so on. Historically +/// every WeavePy type mirror hard-coded `ob_type = PyType_Type` (bare +/// `type`), so `__Pyx_CalculateMetaclass((Enum,))` resolved to `type`, +/// `type.__prepare__` returned a plain dict instead of `EnumType`'s +/// `_EnumDict`, and a Cython module defining a Python `class X(Enum)` +/// failed its multi-phase init with +/// `AttributeError: 'dict' object has no attribute '_member_names'`. +/// +/// The metaclass mirror is minted on demand. Recursion bottoms out at the +/// static `PyType_Type`: `type`'s own metaclass is `type`, and every +/// well-formed metaclass chain terminates there. +fn metaclass_ptr(t: &Rc) -> *mut PyTypeObject { + let mc = t.metaclass_or_type(); + let bt = weavepy_vm::builtin_types::builtin_types(); + if Rc::ptr_eq(&mc, &bt.type_) || Rc::ptr_eq(&mc, t) { + return PyType_Type.as_ptr(); + } + if let Some(p) = find_type_ptr(&mc) { + return p; + } + install_user_type(&mc) +} + +/// Resolve `t`'s first base to its canonical C `PyTypeObject*` and decide +/// whether `t` inherits that base's **inline `tp_basicsize` storage**. +/// +/// A pure-Python (VM) class can subclass an *inline* C extension type — +/// pandas' `class NaTType(_NaT)` (the `_NaT ← datetime` shell) or numpy's +/// `class MaskedArray(ndarray)`. CPython inherits `tp_basicsize`, so the +/// subclass instance carries the base's faithful C layout: a stock +/// `tp_new` packs fields at fixed offsets and the extension reads +/// `((BaseObject *)self)->field` directly. The mirror must therefore get a +/// faithful inline body, not a `PyObjectBox` (whose Rust payload sits +/// exactly where the extension expects `self->field`, so those writes +/// corrupt it — RFC 0045 / 0029). +/// +/// Returns `(base_ptr, base_inline, basicsize)`. When the base is inline, +/// `basicsize` is the base's `tp_basicsize`; otherwise it is +/// `non_inline_default`. An unregistered pure-Python intermediate base +/// (pytz `UTC ← BaseTzInfo ← tzinfo`) is minted via `install_user_type` +/// so the `tp_base` chain reaches the faithful root; the `object` root +/// (no bases) yields `None`. Shared by [`install_user_type`] and +/// [`synth_type_for_class`] so a VM subclass of an inline C type gets a +/// faithful body regardless of which mirror-minting path it takes. +fn inherit_inline_base_layout( + t: &Rc, + non_inline_default: PySsizeT, +) -> (Option<*mut PyTypeObject>, bool, PySsizeT) { + // Resolve a direct base to its canonical C `PyTypeObject*`, minting a + // mirror for a pure-Python intermediate base (one that itself has + // bases) so the layout probe reaches the faithful root; the `object` + // root (no bases) yields `None`. + let resolve = |b: &Rc| -> Option<*mut PyTypeObject> { + type_ptr_for_class(b).or_else(|| { + if b.bases.borrow().is_empty() { + None + } else { + Some(install_user_type(b)) + } + }) + }; + + let bases = t.bases.borrow(); + let first_ptr = bases.first().and_then(&resolve); + + // CPython's `best_base()`: the "solid base" is the base with the widest + // instance layout (`tp_basicsize`), scanned across *all* bases — not + // just the first. A VM class that lists an inline C extension type + // anywhere in its bases must inherit that faithful inline body. pandas' + // `class Block(PandasObject, libinternals.Block)` puts its inline + // Cython base *second* (the first base is the pure-Python + // `PandasObject`); probing only `bases.first()` left `Block` — and its + // subclasses `NumpyBlock`/`NumericBlock`/`ObjectBlock` — on a 16-byte + // `PyObjectBox`, so Cython's fixed-offset field writes (`_mgr_locs`, + // `values`, `refs`) clobbered the Rust `obj` payload and the later free + // dereferenced the garbage (SIGBUS). + let mut solid: Option<*mut PyTypeObject> = None; + let mut solid_size: PySsizeT = 0; + for b in bases.iter() { + if let Some(bp) = resolve(b) { + if is_inline_instance_type(bp) { + let sz = unsafe { (*bp).tp_basicsize }; + if solid.is_none() || sz > solid_size { + solid = Some(bp); + solid_size = sz; + } + } + } + } + + match solid { + Some(sp) => (Some(sp), true, solid_size), + None => (first_ptr, false, non_inline_default), + } +} + /// Find the static [`PyTypeObject`] pointer that bridges to `t`, /// installing one on demand for user-defined classes (e.g. heap /// types created without `PyType_FromSpec` — usually never; this is @@ -412,31 +1338,135 @@ pub fn install_user_type(t: &Rc) -> *mut PyTypeObject { if let Some(p) = find_type_ptr(t) { return p; } + // Resolve (minting if needed) the metaclass mirror *before* building + // this type's box so `Py_TYPE(t)` reports the real metaclass. + let meta = metaclass_ptr(t); let owned_name = format!("{}\0", t.name).into_bytes(); + // Stock extensions read `tp_flags` directly to classify a type. In + // particular Cython's `__Pyx_Raise` gates `raise exc` on + // `PyExceptionInstance_Check(x)` ≡ `Py_TYPE(x)->tp_flags & + // Py_TPFLAGS_BASE_EXC_SUBCLASS`, so a WeavePy exception type published + // here with `tp_flags == 0` makes every `raise RuntimeError(...)` from + // a `cdef` method fail with "exception class must be a subclass of + // BaseException". Mirror CPython: every readied type carries + // DEFAULT | BASETYPE | READY, and an exception subclass also carries + // the BASE_EXC_SUBCLASS fast-subclass bit. + use crate::layout::tpflags; + let flags = + tpflags::DEFAULT | tpflags::BASETYPE | tpflags::READY | fast_subclass_flags(t); + // RFC 0029 / 0045 (wave 5): inherit an *inline* C base's `tp_basicsize` + // and inline-storage status (see [`inherit_inline_base_layout`]) so a VM + // subclass of e.g. the `datetime` shell (pandas `NaTType`) or + // `numpy.ndarray` gets a faithful body, not a `PyObjectBox` the base's + // fixed-offset field writes would corrupt. A non-inline base keeps the + // identity-box size. + let (base_ptr, base_inline, basicsize) = inherit_inline_base_layout( + t, + std::mem::size_of::() as PySsizeT, + ); + // RFC 0046 (wave 4/5): mirror CPython's `PyType_Ready` defaults for + // `tp_alloc`/`tp_free` (inherited from `object`). A *foreign* C `tp_new` + // that subclasses this VM type allocates through `subtype->tp_alloc( + // subtype, 0)` directly — pandas' `class NAType(C_NAType)` runs the cdef + // base's `C_NAType.__pyx_tp_new`, which calls `NAType->tp_alloc`. With a + // NULL slot that is a jump through address 0 (SIGSEGV). `PyType_GenericAlloc` + // mints a faithful instance body bound to this type's bridged class. + let alloc_fp: unsafe extern "C" fn(*mut PyTypeObject, PySsizeT) -> *mut PyObject = + crate::genericalloc::PyType_GenericAlloc; + let new_fp: unsafe extern "C" fn(*mut PyTypeObject, *mut PyObject, *mut PyObject) -> *mut PyObject = + crate::genericalloc::PyType_GenericNew; + let free_fp: unsafe extern "C" fn(*mut c_void) = crate::memory::PyObject_Free; + // RFC 0047 (wave 5): mirror CPython's `tp_new` inheritance. CPython's + // `type_ready_inherit`/`inherit_special` copies `tp_new` down from + // `tp_base` — which for a multiply-inheriting class is the *solid base* + // (`best_base`), i.e. the base that owns the widest inline instance + // layout, **not** the first base on the MRO. `inherit_inline_base_layout` + // already resolved that solid base into `base_ptr`; adopt its faithful + // `tp_new` so the C struct fields it declares are initialised + // (Cython's `__pyx_tp_new` zeroes every `cdef object` field to `None`). + // + // This is the fix for pandas' `class MultiIndexUIntEngine( + // BaseMultiIndexCodesEngine, UInt64Engine)`: the first MRO base + // (`BaseMultiIndexCodesEngine`, no inline fields) has a `tp_new` that + // leaves the inherited `IndexEngine` fields (`values`/`mask`/`mapping`) + // NULL, so `IndexEngine.__init__`'s plain `__Pyx_DECREF(self->values)` + // dereferenced NULL. The solid base (`UInt64Engine ← IndexEngine`) owns + // those fields and its `tp_new` sets them to `None`. Only inline bases + // carry a faithful C `tp_new` worth inheriting; a non-inline base keeps + // the generic allocator (the shim still forwards to the base's captured + // slot for those, unchanged). + let tp_new_slot: *mut c_void = if base_inline { + let inherited = base_ptr + .filter(|bp| !bp.is_null()) + .map(|bp| unsafe { (*bp).tp_new }) + .unwrap_or(ptr::null_mut()); + if inherited.is_null() { + new_fp as *mut c_void + } else { + inherited + } + } else { + new_fp as *mut c_void + }; let bx = Box::new(PyTypeObjectBox { head: PyTypeObject { head: PyObject { ob_refcnt: IMMORTAL_REFCNT, - ob_type: PyType_Type.as_ptr(), + ob_type: meta, }, tp_name: owned_name.as_ptr() as *const c_char, - tp_basicsize: std::mem::size_of::() as PySsizeT, - tp_itemsize: 0, - tp_flags: 0, - tp_slots: ptr::null_mut(), + tp_basicsize: basicsize, + tp_base: base_ptr.unwrap_or(ptr::null_mut()), + tp_dealloc: Some(crate::object::_PyWeavePy_Dealloc), + tp_alloc: alloc_fp as *mut c_void, + tp_new: tp_new_slot, + tp_free: free_fp as *mut c_void, + tp_flags: flags, bridge: Box::into_raw(Box::new(t.clone())), - _filler: [0; 4], + ..PyTypeObject::new_zeroed() }, owned_name, slot_table: SlotTable::empty(), }); let p = Box::leak(bx); + // Derive the C-level protocol slots (`tp_as_mapping`/`mp_subscript`, + // `tp_call`, `tp_iter`, …) from the class's dunders so an extension that + // reads them straight off `Py_TYPE(obj)` (Cython's inlined subscript / + // call / iteration helpers) sees a faithful table — not the NULL suite a + // bare mirror would carry. Without this a frozen Python class crossing + // here (e.g. `typing._SpecialForm`, which only defines `__getitem__` and + // so misses the iter/ctx gate in `synth_type_for_class`) is "not + // subscriptable" to a wheel's module-init code. + synth_protocol_slots(&mut p.head, t); let ty_ptr = &mut p.head as *mut PyTypeObject; + // RFC 0047 (wave 5): faithful `inherit_slots`, exactly as `PyType_Ready` + // does. `synth_protocol_slots` above installs only the subtype's *own* + // mapping/call/iter dunders; every function slot and method suite it + // leaves NULL — most importantly the numeric suite `tp_as_number` + // (`nb_add`/`nb_subtract`/…) and `tp_richcompare` — must be copied down + // from the base so a Cython extension's *inlined* + // `Py_TYPE(self)->tp_as_number->nb_add` read (with no MRO walk) resolves. + // Without this, a VM subclass of a readied Cython `cdef class` + // (pandas' `Timestamp(_Timestamp)`, `Timedelta(_Timedelta)`, + // `Period(_Period)`) crossed into C with a NULL number suite: the C-API + // `PyNumber_Add`/`PyNumber_Subtract` slot dispatch skipped the base's + // `__add__`/`__sub__` and fell through to the *other* operand's slot + // (numpy's `timedelta64`), which for nanosecond resolution coerces to a + // Python `int` and trips pandas' `integer_op_not_supported` guard — + // `Timestamp - np.timedelta64(n, "ns")` raised spuriously. + let base_for_inherit = p.head.tp_base; + unsafe { crate::inherit::inherit_slots(ty_ptr, &mut p.slot_table, base_for_inherit) }; // Cache so subsequent calls with the same native `Rc` return the // same pointer instead of leaking a fresh box every time // (`PyExc_*` aliases — e.g. `SystemError` → `runtime_error` — // would otherwise install distinct slots for the same type). register_heap_type(ty_ptr); + // Mirror the inline base: instances of this subclass need the same + // faithful inline body (RFC 0045) so fixed-offset field reads/writes + // land on real CPython-shaped memory. + if base_inline { + maybe_register_inline_type(ty_ptr); + } ty_ptr } @@ -445,11 +1475,312 @@ pub fn install_user_type(t: &Rc) -> *mut PyTypeObject { // ---------------------------------------------------------------- pub const PY_TPFLAGS_HEAPTYPE: u32 = 1 << 9; +/// `Py_TPFLAGS_IS_ABSTRACT` — set on abstract base classes. Used +/// transiently by [`crate::instance`] to neutralise a Cython +/// `@cython.freelist` `tp_dealloc`: the freelist stash is guarded by +/// `!HasFeature(Py_TPFLAGS_IS_ABSTRACT)` in **both** Cython codegen +/// variants (classic and type-specs), so temporarily setting it forces +/// the release (`tp_free`) branch instead of the raw-pointer stash. +pub const PY_TPFLAGS_IS_ABSTRACT: u32 = 1 << 20; pub const PY_TPFLAGS_BASETYPE: u32 = 1 << 10; pub const PY_TPFLAGS_HAVE_GC: u32 = 1 << 14; pub const PY_TPFLAGS_DEFAULT: u32 = 1 << 18; pub const PY_TPFLAGS_HAVE_VECTORCALL: u32 = 1 << 11; pub const PY_TPFLAGS_DISALLOW_INSTANTIATION: u32 = 1 << 7; +/// `Py_TPFLAGS_READY` — set on `tp_flags` once a type is finalised. +pub const PY_TPFLAGS_READY: u64 = 1 << 12; + +/// Assemble a heap/readied type's `__dict__`: `__doc__` / `__module__` +/// / `__qualname__`, the method/getset/member descriptors, and the +/// synthesised dunder shims that forward to the C slots. Shared by +/// [`PyType_FromMetaclass`] and [`PyType_Ready`] (RFC 0044, WS2) so the +/// two type-definition styles converge on identical dispatch. +fn assemble_type_dict( + qualified: &str, + bare: &str, + slot_table: &SlotTable, + methods: &[crate::module::MethodEntry], + getset_pairs: Vec<(String, Object)>, + member_pairs: Vec<(String, Object)>, + doc: Option<&str>, +) -> DictData { + let mut dict = DictData::new(); + if let Some(d) = doc { + dict.insert( + DictKey(Object::from_static("__doc__")), + Object::from_str(d.to_owned()), + ); + } + dict.insert( + DictKey(Object::from_static("__module__")), + if let Some(idx) = qualified.rfind('.') { + Object::from_str(qualified[..idx].to_owned()) + } else { + Object::from_static("builtins") + }, + ); + dict.insert( + DictKey(Object::from_static("__qualname__")), + Object::from_str(bare.to_owned()), + ); + for entry in methods { + dict.insert( + DictKey(Object::from_str(entry.name.clone())), + entry.bind_unbound(), + ); + } + for (name, obj) in getset_pairs { + dict.insert(DictKey(Object::from_str(name)), obj); + } + for (name, obj) in member_pairs { + dict.insert(DictKey(Object::from_str(name)), obj); + } + let dunder_pairs = crate::dunder_shim::install_dunder_shims(slot_table, qualified.to_owned()); + for (name, obj) in dunder_pairs { + dict.insert(DictKey(Object::from_str(name)), obj); + } + dict +} + +// ---------------------------------------------------------------- +// Readied stock types (RFC 0044, WS2). +// +// A stock extension defines a *static* `PyTypeObject` (exactly 416 +// bytes — the CPython layout, with no room for WeavePy's private +// `bridge`/`slot_table` trailing fields) and calls `PyType_Ready`. +// We therefore cannot stash the bridge in the caller's struct; the +// bridge + decoded slot table live in this side registry, keyed by +// the extension's own type pointer (which is what flows through +// `PyModule_AddObject`, instance `ob_type`, `type(x)`, …). +// +// Entries are `Box::leak`'d (readied types live for the process +// lifetime), so the `&'static` borrows handed out by `bridge_type` / +// `slot_table_for` stay valid. The map itself is thread-local: a stock +// extension readies its types and uses them on the same thread, so +// (unlike the cross-thread `PyExc_*` statics in the now-global +// `HEAP_TYPES`) no cross-thread visibility is required here. +// ---------------------------------------------------------------- + +/// WeavePy-owned data backing a readied stock type. +pub struct ReadiedType { + /// The extension's own `&MyType` pointer (the canonical identity). + pub ext_ptr: *mut PyTypeObject, + /// Bridge to the native type. + pub bridge: Rc, + /// Slots decoded from the faithful struct + method suites. + pub slot_table: SlotTable, +} + +unsafe impl Send for ReadiedType {} +unsafe impl Sync for ReadiedType {} + +thread_local! { + /// Map from an extension type pointer to its readied data. + static READIED_BY_PTR: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); + /// Insertion-ordered list for the `Rc` → pointer scan. + static READIED_TYPES: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +thread_local! { + /// Types whose instances get a faithful **inline `tp_basicsize` + /// body** (RFC 0045, wave 3): C-extension types finalised by + /// `PyType_FromSpec` / `PyType_Ready` that declare storage beyond the + /// object head. Membership is the opt-in gate that + /// [`crate::object::into_owned`] and + /// [`crate::genericalloc::PyType_GenericAlloc`] consult; a type absent + /// from this set keeps the wave-1/2 `PyObjectBox` instance shape, so + /// the change is purely additive (pure-Python classes and every + /// dict-backed fixture are unaffected). + static INLINE_TYPES: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashSet::new()); +} + +/// Register `ty` as an inline-instance type iff it declares storage +/// beyond `PyObject_HEAD` (`tp_basicsize > sizeof(PyObject)`) — i.e. it +/// has real inline fields a stock reader pokes at fixed offsets (the +/// `PyArrayObject` shape). Called at `PyType_FromSpec` / `PyType_Ready` +/// finalisation. Types that keep all state in `__dict__` +/// (`tp_basicsize <= sizeof(PyObject)`, which is every current fixture) +/// are not registered and keep the legacy box (RFC 0045, WS1). +pub fn maybe_register_inline_type(ty: *mut PyTypeObject) { + if ty.is_null() { + return; + } + let basicsize = unsafe { (*ty).tp_basicsize } as usize; + let registered = basicsize > std::mem::size_of::(); + if registered { + INLINE_TYPES.with(|s| s.borrow_mut().insert(ty as usize)); + } + if std::env::var_os("WEAVEPY_TRACE_CTOR").is_some() { + eprintln!( + "[CTOR] register_inline name={} ty={:p} basicsize={} sizeof_pyobj={} registered={}", + ctor_trace_name(ty), + ty, + basicsize, + std::mem::size_of::(), + registered + ); + } +} + +/// Best-effort `tp_name` for constructor tracing (`WEAVEPY_TRACE_CTOR`). +pub fn ctor_trace_name(ty: *mut PyTypeObject) -> String { + if ty.is_null() { + return "".to_owned(); + } + let n = unsafe { (*ty).tp_name }; + if n.is_null() { + return "".to_owned(); + } + unsafe { CStr::from_ptr(n) }.to_string_lossy().into_owned() +} + +/// True if instances of `ty` use a faithful inline `tp_basicsize` body +/// (RFC 0045, wave 3). O(1) hash lookup; false for every non-extension +/// type, so the wave-1/2 paths are unchanged for them. +pub fn is_inline_instance_type(ty: *mut PyTypeObject) -> bool { + if ty.is_null() { + return false; + } + INLINE_TYPES.with(|s| s.borrow().contains(&(ty as usize))) +} + +/// True iff the inline-instance type registry's thread-local storage is +/// still live (see [`crate::containers::caches_alive`]). +/// [`crate::object::free_box`] probes this before `is_mirror` / +/// `is_instance_body` (which read `INLINE_TYPES`) so it can leak rather than +/// panic-abort during thread/process teardown — and, critically, so it never +/// *misclassifies* a plain box as a mirror against a half-destroyed registry +/// and double-frees it. +pub(crate) fn inline_types_alive() -> bool { + INLINE_TYPES.try_with(|_| ()).is_ok() +} + +/// Look up the readied-type data for an extension type pointer. +fn readied_for(ty: *mut PyTypeObject) -> Option<&'static ReadiedType> { + if ty.is_null() { + return None; + } + READIED_BY_PTR.with(|m| m.borrow().get(&(ty as usize)).copied()) +} + +/// The decoded slot table for a readied stock type, or `None` if `ty` +/// was not readied via [`PyType_Ready`]. Used by +/// [`crate::slottable::slot_table_for`] so readied static types (which +/// don't carry the `Py_TPFLAGS_HEAPTYPE` bit and have no embedded +/// `PyTypeObjectBox`) still expose their slots for direct dispatch +/// (buffer protocol, vectorcall, GC traverse, …). +pub fn readied_slot_table(ty: *mut PyTypeObject) -> Option<&'static SlotTable> { + readied_for(ty).map(|rt| &rt.slot_table) +} + +/// True if `ty` is a stock type finalised through [`PyType_Ready`] +/// (RFC 0044). Used by [`crate::genericalloc::PyType_GenericAlloc`] to +/// decide whether a freshly-allocated instance should carry a real +/// `Object::Instance` payload (so the extension's `tp_init` / +/// `PyObject_SetAttrString` operate on a genuine instance dict). +pub fn is_readied_type(ty: *mut PyTypeObject) -> bool { + readied_for(ty).is_some() +} + +/// The native bridge type for a readied stock type, if any. +pub fn readied_bridge(ty: *mut PyTypeObject) -> Option> { + readied_for(ty).map(|rt| rt.bridge.clone()) +} + +/// The live `PyTypeObject *` backing `cls`, if `cls` is bridged from a +/// C type — static, heap (`PyType_FromSpec`), or readied +/// (`PyType_Ready`). Public so the GC bridge (RFC 0044, WS4) can reach +/// an instance's `tp_traverse` / `tp_clear` slots; returns `None` for a +/// pure-Python class (no C `PyTypeObject` exists to consult). +pub fn type_ptr_for_class(cls: &Rc) -> Option<*mut PyTypeObject> { + find_type_ptr(cls) +} + +/// Build a faithful C-level tuple of the canonical `PyTypeObject*` for +/// each class in `types`. Used to publish `tp_bases` / `tp_mro` on a +/// readied / spec-built type so Cython-generated code can walk them via +/// the `PyTuple_GET_SIZE` / `PyTuple_GET_ITEM` macros (direct struct +/// reads, no function call). Each entry must already have a registered +/// canonical pointer (built after the type itself is registered so its +/// own `tp_mro[0]` slot resolves). Returns NULL on allocation failure. +unsafe fn build_type_ptr_tuple(types: &[Rc]) -> *mut PyObject { + let tup = unsafe { crate::containers::PyTuple_New(types.len() as PySsizeT) }; + if tup.is_null() { + return ptr::null_mut(); + } + for (i, cls) in types.iter().enumerate() { + // `into_owned(Object::Type)` resolves to the canonical + // `PyTypeObject*` (static / heap / readied) and hands back an + // owned reference, which `PyTuple_SetItem` then steals. + let p = crate::object::into_owned(Object::Type(cls.clone())); + unsafe { crate::containers::PyTuple_SetItem(tup, i as PySsizeT, p) }; + } + tup +} + +/// Gate for [`publish_static_type_hierarchy`] (run-once). +static STATIC_HIERARCHY_PUBLISHED: Mutex = Mutex::new(false); + +/// Publish faithful C-level `tp_bases` / `tp_mro` tuples on every static +/// builtin type (`object`, `int`, `str`, …). +/// +/// This is deliberately *deferred* out of [`init_static_types`]: building +/// the tuples calls [`crate::containers::PyTuple_New`] / `into_owned`, +/// which need a live interpreter + allocator context. `init_static_types` +/// runs at the very first C-API touch (possibly before any `ActiveContext` +/// is established), so we publish at the first VM→C transition +/// ([`crate::interp::ensure_active`]) instead, where the context is +/// guaranteed. Idempotent and re-entrancy-safe (claims the flag before +/// building, so a nested build call is a no-op rather than a deadlock). +/// +/// Without this, each static builtin crosses into C with `tp_mro == NULL`. +/// numpy's `_descr_from_subtype` resolves a 0-d array's scalar descr by +/// reading `Py_TYPE(sc)->tp_mro->ob_size` (`maybe_convert_objects` → +/// `PyArray_DescrFromScalar` on the `object` type) and dereferences the +/// NULL `tp_mro` (fault at `0x10`, the `PyTupleObject::ob_size` offset). +/// CPython walks a valid 1-element MRO and returns `dtype('O')`. +pub fn publish_static_type_hierarchy() { + { + let mut done = match STATIC_HIERARCHY_PUBLISHED.lock() { + Ok(g) => g, + Err(_) => return, + }; + if *done { + return; + } + // Claim before building and release the lock, so a re-entrant + // `ensure_active` triggered while constructing the tuples sees the + // flag set and returns immediately instead of deadlocking. + *done = true; + } + unsafe { + for slot in STATIC_TYPE_TABLE { + let ty = &mut *slot.as_ptr(); + let bridge = ty.bridge; + if bridge.is_null() { + continue; + } + let rc: &Rc = &*bridge; + if ty.tp_mro.is_null() { + let mro = rc.mro.borrow().clone(); + let tup = build_type_ptr_tuple(&mro); + if !tup.is_null() { + ty.tp_mro = tup; + } + } + if ty.tp_bases.is_null() { + let bases = rc.bases.borrow().clone(); + let tup = build_type_ptr_tuple(&bases); + if !tup.is_null() { + ty.tp_bases = tup; + } + } + } + } +} #[no_mangle] pub unsafe extern "C" fn PyType_FromSpec(spec: *mut PyType_Spec) -> *mut PyObject { @@ -509,7 +1840,7 @@ pub unsafe extern "C" fn PyType_FromMetaclass( x if x == crate::slottable::Py_tp_base => { if !slot.pfunc.is_null() { let ty_ptr = slot.pfunc as *mut PyTypeObject; - if let Some(t) = unsafe { bridge_type(ty_ptr) } { + if let Some(t) = unsafe { bridge_or_ready(ty_ptr) } { spec_base = Some(t); } } @@ -633,52 +1964,41 @@ pub unsafe extern "C" fn PyType_FromMetaclass( // ---------------------------------------------------------------- // Build the type's dict: doc + module + methods + getset/member - // descriptors + synthesised dunder shims. + // descriptors + synthesised dunder shims. Shared with the + // `PyType_Ready` path (RFC 0044, WS2) via [`assemble_type_dict`]. // ---------------------------------------------------------------- - let mut dict = DictData::new(); - if let Some(d) = doc.as_ref() { - dict.insert( - DictKey(Object::from_static("__doc__")), - Object::from_str(d.clone()), - ); - } - dict.insert( - DictKey(Object::from_static("__module__")), - if let Some(idx) = qualified.rfind('.') { - Object::from_str(qualified[..idx].to_owned()) - } else { - Object::from_static("builtins") - }, + let dict = assemble_type_dict( + &qualified, + &bare, + &slot_table, + &methods, + getset_pairs, + member_pairs, + doc.as_deref(), ); - dict.insert( - DictKey(Object::from_static("__qualname__")), - Object::from_str(bare.clone()), - ); - for entry in &methods { - dict.insert( - DictKey(Object::from_str(entry.name.clone())), - entry.bind_unbound(), - ); - } - for (name, obj) in getset_pairs { - dict.insert(DictKey(Object::from_str(name)), obj); - } - for (name, obj) in member_pairs { - dict.insert(DictKey(Object::from_str(name)), obj); - } - let dunder_pairs = crate::dunder_shim::install_dunder_shims(&slot_table, qualified.clone()); - for (name, obj) in dunder_pairs { - dict.insert(DictKey(Object::from_str(name)), obj); - } let ty = match TypeObject::new_user(&bare, bases_resolved, dict) { Ok(ty) => ty, - Err(_) => { - crate::errors::set_runtime_error("could not linearise base classes"); + Err(e) => { + crate::errors::set_pending_from_runtime(e); return ptr::null_mut(); } }; let owned_name = format!("{qualified}\0").into_bytes(); + // RFC 0047 (wave 5): publish the C-level `tp_dict` backed by the + // bridge's shared `DictData` (see the `PyType_Ready` path for the full + // rationale — Cython's `__Pyx_SetVtable`/`__Pyx_GetVtable` read and + // write `type->tp_dict` directly). + let tp_dict_box = crate::object::into_owned(Object::Dict(ty.dict.clone())); + // Snapshot the MRO / bases before `ty` is moved into the box; the + // faithful C-level `tp_bases` / `tp_mro` are built after the type is + // registered (so its own pointer resolves for the `tp_mro[0]` slot). + let mro_for_c: Vec> = ty.mro.borrow().clone(); + let bases_for_c: Vec> = ty.bases.borrow().clone(); + // RFC 0047 (wave 5): stamp the `inherit_special` fast-subclass bit (see + // `fast_subclass_flags`) so a spec-built metaclass / builtin subclass is + // classified correctly by an extension's inlined `tp_flags` reads. + let subclass_flags = fast_subclass_flags(&ty); let bx = Box::new(PyTypeObjectBox { head: PyTypeObject { head: PyObject { @@ -688,10 +2008,12 @@ pub unsafe extern "C" fn PyType_FromMetaclass( tp_name: owned_name.as_ptr() as *const c_char, tp_basicsize: spec_ref.basicsize as PySsizeT, tp_itemsize: spec_ref.itemsize as PySsizeT, - tp_flags: spec_ref.flags | PY_TPFLAGS_HEAPTYPE, + tp_flags: (spec_ref.flags | PY_TPFLAGS_HEAPTYPE) as u64 | subclass_flags, + tp_dealloc: Some(crate::object::_PyWeavePy_Dealloc), tp_slots: spec_ref.slots, + tp_dict: tp_dict_box, bridge: Box::into_raw(Box::new(ty)), - _filler: [0; 4], + ..PyTypeObject::new_zeroed() }, owned_name, slot_table, @@ -699,6 +2021,25 @@ pub unsafe extern "C" fn PyType_FromMetaclass( let leaked = Box::leak(bx); let ty_ptr = &mut leaked.head as *mut PyTypeObject; register_heap_type(ty_ptr); + // RFC 0045 (wave 3): a heap type that declares inline fields beyond + // the object head gets faithful `tp_basicsize` instance storage. + maybe_register_inline_type(ty_ptr); + // RFC 0047 (wave 5): publish faithful C-level `tp_base` / `tp_bases` / + // `tp_mro` (CPython's `PyType_FromMetaclass` sets all three). Cython + // and other extensions read them directly off the struct. + unsafe { + if let Some(bp) = bases_for_c.first().and_then(type_ptr_for_class) { + (*ty_ptr).tp_base = bp; + } + let tp_bases_box = build_type_ptr_tuple(&bases_for_c); + if !tp_bases_box.is_null() { + (*ty_ptr).tp_bases = tp_bases_box; + } + let tp_mro_box = build_type_ptr_tuple(&mro_for_c); + if !tp_mro_box.is_null() { + (*ty_ptr).tp_mro = tp_mro_box; + } + } ty_ptr as *mut PyObject } @@ -719,6 +2060,7 @@ pub unsafe extern "C" fn PyType_HasFeature(ty: *mut PyTypeObject, flag: u32) -> return 0; } let f = unsafe { (*ty).tp_flags }; + let flag = flag as u64; if (f & flag) == flag { 1 } else { @@ -727,12 +2069,13 @@ pub unsafe extern "C" fn PyType_HasFeature(ty: *mut PyTypeObject, flag: u32) -> } /// `PyType_GetFlags(type)` — return the type's `tp_flags` field. +/// CPython returns `unsigned long` (`c_ulong`, 64-bit on LP64). #[no_mangle] -pub unsafe extern "C" fn PyType_GetFlags(ty: *mut PyTypeObject) -> u32 { +pub unsafe extern "C" fn PyType_GetFlags(ty: *mut PyTypeObject) -> std::os::raw::c_ulong { if ty.is_null() { return 0; } - unsafe { (*ty).tp_flags } + unsafe { (*ty).tp_flags as std::os::raw::c_ulong } } /// `PyType_GetQualName(type)` — return the type's qualified name as @@ -760,24 +2103,547 @@ pub unsafe extern "C" fn PyType_FromModuleAndSpec( unsafe { PyType_FromSpecWithBases(spec, bases) } } +/// True if `ty` is a WeavePy-owned type object (a static built-in or a +/// `PyType_FromSpec` heap type) — i.e. its struct carries the private +/// `bridge`/`slot_table` trailing fields and is already "ready". Decided +/// purely by pointer identity so we never read past a 416-byte stock +/// struct. +pub(crate) fn is_weavepy_owned_type(ty: *mut PyTypeObject) -> bool { + for slot in STATIC_TYPE_TABLE { + if slot.as_ptr() == ty { + return true; + } + } + HEAP_TYPES + .lock() + .map(|g| g.contains(&(ty as usize))) + .unwrap_or(false) +} + +/// Decode a faithfully-laid-out `PyTypeObject` (a stock extension's +/// statically-initialised type + its method suites) into a +/// [`SlotTable`] plus the dict ingredients (RFC 0044, WS2). +struct Harvested { + slot_table: SlotTable, + methods: Vec, + getset_pairs: Vec<(String, Object)>, + member_pairs: Vec<(String, Object)>, + doc: Option, + base: Option>, +} + +/// Read every populated slot of a faithful `PyTypeObject` into a +/// [`SlotTable`]. The direct `tp_*` function pointers map to their +/// `Py_tp_*` ids; each non-null method suite (`tp_as_number`, …) is +/// decomposed into its `Py_nb_*` / `Py_sq_*` / `Py_mp_*` / `Py_am_*` / +/// `Py_bf_*` ids at the faithful offsets pinned in [`crate::layout`]. +/// +/// # Safety +/// `ty` must point at a readable, faithfully-laid-out `PyTypeObject` +/// (at least the 416-byte CPython prefix). +unsafe fn harvest_faithful(ty: *mut PyTypeObject) -> Harvested { + use crate::slottable as ids; + let mut t = SlotTable::empty(); + + unsafe fn put(t: &mut SlotTable, id: c_int, p: *mut c_void) { + if !p.is_null() { + t.install(id, p); + } + } + + let tref = unsafe { &*ty }; + + // Direct type-level slots. + unsafe { + put(&mut t, ids::Py_tp_call, tref.tp_call); + put(&mut t, ids::Py_tp_init, tref.tp_init); + put(&mut t, ids::Py_tp_new, tref.tp_new); + put(&mut t, ids::Py_tp_iter, tref.tp_iter); + put(&mut t, ids::Py_tp_iternext, tref.tp_iternext); + put(&mut t, ids::Py_tp_richcompare, tref.tp_richcompare); + put(&mut t, ids::Py_tp_getattro, tref.tp_getattro); + put(&mut t, ids::Py_tp_setattro, tref.tp_setattro); + put(&mut t, ids::Py_tp_descr_get, tref.tp_descr_get); + put(&mut t, ids::Py_tp_descr_set, tref.tp_descr_set); + put(&mut t, ids::Py_tp_hash, tref.tp_hash); + put(&mut t, ids::Py_tp_repr, tref.tp_repr); + put(&mut t, ids::Py_tp_str, tref.tp_str); + put(&mut t, ids::Py_tp_traverse, tref.tp_traverse); + put(&mut t, ids::Py_tp_clear, tref.tp_clear); + put(&mut t, ids::Py_tp_alloc, tref.tp_alloc); + put(&mut t, ids::Py_tp_free, tref.tp_free); + put(&mut t, ids::Py_tp_getattr, tref.tp_getattr); + put(&mut t, ids::Py_tp_setattr, tref.tp_setattr); + } + + // Number suite. + if !tref.tp_as_number.is_null() { + let n = unsafe { &*(tref.tp_as_number as *const crate::layout::PyNumberMethods) }; + unsafe { + put(&mut t, ids::Py_nb_add, n.nb_add); + put(&mut t, ids::Py_nb_subtract, n.nb_subtract); + put(&mut t, ids::Py_nb_multiply, n.nb_multiply); + put(&mut t, ids::Py_nb_remainder, n.nb_remainder); + put(&mut t, ids::Py_nb_divmod, n.nb_divmod); + put(&mut t, ids::Py_nb_power, n.nb_power); + put(&mut t, ids::Py_nb_negative, n.nb_negative); + put(&mut t, ids::Py_nb_positive, n.nb_positive); + put(&mut t, ids::Py_nb_absolute, n.nb_absolute); + put(&mut t, ids::Py_nb_bool, n.nb_bool); + put(&mut t, ids::Py_nb_invert, n.nb_invert); + put(&mut t, ids::Py_nb_lshift, n.nb_lshift); + put(&mut t, ids::Py_nb_rshift, n.nb_rshift); + put(&mut t, ids::Py_nb_and, n.nb_and); + put(&mut t, ids::Py_nb_xor, n.nb_xor); + put(&mut t, ids::Py_nb_or, n.nb_or); + put(&mut t, ids::Py_nb_int, n.nb_int); + put(&mut t, ids::Py_nb_float, n.nb_float); + put(&mut t, ids::Py_nb_inplace_add, n.nb_inplace_add); + put(&mut t, ids::Py_nb_inplace_subtract, n.nb_inplace_subtract); + put(&mut t, ids::Py_nb_inplace_multiply, n.nb_inplace_multiply); + put(&mut t, ids::Py_nb_inplace_remainder, n.nb_inplace_remainder); + put(&mut t, ids::Py_nb_inplace_power, n.nb_inplace_power); + put(&mut t, ids::Py_nb_inplace_lshift, n.nb_inplace_lshift); + put(&mut t, ids::Py_nb_inplace_rshift, n.nb_inplace_rshift); + put(&mut t, ids::Py_nb_inplace_and, n.nb_inplace_and); + put(&mut t, ids::Py_nb_inplace_xor, n.nb_inplace_xor); + put(&mut t, ids::Py_nb_inplace_or, n.nb_inplace_or); + put(&mut t, ids::Py_nb_floor_divide, n.nb_floor_divide); + put(&mut t, ids::Py_nb_true_divide, n.nb_true_divide); + put( + &mut t, + ids::Py_nb_inplace_floor_divide, + n.nb_inplace_floor_divide, + ); + put( + &mut t, + ids::Py_nb_inplace_true_divide, + n.nb_inplace_true_divide, + ); + put(&mut t, ids::Py_nb_index, n.nb_index); + put(&mut t, ids::Py_nb_matrix_multiply, n.nb_matrix_multiply); + put( + &mut t, + ids::Py_nb_inplace_matrix_multiply, + n.nb_inplace_matrix_multiply, + ); + } + } + + // Sequence suite. + if !tref.tp_as_sequence.is_null() { + let s = unsafe { &*(tref.tp_as_sequence as *const crate::layout::PySequenceMethods) }; + unsafe { + put(&mut t, ids::Py_sq_length, s.sq_length); + put(&mut t, ids::Py_sq_concat, s.sq_concat); + put(&mut t, ids::Py_sq_repeat, s.sq_repeat); + put(&mut t, ids::Py_sq_item, s.sq_item); + put(&mut t, ids::Py_sq_ass_item, s.sq_ass_item); + put(&mut t, ids::Py_sq_contains, s.sq_contains); + put(&mut t, ids::Py_sq_inplace_concat, s.sq_inplace_concat); + put(&mut t, ids::Py_sq_inplace_repeat, s.sq_inplace_repeat); + } + } + + // Mapping suite. + if !tref.tp_as_mapping.is_null() { + let m = unsafe { &*(tref.tp_as_mapping as *const crate::layout::PyMappingMethods) }; + unsafe { + put(&mut t, ids::Py_mp_length, m.mp_length); + put(&mut t, ids::Py_mp_subscript, m.mp_subscript); + put(&mut t, ids::Py_mp_ass_subscript, m.mp_ass_subscript); + } + } + + // Async suite. + if !tref.tp_as_async.is_null() { + let a = unsafe { &*(tref.tp_as_async as *const crate::layout::PyAsyncMethods) }; + unsafe { + put(&mut t, ids::Py_am_await, a.am_await); + put(&mut t, ids::Py_am_aiter, a.am_aiter); + put(&mut t, ids::Py_am_anext, a.am_anext); + put(&mut t, ids::Py_am_send, a.am_send); + } + } + + // Buffer suite. + if !tref.tp_as_buffer.is_null() { + let b = unsafe { &*(tref.tp_as_buffer as *const crate::layout::PyBufferProcs) }; + unsafe { + put(&mut t, ids::Py_bf_getbuffer, b.bf_getbuffer); + put(&mut t, ids::Py_bf_releasebuffer, b.bf_releasebuffer); + } + } + + // Descriptor tables + doc + base for the dict / linearisation. + let methods = if tref.tp_methods.is_null() { + Vec::new() + } else { + unsafe { + crate::module::collect_methods(tref.tp_methods as *mut crate::module::PyMethodDef) + } + }; + let getset_pairs = if tref.tp_getset.is_null() { + Vec::new() + } else { + unsafe { crate::getset::collect_getsets(tref.tp_getset as *mut crate::getset::PyGetSetDef) } + }; + let member_pairs = if tref.tp_members.is_null() { + Vec::new() + } else { + unsafe { + crate::getset::collect_members(tref.tp_members as *mut crate::getset::PyMemberDef) + } + }; + let doc = if tref.tp_doc.is_null() { + None + } else { + Some( + unsafe { CStr::from_ptr(tref.tp_doc) } + .to_string_lossy() + .into_owned(), + ) + }; + let base = if tref.tp_base.is_null() { + None + } else { + unsafe { bridge_or_ready(tref.tp_base) } + }; + + Harvested { + slot_table: t, + methods, + getset_pairs, + member_pairs, + doc, + base, + } +} + +/// Resolve a stock type's full `tp_bases` tuple into bridged VM types. +/// +/// CPython's `PyType_Ready` linearises the MRO from **all** of a type's +/// bases, not just `tp_base`. NumPy's numeric scalars use *dual +/// inheritance* (`Objects/typeobject.c`-style `tp_bases`): e.g. +/// `numpy.float64.tp_base == numpy.floating` but +/// `numpy.float64.tp_bases == (numpy.floating, float)`, and likewise +/// `complex128 → (complexfloating, complex)`, `str_ → (str, character)`. +/// Following only `tp_base` truncates the MRO and — critically — drops the +/// Python parent, so `isinstance(np.float64(x), float)` and CPython's +/// `round()` (which requires a `float` subclass) both fail. +/// +/// Returns the resolved bases in `tp_bases` order (each readied on +/// demand), or an empty vec when `tp_bases` is absent/unreadable so the +/// caller falls back to the single-`tp_base` path. +unsafe fn harvest_bases(ty: *mut PyTypeObject) -> Vec> { + let tp_bases = unsafe { (*ty).tp_bases }; + if tp_bases.is_null() { + return Vec::new(); + } + let n = unsafe { crate::containers::PyTuple_Size(tp_bases) }; + if n <= 0 { + return Vec::new(); + } + let mut out = Vec::with_capacity(n as usize); + for i in 0..n { + let item = unsafe { crate::containers::PyTuple_GetItem(tp_bases, i) }; + if item.is_null() { + continue; + } + if let Some(cls) = unsafe { bridge_or_ready(item as *mut PyTypeObject) } { + out.push(cls); + } + } + out +} + +/// `PyType_Ready(t)` — finalise a type object. +/// +/// For WeavePy's own types (static built-ins, `PyType_FromSpec` heap +/// types) this is a no-op: they are ready the moment their bridge is +/// installed. For a **stock extension's statically-initialised +/// `PyTypeObject`** (RFC 0044, WS2) it harvests the faithful struct + +/// method suites into a [`SlotTable`], builds the bridged native type +/// with synthesised dunder shims, and registers it in the readied-type +/// side table — then writes `ob_type` and the `Py_TPFLAGS_READY` bit +/// back into the caller's struct (both at offsets inside the faithful +/// 416-byte region, so a stock struct is never overrun). #[no_mangle] -pub unsafe extern "C" fn PyType_Ready(_t: *mut PyTypeObject) -> c_int { - // Type objects in WeavePy are always "ready" the moment their - // bridge is installed. CPython uses `PyType_Ready` to lazily - // populate slot tables; we don't have that complication. +pub unsafe extern "C" fn PyType_Ready(t: *mut PyTypeObject) -> c_int { + if t.is_null() { + return 0; + } + crate::interp::ensure_initialised(); + // Idempotent: already readied, or one of our own ready types. + if readied_for(t).is_some() || is_weavepy_owned_type(t) { + return 0; + } + + let mut h = unsafe { harvest_faithful(t) }; + + // Resolve name (qualified + bare) from tp_name. + let raw_name = unsafe { (*t).tp_name }; + let qualified = if raw_name.is_null() { + "".to_owned() + } else { + unsafe { CStr::from_ptr(raw_name) } + .to_string_lossy() + .into_owned() + }; + let bare = qualified + .rsplit('.') + .next() + .unwrap_or(&qualified) + .to_owned(); + + // MRO bases: prefer the full `tp_bases` when the type declares more + // than one (numpy's dual-inherited scalars — `float64`, `complex128`, + // `str_`, `bytes_`), so the Python parent lands in the linearised MRO + // (`isinstance(np.float64(x), float)`, `round(...)`, …). Single-base + // types keep the historical `tp_base`-only path. + let multi_bases = unsafe { harvest_bases(t) }; + let single_base = h + .base + .clone() + .unwrap_or_else(|| weavepy_vm::builtin_types::builtin_types().object_.clone()); + + let dict = assemble_type_dict( + &qualified, + &bare, + &h.slot_table, + &h.methods, + h.getset_pairs, + h.member_pairs, + h.doc.as_deref(), + ); + + let ty = if multi_bases.len() >= 2 { + // Retry with only the primary `tp_base` if the full dual-inheritance + // set has no consistent C3 linearisation — never worse than the + // historical single-base MRO. `dict` is cloned for the retry. + match TypeObject::new_user(&bare, multi_bases, dict.clone()) { + Ok(ty) => ty, + Err(_) => match TypeObject::new_user(&bare, vec![single_base], dict) { + Ok(ty) => ty, + Err(e) => { + crate::errors::set_pending_from_runtime(e); + return -1; + } + }, + } + } else { + match TypeObject::new_user(&bare, vec![single_base], dict) { + Ok(ty) => ty, + Err(e) => { + crate::errors::set_pending_from_runtime(e); + return -1; + } + } + }; + + // RFC 0047 (wave 5): publish the C-level `tp_dict`. Cython-generated + // module init writes into `type->tp_dict` *directly* + // (`__Pyx_SetVtable` does `PyDict_SetItem(type->tp_dict, + // "__pyx_vtable__", capsule)`) and reads it back through + // `__Pyx_GetVtable` for cpdef dispatch. We back it with a dict box + // sharing the bridge's `DictData`, so a direct `tp_dict` mutation and + // the VM's MRO lookup observe the same storage. The type is immortal, + // so the box (refcount 1) lives for the process, matching CPython + // where the type owns its `tp_dict`. + let tp_dict_box = crate::object::into_owned(Object::Dict(ty.dict.clone())); + + // RFC 0047 (wave 5): faithful `inherit_slots`. The type dict above + // carries only the subtype's *own* dunders (inherited behaviour is + // reached through the MRO, exactly as CPython). But a Cython-generated + // extension reads `Py_TYPE(self)->tp_*` and `…->tp_as_number->nb_add` + // **directly off the C struct**, with no MRO walk, so the inherited + // slots must be baked into both the decoded table (for direct-table + // dispatch) and the faithful struct (for inlined reads). The base was + // already readied + flattened during harvest, so one level of copy + // carries the whole ancestor chain. + let base_ptr = unsafe { (*t).tp_base }; + unsafe { crate::inherit::inherit_slots(t, &mut h.slot_table, base_ptr) }; + + let readied: &'static ReadiedType = Box::leak(Box::new(ReadiedType { + ext_ptr: t, + bridge: ty, + slot_table: h.slot_table, + })); + if std::env::var_os("WEAVEPY_TRACE_TYPEPTR").is_some() { + eprintln!( + "[READY] name={:?} ext_ptr={:p} bridge={:p}", + bare, + t, + Rc::as_ptr(&readied.bridge) + ); + } + READIED_BY_PTR.with(|m| m.borrow_mut().insert(t as usize, readied)); + READIED_TYPES.with(|v| v.borrow_mut().push(readied)); + // RFC 0045 (wave 3): a readied static type that declares inline + // fields beyond the object head gets faithful `tp_basicsize` + // instance storage (the `PyArrayObject` shape). + maybe_register_inline_type(t); + + // RFC 0047 (wave 5): publish faithful C-level `tp_base` / `tp_bases` / + // `tp_mro`. Cython's `__Pyx_MergeVtables` reads `type->tp_base` and + // indexes `type->tp_bases` through the `PyTuple_GET_SIZE` / + // `PyTuple_GET_ITEM` macros (direct struct access), and + // `__Pyx_setup_reduce` walks `tp_mro` — leaving any of them NULL + // segfaults. Built *after* the type is registered so its own pointer + // resolves for the `tp_mro[0]` self entry. + let base_for_c: Option<*mut PyTypeObject> = readied + .bridge + .bases + .borrow() + .first() + .and_then(type_ptr_for_class); + let bases_for_c: Vec> = readied.bridge.bases.borrow().clone(); + let mro_for_c: Vec> = readied.bridge.mro.borrow().clone(); + let tp_bases_box = unsafe { build_type_ptr_tuple(&bases_for_c) }; + let tp_mro_box = unsafe { build_type_ptr_tuple(&mro_for_c) }; + + // Write-back into the caller's struct — both offsets live inside + // the faithful 416-byte CPython prefix, so a stock static type is + // never overrun. + // + // RFC 0046 (wave 4): only *fill* a missing metaclass. CPython's + // `PyType_Ready` sets `ob_type` to `Py_TYPE(tp_base)` (defaulting to + // `&PyType_Type`) only when it is NULL; it must never clobber a + // metaclass the extension pre-installed. numpy mallocs each DType + // class with `ob_type = &PyArrayDTypeMeta_Type` (its metaclass) and + // relies on the stock inlined `PyObject_TypeCheck(dt, + // &PyArrayDTypeMeta_Type)` — a direct `ob_type` pointer compare — so + // overwriting it with `&PyType_Type` made every DType fail + // validation ("provided object … is not a DType"). + unsafe { + if (*t).head.ob_type.is_null() { + (*t).head.ob_type = PyType_Type.as_ptr(); + } + (*t).head.ob_refcnt = IMMORTAL_REFCNT; + (*t).tp_flags |= PY_TPFLAGS_READY; + // RFC 0047 (wave 5): mirror CPython's `inherit_special` and stamp + // the fast-subclass bit for the most-derived builtin in this type's + // ancestry. A stock extension reads these directly off `tp_flags` + // (`PyType_Check`, `PyExceptionInstance_Check`, …); without them + // numpy.random's Cython rejects `numpy.dtype` whose metaclass + // `_DTypeMeta` is a `type` subclass that here would carry no + // `Py_TPFLAGS_TYPE_SUBCLASS`. + (*t).tp_flags |= fast_subclass_flags(&readied.bridge); + if (*t).tp_dict.is_null() { + (*t).tp_dict = tp_dict_box; + } else { + crate::object::Py_DecRef(tp_dict_box); + } + if (*t).tp_base.is_null() { + if let Some(bp) = base_for_c { + (*t).tp_base = bp; + } + } + if (*t).tp_bases.is_null() { + (*t).tp_bases = tp_bases_box; + } else if !tp_bases_box.is_null() { + crate::object::Py_DecRef(tp_bases_box); + } + if (*t).tp_mro.is_null() { + (*t).tp_mro = tp_mro_box; + } else if !tp_mro_box.is_null() { + crate::object::Py_DecRef(tp_mro_box); + } + if (*t).tp_dealloc.is_none() { + (*t).tp_dealloc = Some(crate::object::_PyWeavePy_Dealloc); + } + // RFC 0046 (wave 4): CPython's `PyType_Ready` installs a default + // `tp_free` (`PyObject_Free`) when the type provides none. An + // extension `tp_dealloc` that ends with `Py_TYPE(self)->tp_free(self)` + // — numpy's `boundarraymethod_dealloc` does — would otherwise call + // through a NULL slot. Our `PyObject_Free` absorbs the free of a + // faithful instance body (its block is owned by the native + // instance) and `PyMem_Free`s a plain block. + if (*t).tp_free.is_null() { + let free_fp: unsafe extern "C" fn(*mut c_void) = crate::memory::PyObject_Free; + (*t).tp_free = free_fp as *mut c_void; + } + // RFC 0046 (wave 4): likewise default `tp_alloc` to + // `PyType_GenericAlloc`. CPython inherits it from `object`; an + // extension that calls `subtype->tp_alloc(subtype, 0)` directly — + // numpy's `arraydescr_new` does, to mint a fresh DType instance — + // would otherwise jump through a NULL slot. + if (*t).tp_alloc.is_null() { + let alloc_fp: unsafe extern "C" fn(*mut PyTypeObject, PySsizeT) -> *mut PyObject = + crate::genericalloc::PyType_GenericAlloc; + (*t).tp_alloc = alloc_fp as *mut c_void; + } + } + + // RFC 0046 (wave 4): reflect a *foreign* metaclass onto the bridged + // type so metatype-level descriptors resolve through the VM's + // `load_attr_type` (CPython's `type_getattro` searches `Py_TYPE(type)` + // first). numpy mallocs each DType class (`Float64DType`, …) with + // `ob_type = &PyArrayDTypeMeta_Type`, whose `_DTypeMeta` exposes + // `_legacy` / `_abstract` / the `type` property as getsets; a dtype's + // `arraydescr_repr` / `.name` read `type(dtype)._legacy`. Without the + // metaclass link `metaclass_or_type()` collapses to `type`, those reads + // raise `AttributeError`, and dtype `repr`/`str`/`.name` degrade to the + // foreign placeholder. Only adopt a genuine *foreign* metatype (never + // our own `PyType_Type`, never the type itself), readied on demand so + // its getsets are harvested. + unsafe { + let meta_ptr = (*t).head.ob_type; + if !meta_ptr.is_null() + && meta_ptr != t + && meta_ptr != PyType_Type.as_ptr() + && !is_weavepy_owned_type(meta_ptr) + { + if let Some(meta_t) = bridge_or_ready(meta_ptr) { + readied.bridge.set_metaclass(meta_t); + } + } + } 0 } #[no_mangle] pub unsafe extern "C" fn PyType_IsSubtype(a: *mut PyTypeObject, b: *mut PyTypeObject) -> c_int { + if a.is_null() || b.is_null() { + return 0; + } + // CPython-faithful C-level test first: `a` is a subtype of `b` if `b` + // is reachable from `a` through the `tp_base` chain. A pure pointer + // walk — correct no matter which interpreter minted either type, and + // never an out-of-bounds read (every `PyTypeObject`, stock or ours, + // carries `tp_base` at the standard offset). This is what makes the + // process-global datetime shells (RFC 0029) answer + // `PyDate_Check(datetime_instance)` correctly via their + // `datetime → date → object` chain, where a bridge-identity + // comparison would fail across the test harness's per-case + // interpreters. + if unsafe { c_base_chain_contains(a, b) } { + return 1; + } + // Fall back to the bridged-MRO comparison for types whose faithful C + // base chain is not populated (the common WeavePy bridged type). let (Some(a), Some(b)) = (unsafe { bridge_type(a) }, unsafe { bridge_type(b) }) else { return 0; }; - if a.is_subclass_of(&b) { - 1 - } else { - 0 + c_int::from(a.is_subclass_of(&b)) +} + +/// Walk `a`'s `tp_base` ancestry looking for `b` — the pointer-only core +/// of CPython's `PyType_IsSubtype`. Returns `false` (not "unknown") when +/// the chain runs out, so callers fall back to the bridged comparison. +/// +/// # Safety +/// `a` must be null or a valid `PyTypeObject*`; the chain is acyclic and +/// terminates at a type whose `tp_base` is null (`object`). +unsafe fn c_base_chain_contains(a: *mut PyTypeObject, b: *mut PyTypeObject) -> bool { + let mut cur = a; + while !cur.is_null() { + if std::ptr::eq(cur, b) { + return true; + } + cur = unsafe { (*cur).tp_base }; } + false } #[no_mangle] diff --git a/crates/weavepy-capi/src/varargs.c b/crates/weavepy-capi/src/varargs.c index 14cd39e..8c1cb0a 100644 --- a/crates/weavepy-capi/src/varargs.c +++ b/crates/weavepy-capi/src/varargs.c @@ -50,13 +50,125 @@ #include "../include/Python.h" #include +#include #include #include +#include #include #include #include #include +/* -------------------------------------------------------------- + * Debug crash handler (RFC 0046, wave 4). + * + * Dumping a native backtrace on SIGSEGV/SIGBUS/SIGABRT is invaluable + * when a freshly-loaded C extension (e.g. numpy's `_multiarray_umath` + * `Py_mod_exec`) faults deep inside its own initialiser, where lldb is + * unavailable. The handler uses async-signal-safe `backtrace*` and then + * re-raises with the default disposition so the real exit status is + * preserved. Installed only when `WEAVEPY_CRASH_BT` is set. + * + * `execinfo.h`/`backtrace*`, ``, and signals such as `SIGBUS` + * are POSIX-only, so on Windows the installer is a no-op that still + * resolves the `extern` symbol referenced from `interp.rs`. + * -------------------------------------------------------------- */ + +#if !defined(_WIN32) + +#include +#include +#include + +/* Async-signal-safe hex writer for the fault diagnostic below. */ +static void weavepy_write_hex(const char *label, unsigned long long v) { + char buf[32]; + int i = 0; + buf[i++] = ' '; + static const char hex[] = "0123456789abcdef"; + buf[i++] = '0'; + buf[i++] = 'x'; + for (int shift = 60; shift >= 0; shift -= 4) { + buf[i++] = hex[(v >> shift) & 0xf]; + } + buf[i++] = '\n'; + write(2, label, strlen(label)); + write(2, buf, i); +} + +static void weavepy_crash_handler_si(int sig, siginfo_t *info, void *ucv) { + const char *msg = "\n[weavepy] FAULTV2 caught fatal signal; native backtrace:\n"; + write(2, msg, strlen(msg)); + weavepy_write_hex("[weavepy] fault addr:", + (unsigned long long)(uintptr_t)(info ? info->si_addr : (void *)0)); + void *frames[512]; + int n = 0; +#if defined(__APPLE__) && defined(__aarch64__) + if (ucv) { + ucontext_t *uc = (ucontext_t *)ucv; + if (uc->uc_mcontext) { + unsigned long long pc = (unsigned long long)uc->uc_mcontext->__ss.__pc; + unsigned long long lr = (unsigned long long)uc->uc_mcontext->__ss.__lr; + unsigned long long fp = (unsigned long long)uc->uc_mcontext->__ss.__fp; + weavepy_write_hex("[weavepy] pc:", pc); + weavepy_write_hex("[weavepy] lr:", lr); + /* Manually walk the arm64 frame-pointer chain from the + * interrupted context. backtrace() from a signal handler on + * macOS only sees the handler's own (alt-stack) frames, so to + * capture the *faulting* stack (e.g. a recursion cycle that + * overflowed) we chase [fp] = {saved_fp, saved_lr}. */ + frames[n++] = (void *)pc; + if (lr) frames[n++] = (void *)lr; + unsigned long long cur = fp; + unsigned long long prev = 0; + while (cur && cur > prev && n < 500) { + unsigned long long next = *(unsigned long long *)cur; + unsigned long long ret = *(unsigned long long *)(cur + 8); + if (!ret) break; + frames[n++] = (void *)ret; + prev = cur; + cur = next; + } + } + } +#endif + if (n == 0) { + n = backtrace(frames, 512); + } + backtrace_symbols_fd(frames, n, 2); + signal(sig, SIG_DFL); + raise(sig); +} + +/* Alternate signal stack so the handler can run even when the main + * stack is exhausted (the recursion-driven stack-overflow case). */ +static char weavepy_altstack[SIGSTKSZ > 65536 ? SIGSTKSZ : 65536]; + +void weavepy_install_crash_handler(void) { + stack_t ss; + memset(&ss, 0, sizeof(ss)); + ss.ss_sp = weavepy_altstack; + ss.ss_size = sizeof(weavepy_altstack); + ss.ss_flags = 0; + sigaltstack(&ss, NULL); + + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = weavepy_crash_handler_si; + sa.sa_flags = SA_SIGINFO | SA_ONSTACK; + sigemptyset(&sa.sa_mask); + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + sigaction(SIGABRT, &sa, NULL); + sigaction(SIGILL, &sa, NULL); +} + +#else /* _WIN32 */ + +void weavepy_install_crash_handler(void) {} + +#endif /* _WIN32 */ + /* -------------------------------------------------------------- * Forward declarations of Rust helpers (matching argparse.rs). * -------------------------------------------------------------- */ @@ -131,12 +243,29 @@ static PyObject *fetch_arg(PyObject *args, int index) { return _WeavePy_Arg_Item(args, index); } +/* Nested-sequence group support (CPython `converttuple`). Forward- + * declared here because `parse_one` and `parse_group` are mutually + * recursive (a group element may itself be a group). */ +static int parse_group(fmt_state *st, PyObject *arg, va_list *ap); +static int count_group_units(const char *p); + /* Convert a single format unit into the va_arg destination(s). * Returns 0 on success, -1 on failure (with an exception set). */ static int parse_one(fmt_state *st, PyObject *arg, va_list *ap) { char unit = *st->fmt; if (unit == 0) return -1; + /* A `(...)` group binds to *one* argument that must itself be a + * sequence; its units are unpacked against that sequence's items + * (CPython `converttuple`). Extensions lean on this heavily — + * numpy's `array_setstate` parses its pickle state with + * `"(iO!O!iO):__setstate__"`, so without group support every + * ndarray (hence Index/Series/DataFrame) failed to unpickle with + * "function requires more arguments than were given". */ + if (unit == '(') { + return parse_group(st, arg, ap); + } + /* The 's#'/'y#'/'z#' family takes both a buffer pointer and a length. */ bool has_len_flag = (st->fmt[1] == '#'); @@ -163,6 +292,14 @@ static int parse_one(fmt_state *st, PyObject *arg, va_list *ap) { st->fmt++; return 0; } + case 'H': { + unsigned short *dest = va_arg(*ap, unsigned short *); + long long tmp = 0; + if (_WeavePy_Arg_Long(arg, &tmp) != 0) return -1; + *dest = (unsigned short)tmp; + st->fmt++; + return 0; + } case 'b': case 'B': { unsigned char *dest = va_arg(*ap, unsigned char *); int tmp = 0; @@ -179,6 +316,22 @@ static int parse_one(fmt_state *st, PyObject *arg, va_list *ap) { st->fmt++; return 0; } + case 'k': { + /* unsigned long. numpy's `arraydescr_setstate` parses its + * pickle state with `"(iOOOOnnkO)"` — the `k` slot is the + * dtype's `flags` word. A missing case here fell through to + * `default` WITHOUT consuming the `unsigned long *`, which + * desynced the trailing `O` (datetime `metadata`) onto the + * flags pointer and left numpy dereferencing an uninitialised + * `metadata` — SIGSEGV unpickling any `datetime64`/`timedelta64` + * dtype (hence every DatetimeIndex/TimedeltaIndex/Period). */ + unsigned long *dest = va_arg(*ap, unsigned long *); + long long tmp = 0; + if (_WeavePy_Arg_Long(arg, &tmp) != 0) return -1; + *dest = (unsigned long)tmp; + st->fmt++; + return 0; + } case 'L': case 'q': { long long *dest = va_arg(*ap, long long *); if (_WeavePy_Arg_Long(arg, dest) != 0) return -1; @@ -282,12 +435,154 @@ static int parse_one(fmt_state *st, PyObject *arg, va_list *ap) { return 0; } default: - /* Unknown unit — log and skip the slot. */ + /* Unknown *conversion* code. Every CPython single-letter + * conversion writes through exactly one pointer destination, + * so consume one `void *` to keep the `va_list` in sync — a + * silent skip here desyncs every later unit and segfaults the + * caller (this is exactly how the missing `k` above corrupted + * numpy's dtype `__setstate__`). Punctuation (which carries no + * va_arg) is filtered out before we reach the switch, so only + * alpha codes hit this branch. */ + if (isalpha((unsigned char)unit)) { + (void)va_arg(*ap, void *); + } st->fmt++; return 0; } } +/* Count the top-level format units inside a `(...)` group. `p` points + * just past the opening `(`; scanning stops at the matching `)` (or a + * `:`/`;`/NUL terminator for a malformed format). Nested groups count + * as a single unit; only alpha unit-letters (bar the exponent flag + * `e`) and `(` groups consume a sequence element. Mirrors the counting + * pass of CPython's `converttuple`. */ +static int count_group_units(const char *p) { + int level = 0; + int n = 0; + for (;; p++) { + char c = *p; + if (c == '\0' || c == ':' || c == ';') { + break; + } + if (c == '(') { + if (level == 0) n++; + level++; + continue; + } + if (c == ')') { + if (level == 0) break; + level--; + continue; + } + if (level == 0 && isalpha((unsigned char)c) && c != 'e') { + n++; + } + } + return n; +} + +/* Parse a `(...)` nested-sequence group. On entry `st->fmt` points at + * the `(`. The bound argument must be a sequence whose length equals + * the number of top-level units inside the group; each element is then + * converted against the corresponding inner unit (recursing through + * `parse_one`, so groups nest). Mirrors CPython's `converttuple`. */ +static int parse_group(fmt_state *st, PyObject *arg, va_list *ap) { + int n = count_group_units(st->fmt + 1); + int len = _WeavePy_Arg_Length(arg); + if (len < 0) { + char buf[96]; + snprintf(buf, sizeof(buf), "expected a sequence (%d-tuple)", n); + PyErr_SetString(PyExc_TypeError, buf); + return -1; + } + if (len != n) { + char buf[96]; + snprintf(buf, sizeof(buf), + "must be sequence of length %d, not %d", n, len); + PyErr_SetString(PyExc_TypeError, buf); + return -1; + } + st->fmt++; /* consume '(' */ + int i = 0; + while (*st->fmt && *st->fmt != ')') { + char c = *st->fmt; + /* Whitespace / commas are cosmetic separators inside a group. */ + if (c == ' ' || c == '\t' || c == ',') { + st->fmt++; + continue; + } + PyObject *elem = fetch_arg(arg, i); + if (!elem) { + PyErr_SetString(PyExc_RuntimeError, + "PyArg_ParseTuple: NULL group element"); + return -1; + } + int rc = parse_one(st, elem, ap); + Py_DECREF(elem); + if (rc != 0) return -1; + i++; + } + if (*st->fmt == ')') st->fmt++; + return 0; +} + +/* Advance `st->fmt` over one format unit *and* consume the matching + * number of `va_arg` destination pointers WITHOUT storing anything — + * CPython's `skipitem()`. This must run for every optional slot the + * caller did not supply, otherwise the `va_list` desynchronises and + * every later unit writes through the wrong destination. (pandas' + * `ujson_dumps` uses `O|OiOssOOi` and omits `encode_html_chars`; without + * this the kw `date_unit='ms'` landed in the `orient` pointer, raising + * "Invalid value 'ms' for option 'orient'".) + * + * Every PyArg parse destination is a pointer (the `#` length slots and + * `O&`'s converter are all pointer-sized), so reading each skipped slot + * as `void *` is ABI-correct on every supported target. The fmt-cursor + * advancement mirrors `parse_one` exactly. */ +static void skip_one(fmt_state *st, va_list *ap) { + char unit = *st->fmt; + if (unit == 0) return; + char modifier = st->fmt[1]; + switch (unit) { + case 'O': + if (modifier == '!' || modifier == '&') { + (void)va_arg(*ap, void *); + (void)va_arg(*ap, void *); + st->fmt += 2; + } else { + (void)va_arg(*ap, void *); + st->fmt++; + } + return; + case 's': case 'z': case 'y': + (void)va_arg(*ap, void *); /* buffer pointer */ + if (modifier == '#') { + (void)va_arg(*ap, void *); /* length pointer */ + st->fmt += 2; + } else { + st->fmt++; + } + return; + case 'i': case 'I': case 'h': case 'H': case 'b': case 'B': + case 'l': case 'k': case 'L': case 'q': case 'K': case 'Q': + case 'n': case 'f': case 'd': case 'p': case 'U': + case 'c': case 'C': + (void)va_arg(*ap, void *); + st->fmt++; + return; + default: + /* Unknown conversion code: consume one pointer to stay in sync + * (see the matching note in `parse_one`). Only alpha codes carry + * a va_arg; punctuation is handled by the callers. */ + if (isalpha((unsigned char)unit)) { + (void)va_arg(*ap, void *); + } + st->fmt++; + return; + } +} + /* NB: `va_list` is an *array type* on the x86_64 SysV ABI * (`__va_list_tag[1]`). Passing it by value to a function makes the * parameter decay to `__va_list_tag *`, so `&ap` inside the callee @@ -308,12 +603,31 @@ static int parse_args_from(PyObject *args, const char *fmt, va_list *ap) { int n_args = _WeavePy_Arg_Length(args); int idx = 0; int min_required = 0; - /* First pass: count required slots (units before `|`). */ - for (const char *p = fmt; *p; p++) { - if (*p == '|') break; - if (*p == ':' || *p == ';') break; - if (isalpha((unsigned char)*p)) min_required++; - if (*p == '#') min_required--; /* `#` is paired with the previous unit */ + /* First pass: count required slots (units before `|`). A `(...)` + * group is a *single* argument (a nested sequence), so its inner + * unit-letters must not be counted individually — otherwise a + * format like numpy's `"(iO!O!iO)"` demands 5 args when the caller + * legitimately passes 1 (the state tuple). Track paren depth and + * count only depth-0 units. */ + { + int level = 0; + for (const char *p = fmt; *p; p++) { + char c = *p; + if (level == 0 && c == '|') break; + if (c == ':' || c == ';') break; + if (c == '(') { + if (level == 0) min_required++; + level++; + continue; + } + if (c == ')') { + if (level > 0) level--; + continue; + } + if (level > 0) continue; + if (isalpha((unsigned char)c)) min_required++; + if (c == '#') min_required--; /* `#` is paired with the previous unit */ + } } if (n_args < 0 || n_args < min_required) { PyErr_SetString(PyExc_TypeError, "function requires more arguments than were given"); @@ -426,9 +740,11 @@ static int parse_args_kw_from(PyObject *args, PyObject *kwargs, const char *fmt, PyErr_SetString(PyExc_TypeError, "missing required argument"); return 0; } - /* Consume the format slot without touching the va_list. */ - st.fmt++; - if (*st.fmt == '#') st.fmt++; + /* Optional slot not supplied: advance the format AND consume + * the matching va_arg destination(s) (CPython's skipitem), + * so a later keyword-supplied unit still writes through its + * own pointer rather than this skipped slot's. */ + skip_one(&st, ap); slot_idx++; continue; } @@ -668,17 +984,20 @@ static PyObject *build_one(const char **fmt, va_list *ap) { } } -PyObject *Py_BuildValue(const char *fmt, ...) { - if (!fmt) return _WeavePy_Build_None(); - va_list ap; - va_start(ap, fmt); - PyObject *result = NULL; - /* If the format starts with a single unit, return that; otherwise - * wrap in a tuple. */ +/* Shared core for Py_BuildValue / Py_VaBuildValue. + * + * CPython's `va_build_value` semantics: a format string with a single + * top-level unit yields *that* unit; two or more top-level units yield + * a *tuple* of them. Both the `...` and `va_list` entry points must + * agree — a previous version open-coded the single-unit case in + * `Py_VaBuildValue`, so `PyObject_CallFunction(c, "ll", a, b)` (which + * routes through `Py_VaBuildValue`) silently dropped every argument + * past the first and called `c` with a 1-tuple. */ +static PyObject *build_value_impl(const char *fmt, va_list *ap) { const char *p = fmt; - /* Quick scan to count top-level units. A unit at depth 0 is - * either an alpha format code (`i`, `s`, `O`, etc.) or an - * opening bracket that begins a nested tuple/list/dict. */ + /* Count top-level units. A unit at depth 0 is either an alpha + * format code (`i`, `s`, `O`, …) or an opening bracket that begins + * a nested tuple/list/dict. */ int top_units = 0; int depth = 0; for (const char *q = fmt; *q; q++) { @@ -694,25 +1013,34 @@ PyObject *Py_BuildValue(const char *fmt, ...) { } } if (top_units == 1) { - result = build_one(&p, &ap); - } else { - PyObject **items = NULL; - Py_ssize_t n = 0; - Py_ssize_t cap = top_units > 0 ? top_units : 1; - items = (PyObject **)malloc(cap * sizeof(PyObject *)); - while (*p) { - PyObject *one = build_one(&p, &ap); - if (!one) { - for (Py_ssize_t i = 0; i < n; i++) Py_DECREF(items[i]); - free(items); - va_end(ap); - return NULL; - } - items[n++] = one; + return build_one(&p, ap); + } + PyObject **items = NULL; + Py_ssize_t n = 0; + Py_ssize_t cap = top_units > 0 ? top_units : 1; + items = (PyObject **)malloc((size_t)cap * sizeof(PyObject *)); + if (!items) { + return NULL; + } + while (*p) { + PyObject *one = build_one(&p, ap); + if (!one) { + for (Py_ssize_t i = 0; i < n; i++) Py_DECREF(items[i]); + free(items); + return NULL; } - result = _WeavePy_Build_TupleFromArray(n, items); - free(items); + items[n++] = one; } + PyObject *result = _WeavePy_Build_TupleFromArray(n, items); + free(items); + return result; +} + +PyObject *Py_BuildValue(const char *fmt, ...) { + if (!fmt) return _WeavePy_Build_None(); + va_list ap; + va_start(ap, fmt); + PyObject *result = build_value_impl(fmt, &ap); va_end(ap); return result; } @@ -722,7 +1050,7 @@ PyObject *Py_VaBuildValue(const char *fmt, va_list ap) { /* Re-establish a real va_list local (see `PyArg_VaParse`). */ va_list local; va_copy(local, ap); - PyObject *result = build_one(&fmt, &local); + PyObject *result = build_value_impl(fmt, &local); va_end(local); return result; } @@ -745,24 +1073,267 @@ PyObject *PyTuple_Pack(Py_ssize_t n, ...) { * String / error formatters. * -------------------------------------------------------------- */ +static void wpy_append(char *buf, size_t bufsize, size_t *pos, const char *s, size_t len) { + if (*pos + 1 >= bufsize || s == NULL) { + return; + } + size_t room = bufsize - 1 - *pos; + size_t copy = len < room ? len : room; + memcpy(buf + *pos, s, copy); + *pos += copy; + buf[*pos] = '\0'; +} + +/* CPython's `PyUnicode_FromFormat` / `PyErr_Format` accept a printf-like + * grammar that is *not* C's printf: it adds object conversions (`%S` str, + * `%R` repr, `%A` ascii, `%U` unicode, `%V` unicode-or-fallback, `%T` + * fully-qualified type name) and only a documented subset of the integer + * family. C's `vsnprintf` mangles `%R` (prints a literal `R` and consumes + * no argument), so we must walk the format ourselves: object specs are + * rendered by calling the object protocol and the result is spliced in + * (honouring width/precision); standard specs are reconstructed verbatim + * and handed to `snprintf` one directive at a time with the correctly + * typed argument peeled off the `va_list`. */ static int weavepy_format_into(char *buf, size_t bufsize, const char *fmt, va_list ap) { - /* Translate CPython %-specs that don't appear in C's printf - * (`%S`, `%R`, `%U`, `%V`, `%T`, `%A`) into placeholders. - * Everything else is forwarded to vsnprintf. */ + if (bufsize == 0) { + return 0; + } + size_t pos = 0; + buf[0] = '\0'; + const char *p = fmt; char tmp[8192]; - int written = 0; - char *out = bufsize > sizeof(tmp) ? buf : tmp; - size_t outsize = bufsize > sizeof(tmp) ? bufsize : sizeof(tmp); - int n = vsnprintf(out, outsize, fmt, ap); - if (n < 0) return -1; - if (out != buf) { - size_t copy = (size_t)n < bufsize ? (size_t)n : bufsize - 1; - memcpy(buf, out, copy); - buf[copy] = '\0'; - n = (int)copy; + while (*p) { + if (*p != '%') { + wpy_append(buf, bufsize, &pos, p, 1); + p++; + continue; + } + const char *start = p; + p++; /* skip '%' */ + if (*p == '%') { + wpy_append(buf, bufsize, &pos, "%", 1); + p++; + continue; + } + /* flags */ + char flags[8]; + int nf = 0; + while (*p && strchr("-+ 0#", *p)) { + if (nf < 7) flags[nf++] = *p; + p++; + } + flags[nf] = '\0'; + /* width */ + char width[16]; + int nw = 0; + int width_star = 0; + if (*p == '*') { + width_star = 1; + p++; + } else { + while (isdigit((unsigned char)*p)) { + if (nw < 15) width[nw++] = *p; + p++; + } + } + width[nw] = '\0'; + /* precision */ + char prec[16]; + int npr = 0; + int prec_star = 0; + int has_prec = 0; + if (*p == '.') { + has_prec = 1; + p++; + if (*p == '*') { + prec_star = 1; + p++; + } else { + while (isdigit((unsigned char)*p)) { + if (npr < 15) prec[npr++] = *p; + p++; + } + } + } + prec[npr] = '\0'; + /* length modifiers */ + char length[4]; + int nl = 0; + while (*p && strchr("hljztL", *p)) { + if (nl < 3) length[nl++] = *p; + p++; + } + length[nl] = '\0'; + char conv = *p; + if (conv == '\0') { + /* dangling '%': emit verbatim. */ + wpy_append(buf, bufsize, &pos, start, (size_t)(p - start)); + break; + } + p++; + + /* Object conversions: render via the object protocol, then apply + * width/precision by reformatting the resulting C string with a + * synthesised `%[flags][width][.prec]s` directive. */ + if (conv == 'S' || conv == 'R' || conv == 'A' || conv == 'U' || + conv == 'V' || conv == 'T') { + int wv = 0, pv = 0; + if (width_star) wv = va_arg(ap, int); + if (prec_star) pv = va_arg(ap, int); + PyObject *owned = NULL; + const char *cs = NULL; + if (conv == 'V') { + PyObject *o = va_arg(ap, PyObject *); + const char *fb = va_arg(ap, const char *); + if (o) { + owned = PyObject_Str(o); + cs = owned ? PyUnicode_AsUTF8(owned) : fb; + } else { + cs = fb; + } + } else if (conv == 'T') { + PyObject *o = va_arg(ap, PyObject *); + cs = o ? PyType_GetName(Py_TYPE(o)) : "NULL"; + } else { + PyObject *o = va_arg(ap, PyObject *); + if (o == NULL) { + cs = "NULL"; + } else if (conv == 'S') { + owned = PyObject_Str(o); + cs = owned ? PyUnicode_AsUTF8(owned) : NULL; + } else if (conv == 'R') { + owned = PyObject_Repr(o); + cs = owned ? PyUnicode_AsUTF8(owned) : NULL; + } else if (conv == 'A') { + owned = PyObject_ASCII(o); + cs = owned ? PyUnicode_AsUTF8(owned) : NULL; + } else { /* 'U' */ + cs = PyUnicode_AsUTF8(o); + } + } + if (cs == NULL) cs = ""; + /* Reformat with width/precision if either was requested. */ + if (nf || nw || width_star || has_prec) { + char sspec[48]; + if (width_star && prec_star) { + snprintf(sspec, sizeof(sspec), "%%%s%d.%ds", flags, wv, pv); + } else if (width_star) { + snprintf(sspec, sizeof(sspec), "%%%s%d%s%ss", flags, wv, + has_prec ? "." : "", has_prec ? prec : ""); + } else if (prec_star) { + snprintf(sspec, sizeof(sspec), "%%%s%s.%ds", flags, width, pv); + } else { + snprintf(sspec, sizeof(sspec), "%%%s%s%s%ss", flags, width, + has_prec ? "." : "", has_prec ? prec : ""); + } + int n = snprintf(tmp, sizeof(tmp), sspec, cs); + if (n > 0) wpy_append(buf, bufsize, &pos, tmp, (size_t)n); + } else { + wpy_append(buf, bufsize, &pos, cs, strlen(cs)); + } + Py_XDECREF(owned); + continue; + } + + /* Standard C conversions: rebuild the directive verbatim and hand + * it to snprintf with a correctly typed argument. */ + char dir[48]; + { + size_t dl = (size_t)(p - start); + if (dl >= sizeof(dir)) dl = sizeof(dir) - 1; + memcpy(dir, start, dl); + dir[dl] = '\0'; + } + int wv = 0, pv = 0; + if (width_star) wv = va_arg(ap, int); + if (prec_star) pv = va_arg(ap, int); + int n = 0; + int is_ll = (nl >= 2 && length[0] == 'l' && length[1] == 'l'); + int is_l = (nl == 1 && length[0] == 'l'); + int is_z = (nl >= 1 && length[0] == 'z'); + int is_j = (nl >= 1 && length[0] == 'j'); + int is_t = (nl >= 1 && length[0] == 't'); +#define WPY_SNPRINTF(argexpr) \ + do { \ + if (width_star && prec_star) \ + n = snprintf(tmp, sizeof(tmp), dir, wv, pv, argexpr); \ + else if (width_star || prec_star) \ + n = snprintf(tmp, sizeof(tmp), dir, (width_star ? wv : pv), \ + argexpr); \ + else \ + n = snprintf(tmp, sizeof(tmp), dir, argexpr); \ + } while (0) + switch (conv) { + case 'd': + case 'i': { + if (is_ll) { + WPY_SNPRINTF(va_arg(ap, long long)); + } else if (is_l) { + WPY_SNPRINTF(va_arg(ap, long)); + } else if (is_z) { + WPY_SNPRINTF(va_arg(ap, Py_ssize_t)); + } else if (is_j) { + WPY_SNPRINTF(va_arg(ap, intmax_t)); + } else if (is_t) { + WPY_SNPRINTF(va_arg(ap, ptrdiff_t)); + } else { + WPY_SNPRINTF(va_arg(ap, int)); + } + break; + } + case 'u': + case 'o': + case 'x': + case 'X': { + if (is_ll) { + WPY_SNPRINTF(va_arg(ap, unsigned long long)); + } else if (is_l) { + WPY_SNPRINTF(va_arg(ap, unsigned long)); + } else if (is_z) { + WPY_SNPRINTF(va_arg(ap, size_t)); + } else if (is_j) { + WPY_SNPRINTF(va_arg(ap, uintmax_t)); + } else if (is_t) { + WPY_SNPRINTF(va_arg(ap, size_t)); + } else { + WPY_SNPRINTF(va_arg(ap, unsigned int)); + } + break; + } + case 'c': { + WPY_SNPRINTF(va_arg(ap, int)); + break; + } + case 'e': + case 'E': + case 'f': + case 'F': + case 'g': + case 'G': { + WPY_SNPRINTF(va_arg(ap, double)); + break; + } + case 's': { + WPY_SNPRINTF(va_arg(ap, const char *)); + break; + } + case 'p': { + WPY_SNPRINTF(va_arg(ap, void *)); + break; + } + default: { + /* Unknown spec: emit verbatim, consume nothing. */ + wpy_append(buf, bufsize, &pos, start, (size_t)(p - start)); + n = -1; + break; + } + } +#undef WPY_SNPRINTF + if (n > 0) { + wpy_append(buf, bufsize, &pos, tmp, (size_t)n); + } } - written = n; - return written; + return (int)pos; } PyObject *PyUnicode_FromFormatV(const char *fmt, va_list ap) { @@ -904,3 +1475,34 @@ PyObject *PyObject_CallFunctionObjArgs(PyObject *callable, ...) { Py_DECREF(args); return result; } + +/* -------------------------------------------------------------- + * RFC 0046 (wave 4): variadic tail numpy links. + * -------------------------------------------------------------- */ + +/* PyOS_snprintf — a thin, locale-independent vsnprintf wrapper, matching + * CPython's behaviour of always NUL-terminating the buffer. */ +int PyOS_snprintf(char *str, size_t size, const char *format, ...) { + va_list ap; + va_start(ap, format); + int n = vsnprintf(str, size, format, ap); + va_end(ap); + if (size > 0) { + str[size - 1] = '\0'; + } + return n; +} + +/* PyErr_WarnFormat — format the message and route it through the + * non-variadic PyErr_WarnEx. Warnings are advisory; a failure to render + * the warning never aborts the caller. */ +int PyErr_WarnFormat(PyObject *category, Py_ssize_t stack_level, + const char *format, ...) { + char buf[1024]; + va_list ap; + va_start(ap, format); + vsnprintf(buf, sizeof(buf), format, ap); + va_end(ap); + buf[sizeof(buf) - 1] = '\0'; + return PyErr_WarnEx(category, buf, stack_level); +} diff --git a/crates/weavepy-capi/src/vectorcall.rs b/crates/weavepy-capi/src/vectorcall.rs index df8c3d8..bdb20b0 100644 --- a/crates/weavepy-capi/src/vectorcall.rs +++ b/crates/weavepy-capi/src/vectorcall.rs @@ -22,9 +22,14 @@ //! 2. Otherwise, decode the `(args, nargsf, kwnames)` triple into a //! `(args, kwargs)` pair and forward to [`PyObject_Call`]. //! -//! [`PY_VECTORCALL_ARGUMENTS_OFFSET`] is honoured: when the bit is -//! set, we skip the leading slot of `args`, matching CPython's -//! convention that callers may pre-reserve a slot for `self`. +//! [`PY_VECTORCALL_ARGUMENTS_OFFSET`] is honoured per CPython's exact +//! contract: the bit does **not** shift `args`. `args[0]` is always the +//! first argument (the receiver, for [`PyObject_VectorcallMethod`]) and +//! the positional count `PyVectorcall_NARGS(nargsf)` counts the elements +//! at `args[0..nargs]`. The bit only tells the callee that the slot at +//! `args[-1]` is scratch space it may temporarily overwrite (CPython's +//! trick for prepending `self` without reallocating) — a guarantee we +//! never need to read, so it has no effect on our decoding. use std::ptr; @@ -32,8 +37,10 @@ use weavepy_vm::object::{DictKey, Object}; use crate::object::{PyObject, PySsizeT}; -/// Top bit of `nargsf`. Indicates the caller already left a slot -/// for `self` at `args[-1]`, so we should skip the first element. +/// Top bit of `nargsf`. Indicates the caller left a scratch slot at +/// `args[-1]` that the callee may temporarily overwrite (e.g. to +/// prepend `self`). It does **not** shift `args`: `args[0]` is still +/// the first argument and `PyVectorcall_NARGS` still counts from there. pub const PY_VECTORCALL_ARGUMENTS_OFFSET: usize = 1_usize << (usize::BITS - 1); /// `PyVectorcall_NARGS(nargsf)` — strip the high `args-offset` bit. @@ -51,14 +58,54 @@ pub type VectorcallFunc = unsafe extern "C" fn( kwnames: *mut PyObject, ) -> *mut PyObject; -/// `PyVectorcall_Function(callable)` — return the vectorcall slot -/// for `callable`'s type, or null if the callable doesn't support -/// vectorcall. +/// `Py_TPFLAGS_HAVE_VECTORCALL` — the type advertises a per-instance +/// `vectorcallfunc` projected through `tp_vectorcall_offset`. +const HAVE_VECTORCALL: u64 = 1 << 11; + +/// Read the **per-instance** `vectorcallfunc` of `callable` the way +/// CPython's inlined `PyVectorcall_Function` does: require +/// `Py_TPFLAGS_HAVE_VECTORCALL` on the type, then load the function +/// pointer from `*(char *)callable + tp->tp_vectorcall_offset`. +/// +/// This is the load-bearing piece for vectorcall types whose `tp_call` +/// is `PyVectorcall_Call` (the standard idiom — numpy's +/// `_ArrayFunctionDispatcher` stores `dispatcher_vectorcall` in a +/// per-instance `vectorcall` field). Routing such a call back through +/// `PyObject_Call` would re-enter `tp_call` and recurse forever. +/// +/// # Safety +/// `callable` must be non-null and its `ob_type` a faithful `PyTypeObject`. +unsafe fn instance_vectorcall_func(callable: *mut PyObject) -> *mut std::ffi::c_void { + let tp = unsafe { (*callable).ob_type } as *mut crate::types::PyTypeObject; + if tp.is_null() { + return ptr::null_mut(); + } + let flags = unsafe { (*tp).tp_flags }; + if flags & HAVE_VECTORCALL == 0 { + return ptr::null_mut(); + } + let offset = unsafe { (*tp).tp_vectorcall_offset }; + if offset <= 0 { + return ptr::null_mut(); + } + let slot = + unsafe { (callable as *const u8).offset(offset as isize) } as *const *mut std::ffi::c_void; + unsafe { *slot } +} + +/// `PyVectorcall_Function(callable)` — return the vectorcall entry point +/// for `callable`, or null if it doesn't support vectorcall. Prefers the +/// per-instance `vectorcallfunc` (the `tp_vectorcall_offset` projection), +/// falling back to a type-level `tp_vectorcall` slot. #[no_mangle] pub unsafe extern "C" fn PyVectorcall_Function(callable: *mut PyObject) -> *mut std::ffi::c_void { if callable.is_null() { return ptr::null_mut(); } + let inst = unsafe { instance_vectorcall_func(callable) }; + if !inst.is_null() { + return inst; + } let head = unsafe { &*callable }; let Some(slot_table) = (unsafe { crate::slottable::slot_table_for(head.ob_type) }) else { return ptr::null_mut(); @@ -66,6 +113,63 @@ pub unsafe extern "C" fn PyVectorcall_Function(callable: *mut PyObject) -> *mut slot_table.get(crate::slottable::Py_tp_vectorcall).0 } +/// Invoke a per-instance `vectorcallfunc` with arguments supplied as a +/// CPython `(args_tuple, kwargs_dict)` pair, translating into the +/// vectorcall convention: a flat `args` array of positionals followed by +/// the keyword values, with `kwnames` a tuple of the keyword names and +/// `nargsf` the positional count. +/// +/// # Safety +/// `func` must be a valid `vectorcallfunc`; `callable` non-null. +unsafe fn call_via_vectorcall( + callable: *mut PyObject, + func: *mut std::ffi::c_void, + tuple: *mut PyObject, + kw: *mut PyObject, +) -> *mut PyObject { + let f: VectorcallFunc = unsafe { std::mem::transmute(func) }; + let nargs = if tuple.is_null() { + 0 + } else { + unsafe { crate::containers::PyTuple_Size(tuple) } + }; + let nargs = if nargs < 0 { 0usize } else { nargs as usize }; + let mut argvec: Vec<*mut PyObject> = Vec::with_capacity(nargs + 4); + for i in 0..nargs { + // Borrowed references owned by the tuple (alive across the call). + argvec.push(unsafe { crate::containers::PyTuple_GetItem(tuple, i as PySsizeT) }); + } + let mut kwnames: *mut PyObject = ptr::null_mut(); + let mut owned_values: Vec<*mut PyObject> = Vec::new(); + if !kw.is_null() { + if let Object::Dict(d) = unsafe { crate::object::clone_object(kw) } { + let items: Vec<(Object, Object)> = d + .borrow() + .iter() + .map(|(k, v)| (k.0.clone(), v.clone())) + .collect(); + if !items.is_empty() { + let mut names = Vec::with_capacity(items.len()); + for (k, v) in items { + names.push(k); + let vp = crate::object::into_owned(v); + owned_values.push(vp); + argvec.push(vp); + } + kwnames = crate::object::into_owned(Object::new_tuple(names)); + } + } + } + let result = unsafe { f(callable, argvec.as_ptr(), nargs, kwnames) }; + for vp in owned_values { + unsafe { crate::object::Py_DecRef(vp) }; + } + if !kwnames.is_null() { + unsafe { crate::object::Py_DecRef(kwnames) }; + } + result +} + /// `PyObject_Vectorcall(callable, args, nargsf, kwnames)` — fast /// invocation path. #[no_mangle] @@ -82,17 +186,39 @@ pub unsafe extern "C" fn PyObject_Vectorcall( // 1) Try the type's vectorcall slot. let slot = unsafe { PyVectorcall_Function(callable) }; + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + let nkw = if kwnames.is_null() { + 0 + } else { + match unsafe { crate::object::clone_object(kwnames) } { + Object::Tuple(t) => t.len(), + _ => 0, + } + }; + eprintln!( + "[TRACE_VEC] nargs={} nkw={} slot_null={}", + nargsf & !PY_VECTORCALL_ARGUMENTS_OFFSET, + nkw, + slot.is_null() + ); + } if !slot.is_null() { let f: VectorcallFunc = unsafe { std::mem::transmute(slot) }; return unsafe { f(callable, args, nargsf, kwnames) }; } - // 2) Fallback: decode and forward to PyObject_Call. + // 2) Fallback: decode and forward to PyObject_Call. A keyword-less + // call forwards a NULL `kwds` (CPython's convention) rather than a + // fresh empty dict — extensions branch on `kwds != NULL`. let (positional, kwargs) = unsafe { decode_vectorcall(args, nargsf, kwnames) }; let arg_tuple = crate::object::into_owned(Object::new_tuple(positional)); - let kw_dict = crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( - weavepy_vm::sync::RefCell::new(kwargs), - ))); + let kw_dict = if kwargs.is_empty() { + ptr::null_mut() + } else { + crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( + weavepy_vm::sync::RefCell::new(kwargs), + ))) + }; let result = unsafe { crate::abstract_::PyObject_Call(callable, arg_tuple, kw_dict) }; unsafe { crate::object::Py_DecRef(arg_tuple) }; unsafe { crate::object::Py_DecRef(kw_dict) }; @@ -113,23 +239,45 @@ pub unsafe extern "C" fn PyObject_VectorcallDict( crate::errors::set_type_error("PyObject_VectorcallDict: NULL callable"); return ptr::null_mut(); } + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + eprintln!("[TRACE_VCDICT] kwdict_null={}", kwdict.is_null()); + } let nargs = (nargsf & !PY_VECTORCALL_ARGUMENTS_OFFSET) as usize; - let positional = unsafe { collect_positional(args, nargs, nargsf) }; + let positional = unsafe { collect_positional(args, nargs) }; let arg_tuple = crate::object::into_owned(Object::new_tuple(positional)); let result = unsafe { crate::abstract_::PyObject_Call(callable, arg_tuple, kwdict) }; unsafe { crate::object::Py_DecRef(arg_tuple) }; result } -/// `PyVectorcall_Call(callable, tuple, kw)` — slow-path entry from -/// inside extensions that want to forward a `(args, kwargs)` pair -/// through the vectorcall protocol. +/// `PyVectorcall_Call(callable, tuple, kw)` — forward a `(args, kwargs)` +/// pair through the vectorcall protocol. +/// +/// CPython types that opt into vectorcall set `tp_call = PyVectorcall_Call` +/// and store the real `vectorcallfunc` per instance. We therefore read +/// that per-instance function and invoke it directly. Falling back to +/// `PyObject_Call` here (the previous behaviour) re-entered the same +/// `tp_call` slot and recursed without bound (numpy's +/// `_ArrayFunctionDispatcher`, called as `ones(...)`, overflowed the +/// stack). Only when the callable exposes no vectorcall do we forward to +/// the generic call machinery. #[no_mangle] pub unsafe extern "C" fn PyVectorcall_Call( callable: *mut PyObject, tuple: *mut PyObject, kw: *mut PyObject, ) -> *mut PyObject { + if callable.is_null() { + crate::errors::set_type_error("PyVectorcall_Call: NULL callable"); + return ptr::null_mut(); + } + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + eprintln!("[TRACE_PYVCALL] kw_null={}", kw.is_null()); + } + let func = unsafe { instance_vectorcall_func(callable) }; + if !func.is_null() { + return unsafe { call_via_vectorcall(callable, func, tuple, kw) }; + } unsafe { crate::abstract_::PyObject_Call(callable, tuple, kw) } } @@ -156,55 +304,131 @@ pub unsafe extern "C" fn PyObject_VectorcallMethod( if method.is_null() { return ptr::null_mut(); } - let positional = unsafe { collect_positional_after(args, nargs, nargsf) }; + if std::env::var_os("WEAVEPY_TRACE_CALL").is_some() { + let mname = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + other => format!("{other:?}"), + }; + let robj = unsafe { crate::object::clone_object(receiver) }; + let rdesc = match &robj { + Object::Instance(i) => format!("Instance(class={})", i.cls().name), + Object::Foreign(_) => "Foreign".to_string(), + other => other.type_name().to_string(), + }; + let mdesc = match unsafe { crate::object::clone_object(method) } { + Object::Builtin(b) => format!("Builtin({})", b.name), + Object::Function(_) => "Function".to_string(), + Object::BoundMethod(bm) => format!("BoundMethod({})", bm.function.type_name()), + other => other.type_name().to_string(), + }; + if mname == "__init__" { + eprintln!( + "[TRACE_VCMETHOD] method={mname} nargs={nargs} receiver={rdesc} resolved={mdesc}" + ); + } + } + let dbg = std::env::var_os("WEAVEPY_VCM_DBG").is_some(); + if dbg { + let mname = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + _ => "".to_string(), + }; + let mut raw = String::new(); + for i in 0..nargs { + let p = unsafe { *args.add(i) }; + use std::fmt::Write; + let _ = write!(raw, " a{i}={p:p}"); + } + eprintln!( + "[VCM] pre-collect name={mname} nargs={nargs} kwnull={} raw=[{raw}]", + kwnames.is_null() + ); + } + let positional = unsafe { collect_positional_after(args, nargs) }; + if dbg { + let kinds: Vec<&str> = positional.iter().map(obj_kind).collect(); + eprintln!("[VCM] pre-call collected argkinds={kinds:?}"); + } let arg_tuple = crate::object::into_owned(Object::new_tuple(positional)); - let kwargs = if kwnames.is_null() { - weavepy_vm::object::DictData::new() + let kw_dict = if kwnames.is_null() { + ptr::null_mut() } else { - unsafe { kwnames_to_dict(kwnames, args, nargs, nargsf) } + let kwargs = unsafe { kwnames_to_dict(kwnames, args, nargs) }; + if kwargs.is_empty() { + ptr::null_mut() + } else { + crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( + weavepy_vm::sync::RefCell::new(kwargs), + ))) + } }; - let kw_dict = crate::object::into_owned(Object::Dict(weavepy_vm::sync::Rc::new( - weavepy_vm::sync::RefCell::new(kwargs), - ))); let result = unsafe { crate::abstract_::PyObject_Call(method, arg_tuple, kw_dict) }; + if dbg { + eprintln!("[VCM] post-call result_null={}", result.is_null()); + } unsafe { crate::object::Py_DecRef(arg_tuple) }; unsafe { crate::object::Py_DecRef(kw_dict) }; unsafe { crate::object::Py_DecRef(method) }; + // `receiver.method(...)` mutates the *bound* receiver (e.g. + // `s.difference_update(other)`), which — unlike the unbound-method path — + // is not among the forwarded positional args; refresh its macro-visible + // size directly (RFC 0047, wave 5). + unsafe { crate::mirror::sync_container_size(receiver) }; result } +/// Debug-only: the VM-variant name of a cloned [`Object`]. +fn obj_kind(o: &Object) -> &'static str { + match o { + Object::None => "None", + Object::Bool(_) => "Bool", + Object::Int(_) => "Int", + Object::Long(_) => "Long", + Object::Float(_) => "Float", + Object::Str(_) => "Str", + Object::Bytes(_) => "Bytes", + Object::Tuple(_) => "Tuple", + Object::List(_) => "List", + Object::Dict(_) => "Dict", + Object::Set(_) => "Set", + Object::Type(_) => "Type", + Object::Instance(_) => "Instance", + Object::Foreign(_) => "Foreign", + Object::Function(_) => "Function", + Object::Builtin(_) => "Builtin", + Object::BoundMethod(_) => "BoundMethod", + _ => "other", + } +} + unsafe fn decode_vectorcall( args: *const *mut PyObject, nargsf: usize, kwnames: *mut PyObject, ) -> (Vec, weavepy_vm::object::DictData) { let nargs = (nargsf & !PY_VECTORCALL_ARGUMENTS_OFFSET) as usize; - let positional = unsafe { collect_positional(args, nargs, nargsf) }; + let positional = unsafe { collect_positional(args, nargs) }; let kwargs = if kwnames.is_null() { weavepy_vm::object::DictData::new() } else { - unsafe { kwnames_to_dict(kwnames, args, nargs, nargsf) } + unsafe { kwnames_to_dict(kwnames, args, nargs) } }; (positional, kwargs) } -unsafe fn collect_positional( - args: *const *mut PyObject, - nargs: usize, - nargsf: usize, -) -> Vec { +/// Decode the `nargs` positional arguments at `args[0..nargs]`. +/// +/// `PY_VECTORCALL_ARGUMENTS_OFFSET` deliberately plays no part here: it +/// concerns only the scratch slot at `args[-1]`, never the index of the +/// first real argument (see the module docs). +unsafe fn collect_positional(args: *const *mut PyObject, nargs: usize) -> Vec { if args.is_null() || nargs == 0 { return Vec::new(); } - let offset = if (nargsf & PY_VECTORCALL_ARGUMENTS_OFFSET) != 0 { - 1 - } else { - 0 - }; let mut out = Vec::with_capacity(nargs); - for i in offset..(nargs + offset) { + for i in 0..nargs { let p = unsafe { *args.add(i) }; if p.is_null() { out.push(Object::None); @@ -215,21 +439,16 @@ unsafe fn collect_positional( out } -unsafe fn collect_positional_after( - args: *const *mut PyObject, - nargs: usize, - nargsf: usize, -) -> Vec { +/// Decode the positional arguments *after* the receiver — `args[1..nargs]` +/// — for [`PyObject_VectorcallMethod`], whose `args[0]` is `self` (already +/// folded into the bound method we resolved) and whose `nargs` counts that +/// receiver. +unsafe fn collect_positional_after(args: *const *mut PyObject, nargs: usize) -> Vec { if args.is_null() || nargs <= 1 { return Vec::new(); } - let offset = if (nargsf & PY_VECTORCALL_ARGUMENTS_OFFSET) != 0 { - 1 - } else { - 0 - }; let mut out = Vec::with_capacity(nargs - 1); - for i in (offset + 1)..(nargs + offset) { + for i in 1..nargs { let p = unsafe { *args.add(i) }; if p.is_null() { out.push(Object::None); @@ -240,11 +459,13 @@ unsafe fn collect_positional_after( out } +/// Decode keyword arguments: their values sit at `args[nargs..nargs+nkw]`, +/// immediately after every positional, with the names in the `kwnames` +/// tuple. unsafe fn kwnames_to_dict( kwnames: *mut PyObject, args: *const *mut PyObject, nargs: usize, - nargsf: usize, ) -> weavepy_vm::object::DictData { let mut out = weavepy_vm::object::DictData::new(); if kwnames.is_null() { @@ -255,13 +476,8 @@ unsafe fn kwnames_to_dict( Object::Tuple(items) => items.iter().cloned().collect::>(), _ => return out, }; - let offset = if (nargsf & PY_VECTORCALL_ARGUMENTS_OFFSET) != 0 { - 1 - } else { - 0 - }; for (i, name) in names_vec.into_iter().enumerate() { - let p = unsafe { *args.add(offset + nargs + i) }; + let p = unsafe { *args.add(nargs + i) }; let value = if p.is_null() { Object::None } else { diff --git a/crates/weavepy-capi/src/wave4.rs b/crates/weavepy-capi/src/wave4.rs new file mode 100644 index 0000000..b3578a9 --- /dev/null +++ b/crates/weavepy-capi/src/wave4.rs @@ -0,0 +1,921 @@ +//! RFC 0046 (wave 4): the CPython 3.13 C-API tail that stock numpy's +//! `_multiarray_umath` extension links but that waves 1-3 had not yet +//! exported. +//! +//! These are the leaf entry points discovered by diffing the undefined +//! `Py*`/`_Py*` symbols of a from-source-built `numpy-2.x` +//! `_multiarray_umath.cpython-313-*.so` against the host binary's +//! dynamic symbol table. Most delegate to the existing wave-1/2/3 +//! surface (`crate::abstract_`, `crate::containers`, `crate::numbers`, +//! `crate::strings`); a handful that have no behavioural meaning under +//! WeavePy's single-threaded-GIL, non-tracemalloc runtime are sound +//! no-ops (matching what CPython does when the corresponding subsystem +//! is disabled). +//! +//! The variadic members of the tail (`PyOS_snprintf`, `PyErr_WarnFormat`) +//! cannot be expressed as Rust `extern "C"` definitions and live in +//! `src/varargs.c` instead. + +#![allow(clippy::missing_safety_doc)] + +use core::ffi::{c_char, c_double, c_int, c_long, c_uint, c_ulong, c_void}; +use std::ptr; + +use weavepy_vm::object::Object; + +use crate::object::PyObject; + +/// Return a new (owned) reference to `p`, or NULL unchanged. +unsafe fn new_ref(p: *mut PyObject) -> *mut PyObject { + if !p.is_null() { + unsafe { crate::object::Py_IncRef(p) }; + } + p +} + +/// Pin cache for the handful of C-API functions that return a *borrowed* +/// reference (`PySys_GetObject`, `PyEval_GetBuiltins`). +/// +/// WeavePy mints a fresh `PyObject` box every time a VM value crosses the +/// boundary, so there is no persistent owner to borrow from: decref'ing +/// the freshly-minted box (as the "borrowed" contract would imply) frees +/// it and hands the caller a dangling pointer. Instead we mint once, +/// retain the reference forever (pinning it — a bounded, per-key leak), +/// and return the same stable pointer on every subsequent call. This both +/// satisfies the borrowed contract (the caller must not decref) and gives +/// the object stable identity across calls, which numpy relies on. +fn pinned_borrowed(key: String, produce: impl FnOnce() -> *mut PyObject) -> *mut PyObject { + use std::collections::HashMap; + use std::sync::Mutex; + static CACHE: Mutex>> = Mutex::new(None); + + { + let guard = CACHE.lock().unwrap(); + if let Some(map) = guard.as_ref() { + if let Some(&addr) = map.get(&key) { + return addr as *mut PyObject; + } + } + } + // Produce outside the lock (it re-enters the interpreter / capi). + let p = produce(); + if p.is_null() { + return ptr::null_mut(); + } + let mut guard = CACHE.lock().unwrap(); + let map = guard.get_or_insert_with(HashMap::new); + // Another thread may have produced concurrently; keep the first. + if let Some(&addr) = map.get(&key) { + unsafe { crate::object::Py_DecRef(p) }; + return addr as *mut PyObject; + } + map.insert(key, p as usize); + p +} + +/// A fresh owned reference to `None`. +unsafe fn new_none() -> *mut PyObject { + unsafe { new_ref(crate::singletons::none_ptr()) } +} + +// --------------------------------------------------------------------------- +// Predicates +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyCallable_Check(o: *mut PyObject) -> c_int { + if o.is_null() { + return 0; + } + // `callable(x)` is `type(x)` having `__call__`; querying the object + // resolves the slot through its type, which covers functions, + // methods, builtins, types, and instances of `__call__`-defining + // classes. + unsafe { crate::abstract_::PyObject_HasAttrString(o, b"__call__\0".as_ptr() as *const c_char) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyIndex_Check(o: *mut PyObject) -> c_int { + if o.is_null() { + return 0; + } + match unsafe { crate::object::clone_object(o) } { + Object::Int(_) | Object::Long(_) | Object::Bool(_) => 1, + _ => unsafe { + crate::abstract_::PyObject_HasAttrString(o, b"__index__\0".as_ptr() as *const c_char) + }, + } +} + +#[no_mangle] +pub unsafe extern "C" fn PyIter_Check(o: *mut PyObject) -> c_int { + if o.is_null() { + return 0; + } + unsafe { crate::abstract_::PyObject_HasAttrString(o, b"__next__\0".as_ptr() as *const c_char) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyObject_SelfIter(o: *mut PyObject) -> *mut PyObject { + unsafe { new_ref(o) } +} + +#[no_mangle] +pub unsafe extern "C" fn PySeqIter_New(seq: *mut PyObject) -> *mut PyObject { + if seq.is_null() { + crate::errors::set_value_error("PySeqIter_New: NULL sequence"); + return ptr::null_mut(); + } + // CPython's `PySeqIter_New` builds a `seqiterobject`: a lazy iterator + // that indexes `seq[0]`, `seq[1]`, … via `__getitem__` and stops on + // `IndexError`. It must NOT call `seq.__iter__` — numpy's `array_iter` + // (the ndarray `tp_iter`) returns `PySeqIter_New(self)`, so delegating + // to `PyObject_GetIter` here would loop `__iter__` → `array_iter` → + // `PySeqIter_New` → `__iter__` … and overflow the stack. + let obj = unsafe { crate::object::clone_object(seq) }; + match crate::interp::with_interp_mut(|interp| interp.seq_iter_object(obj)) { + Some(Ok(it)) => crate::object::into_owned(it), + Some(Err(err)) => { + crate::errors::set_pending_from_runtime(err); + ptr::null_mut() + } + None => { + crate::errors::set_runtime_error("PySeqIter_New: no active interpreter"); + ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// Sound no-ops (subsystem disabled under WeavePy) +// --------------------------------------------------------------------------- + +/// No pending signals to deliver here; numpy polls this in long loops. +#[no_mangle] +pub extern "C" fn PyErr_CheckSignals() -> c_int { + 0 +} + +/// tracemalloc is not wired into the C allocator domain; tracking is a +/// no-op (CPython behaves identically when tracemalloc is stopped). +#[no_mangle] +pub extern "C" fn PyTraceMalloc_Track(_domain: c_uint, _ptr: usize, _size: usize) -> c_int { + 0 +} + +#[no_mangle] +pub extern "C" fn PyTraceMalloc_Untrack(_domain: c_uint, _ptr: usize) -> c_int { + 0 +} + +/// WeavePy does not maintain CPython's per-type attribute-cache version +/// tag, so an explicit invalidation is a no-op. +#[no_mangle] +pub extern "C" fn PyType_Modified(_ty: *mut PyObject) {} + +/// `PyMutex` is uncontended under WeavePy's GIL-serialised execution. +#[no_mangle] +pub extern "C" fn PyMutex_Lock(_m: *mut c_void) {} + +#[no_mangle] +pub extern "C" fn PyMutex_Unlock(_m: *mut c_void) {} + +/// Weakrefs are cleared by the VM when the referent is collected; the +/// explicit C hook is a no-op. +#[no_mangle] +pub extern "C" fn PyObject_ClearWeakRefs(_o: *mut PyObject) {} + +/// Discard the pending exception (CPython prints it to `sys.stderr`; +/// numpy calls this only on best-effort cleanup paths). +#[no_mangle] +pub extern "C" fn PyErr_WriteUnraisable(_o: *mut PyObject) { + crate::errors::clear_thread_local(); +} + +// --------------------------------------------------------------------------- +// Exception chaining +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyException_SetCause(ex: *mut PyObject, cause: *mut PyObject) { + // Steals a reference to `cause`. + unsafe { + crate::abstract_::PyObject_SetAttrString( + ex, + b"__cause__\0".as_ptr() as *const c_char, + cause, + ); + if !cause.is_null() { + crate::object::Py_DecRef(cause); + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn PyException_SetContext(ex: *mut PyObject, context: *mut PyObject) { + // Steals a reference to `context`. + unsafe { + crate::abstract_::PyObject_SetAttrString( + ex, + b"__context__\0".as_ptr() as *const c_char, + context, + ); + if !context.is_null() { + crate::object::Py_DecRef(context); + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn PyException_SetTraceback(ex: *mut PyObject, tb: *mut PyObject) -> c_int { + // Borrows `tb` (does not steal). + unsafe { + crate::abstract_::PyObject_SetAttrString( + ex, + b"__traceback__\0".as_ptr() as *const c_char, + tb, + ) + } +} + +// --------------------------------------------------------------------------- +// Dict tail (3.13 *Ref accessors + string-keyed helpers) +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyDict_GetItemWithError( + d: *mut PyObject, + key: *mut PyObject, +) -> *mut PyObject { + unsafe { crate::containers::PyDict_GetItem(d, key) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyDict_GetItemRef( + d: *mut PyObject, + key: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::containers::PyDict_GetItem(d, key) }; + if v.is_null() { + if !result.is_null() { + unsafe { *result = ptr::null_mut() }; + } + return 0; + } + if !result.is_null() { + unsafe { *result = new_ref(v) }; + } + 1 +} + +#[no_mangle] +pub unsafe extern "C" fn PyDict_GetItemStringRef( + d: *mut PyObject, + key: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::containers::PyDict_GetItemString(d, key) }; + if v.is_null() { + if !result.is_null() { + unsafe { *result = ptr::null_mut() }; + } + return 0; + } + if !result.is_null() { + unsafe { *result = new_ref(v) }; + } + 1 +} + +#[no_mangle] +pub unsafe extern "C" fn PyDict_ContainsString(d: *mut PyObject, key: *const c_char) -> c_int { + let v = unsafe { crate::containers::PyDict_GetItemString(d, key) }; + c_int::from(!v.is_null()) +} + +#[no_mangle] +pub unsafe extern "C" fn PyDict_SetDefaultRef( + d: *mut PyObject, + key: *mut PyObject, + default_value: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + // CPython's `PyDict_SetDefaultRef` returns 1 when the key was already + // present (and yields its value), 0 when it was missing and + // `default_value` was inserted, -1 on error. numpy's `PyUFunc_AddLoop` + // treats a `1` as "loop already registered", so the polarity is load- + // bearing — returning it inverted made every fresh ufunc loop look like + // a duplicate ("A loop/promoter has already been registered…"). + let existing = unsafe { crate::containers::PyDict_GetItem(d, key) }; + if !existing.is_null() { + if !result.is_null() { + unsafe { *result = new_ref(existing) }; + } + return 1; + } + if unsafe { crate::containers::PyDict_SetItem(d, key, default_value) } < 0 { + if !result.is_null() { + unsafe { *result = ptr::null_mut() }; + } + return -1; + } + if !result.is_null() { + unsafe { *result = new_ref(default_value) }; + } + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn PyDictProxy_New(mapping: *mut PyObject) -> *mut PyObject { + // A read-only view; WeavePy does not yet mint a distinct + // mappingproxy, so we hand back the mapping itself (reads are + // faithful; the immutability guard is a known wave-4 simplification). + unsafe { new_ref(mapping) } +} + +// --------------------------------------------------------------------------- +// Internal call helpers +// --------------------------------------------------------------------------- + +/// Build a Python tuple owning new references to `items`. +unsafe fn pack_tuple(items: &[*mut PyObject]) -> *mut PyObject { + let t = unsafe { crate::containers::PyTuple_New(items.len() as isize) }; + if t.is_null() { + return ptr::null_mut(); + } + for (i, &it) in items.iter().enumerate() { + // PyTuple_SetItem steals a reference, so hand it a new one. + unsafe { crate::containers::PyTuple_SetItem(t, i as isize, new_ref(it)) }; + } + t +} + +/// `getattr(import(modname), attr)(*args)`. +unsafe fn call_module_attr( + modname: *const c_char, + attr: *const c_char, + args: &[*mut PyObject], +) -> *mut PyObject { + let module = unsafe { crate::module::PyImport_ImportModule(modname) }; + if module.is_null() { + return ptr::null_mut(); + } + let func = unsafe { crate::abstract_::PyObject_GetAttrString(module, attr) }; + unsafe { crate::object::Py_DecRef(module) }; + if func.is_null() { + return ptr::null_mut(); + } + let argt = unsafe { pack_tuple(args) }; + if argt.is_null() { + unsafe { crate::object::Py_DecRef(func) }; + return ptr::null_mut(); + } + let res = unsafe { crate::abstract_::PyObject_CallObject(func, argt) }; + unsafe { + crate::object::Py_DecRef(func); + crate::object::Py_DecRef(argt); + } + res +} + +// --------------------------------------------------------------------------- +// Numbers +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyComplex_AsCComplex(op: *mut PyObject) -> crate::layout::PyComplexValue { + crate::layout::PyComplexValue { + real: unsafe { crate::numbers::PyComplex_RealAsDouble(op) }, + imag: unsafe { crate::numbers::PyComplex_ImagAsDouble(op) }, + } +} + +#[no_mangle] +pub unsafe extern "C" fn PyComplex_FromCComplex(c: crate::layout::PyComplexValue) -> *mut PyObject { + unsafe { crate::numbers::PyComplex_FromDoubles(c.real, c.imag) } +} + +#[no_mangle] +pub unsafe extern "C" fn _PyLong_Sign(v: *mut PyObject) -> c_int { + match unsafe { crate::object::clone_object(v) } { + Object::Bool(b) => c_int::from(b), + Object::Int(i) => match i.cmp(&0) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + }, + Object::Long(big) => match big.sign() { + num_bigint::Sign::Minus => -1, + num_bigint::Sign::NoSign => 0, + num_bigint::Sign::Plus => 1, + }, + _ => 0, + } +} + +/// Hash a `double` consistently with WeavePy's own `float` hashing (so a +/// numpy scalar and the equal Python `float` land in the same dict slot). +#[no_mangle] +pub unsafe extern "C" fn _Py_HashDouble(_inst: *mut PyObject, v: c_double) -> isize { + let f = unsafe { crate::numbers::PyFloat_FromDouble(v) }; + if f.is_null() { + return 0; + } + let h = unsafe { crate::abstract_::PyObject_Hash(f) }; + unsafe { crate::object::Py_DecRef(f) }; + h +} + +#[no_mangle] +pub unsafe extern "C" fn PyLong_FromUnicodeObject(u: *mut PyObject, base: c_int) -> *mut PyObject { + let s = unsafe { crate::strings::PyUnicode_AsUTF8(u) }; + if s.is_null() { + return ptr::null_mut(); + } + unsafe { crate::numbers::PyLong_FromString(s, ptr::null_mut(), base) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyFloat_FromString(s: *mut PyObject) -> *mut PyObject { + let text = match unsafe { crate::object::clone_object(s) } { + Object::Str(t) => t.to_string(), + Object::Bytes(b) => String::from_utf8_lossy(&b).into_owned(), + _ => { + crate::errors::set_type_error("PyFloat_FromString: argument must be string"); + return ptr::null_mut(); + } + }; + match text.trim().parse::() { + Ok(v) => unsafe { crate::numbers::PyFloat_FromDouble(v) }, + Err(_) => { + crate::errors::set_value_error(format!("could not convert string to float: '{text}'")); + ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// Unicode tail +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_AsUCS4( + u: *mut PyObject, + buffer: *mut u32, + buflen: isize, + copy_null: c_int, +) -> *mut u32 { + let text = match unsafe { crate::object::clone_object(u) } { + Object::Str(t) => t.to_string(), + _ => { + crate::errors::set_type_error("PyUnicode_AsUCS4: argument must be str"); + return ptr::null_mut(); + } + }; + let chars: Vec = text.chars().map(|c| c as u32).collect(); + let need = chars.len() + if copy_null != 0 { 1 } else { 0 }; + if std::env::var_os("WEAVEPY_TRACE_UCS4").is_some() { + eprintln!( + "[UCS4] AsUCS4({u:p}, buf={buffer:p}, buflen={buflen}, copy_null={copy_null}) value={text:?} nchars={}", + chars.len() + ); + } + if buflen < need as isize { + crate::errors::set_value_error("PyUnicode_AsUCS4: buffer too small"); + return ptr::null_mut(); + } + unsafe { + for (i, &c) in chars.iter().enumerate() { + *buffer.add(i) = c; + } + if copy_null != 0 { + *buffer.add(chars.len()) = 0; + } + } + buffer +} + +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_AsUCS4Copy(u: *mut PyObject) -> *mut u32 { + let text = match unsafe { crate::object::clone_object(u) } { + Object::Str(t) => t.to_string(), + _ => { + crate::errors::set_type_error("PyUnicode_AsUCS4Copy: argument must be str"); + return ptr::null_mut(); + } + }; + let chars: Vec = text.chars().map(|c| c as u32).collect(); + let n = chars.len() + 1; + let buf = unsafe { crate::memory::PyMem_Malloc(n * 4) } as *mut u32; + if buf.is_null() { + return ptr::null_mut(); + } + unsafe { + for (i, &c) in chars.iter().enumerate() { + *buf.add(i) = c; + } + *buf.add(chars.len()) = 0; + } + buf +} + +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_Format( + format: *mut PyObject, + args: *mut PyObject, +) -> *mut PyObject { + unsafe { crate::abstract_::PyNumber_Remainder(format, args) } +} + +macro_rules! ucs4_classifier { + ($($name:ident => $f:expr);* $(;)?) => { + $( + #[no_mangle] + pub extern "C" fn $name(ch: u32) -> c_int { + match char::from_u32(ch) { + Some(c) => c_int::from($f(c)), + None => 0, + } + } + )* + }; +} + +ucs4_classifier! { + _PyUnicode_IsAlpha => |c: char| c.is_alphabetic(); + _PyUnicode_IsDecimalDigit => |c: char| c.is_ascii_digit(); + _PyUnicode_IsDigit => |c: char| c.is_ascii_digit(); + _PyUnicode_IsNumeric => |c: char| c.is_numeric(); + _PyUnicode_IsLowercase => |c: char| c.is_lowercase(); + _PyUnicode_IsUppercase => |c: char| c.is_uppercase(); + _PyUnicode_IsTitlecase => |c: char| c.is_uppercase() && !c.is_lowercase() && c.is_alphabetic() && false; + _PyUnicode_IsWhitespace => |c: char| c.is_whitespace(); +} + +/// CPython's `_Py_ascii_whitespace[128]` table: 1 at the ASCII +/// whitespace code points (`\t \n \v \f \r`, `0x1c-0x1f`, space). +#[no_mangle] +pub static _Py_ascii_whitespace: [u8; 128] = { + let mut t = [0u8; 128]; + t[0x09] = 1; + t[0x0a] = 1; + t[0x0b] = 1; + t[0x0c] = 1; + t[0x0d] = 1; + t[0x1c] = 1; + t[0x1d] = 1; + t[0x1e] = 1; + t[0x1f] = 1; + t[0x20] = 1; + t +}; + +// --------------------------------------------------------------------------- +// OS string parsing +// --------------------------------------------------------------------------- + +extern "C" { + fn strtod(s: *const c_char, endptr: *mut *mut c_char) -> c_double; + fn strtol(s: *const c_char, endptr: *mut *mut c_char, base: c_int) -> c_long; + fn strtoul(s: *const c_char, endptr: *mut *mut c_char, base: c_int) -> c_ulong; +} + +#[no_mangle] +pub unsafe extern "C" fn PyOS_string_to_double( + s: *const c_char, + endptr: *mut *mut c_char, + _overflow_exception: *mut PyObject, +) -> c_double { + let mut local: *mut c_char = ptr::null_mut(); + let v = unsafe { strtod(s, &mut local) }; + if endptr.is_null() { + // No endptr means the whole string must convert. + if !local.is_null() && unsafe { *local } != 0 { + crate::errors::set_value_error("could not convert string to float"); + return -1.0; + } + } else { + unsafe { *endptr = local }; + } + v +} + +#[no_mangle] +pub unsafe extern "C" fn PyOS_strtol( + s: *const c_char, + endptr: *mut *mut c_char, + base: c_int, +) -> c_long { + unsafe { strtol(s, endptr, base) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyOS_strtoul( + s: *const c_char, + endptr: *mut *mut c_char, + base: c_int, +) -> c_ulong { + unsafe { strtoul(s, endptr, base) } +} + +// --------------------------------------------------------------------------- +// Object tail +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyObject_AsFileDescriptor(o: *mut PyObject) -> c_int { + if let Object::Int(i) = unsafe { crate::object::clone_object(o) } { + return i as c_int; + } + // Fall back to `o.fileno()`. + let meth = unsafe { + crate::abstract_::PyObject_GetAttrString(o, b"fileno\0".as_ptr() as *const c_char) + }; + if meth.is_null() { + crate::errors::set_type_error("argument must be an int, or have a fileno() method"); + return -1; + } + let empty = unsafe { crate::containers::PyTuple_New(0) }; + let res = unsafe { crate::abstract_::PyObject_CallObject(meth, empty) }; + unsafe { + crate::object::Py_DecRef(meth); + crate::object::Py_DecRef(empty); + } + if res.is_null() { + return -1; + } + let fd = unsafe { crate::numbers::PyLong_AsLong(res) }; + unsafe { crate::object::Py_DecRef(res) }; + fd as c_int +} + +#[no_mangle] +pub unsafe extern "C" fn PyObject_GetOptionalAttr( + obj: *mut PyObject, + name: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::abstract_::PyObject_GetAttr(obj, name) }; + if !v.is_null() { + if !result.is_null() { + unsafe { *result = v }; + } else { + unsafe { crate::object::Py_DecRef(v) }; + } + return 1; + } + // Missing attribute is reported as 0 with the error suppressed. + crate::errors::clear_thread_local(); + if !result.is_null() { + unsafe { *result = ptr::null_mut() }; + } + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn PyObject_Print(o: *mut PyObject, fp: *mut c_void, flags: c_int) -> c_int { + extern "C" { + fn fwrite(ptr: *const c_void, size: usize, n: usize, stream: *mut c_void) -> usize; + } + let text = if flags & 1 != 0 { + unsafe { crate::abstract_::PyObject_Str(o) } + } else { + unsafe { crate::abstract_::PyObject_Repr(o) } + }; + if text.is_null() { + return -1; + } + let mut len: isize = 0; + let utf8 = unsafe { crate::strings::PyUnicode_AsUTF8AndSize(text, &mut len) }; + if !utf8.is_null() && !fp.is_null() { + unsafe { fwrite(utf8 as *const c_void, 1, len as usize, fp) }; + } + unsafe { crate::object::Py_DecRef(text) }; + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn PyMethod_New(func: *mut PyObject, self_: *mut PyObject) -> *mut PyObject { + if func.is_null() || self_.is_null() { + crate::errors::PyErr_BadInternalCall(); + return ptr::null_mut(); + } + unsafe { + call_module_attr( + b"types\0".as_ptr() as *const c_char, + b"MethodType\0".as_ptr() as *const c_char, + &[func, self_], + ) + } +} + +// --------------------------------------------------------------------------- +// Import / sys / eval +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyImport_Import(name: *mut PyObject) -> *mut PyObject { + let utf8 = unsafe { crate::strings::PyUnicode_AsUTF8(name) }; + if utf8.is_null() { + return ptr::null_mut(); + } + unsafe { crate::module::PyImport_ImportModule(utf8) } +} + +#[no_mangle] +pub unsafe extern "C" fn PySys_GetObject(name: *const c_char) -> *mut PyObject { + // The only borrowed-attr name numpy fetches at init is "flags"; pin + // each distinct name the first time it is requested (see + // `pinned_borrowed`). Names other than the common ones still work — + // they just intern their own pinned slot. + let nm = if name.is_null() { + return ptr::null_mut(); + } else { + unsafe { core::ffi::CStr::from_ptr(name) } + .to_str() + .unwrap_or("") + }; + let name_owned = nm.to_string(); + pinned_borrowed(format!("sys.{nm}"), move || { + let sys = + unsafe { crate::module::PyImport_ImportModule(b"sys\0".as_ptr() as *const c_char) }; + if sys.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + let cname = match std::ffi::CString::new(name_owned) { + Ok(c) => c, + Err(_) => { + unsafe { crate::object::Py_DecRef(sys) }; + return ptr::null_mut(); + } + }; + let v = unsafe { crate::abstract_::PyObject_GetAttrString(sys, cname.as_ptr()) }; + unsafe { crate::object::Py_DecRef(sys) }; + if v.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + v + }) +} + +#[no_mangle] +pub extern "C" fn PyEval_GetBuiltins() -> *mut PyObject { + // Borrowed return — pin once (see `pinned_borrowed`). + pinned_borrowed("eval.builtins".to_string(), || { + let builtins = unsafe { + crate::module::PyImport_ImportModule(b"builtins\0".as_ptr() as *const c_char) + }; + if builtins.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + let d = unsafe { + crate::abstract_::PyObject_GetAttrString( + builtins, + b"__dict__\0".as_ptr() as *const c_char, + ) + }; + unsafe { crate::object::Py_DecRef(builtins) }; + if d.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + d + }) +} + +/// Opaque non-NULL handle. numpy only uses the result as a key / +/// liveness sentinel, never dereferencing the interpreter-state layout. +#[no_mangle] +pub extern "C" fn PyInterpreterState_Main() -> *mut c_void { + static MAIN_STATE: u8 = 0; + &MAIN_STATE as *const u8 as *mut c_void +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn _PyErr_BadInternalCall(_filename: *const c_char, _lineno: c_int) { + unsafe { crate::errors::PyErr_BadInternalCall() }; +} + +#[no_mangle] +pub unsafe extern "C" fn PyErr_SetFromErrno(ty: *mut PyObject) -> *mut PyObject { + let err = std::io::Error::last_os_error(); + let msg = std::ffi::CString::new(err.to_string()) + .unwrap_or_else(|_| std::ffi::CString::new("OS error").unwrap()); + unsafe { crate::errors::PyErr_SetString(ty, msg.as_ptr()) }; + ptr::null_mut() +} + +// --------------------------------------------------------------------------- +// ContextVar (routed through the `contextvars` module) +// --------------------------------------------------------------------------- + +#[no_mangle] +pub unsafe extern "C" fn PyContextVar_New( + name: *const c_char, + def: *mut PyObject, +) -> *mut PyObject { + let name_obj = unsafe { crate::strings::PyUnicode_FromString(name) }; + if name_obj.is_null() { + return ptr::null_mut(); + } + let module = + unsafe { crate::module::PyImport_ImportModule(b"contextvars\0".as_ptr() as *const c_char) }; + if module.is_null() { + unsafe { crate::object::Py_DecRef(name_obj) }; + return ptr::null_mut(); + } + let cls = unsafe { + crate::abstract_::PyObject_GetAttrString(module, b"ContextVar\0".as_ptr() as *const c_char) + }; + unsafe { crate::object::Py_DecRef(module) }; + if cls.is_null() { + unsafe { crate::object::Py_DecRef(name_obj) }; + return ptr::null_mut(); + } + let args = unsafe { pack_tuple(&[name_obj]) }; + unsafe { crate::object::Py_DecRef(name_obj) }; + let kwargs = if def.is_null() { + ptr::null_mut() + } else { + let kw = unsafe { crate::containers::PyDict_New() }; + unsafe { + crate::containers::PyDict_SetItemString(kw, b"default\0".as_ptr() as *const c_char, def) + }; + kw + }; + let var = unsafe { crate::abstract_::PyObject_Call(cls, args, kwargs) }; + unsafe { + crate::object::Py_DecRef(cls); + crate::object::Py_DecRef(args); + if !kwargs.is_null() { + crate::object::Py_DecRef(kwargs); + } + } + var +} + +#[no_mangle] +pub unsafe extern "C" fn PyContextVar_Get( + var: *mut PyObject, + default_value: *mut PyObject, + value: *mut *mut PyObject, +) -> c_int { + let getter = unsafe { + crate::abstract_::PyObject_GetAttrString(var, b"get\0".as_ptr() as *const c_char) + }; + if getter.is_null() { + return -1; + } + let args = if default_value.is_null() { + unsafe { crate::containers::PyTuple_New(0) } + } else { + unsafe { pack_tuple(&[default_value]) } + }; + let res = unsafe { crate::abstract_::PyObject_CallObject(getter, args) }; + unsafe { + crate::object::Py_DecRef(getter); + crate::object::Py_DecRef(args); + } + if res.is_null() { + // No value and no default: report "unset" without raising. + crate::errors::clear_thread_local(); + if !value.is_null() { + unsafe { *value = ptr::null_mut() }; + } + return 0; + } + if !value.is_null() { + unsafe { *value = res }; + } else { + unsafe { crate::object::Py_DecRef(res) }; + } + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn PyContextVar_Set( + var: *mut PyObject, + value: *mut PyObject, +) -> *mut PyObject { + let setter = unsafe { + crate::abstract_::PyObject_GetAttrString(var, b"set\0".as_ptr() as *const c_char) + }; + if setter.is_null() { + return ptr::null_mut(); + } + let args = unsafe { pack_tuple(&[value]) }; + let token = unsafe { crate::abstract_::PyObject_CallObject(setter, args) }; + unsafe { + crate::object::Py_DecRef(setter); + crate::object::Py_DecRef(args); + } + token +} diff --git a/crates/weavepy-capi/src/wave5.rs b/crates/weavepy-capi/src/wave5.rs new file mode 100644 index 0000000..73101cc --- /dev/null +++ b/crates/weavepy-capi/src/wave5.rs @@ -0,0 +1,563 @@ +//! RFC 0047 (wave 5): the CPython 3.13 C-API leaf tail that +//! **Cython-generated** extensions (and pandas, which is ~70% Cython) +//! link but that waves 1-4 had not yet exported. +//! +//! These were found the same way wave 4 found numpy's tail: diff the +//! undefined `Py*`/`_Py*` symbols of a Cython-generated +//! `*.cpython-313-*.so` (`nm -u`) against the symbols the host binary +//! already exports. Cython's `Cython/Utility/*.c` runtime leans on a +//! characteristic cluster of helpers — the "optional attribute" probes +//! (`PyObject_GetOptionalAttr*`), the fast method-call path +//! (`_PyObject_GetMethod` + `PyObject_CallMethodOneArg`), the interned +//! string fast-compare (`PyUnicode_EqualToUTF8*`), the presized-dict +//! constructor (`_PyDict_NewPresized`), the in-body `__dict__` pointer +//! (`_PyObject_GetDictPtr`), and the relative-import entry +//! (`PyImport_ImportModuleLevelObject`). +//! +//! Every function here is a thin delegator onto the wave-1/2/3 surface +//! (`crate::abstract_`, `crate::containers`, `crate::numbers`, +//! `crate::strings`, `crate::module`) or a sound no-op under WeavePy's +//! object model. None introduces new behaviour; they exist so a stock +//! Cython `.so` *links and runs*. + +#![allow(clippy::missing_safety_doc)] + +use core::ffi::{c_char, c_int, c_long, c_ulong, c_void}; +use std::collections::HashMap; +use std::ptr; +use std::sync::{Mutex, OnceLock}; + +use weavepy_vm::object::Object; + +use crate::object::PyObject; +use crate::types::PyTypeObject; + +// --------------------------------------------------------------------------- +// Instance `__dict__` access +// --------------------------------------------------------------------------- + +/// `_PyObject_GetDictPtr(obj)` — pointer to the in-body `__dict__` slot. +/// +/// WeavePy keeps an instance's `__dict__` in a managed Rust cell, not at a +/// fixed `tp_dictoffset` inside the object body, so there is no in-body +/// `PyObject **dictptr` to hand back. CPython itself returns NULL for any +/// type with `tp_dictoffset == 0`, and every caller — Cython's +/// `__Pyx_GetAttr` / `__Pyx_PyObject_GetAttrStr`, CPython's own +/// `_PyObject_GenericGetAttrWithDict` — treats NULL as "no fast dict" and +/// falls back to `tp_getattro` / `PyObject_GenericGetAttr`, which WeavePy +/// services correctly. (A faithful in-body `tp_dictoffset` dict is a +/// documented wave-5 Non-goal.) +#[no_mangle] +pub extern "C" fn _PyObject_GetDictPtr(_obj: *mut PyObject) -> *mut *mut PyObject { + ptr::null_mut() +} + +// --------------------------------------------------------------------------- +// Optional attribute / item probes (CPython 3.13 additions) +// --------------------------------------------------------------------------- + +/// Shared tail for the `*Optional*` probes: a present value is handed back +/// (1), a missing one clears the error and reports absence (0). Any failure +/// is treated as "missing", matching `crate::wave4::PyObject_GetOptionalAttr`. +unsafe fn optional_result(v: *mut PyObject, result: *mut *mut PyObject) -> c_int { + if !v.is_null() { + if !result.is_null() { + unsafe { *result = v }; + } else { + unsafe { crate::object::Py_DecRef(v) }; + } + return 1; + } + crate::errors::clear_thread_local(); + if !result.is_null() { + unsafe { *result = ptr::null_mut() }; + } + 0 +} + +#[no_mangle] +pub unsafe extern "C" fn PyObject_GetOptionalAttrString( + obj: *mut PyObject, + name: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::abstract_::PyObject_GetAttrString(obj, name) }; + unsafe { optional_result(v, result) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyMapping_GetOptionalItem( + obj: *mut PyObject, + key: *mut PyObject, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::abstract_::PyObject_GetItem(obj, key) }; + unsafe { optional_result(v, result) } +} + +#[no_mangle] +pub unsafe extern "C" fn PyMapping_GetOptionalItemString( + obj: *mut PyObject, + key: *const c_char, + result: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::abstract_::PyMapping_GetItemString(obj, key) }; + unsafe { optional_result(v, result) } +} + +// --------------------------------------------------------------------------- +// Fast method call path +// --------------------------------------------------------------------------- + +/// `_PyObject_GetMethod(obj, name, *method)` — the fast method-load path. +/// +/// CPython returns 1 when `name` resolves to an *unbound* method on the +/// type (so the caller invokes it with `obj` as the first argument) and 0 +/// when it is an ordinary (already-bound) attribute. WeavePy resolves the +/// attribute through the VM's binding protocol, which yields a value that +/// is *already* callable with the user arguments — so the universally +/// correct answer is 0 ("bound"): `*method` is the bound attribute and the +/// caller invokes `(*method)(args...)`. Cython's `__Pyx_PyObject_GetMethod` +/// handles both return values, so this is sound (it just never takes the +/// micro-optimised unbound branch). +#[no_mangle] +pub unsafe extern "C" fn _PyObject_GetMethod( + obj: *mut PyObject, + name: *mut PyObject, + method: *mut *mut PyObject, +) -> c_int { + let v = unsafe { crate::abstract_::PyObject_GetAttr(obj, name) }; + if !method.is_null() { + unsafe { *method = v }; + } else if !v.is_null() { + unsafe { crate::object::Py_DecRef(v) }; + } + 0 +} + +/// `PyObject_CallMethodOneArg(self, name, arg)` — `self.name(arg)`. +#[no_mangle] +pub unsafe extern "C" fn PyObject_CallMethodOneArg( + self_: *mut PyObject, + name: *mut PyObject, + arg: *mut PyObject, +) -> *mut PyObject { + let meth = unsafe { crate::abstract_::PyObject_GetAttr(self_, name) }; + if meth.is_null() { + return ptr::null_mut(); + } + let r = unsafe { crate::abstract_::PyObject_CallOneArg(meth, arg) }; + unsafe { crate::object::Py_DecRef(meth) }; + r +} + +// --------------------------------------------------------------------------- +// Dict / number tail +// --------------------------------------------------------------------------- + +/// `_PyDict_NewPresized(minused)` — a private hint-sized constructor. +/// WeavePy's dict grows on demand, so the presize hint is informational. +#[no_mangle] +pub unsafe extern "C" fn _PyDict_NewPresized(_minused: isize) -> *mut PyObject { + unsafe { crate::containers::PyDict_New() } +} + +/// `PyLong_AsInt(obj)` — the 3.13 public spelling of the bounds-checked +/// `int` conversion Cython reaches for (`__Pyx_PyInt_As_int`). +#[no_mangle] +pub unsafe extern "C" fn PyLong_AsInt(obj: *mut PyObject) -> c_int { + let v = unsafe { crate::numbers::PyLong_AsLong(obj) }; + // A pending error from the inner `long` conversion (overflow, wrong + // type) propagates unchanged. + if !unsafe { crate::errors::PyErr_Occurred() }.is_null() { + return -1; + } + if v < c_long::from(c_int::MIN) || v > c_long::from(c_int::MAX) { + crate::errors::set_overflow_error("Python int too large to convert to C int"); + return -1; + } + v as c_int +} + +// --------------------------------------------------------------------------- +// Import tail +// --------------------------------------------------------------------------- + +/// Read a `str`-valued entry out of a globals **dict** (a module's +/// `__dict__`, which is what Cython's `__Pyx_Import` passes). Returns +/// `None` when the dict lacks the key or its value is not a string. +fn globals_str(globals: *mut PyObject, key: &'static str) -> Option { + if globals.is_null() { + return None; + } + match unsafe { crate::object::clone_object(globals) } { + Object::Dict(d) => match d + .borrow() + .get(&weavepy_vm::object::DictKey(Object::from_static(key))) + { + Some(Object::Str(s)) => Some(s.to_string()), + _ => None, + }, + // A module object can also be handed in (CPython accepts either); + // resolve through its dict. + Object::Module(m) => match m + .dict + .borrow() + .get(&weavepy_vm::object::DictKey(Object::from_static(key))) + { + Some(Object::Str(s)) => Some(s.to_string()), + _ => None, + }, + _ => None, + } +} + +/// True iff a globals dict (or module) defines `key` (any value). Used to +/// detect `__path__` (a module that is itself a package). +fn globals_has_key(globals: *mut PyObject, key: &'static str) -> bool { + if globals.is_null() { + return false; + } + match unsafe { crate::object::clone_object(globals) } { + Object::Dict(d) => d + .borrow() + .get(&weavepy_vm::object::DictKey(Object::from_static(key))) + .is_some(), + Object::Module(m) => m + .dict + .borrow() + .get(&weavepy_vm::object::DictKey(Object::from_static(key))) + .is_some(), + _ => false, + } +} + +/// Determine the importing module's package, mirroring CPython's +/// `importlib._bootstrap._calc___package__`: prefer an explicit, non-empty +/// `__package__`; otherwise derive it from `__name__` (the name itself if +/// the module is a package — it has `__path__` — else the name with its +/// last dotted component stripped). +fn calc_package(globals: *mut PyObject) -> Option { + if let Some(pkg) = globals_str(globals, "__package__") { + if !pkg.is_empty() { + return Some(pkg); + } + } + let name = globals_str(globals, "__name__")?; + if globals_has_key(globals, "__path__") { + Some(name) + } else { + Some(match name.rsplit_once('.') { + Some((head, _)) => head.to_string(), + None => String::new(), + }) + } +} + +/// Resolve a relative import to its absolute dotted name, mirroring +/// CPython's `importlib._bootstrap._resolve_name`: strip `level - 1` +/// trailing components off `package`, then append `name` (which may be +/// empty for `from . import x`). +fn resolve_relative_name(name: &str, package: &str, level: c_int) -> Result { + if package.is_empty() { + return Err("attempted relative import with no known parent package".to_owned()); + } + let level = level as usize; + // `package.rsplitn(level, '.')` yields at most `level` pieces, the last + // of which is the surviving left-hand base. Fewer pieces than `level` + // means the relative import reaches above the top-level package. + let pieces: Vec<&str> = package.rsplitn(level, '.').collect(); + if pieces.len() < level { + return Err("attempted relative import beyond top-level package".to_owned()); + } + let base = *pieces.last().unwrap(); + if name.is_empty() { + Ok(base.to_owned()) + } else { + Ok(format!("{base}.{name}")) + } +} + +/// `PyImport_ImportModuleLevelObject(name, globals, locals, fromlist, level)` +/// — the entry point behind Cython's `__Pyx_Import` and CPython's `import` +/// statement. +/// +/// `level == 0` is a plain absolute import of `name`. `level > 0` is a +/// relative import resolved against the importing module's package +/// (`globals['__package__']`, else derived from `__name__`), exactly as +/// `importlib._bootstrap._gcd_import` does — numpy's `numpy/random/*.pyx` +/// and pandas both `from ._sibling cimport …`, so this must work. When a +/// non-empty `fromlist` names submodules that are not already attributes +/// of the imported package (`from . import _pcg64`), each is imported as +/// `package.sub`, matching CPython's `_handle_fromlist`. +#[no_mangle] +pub unsafe extern "C" fn PyImport_ImportModuleLevelObject( + name: *mut PyObject, + globals: *mut PyObject, + _locals: *mut PyObject, + fromlist: *mut PyObject, + level: c_int, +) -> *mut PyObject { + // `name` may be NULL/empty for `from . import x`; treat as "". + let name_str = if name.is_null() { + String::new() + } else { + match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + _ => { + let utf8 = unsafe { crate::strings::PyUnicode_AsUTF8(name) }; + if utf8.is_null() { + return ptr::null_mut(); + } + unsafe { core::ffi::CStr::from_ptr(utf8) } + .to_string_lossy() + .into_owned() + } + } + }; + + let abs_name = if level > 0 { + let package = match calc_package(globals) { + Some(p) => p, + None => { + crate::errors::set_import_error( + "attempted relative import with no known parent package", + ); + return ptr::null_mut(); + } + }; + match resolve_relative_name(&name_str, &package, level) { + Ok(a) => a, + Err(e) => { + crate::errors::set_import_error(e); + return ptr::null_mut(); + } + } + } else { + name_str + }; + + let cname = match std::ffi::CString::new(abs_name.clone()) { + Ok(c) => c, + Err(_) => { + crate::errors::set_value_error("PyImport_ImportModuleLevelObject: embedded NUL in name"); + return ptr::null_mut(); + } + }; + let trace_imp = std::env::var_os("WEAVEPY_TRACE_IMPORT").is_some(); + if trace_imp { + let fl = if fromlist.is_null() { + "".to_string() + } else { + format!("{:?}", unsafe { crate::object::clone_object(fromlist) }) + }; + eprintln!("[IMP] name={abs_name:?} level={level} fromlist={fl}"); + } + let module = unsafe { crate::module::PyImport_ImportModule(cname.as_ptr()) }; + if module.is_null() { + if trace_imp { + eprintln!("[IMP] name={abs_name:?} -> MODULE NULL"); + } + return ptr::null_mut(); + } + + // `_handle_fromlist`: ensure any submodule named in `fromlist` that is + // not already an attribute of the imported package is imported as + // `abs_name.sub`. A failure here is non-fatal (the name may be a plain + // attribute the module defines itself, which Cython resolves with its + // own `GetAttr`), so swallow the error and let the caller decide. + if !fromlist.is_null() { + let items = match unsafe { crate::object::clone_object(fromlist) } { + Object::Tuple(t) => t.iter().cloned().collect::>(), + Object::List(l) => l.borrow().iter().cloned().collect::>(), + _ => Vec::new(), + }; + for item in items { + let Object::Str(sub) = item else { continue }; + let sub = sub.to_string(); + if sub == "*" { + continue; + } + let has_attr = match unsafe { crate::object::clone_object(module) } { + Object::Module(m) => m + .dict + .borrow() + .get(&weavepy_vm::object::DictKey(Object::from_str(sub.clone()))) + .is_some(), + _ => true, + }; + if trace_imp { + eprintln!("[IMP] fromlist item {sub:?} has_attr={has_attr}"); + if !has_attr { + if let Object::Module(m) = unsafe { crate::object::clone_object(module) } { + let d = m.dict.borrow(); + let n = d.len(); + let has_via_iter = d.iter().any(|(k, _)| { + matches!(&k.0, Object::Str(s) if s.as_ref() == sub.as_str()) + }); + let sample: Vec = d + .keys() + .filter_map(|k| match &k.0 { + Object::Str(s) => Some(s.to_string()), + _ => None, + }) + .take(8) + .collect(); + eprintln!( + "[IMP] module={:?} nkeys={n} has_via_iter={has_via_iter} sample={sample:?}", + m.name + ); + } + } + } + if has_attr { + continue; + } + if let Ok(subc) = std::ffi::CString::new(format!("{abs_name}.{sub}")) { + let subm = unsafe { crate::module::PyImport_ImportModule(subc.as_ptr()) }; + if subm.is_null() { + crate::errors::clear_thread_local(); + } else { + unsafe { crate::object::Py_DecRef(subm) }; + } + } + } + } + + module +} + +// --------------------------------------------------------------------------- +// Type MRO lookup +// --------------------------------------------------------------------------- + +/// Dedup cache for [`_PyType_Lookup`] results, keyed by +/// `(type pointer, attribute name)`. CPython's `_PyType_Lookup` returns a +/// *borrowed* reference owned by the type's (immortal) MRO cache, so callers +/// `Py_INCREF`/`Py_DECREF` around their use and never free the base +/// reference. WeavePy has no such C-level descriptor table, so we mint one +/// box per `(type, name)` and keep it here: that both honours the borrowed +/// contract (no per-call leak on the method-call fast path that calls this) +/// and gives the pointer identity stability `__Pyx_setup_reduce` compares on. +/// Entries live for the process (types are immortal); a runtime +/// reassignment of a cached attribute is a documented bound (CPython would +/// invalidate via `PyType_Modified`). +fn type_lookup_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// `_PyType_Lookup(type, name)` — walk `type`'s MRO for `name`, returning the +/// raw descriptor (no `__get__` binding) as a borrowed reference, or NULL +/// (no error set) when absent. Delegates to the VM's `TypeObject::lookup`, +/// which is the faithful MRO walk. +#[no_mangle] +pub unsafe extern "C" fn _PyType_Lookup( + tp: *mut PyTypeObject, + name: *mut PyObject, +) -> *mut PyObject { + if tp.is_null() || name.is_null() { + return ptr::null_mut(); + } + let class = match unsafe { crate::object::clone_object(tp as *mut PyObject) } { + Object::Type(t) => t, + _ => return ptr::null_mut(), + }; + let name_s = match unsafe { crate::object::clone_object(name) } { + Object::Str(s) => s.to_string(), + _ => return ptr::null_mut(), + }; + let key = (tp as usize, name_s.clone()); + if let Some(&p) = type_lookup_cache().lock().unwrap().get(&key) { + return p as *mut PyObject; + } + let Some(descr) = class.lookup(&name_s) else { + return ptr::null_mut(); + }; + let p = crate::object::into_owned(descr); + type_lookup_cache().lock().unwrap().insert(key, p as usize); + p +} + +// --------------------------------------------------------------------------- +// Argument validation +// --------------------------------------------------------------------------- + +/// `PyArg_ValidateKeywordArguments(kwds)` — assert every key in the keyword +/// dict is a string (1 on success, 0 with a `TypeError` otherwise). Cython +/// guards `tp_init` with this. +#[no_mangle] +pub unsafe extern "C" fn PyArg_ValidateKeywordArguments(kwds: *mut PyObject) -> c_int { + if kwds.is_null() { + return 1; + } + match unsafe { crate::object::clone_object(kwds) } { + Object::Dict(d) => { + for key in d.borrow().keys() { + if !matches!(key.0, Object::Str(_)) { + crate::errors::set_type_error("keywords must be strings"); + return 0; + } + } + 1 + } + _ => { + crate::errors::set_type_error("keyword arguments must be a dict"); + 0 + } + } +} + +// --------------------------------------------------------------------------- +// GC / managed-dict tail — sound no-ops under WeavePy's object model. +// +// WeavePy's cycle collector is driven from the VM side (RFC 0044), and an +// instance's `__dict__` lives in a managed Rust cell, not an in-body +// `tp_dictoffset` slot. The CPython entry points Cython emits for these +// concepts therefore have nothing to do here; each is a sound no-op. +// --------------------------------------------------------------------------- + +type Visitproc = unsafe extern "C" fn(*mut PyObject, *mut c_void) -> c_int; + +/// `PyObject_VisitManagedDict(obj, visit, arg)` — GC traversal of an +/// object's managed dict. WeavePy's GC traces the dict natively, so the +/// C-level traversal visits nothing. +#[no_mangle] +pub unsafe extern "C" fn PyObject_VisitManagedDict( + _obj: *mut PyObject, + _visit: Option, + _arg: *mut c_void, +) -> c_int { + 0 +} + +/// `PyObject_ClearManagedDict(obj)` — clear an object's managed dict. +/// WeavePy owns the dict's lifetime on the VM side; nothing to clear here. +#[no_mangle] +pub unsafe extern "C" fn PyObject_ClearManagedDict(_obj: *mut PyObject) {} + +/// `PyObject_GC_IsFinalized(obj)` — whether `obj`'s finalizer has run. +/// WeavePy doesn't expose tp_finalize resurrection through this probe. +#[no_mangle] +pub unsafe extern "C" fn PyObject_GC_IsFinalized(_obj: *mut PyObject) -> c_int { + 0 +} + +/// `PyObject_CallFinalizerFromDealloc(obj)` — run `obj`'s finalizer during +/// deallocation, returning -1 if it resurrected the object. WeavePy runs +/// finalizers through the VM; report "no resurrection" (0). +#[no_mangle] +pub unsafe extern "C" fn PyObject_CallFinalizerFromDealloc(_obj: *mut PyObject) -> c_int { + 0 +} + +// --------------------------------------------------------------------------- +// Version data symbol +// --------------------------------------------------------------------------- + +/// `Py_Version` — the runtime `PY_VERSION_HEX` as a data symbol (3.11+). +/// Cython's `__Pyx_check_binary_version` masks `Py_Version & ~0xFFUL` and +/// compares it against the compile-time hex; WeavePy targets CPython +/// 3.13.0, so we publish `0x030d00f0` (3.13.0 final). +#[no_mangle] +pub static Py_Version: c_ulong = 0x030d_00f0; diff --git a/crates/weavepy-capi/src/wave5_pandas.rs b/crates/weavepy-capi/src/wave5_pandas.rs new file mode 100644 index 0000000..3a56741 --- /dev/null +++ b/crates/weavepy-capi/src/wave5_pandas.rs @@ -0,0 +1,455 @@ +//! RFC 0047 (wave 5): the CPython 3.13 C-API leaf tail that **real +//! numpy.random** and **real pandas** link but that waves 1-4 (and the +//! initial Cython tail in [`crate::wave5`]) had not yet exported. +//! +//! numpy is mostly hand-written C, but `numpy.random` is itself +//! Cython-generated (`_generator`, `_mt19937`, `bit_generator`, …), and +//! pandas is ~70% Cython. They lean on a further cluster of leaves — +//! the single-threaded lock API (`PyThread_allocate_lock` &co), a handful +//! of `PyUnicode_*` builders, the in-place number ops, list-slice +//! assignment, the static-/class-method constructors, and a few data +//! symbols (`PyWrapperDescr_Type`, `_PyByteArray_empty_string`). +//! +//! Found the wave-4 way: diff the undefined `Py*`/`_Py*` symbols of the +//! real `numpy/random/*.so` and `pandas/_libs/**/*.so` against the host's +//! dynamic symbol table. Each entry here is a thin delegator onto the +//! wave-1/2/3 surface or a sound no-op under WeavePy's single-threaded-GIL +//! object model. + +#![allow(clippy::missing_safety_doc)] + +use core::ffi::{c_char, c_int, c_longlong, c_void}; +use std::ptr; + +use weavepy_vm::object::Object; + +use crate::object::{PyObject, PySsizeT}; +use crate::types::PyTypeObject; + +// --------------------------------------------------------------------------- +// The single-threaded lock API +// --------------------------------------------------------------------------- +// +// Cython's generated module-exec allocates locks (e.g. for the cached +// `__pyx_*` globals and, in numpy.random, the bit-generator guard). +// WeavePy runs one interpreter thread under a single GIL, so a lock is a +// non-NULL opaque handle whose acquire always succeeds and whose release +// is a no-op — exactly the value CPython's lock returns to an uncontended +// single thread. The handle is a leaked one-byte allocation so each lock +// has a distinct, non-NULL identity (Cython NULL-checks the handle). + +/// CPython's `PyLockStatus`: `PY_LOCK_FAILURE = 0`, `PY_LOCK_ACQUIRED = 1`, +/// `PY_LOCK_INTR = 2`. +const PY_LOCK_ACQUIRED: c_int = 1; + +#[no_mangle] +pub extern "C" fn PyThread_allocate_lock() -> *mut c_void { + Box::into_raw(Box::new(0u8)) as *mut c_void +} + +#[no_mangle] +pub unsafe extern "C" fn PyThread_free_lock(lock: *mut c_void) { + if !lock.is_null() { + unsafe { drop(Box::from_raw(lock as *mut u8)) }; + } +} + +/// `PyThread_acquire_lock(lock, waitflag)` — 1 on success, 0 on failure. +/// Uncontended single thread: always succeeds. +#[no_mangle] +pub extern "C" fn PyThread_acquire_lock(_lock: *mut c_void, _waitflag: c_int) -> c_int { + 1 +} + +#[no_mangle] +pub extern "C" fn PyThread_acquire_lock_timed( + _lock: *mut c_void, + _microseconds: c_longlong, + _intr_flag: c_int, +) -> c_int { + PY_LOCK_ACQUIRED +} + +#[no_mangle] +pub extern "C" fn PyThread_release_lock(_lock: *mut c_void) {} + +// --------------------------------------------------------------------------- +// Thread state / module state +// --------------------------------------------------------------------------- + +/// `PyThreadState_GetFrame(tstate)` — the current frame (new ref) or NULL. +/// WeavePy executes Python on the Rust stack and exposes no C-level +/// `PyFrameObject` for the running frame, so report "no frame" (CPython +/// permits NULL). Cython uses it only for traceback/line bookkeeping. +#[no_mangle] +pub extern "C" fn PyThreadState_GetFrame(_tstate: *mut c_void) -> *mut PyObject { + ptr::null_mut() +} + +// --------------------------------------------------------------------------- +// Per-module `m_size` state (PEP 3121). +// +// A Cython wheel keeps its globals in a `.so`-static, so it never reads +// `PyModule_GetState`. But a *hand-written* single-phase C extension — pandas' +// vendored ujson (`pandas/_libs/json`) — declares `m_size = sizeof(modulestate)` +// in its `PyModuleDef`, then on init does `PyModule_GetState(module)` and writes +// the cached `decimal.Decimal` type into `state->...`. Returning NULL there is a +// NULL-deref store at import. We therefore allocate the `m_size` block on +// `PyModule_Create2`/multi-phase init and hand it back here. +// +// The block is keyed by the module's *native identity* (the `Rc` +// inner pointer), not its C box pointer: WeavePy mints a fresh `PyObjectBox` +// for a module on every crossing, but the underlying `Rc` is shared, so the +// inner pointer is stable across init *and* later runtime calls (which receive +// the module as `self` / via `PyState_FindModule`). Blocks live for the process +// (modules sit in `sys.modules`), so the owning `Box<[u8]>` is simply retained +// in the registry — no free hook needed. +thread_local! { + static MODULE_STATE: std::cell::RefCell>> = + std::cell::RefCell::new(std::collections::HashMap::new()); + /// `PyState_FindModule` index: `PyModuleDef*` → a stable immortal C box + /// for the module created from that def. CPython keys its + /// `interp->modules_by_index` by `def->m_base.m_index`; the def pointer is + /// the equivalent stable per-extension singleton and avoids mutating the + /// caller's def. See [`register_find_module`] / [`PyState_FindModule`]. + static FIND_MODULE: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// The native-identity key for a module's C pointer, or `None` if `module` +/// does not resolve to a WeavePy `Object::Module`. +fn module_state_key(module: *mut PyObject) -> Option { + if module.is_null() { + return None; + } + match unsafe { crate::object::clone_object(module) } { + Object::Module(rc) => Some(weavepy_vm::sync::Rc::as_ptr(&rc) as usize), + _ => None, + } +} + +/// Allocate (once) a zeroed `size`-byte state block for the module identified +/// by `key` and return a stable pointer to it. Idempotent: a repeat call for +/// the same module returns the existing block. `size == 0` yields NULL (CPython +/// reports no state for an `m_size == 0` module). Called from +/// `PyModule_Create2` and the multi-phase init path. +pub(crate) fn ensure_module_state(key: usize, size: usize) -> *mut c_void { + if size == 0 { + return ptr::null_mut(); + } + MODULE_STATE.with(|m| { + let mut map = m.borrow_mut(); + let buf = map + .entry(key) + .or_insert_with(|| vec![0u8; size].into_boxed_slice()); + buf.as_mut_ptr() as *mut c_void + }) +} + +/// `PyModule_GetState(module)` — the `m_size` state buffer allocated at module +/// creation (see [`ensure_module_state`]), or NULL for a stateless module. +#[no_mangle] +pub extern "C" fn PyModule_GetState(module: *mut PyObject) -> *mut c_void { + let Some(key) = module_state_key(module) else { + return ptr::null_mut(); + }; + MODULE_STATE.with(|m| { + m.borrow_mut() + .get_mut(&key) + .map_or(ptr::null_mut(), |buf| buf.as_mut_ptr() as *mut c_void) + }) +} + +/// Register the module created from single-phase `def` so a later +/// `PyState_FindModule(def)` returns it. Mirrors CPython's +/// `_PyState_AddModule` populating `interp->modules_by_index`. +/// +/// A stable, *immortal* borrowed box is minted once per def: modules live +/// for the process (they sit in `sys.modules`), which is exactly the lifetime +/// CPython's per-interpreter registry grants the stored reference, so the +/// pointer stays valid for every later lookup. The box resolves to the same +/// native `Object::Module` used at creation, hence to the same +/// [`PyModule_GetState`] block — the whole point of the round-trip: pandas' +/// vendored ujson re-fetches its own module here to read the cached +/// `Series`/`DataFrame`/`Index`/`NaT` types written into that state at import +/// (`object_is_series_type` &co). Idempotent per def. +pub(crate) fn register_find_module(def: *mut c_void, module: Object) { + if def.is_null() { + return; + } + let key = def as usize; + if FIND_MODULE.with(|m| m.borrow().contains_key(&key)) { + return; + } + let boxed = crate::object::into_owned(module); + if boxed.is_null() { + return; + } + // Never reclaimed: this is the registry's long-lived borrowed reference. + unsafe { + (*boxed).ob_refcnt = crate::object::IMMORTAL_REFCNT; + } + FIND_MODULE.with(|m| { + m.borrow_mut().insert(key, boxed as usize); + }); +} + +/// `PyState_FindModule(def)` — the module previously created from `def` +/// (borrowed ref), or NULL if none was registered (see +/// [`register_find_module`]). NULL is the correct answer for a module that +/// keeps its state in a `.so` static (`m_size < 0`, most Cython modules) and +/// is never registered. +#[no_mangle] +pub extern "C" fn PyState_FindModule(def: *mut c_void) -> *mut PyObject { + if def.is_null() { + return ptr::null_mut(); + } + FIND_MODULE.with(|m| { + m.borrow() + .get(&(def as usize)) + .map_or(ptr::null_mut(), |&p| p as *mut PyObject) + }) +} + +// --------------------------------------------------------------------------- +// List slice assignment +// --------------------------------------------------------------------------- + +/// `PyList_SetSlice(list, low, high, itemlist)` — `list[low:high] = itemlist` +/// (deletion when `itemlist` is NULL). Mirrors CPython's clamping of the +/// bounds to `[0, len]`. +#[no_mangle] +pub unsafe extern "C" fn PyList_SetSlice( + list: *mut PyObject, + low: PySsizeT, + high: PySsizeT, + itemlist: *mut PyObject, +) -> c_int { + if list.is_null() { + crate::errors::set_type_error("PyList_SetSlice: list is NULL"); + return -1; + } + let rc = match unsafe { crate::object::clone_object(list) } { + Object::List(rc) => rc, + _ => { + crate::errors::set_type_error("PyList_SetSlice: expected list"); + return -1; + } + }; + // Collect the replacement items (empty for a deletion). + let new_items: Vec = if itemlist.is_null() { + Vec::new() + } else { + match unsafe { crate::abstract_::collect_iterable(itemlist) } { + Some(v) => v, + None => return -1, + } + }; + { + let mut v = rc.borrow_mut(); + let len = v.len() as PySsizeT; + let lo = low.clamp(0, len) as usize; + let hi = high.clamp(low.max(0), len) as usize; + v.splice(lo..hi, new_items); + } + // Keep a faithful list mirror's inline `ob_item` coherent with the + // spliced prefix `Rc` so a stock `PyList_GET_ITEM` macro reads the new + // contents/length (RFC 0047, wave 5; see `PyObject_SetItem`). + unsafe { crate::mirror::sync_list_ob_item(list) }; + 0 +} + +// --------------------------------------------------------------------------- +// Exception traceback accessor +// --------------------------------------------------------------------------- + +/// `PyException_GetTraceback(exc)` — a **new** reference to `exc.__traceback__` +/// (or NULL when there is none). Delegates to the attribute lookup; a +/// missing/None traceback maps to NULL with no error set. +#[no_mangle] +pub unsafe extern "C" fn PyException_GetTraceback(exc: *mut PyObject) -> *mut PyObject { + if exc.is_null() { + return ptr::null_mut(); + } + let tb = + unsafe { crate::abstract_::PyObject_GetAttrString(exc, c"__traceback__".as_ptr()) }; + if tb.is_null() { + crate::errors::clear_thread_local(); + return ptr::null_mut(); + } + if std::ptr::eq(tb, crate::singletons::none_ptr()) { + unsafe { crate::object::Py_DecRef(tb) }; + return ptr::null_mut(); + } + tb +} + +// --------------------------------------------------------------------------- +// static / class method constructors +// --------------------------------------------------------------------------- + +/// `PyStaticMethod_New(callable)` — Python `staticmethod(callable)`. +#[no_mangle] +pub unsafe extern "C" fn PyStaticMethod_New(callable: *mut PyObject) -> *mut PyObject { + if callable.is_null() { + crate::errors::set_type_error("PyStaticMethod_New: callable is NULL"); + return ptr::null_mut(); + } + let func = unsafe { crate::object::clone_object(callable) }; + crate::object::into_owned(Object::StaticMethod(weavepy_vm::object::MethodWrapper::new( + func, + ))) +} + +// --------------------------------------------------------------------------- +// Long copy +// --------------------------------------------------------------------------- + +/// `_PyLong_Copy(v)` — a new reference to a copy of the int `v`. WeavePy +/// ints are immutable values, so a clone is an identical copy. +#[no_mangle] +pub unsafe extern "C" fn _PyLong_Copy(v: *mut PyObject) -> *mut PyObject { + if v.is_null() { + return ptr::null_mut(); + } + match unsafe { crate::object::clone_object(v) } { + o @ (Object::Int(_) | Object::Long(_) | Object::Bool(_)) => crate::object::into_owned(o), + _ => { + crate::errors::set_type_error("_PyLong_Copy: expected int"); + ptr::null_mut() + } + } +} + +// --------------------------------------------------------------------------- +// Unicode builders / locale codecs +// --------------------------------------------------------------------------- + +/// `PyUnicode_FromWideChar(w, size)` — build a `str` from a `wchar_t` +/// array (4-byte code points on macOS/Linux). `size < 0` means `w` is +/// NUL-terminated. +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_FromWideChar(w: *const i32, size: PySsizeT) -> *mut PyObject { + if w.is_null() { + crate::errors::set_value_error("PyUnicode_FromWideChar: NULL pointer"); + return ptr::null_mut(); + } + let n = if size < 0 { + let mut n = 0isize; + while unsafe { *w.offset(n) } != 0 { + n += 1; + } + n as usize + } else { + size as usize + }; + let mut s = String::with_capacity(n); + for i in 0..n { + let cp = unsafe { *w.add(i) } as u32; + s.push(char::from_u32(cp).unwrap_or('\u{FFFD}')); + } + crate::object::into_owned(Object::from_str(s)) +} + +/// `PyUnicode_DecodeLocale(str, errors)` — decode a NUL-terminated C string +/// using the locale encoding. Modern macOS/Linux locales are UTF-8, so this +/// is the UTF-8 decode WeavePy already provides. +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_DecodeLocale( + s: *const c_char, + _errors: *const c_char, +) -> *mut PyObject { + unsafe { crate::strings::PyUnicode_FromString(s) } +} + +/// `PyUnicode_EncodeLocale(unicode, errors)` — encode a `str` to a `bytes` +/// object using the locale encoding (UTF-8 here). +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_EncodeLocale( + unicode: *mut PyObject, + _errors: *const c_char, +) -> *mut PyObject { + unsafe { crate::strings::PyUnicode_AsUTF8String(unicode) } +} + +/// `PyUnicode_Resize(p_unicode, length)` — resize the string `*p_unicode` +/// to `length` code points (RFC 0047, wave 5). Mints a fresh, writable +/// mirror of the new length preserving the leading characters and the +/// source kind, publishes it through `*p_unicode`, and releases the old +/// reference; the new tail is zero-filled, ready for the +/// `PyUnicode_CopyCharacters` that Cython's in-place concatenation fast +/// path performs next. Returns 0 on success, -1 (with an exception) if +/// `*p_unicode` is not a resizable unicode object. +#[no_mangle] +pub unsafe extern "C" fn PyUnicode_Resize(p_unicode: *mut *mut PyObject, length: PySsizeT) -> c_int { + if p_unicode.is_null() { + crate::errors::set_value_error("PyUnicode_Resize: NULL pointer"); + return -1; + } + let old = unsafe { *p_unicode }; + if old.is_null() { + crate::errors::set_value_error("PyUnicode_Resize: NULL string"); + return -1; + } + let n = if length < 0 { 0 } else { length as usize }; + let np = unsafe { crate::mirror::resize_unicode(old, n) }; + if np.is_null() { + crate::errors::set_value_error("PyUnicode_Resize: not a resizable unicode object"); + return -1; + } + // `*p_unicode` owned one reference to `old`; it now owns the resized + // copy. Release the consumed reference (frees `old` when it was the sole + // owner; leaves it live, copy-on-resize style, when shared). + unsafe { crate::object::Py_DecRef(old) }; + unsafe { *p_unicode = np }; + 0 +} + +// --------------------------------------------------------------------------- +// Fatal error +// --------------------------------------------------------------------------- + +/// `_Py_FatalErrorFunc(func, msg)` — abort with a fatal-error banner +/// (CPython's `Py_FatalError` macro target). Never returns. +#[no_mangle] +pub unsafe extern "C" fn _Py_FatalErrorFunc(func: *const c_char, msg: *const c_char) { + let f = if func.is_null() { + "".to_owned() + } else { + unsafe { core::ffi::CStr::from_ptr(func) } + .to_string_lossy() + .into_owned() + }; + let m = if msg.is_null() { + "".to_owned() + } else { + unsafe { core::ffi::CStr::from_ptr(msg) } + .to_string_lossy() + .into_owned() + }; + eprintln!("Fatal Python error: in {f}: {m}"); + std::process::abort(); +} + +// --------------------------------------------------------------------------- +// Data symbols +// --------------------------------------------------------------------------- + +/// `_PyByteArray_empty_string` — the shared 1-byte NUL buffer CPython hands +/// back from `PyByteArray_AS_STRING` for an empty bytearray. +#[no_mangle] +pub static _PyByteArray_empty_string: [c_char; 1] = [0]; + +/// `PyCMethod_New(ml, self, module, cls)` — build a builtin method object +/// from a `PyMethodDef`, binding `self` (and ignoring the defining-class +/// argument used only by `METH_METHOD` introspection). Delegates to the +/// module bridge's C-function wrapper. +#[no_mangle] +pub unsafe extern "C" fn PyCMethod_New( + ml: *mut crate::module::PyMethodDef, + self_: *mut PyObject, + module: *mut PyObject, + _cls: *mut PyTypeObject, +) -> *mut PyObject { + unsafe { crate::module::PyCFunction_NewEx(ml, self_, module) } +} diff --git a/crates/weavepy-capi/tests/capi_stockabi.rs b/crates/weavepy-capi/tests/capi_stockabi.rs new file mode 100644 index 0000000..22e9462 --- /dev/null +++ b/crates/weavepy-capi/tests/capi_stockabi.rs @@ -0,0 +1,242 @@ +//! Integration test: the RFC 0043 wave-1 hermetic proof. +//! +//! `crates/weavepy-capi/build.rs` compiles `tests/capi_ext/_stockabi.c` +//! against the host's **stock CPython 3.13 headers** (full, non-limited +//! API → real inlined macros) and exports `WEAVEPY_CAPI_STOCKABI_EXTENSION`. +//! Here we `dlopen` that `.so` into WeavePy and drive it, asserting that +//! a binary artifact compiled against real CPython headers — including +//! its *inlined* field-access macros (`PyFloat_AS_DOUBLE`, `Py_SIZE`, +//! `PyTuple_GET_ITEM`, `Py_TYPE` identity, head-poke `Py_INCREF`/`DECREF`) +//! — runs correctly against WeavePy's layout-faithful mirrors. +//! +//! Skipped (passes) when the env var is unset — that happens when +//! CPython 3.13 dev headers (or `cc`) aren't available on the build +//! host, so CI on a bare machine still passes. + +use std::path::PathBuf; + +use weavepy_capi::loader::load_extension_module; +use weavepy_vm::object::Object; +use weavepy_vm::Interpreter; + +fn extension_path() -> Option { + option_env!("WEAVEPY_CAPI_STOCKABI_EXTENSION").map(PathBuf::from) +} + +fn lookup(module: &Object, key: &str) -> Option { + let Object::Module(m) = module else { + return None; + }; + let d = m.dict.borrow(); + for (k, v) in d.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +fn load() -> Option<(Interpreter, Object)> { + let path = extension_path()?; + if !path.is_file() { + eprintln!( + "WEAVEPY_CAPI_STOCKABI_EXTENSION points at missing file: {} — skipping", + path.display() + ); + return None; + } + weavepy_capi::force_link(); + let mut interp = Interpreter::default(); + let interp_ptr: *mut Interpreter = &raw mut interp; + match load_extension_module(interp_ptr, &path, "_stockabi") { + Ok(m) => Some((interp, m)), + Err(err) => { + eprintln!("dlopen of stock-ABI extension failed (treating as skip): {err}"); + None + } + } +} + +fn call(interp: &mut Interpreter, module: &Object, name: &str, args: &[Object]) -> Object { + let f = lookup(module, name).unwrap_or_else(|| panic!("module missing `{name}`")); + interp + .call_object(f, args, &[]) + .unwrap_or_else(|e| panic!("calling `{name}` failed: {e:?}")) +} + +#[test] +fn stockabi_skipped_when_extension_missing() { + if extension_path().is_none() { + eprintln!("WEAVEPY_CAPI_STOCKABI_EXTENSION not set — skipping stock-ABI proof"); + } +} + +#[test] +fn stockabi_module_loads_with_constants() { + let Some((_interp, module)) = load() else { + return; + }; + match lookup(&module, "ANSWER") { + Some(Object::Int(n)) => assert_eq!(n, 42), + other => panic!("ANSWER wrong: {other:?}"), + } + match lookup(&module, "ABI") { + Some(Object::Str(s)) => assert_eq!(&*s, "cp313"), + other => panic!("ABI wrong: {other:?}"), + } +} + +/// The headline assertion: a stock-compiled `PyFloat_AS_DOUBLE` (inlined +/// read of `ob_fval` at offset 16) returns the right value off a WeavePy +/// float mirror. +#[test] +fn stockabi_inlined_float_read() { + let Some((mut interp, module)) = load() else { + return; + }; + match call(&mut interp, &module, "double_it", &[Object::Float(2.5)]) { + Object::Float(f) => assert_eq!(f, 5.0), + other => panic!("double_it: {other:?}"), + } +} + +/// Inlined `Py_SIZE` reads `ob_size` off a faithful tuple mirror. +#[test] +fn stockabi_inlined_size_read() { + let Some((mut interp, module)) = load() else { + return; + }; + let t = Object::new_tuple(vec![Object::Int(1), Object::Int(2), Object::Int(3)]); + match call(&mut interp, &module, "size", &[t]) { + Object::Int(n) => assert_eq!(n, 3), + other => panic!("size: {other:?}"), + } +} + +/// Inlined `PyTuple_GET_ITEM` reads the faithful `ob_item[]` tail. +#[test] +fn stockabi_inlined_tuple_item_read() { + let Some((mut interp, module)) = load() else { + return; + }; + let t = Object::new_tuple(vec![Object::Int(10), Object::Int(20)]); + match call(&mut interp, &module, "tuple_first", &[t]) { + Object::Int(n) => assert_eq!(n, 10), + other => panic!("tuple_first: {other:?}"), + } + let t = Object::new_tuple(vec![ + Object::Int(1), + Object::Int(2), + Object::Int(3), + Object::Int(4), + ]); + match call(&mut interp, &module, "tuple_sum", &[t]) { + Object::Int(n) => assert_eq!(n, 10), + other => panic!("tuple_sum: {other:?}"), + } +} + +/// `Py_TYPE(o) == &PyFloat_Type` / `&PyLong_Type` across the boundary. +#[test] +fn stockabi_type_identity() { + let Some((mut interp, module)) = load() else { + return; + }; + assert!(matches!( + call(&mut interp, &module, "is_float", &[Object::Float(1.0)]), + Object::Bool(true) + )); + assert!(matches!( + call(&mut interp, &module, "is_float", &[Object::Int(1)]), + Object::Bool(false) + )); + assert!(matches!( + call(&mut interp, &module, "is_long", &[Object::Int(7)]), + Object::Bool(true) + )); + match call(&mut interp, &module, "type_name", &[Object::Float(1.0)]) { + Object::Str(s) => assert_eq!(&*s, "float"), + other => panic!("type_name: {other:?}"), + } +} + +/// Head-poke `Py_INCREF` + ownership transfer (`roundtrip`). +#[test] +fn stockabi_roundtrip_incref() { + let Some((mut interp, module)) = load() else { + return; + }; + match call(&mut interp, &module, "roundtrip", &[Object::Int(99)]) { + Object::Int(n) => assert_eq!(n, 99), + other => panic!("roundtrip: {other:?}"), + } +} + +/// Function-API constructors / arg parsing / `Py_BuildValue`. +#[test] +fn stockabi_function_api() { + let Some((mut interp, module)) = load() else { + return; + }; + match call( + &mut interp, + &module, + "add", + &[Object::Int(2), Object::Int(3)], + ) { + Object::Int(n) => assert_eq!(n, 5), + other => panic!("add: {other:?}"), + } + match call( + &mut interp, + &module, + "add_doubles", + &[Object::Float(1.5), Object::Float(2.5)], + ) { + Object::Float(f) => assert_eq!(f, 4.0), + other => panic!("add_doubles: {other:?}"), + } + match call( + &mut interp, + &module, + "echo_str", + &[Object::Str("hello".into())], + ) { + Object::Str(s) => assert_eq!(&*s, "hello"), + other => panic!("echo_str: {other:?}"), + } + match call( + &mut interp, + &module, + "make_pair", + &[Object::Int(1), Object::Int(2)], + ) { + Object::Tuple(t) => { + assert_eq!(t.len(), 2); + assert!(matches!(t[0], Object::Int(1))); + assert!(matches!(t[1], Object::Int(2))); + } + other => panic!("make_pair: {other:?}"), + } + let lst = Object::new_list(vec![Object::Int(1), Object::Int(2), Object::Int(3)]); + match call(&mut interp, &module, "list_sum", &[lst]) { + Object::Int(n) => assert_eq!(n, 6), + other => panic!("list_sum: {other:?}"), + } +} + +/// C-side allocate-then-`Py_DECREF`-to-zero: the inlined `Py_DECREF` +/// calls `_Py_Dealloc` → `tp_dealloc` (offset 48) → frees the mirror. +#[test] +fn stockabi_c_side_dealloc() { + let Some((mut interp, module)) = load() else { + return; + }; + match call(&mut interp, &module, "alloc_free_cycle", &[]) { + // sum(0..100) == 4950 + Object::Int(n) => assert_eq!(n, 4950), + other => panic!("alloc_free_cycle: {other:?}"), + } +} diff --git a/crates/weavepy-capi/tests/capi_stockarray.rs b/crates/weavepy-capi/tests/capi_stockarray.rs new file mode 100644 index 0000000..516b4ee --- /dev/null +++ b/crates/weavepy-capi/tests/capi_stockarray.rs @@ -0,0 +1,426 @@ +//! Integration test: the RFC 0045 wave-3 hermetic proof. +//! +//! `crates/weavepy-capi/build.rs` compiles `tests/capi_ext/_stockarray.c` +//! against the host's **stock CPython 3.13 headers** (full, non-limited +//! API → the genuine 416-byte `PyTypeObject`, the real `PyMemberDef` +//! layout, and `PyCapsule_*`) and exports `WEAVEPY_CAPI_STOCKARRAY_EXTENSION`. +//! Here we `dlopen` that `.so` into WeavePy and drive it, asserting that a +//! `PyArrayObject`-shaped stock type — one that reads its own fields +//! **inline** at fixed `tp_basicsize` offsets — works end to end: +//! +//! * **inline `tp_basicsize` storage** persists across crossings +//! (`StockArray(5).sum() == 10.0`: init wrote the buffer, a later C +//! call read it back through the *same* body); +//! * **`tp_members`** project inline fields (`nd`, `length` read-only; +//! `typenum` writable) at their real `offsetof`; +//! * the inline `data` pointer is **stable** across calls; +//! * **mutation** through one C call is visible to the next +//! (`fill()` then `sum()`); +//! * the **array interchange** protocols `__array_interface__` / +//! `__array_struct__` expose the inline buffer; +//! * the **`import_array()` array-C-API capsule** round-trips +//! (`PyCapsule_Import("_stockarray._ARRAY_API")` → a `void **` table → +//! build a fresh array through it); +//! * a faithful `tp_dealloc` frees `self->data` and its +//! `PyObject_Free(self)` tail is absorbed (the body is owned by the +//! native instance), with no leak. +//! +//! Skipped (passes) when the env var is unset — that happens when CPython +//! 3.13 dev headers (or `cc`) aren't available on the build host. + +use std::path::PathBuf; + +use weavepy_capi::loader::load_extension_module; +use weavepy_vm::error::RuntimeError; +use weavepy_vm::object::Object; +use weavepy_vm::Interpreter; + +fn extension_path() -> Option { + option_env!("WEAVEPY_CAPI_STOCKARRAY_EXTENSION").map(PathBuf::from) +} + +fn lookup(module: &Object, key: &str) -> Option { + let Object::Module(m) = module else { + return None; + }; + let d = m.dict.borrow(); + for (k, v) in d.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +/// Load `_stockarray` and register it in the interpreter's module cache, +/// so the in-C `PyCapsule_Import("_stockarray._ARRAY_API")` (the +/// `import_array()` path) can re-import the module by name. +fn load() -> Option<(Interpreter, Object)> { + let path = extension_path()?; + if !path.is_file() { + eprintln!( + "WEAVEPY_CAPI_STOCKARRAY_EXTENSION points at missing file: {} — skipping", + path.display() + ); + return None; + } + weavepy_capi::force_link(); + let mut interp = Interpreter::default(); + let interp_ptr: *mut Interpreter = &raw mut interp; + match load_extension_module(interp_ptr, &path, "_stockarray") { + Ok(m) => { + interp.module_cache().insert("_stockarray", m.clone()); + Some((interp, m)) + } + Err(err) => { + eprintln!("dlopen of stock-array extension failed (treating as skip): {err}"); + None + } + } +} + +/// Construct an instance by calling the readied type object, as `T(...)` +/// would from Python: drives `tp_new` (→ our inline body) + `tp_init`. +fn construct(interp: &mut Interpreter, ty: &Object, args: &[Object]) -> Object { + interp + .call_object(ty.clone(), args, &[]) + .unwrap_or_else(|e| panic!("constructing StockArray failed: {e:?}")) +} + +fn call_method( + interp: &mut Interpreter, + instance: Object, + name: &str, + args: &[Object], +) -> Result { + let class = match &instance { + Object::Instance(inst) => inst.cls(), + other => panic!("expected instance, got {other:?}"), + }; + let method = class + .lookup(name) + .unwrap_or_else(|| panic!("method '{name}' not in MRO of {}", class.name)); + let mut full = Vec::with_capacity(args.len() + 1); + full.push(instance); + full.extend_from_slice(args); + interp.call_object(method, &full, &[]) +} + +/// Read `instance.` through `__getattribute__` (so member / getset +/// descriptors fire exactly as attribute access would). +fn get_attr(interp: &mut Interpreter, instance: Object, name: &str) -> Object { + call_method( + interp, + instance, + "__getattribute__", + &[Object::from_str(name)], + ) + .unwrap_or_else(|e| panic!("getattr {name} failed: {e:?}")) +} + +fn set_attr(interp: &mut Interpreter, instance: Object, name: &str, value: Object) { + call_method( + interp, + instance, + "__setattr__", + &[Object::from_str(name), value], + ) + .unwrap_or_else(|e| panic!("setattr {name} failed: {e:?}")); +} + +fn call_module_fn( + interp: &mut Interpreter, + module: &Object, + name: &str, + args: &[Object], +) -> Object { + let f = lookup(module, name).unwrap_or_else(|| panic!("module fn `{name}` missing")); + interp + .call_object(f, args, &[]) + .unwrap_or_else(|e| panic!("calling `{name}` failed: {e:?}")) +} + +fn as_f64(o: &Object) -> f64 { + match o { + Object::Float(f) => *f, + Object::Int(i) => *i as f64, + other => panic!("expected float, got {other:?}"), + } +} + +fn as_i64(o: &Object) -> i64 { + match o { + Object::Int(i) => *i, + other => panic!("expected int, got {other:?}"), + } +} + +fn dict_get(d: &Object, key: &str) -> Option { + let Object::Dict(rc) = d else { + return None; + }; + let g = rc.borrow(); + for (k, v) in g.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +#[test] +fn stockarray_skipped_when_extension_missing() { + if extension_path().is_none() { + eprintln!("WEAVEPY_CAPI_STOCKARRAY_EXTENSION not set — skipping inline-storage proof"); + } +} + +#[test] +fn stockarray_module_loads_with_type_and_capsule() { + let Some((_interp, module)) = load() else { + return; + }; + assert!( + lookup(&module, "StockArray").is_some(), + "missing StockArray" + ); + assert!( + lookup(&module, "_ARRAY_API").is_some(), + "missing _ARRAY_API capsule" + ); + match lookup(&module, "ABI") { + Some(Object::Str(s)) => assert_eq!(&*s, "cp313"), + other => panic!("unexpected ABI marker: {other:?}"), + } +} + +/// The headline proof: `tp_init` writes the inline `data`/`length` +/// fields, and a *separate* later C call (`sum()`) reads them back — so +/// the faithful body is the **same block** across both crossings. +#[test] +fn stockarray_inline_storage_persists_across_calls() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(5)]); + + // sum() reads self->data / self->length written by tp_init. A fresh + // per-crossing body would read zeros (or a NULL data pointer). + let total = call_method(&mut interp, arr.clone(), "sum", &[]).expect("sum"); + assert!( + (as_f64(&total) - 10.0).abs() < 1e-9, + "0+1+2+3+4 should be 10.0, got {total:?}" + ); +} + +/// `tp_members` read the very bytes `tp_init` wrote, at their real +/// offsets, and READONLY members reject assignment. +#[test] +fn stockarray_members_read_inline_fields() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(7)]); + + assert_eq!(as_i64(&get_attr(&mut interp, arr.clone(), "nd")), 1, "nd"); + assert_eq!( + as_i64(&get_attr(&mut interp, arr.clone(), "length")), + 7, + "length" + ); + assert_eq!( + as_i64(&get_attr(&mut interp, arr.clone(), "typenum")), + 12, + "typenum default" + ); + + // `length` is READONLY → assignment must raise. + let err = call_method( + &mut interp, + arr, + "__setattr__", + &[Object::from_str("length"), Object::Int(99)], + ); + assert!(err.is_err(), "writing READONLY member should fail"); +} + +/// A writable member round-trips through the same inline field. +#[test] +fn stockarray_member_write_roundtrips() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(3)]); + + set_attr(&mut interp, arr.clone(), "typenum", Object::Int(7)); + assert_eq!( + as_i64(&get_attr(&mut interp, arr, "typenum")), + 7, + "typenum should reflect the written value" + ); +} + +/// The inline `data` pointer is the same address on every crossing — +/// direct evidence the instance presents one stable body. +#[test] +fn stockarray_data_pointer_is_stable() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(4)]); + + let a = as_i64(&call_method(&mut interp, arr.clone(), "data_addr", &[]).expect("data_addr")); + let b = as_i64(&call_method(&mut interp, arr.clone(), "data_addr", &[]).expect("data_addr")); + assert_eq!(a, b, "data pointer must be stable across crossings"); + assert_ne!(a, 0, "data pointer must be non-null"); +} + +/// Mutation written inline by one C call (`fill`) is visible to the next +/// (`sum`) — the bytes live in the shared body, not a transient box. +#[test] +fn stockarray_fill_then_sum() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(5)]); + + call_method(&mut interp, arr.clone(), "fill", &[Object::Float(2.0)]).expect("fill"); + let total = call_method(&mut interp, arr, "sum", &[]).expect("sum"); + assert!( + (as_f64(&total) - 10.0).abs() < 1e-9, + "5 * 2.0 should be 10.0, got {total:?}" + ); +} + +/// `__array_interface__` exposes shape + the live inline data address. +#[test] +fn stockarray_array_interface() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(5)]); + + let iface = get_attr(&mut interp, arr.clone(), "__array_interface__"); + assert_eq!( + as_i64(&dict_get(&iface, "version").expect("version")), + 3, + "array interface version" + ); + match dict_get(&iface, "shape").expect("shape") { + Object::Tuple(t) => { + assert_eq!(t.len(), 1); + assert_eq!(as_i64(&t[0]), 5, "shape[0]"); + } + other => panic!("shape not a tuple: {other:?}"), + } + match dict_get(&iface, "typestr").expect("typestr") { + Object::Str(s) => assert_eq!(&*s, " panic!("typestr not a str: {other:?}"), + } + // data[0] is the same address data_addr() reports. + let data_addr = as_i64(&call_method(&mut interp, arr, "data_addr", &[]).expect("data_addr")); + match dict_get(&iface, "data").expect("data") { + Object::Tuple(t) => { + assert_eq!(as_i64(&t[0]), data_addr, "interface data addr matches"); + assert!(matches!(t[1], Object::Bool(false)), "data not read-only"); + } + other => panic!("data not a tuple: {other:?}"), + } +} + +/// `__array_struct__` yields a `PyArrayInterface` capsule a C consumer +/// reads back with the right layout. +#[test] +fn stockarray_array_struct_capsule() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + let arr = construct(&mut interp, &ty, &[Object::Int(6)]); + + let data_addr = + as_i64(&call_method(&mut interp, arr.clone(), "data_addr", &[]).expect("data_addr")); + // read_array_struct(arr) → (two, nd, typekind, length, data_addr) + match call_module_fn(&mut interp, &module, "read_array_struct", &[arr]) { + Object::Tuple(t) => { + assert_eq!(t.len(), 5, "read_array_struct arity"); + assert_eq!(as_i64(&t[0]), 2, "PyArrayInterface.two"); + assert_eq!(as_i64(&t[1]), 1, "nd"); + assert_eq!(as_i64(&t[2]), 'f' as i64, "typekind 'f'"); + assert_eq!(as_i64(&t[3]), 6, "shape[0]"); + assert_eq!(as_i64(&t[4]), data_addr, "data addr matches"); + } + other => panic!("read_array_struct returned non-tuple: {other:?}"), + } +} + +/// The `import_array()` capsule pattern: `capi_roundtrip(n)` does +/// `PyCapsule_Import("_stockarray._ARRAY_API")`, recovers the `void **` +/// table, and builds a fresh array through `table[FROMLENGTH]`. The +/// result is a real inline-storage instance whose `sum()` works. +#[test] +fn stockarray_import_array_capsule_roundtrip() { + let Some((mut interp, module)) = load() else { + return; + }; + let made = call_module_fn(&mut interp, &module, "capi_roundtrip", &[Object::Int(4)]); + match &made { + Object::Instance(i) => assert_eq!(i.cls().name, "StockArray", "wrong type from capsule"), + other => panic!("capi_roundtrip returned non-StockArray: {other:?}"), + } + // 0+1+2+3 == 6.0 — the capsule-built array has real inline storage. + let total = call_method(&mut interp, made, "sum", &[]).expect("sum"); + assert!( + (as_f64(&total) - 6.0).abs() < 1e-9, + "capsule-built StockArray(4).sum() should be 6.0, got {total:?}" + ); +} + +/// A faithful `tp_dealloc` (frees `self->data`, then `PyObject_Free(self)` +/// which WeavePy absorbs for an instance body) runs when the instance is +/// collected — proving the body's lifetime is owned by the native instance +/// and reclaimed with it, no leak/crash. +/// +/// The proof uses the fixture's **monotonic** `dealloc_count()` rather than +/// the live count: this `.so` (and thus its counters) is shared across all +/// tests in the process, which `cargo test` runs in parallel, so an absolute +/// `live == base + 1` reading races other tests constructing/dropping arrays. +/// `dealloc_count` only ever rises, so `after >= before + 1` holds iff *our* +/// instance was collected, regardless of concurrent deallocs. +#[test] +fn stockarray_dealloc_frees_buffer() { + let Some((mut interp, module)) = load() else { + return; + }; + let ty = lookup(&module, "StockArray").expect("StockArray"); + + let before = as_i64(&call_module_fn(&mut interp, &module, "dealloc_count", &[])); + let arr = construct(&mut interp, &ty, &[Object::Int(8)]); + // Sanity that this is a live inline-storage array (init wrote 0..7 into + // the body): a clone is consumed by the `sum` call, leaving `arr` the + // sole reference to drop below. + let total = call_method(&mut interp, arr.clone(), "sum", &[]).expect("sum"); + assert!( + (as_f64(&total) - 28.0).abs() < 1e-9, + "StockArray(8).sum() should be 0+..+7 == 28.0, got {total:?}" + ); + + // Drop the sole remaining reference: the native instance is collected, + // which runs the C `tp_dealloc` (free buffer) via the free hook. + drop(arr); + let after = as_i64(&call_module_fn(&mut interp, &module, "dealloc_count", &[])); + assert!( + after > before, + "tp_dealloc must run on collection (before={before}, after={after})" + ); +} diff --git a/crates/weavepy-capi/tests/capi_stockcython.rs b/crates/weavepy-capi/tests/capi_stockcython.rs new file mode 100644 index 0000000..721fdf7 --- /dev/null +++ b/crates/weavepy-capi/tests/capi_stockcython.rs @@ -0,0 +1,328 @@ +//! Integration test: the RFC 0047 wave-5 hermetic proof. +//! +//! `crates/weavepy-capi/build.rs` compiles `tests/capi_ext/_stockcython.c` +//! against the host's **stock CPython 3.13 headers** (full, non-limited +//! API) and exports `WEAVEPY_CAPI_STOCKCYTHON_EXTENSION`. The fixture is +//! shaped like a **Cython-generated** extension: it defines a base type +//! with number / sequence / repr / hash / richcompare slots and two +//! subclasses that (almost) nothing of their own, then reads the +//! inherited slots **directly off `Py_TYPE(instance)`** — the inlined +//! idiom Cython emits everywhere. +//! +//! Here we `dlopen` that `.so` into WeavePy and assert: +//! +//! * **`inherit_slots`** — a pure subclass (`CySub`) and a +//! partial-override subclass (`CySub2`) carry the base's `tp_*` +//! function slots and method-suite entries *baked into their own +//! faithful struct*, so a direct `Py_TYPE(self)->tp_as_number->nb_add` +//! read on a subclass instance resolves (it was NULL pre-wave-5); +//! * the **Cython C-API runtime tail** (`_PyObject_GetDictPtr`, +//! `PyObject_GetOptionalAttrString`, `_PyObject_GetMethod`, +//! `PyObject_CallMethodOneArg`, `_PyDict_NewPresized`, +//! `PyMapping_GetOptionalItemString`, `PyLong_AsInt`) links and runs; +//! * the Python-level MRO dispatch on a subclass is unaffected (the +//! inherited dunders are still reached through the bridged MRO). +//! +//! Skipped (passes) when the env var is unset — that happens when +//! CPython 3.13 dev headers (or `cc`) aren't available on the build +//! host, so CI on a bare machine still passes. + +use std::path::PathBuf; + +use weavepy_capi::loader::load_extension_module; +use weavepy_vm::error::RuntimeError; +use weavepy_vm::object::Object; +use weavepy_vm::Interpreter; + +fn extension_path() -> Option { + option_env!("WEAVEPY_CAPI_STOCKCYTHON_EXTENSION").map(PathBuf::from) +} + +fn lookup(module: &Object, key: &str) -> Option { + let Object::Module(m) = module else { + return None; + }; + let d = m.dict.borrow(); + for (k, v) in d.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +fn load() -> Option<(Interpreter, Object)> { + let path = extension_path()?; + if !path.is_file() { + eprintln!( + "WEAVEPY_CAPI_STOCKCYTHON_EXTENSION points at missing file: {} — skipping", + path.display() + ); + return None; + } + weavepy_capi::force_link(); + let mut interp = Interpreter::default(); + let interp_ptr: *mut Interpreter = &raw mut interp; + match load_extension_module(interp_ptr, &path, "_stockcython") { + Ok(m) => Some((interp, m)), + Err(err) => { + eprintln!("dlopen of stock-cython extension failed (treating as skip): {err}"); + None + } + } +} + +fn construct(interp: &mut Interpreter, ty: &Object, args: &[Object]) -> Object { + interp + .call_object(ty.clone(), args, &[]) + .unwrap_or_else(|e| panic!("constructing instance failed: {e:?}")) +} + +fn call_method( + interp: &mut Interpreter, + instance: Object, + name: &str, + args: &[Object], +) -> Result { + let class = match &instance { + Object::Instance(inst) => inst.cls(), + other => panic!("expected instance, got {other:?}"), + }; + let method = class + .lookup(name) + .unwrap_or_else(|| panic!("method '{name}' not in MRO of {}", class.name)); + let mut full = Vec::with_capacity(args.len() + 1); + full.push(instance); + full.extend_from_slice(args); + interp.call_object(method, &full, &[]) +} + +fn call_module_fn( + interp: &mut Interpreter, + module: &Object, + name: &str, + args: &[Object], +) -> Object { + let f = lookup(module, name).unwrap_or_else(|| panic!("module fn `{name}` missing")); + interp + .call_object(f, args, &[]) + .unwrap_or_else(|e| panic!("calling `{name}` failed: {e:?}")) +} + +fn dict_get(d: &Object, key: &str) -> Option { + let Object::Dict(rc) = d else { + return None; + }; + let g = rc.borrow(); + for (k, v) in g.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +fn get_i64(d: &Object, key: &str) -> i64 { + match dict_get(d, key) { + Some(Object::Int(n)) => n, + Some(Object::Bool(b)) => i64::from(b), + other => panic!("dict[{key}] not an int: {other:?}"), + } +} + +fn get_str(d: &Object, key: &str) -> String { + match dict_get(d, key) { + Some(Object::Str(s)) => s.to_string(), + other => panic!("dict[{key}] not a str: {other:?}"), + } +} + +/// Read the slots directly off `Py_TYPE(instance)` (the inlined Cython +/// idiom) by calling the fixture's `probe_slots`. +fn probe(interp: &mut Interpreter, module: &Object, instance: Object) -> Object { + call_module_fn(interp, module, "probe_slots", &[instance]) +} + +#[test] +fn stockcython_skipped_when_extension_missing() { + if extension_path().is_none() { + eprintln!("WEAVEPY_CAPI_STOCKCYTHON_EXTENSION not set — skipping Cython-surface proof"); + } +} + +#[test] +fn stockcython_module_loads_with_types() { + let Some((_interp, module)) = load() else { + return; + }; + for name in ["CyBase", "CySub", "CySub2"] { + match lookup(&module, name) { + Some(Object::Type(_)) => {} + other => panic!("type `{name}` missing or not a type: {other:?}"), + } + } + match lookup(&module, "ABI") { + Some(Object::Str(s)) => assert_eq!(&*s, "cp313"), + other => panic!("ABI wrong: {other:?}"), + } +} + +/// Baseline: the base type's own slots are directly readable and invoke +/// correctly (no inheritance involved — establishes the probe is sound). +#[test] +fn stockcython_base_slots_direct() { + let Some((mut interp, module)) = load() else { + return; + }; + let base_ty = lookup(&module, "CyBase").expect("CyBase"); + let b = construct(&mut interp, &base_ty, &[Object::Int(5)]); + let res = probe(&mut interp, &module, b); + + assert_eq!(get_i64(&res, "has_repr"), 1); + assert_eq!(get_i64(&res, "has_hash"), 1); + assert_eq!(get_i64(&res, "has_nb_add"), 1); + assert_eq!(get_i64(&res, "has_sq_len"), 1); + assert_eq!(get_i64(&res, "has_cmp"), 1); + assert_eq!(get_str(&res, "repr"), "CyBase(5)"); + assert_eq!(get_i64(&res, "hash"), 5); + assert_eq!(get_i64(&res, "len"), 5); + assert_eq!(get_i64(&res, "add"), 10); +} + +/// The headline proof: a **pure** subclass (`CySub`, which declares no +/// slots whatsoever) carries every one of the base's slots baked into +/// its own faithful struct, so a direct `Py_TYPE(sub)->tp_*` read — the +/// Cython idiom, no MRO walk — resolves to the inherited function. +#[test] +fn stockcython_pure_subclass_inherits_all_slots() { + let Some((mut interp, module)) = load() else { + return; + }; + let sub_ty = lookup(&module, "CySub").expect("CySub"); + let s = construct(&mut interp, &sub_ty, &[Object::Int(7)]); + let res = probe(&mut interp, &module, s); + + // Every slot is present on the *subclass* struct (NULL pre-wave-5). + assert_eq!(get_i64(&res, "has_repr"), 1, "tp_repr not inherited"); + assert_eq!(get_i64(&res, "has_hash"), 1, "tp_hash not inherited"); + assert_eq!( + get_i64(&res, "has_nb_add"), + 1, + "tp_as_number->nb_add not inherited" + ); + assert_eq!( + get_i64(&res, "has_sq_len"), + 1, + "tp_as_sequence->sq_length not inherited" + ); + assert_eq!(get_i64(&res, "has_cmp"), 1, "tp_richcompare not inherited"); + // It defined no number subtract, and CyBase has none either. + assert_eq!(get_i64(&res, "has_nb_sub"), 0); + + // The inherited slots, invoked directly, produce the base's results. + assert_eq!(get_str(&res, "repr"), "CyBase(7)", "inherited tp_repr"); + assert_eq!(get_i64(&res, "hash"), 7, "inherited tp_hash"); + assert_eq!(get_i64(&res, "len"), 7, "inherited sq_length"); + assert_eq!(get_i64(&res, "add"), 14, "inherited nb_add (7+7)"); +} + +/// The partial-override proof: `CySub2` defines its *own* `tp_repr` and a +/// number suite carrying only `nb_subtract`. `inherit_slots` must (a) +/// keep the subclass's own `tp_repr` / `nb_subtract`, and (b) fill +/// `nb_add` *into that same suite* from the base — the in-place +/// method-suite merge (CPython's per-slot `COPYSLOT`). +#[test] +fn stockcython_partial_subclass_merges_suite() { + let Some((mut interp, module)) = load() else { + return; + }; + let sub2_ty = lookup(&module, "CySub2").expect("CySub2"); + let s = construct(&mut interp, &sub2_ty, &[Object::Int(9)]); + let res = probe(&mut interp, &module, s); + + // Own slots kept. + assert_eq!(get_i64(&res, "has_repr"), 1); + assert_eq!(get_i64(&res, "has_nb_sub"), 1, "own nb_subtract lost"); + assert_eq!(get_str(&res, "repr"), "CySub2(9)", "own tp_repr overridden"); + assert_eq!(get_i64(&res, "sub"), 0, "own nb_subtract (9-9)"); + + // Inherited slots filled — including nb_add merged into the subclass's + // *existing* (own) number suite alongside its nb_subtract. + assert_eq!( + get_i64(&res, "has_nb_add"), + 1, + "nb_add not merged into own suite" + ); + assert_eq!(get_i64(&res, "has_hash"), 1, "tp_hash not inherited"); + assert_eq!(get_i64(&res, "has_sq_len"), 1, "sq_length not inherited"); + assert_eq!(get_i64(&res, "add"), 18, "inherited nb_add (9+9)"); + assert_eq!(get_i64(&res, "hash"), 9, "inherited tp_hash"); + assert_eq!(get_i64(&res, "len"), 9, "inherited sq_length"); +} + +/// `inherit_slots` must not disturb the Python-level MRO dispatch: the +/// inherited behaviour is *also* reachable through the bridged class's +/// synthesised dunders, exactly as CPython reaches it through the MRO. +#[test] +fn stockcython_python_level_dispatch_on_subclass() { + let Some((mut interp, module)) = load() else { + return; + }; + let sub_ty = lookup(&module, "CySub").expect("CySub"); + let s = construct(&mut interp, &sub_ty, &[Object::Int(4)]); + + // len(s) → inherited sq_length + assert!(matches!( + call_method(&mut interp, s.clone(), "__len__", &[]), + Ok(Object::Int(4)) + )); + // s + s → inherited nb_add + assert!(matches!( + call_method(&mut interp, s.clone(), "__add__", &[s.clone()]), + Ok(Object::Int(8)) + )); + // repr(s) → inherited tp_repr (base's format) + match call_method(&mut interp, s.clone(), "__repr__", &[]) { + Ok(Object::Str(r)) => assert_eq!(&*r, "CyBase(4)"), + other => panic!("unexpected repr: {other:?}"), + } + // s == s → inherited tp_richcompare + assert!(matches!( + call_method(&mut interp, s.clone(), "__eq__", &[s]), + Ok(Object::Bool(true)) + )); +} + +/// The Cython C-API runtime tail links and behaves: optional-attr/item +/// probes, the fast method path, presized dict, bounds-checked int, and +/// the NULL `_PyObject_GetDictPtr` that steers Cython to generic getattr. +#[test] +fn stockcython_runtime_surface() { + let Some((mut interp, module)) = load() else { + return; + }; + let sub_ty = lookup(&module, "CySub").expect("CySub"); + let s = construct(&mut interp, &sub_ty, &[Object::Int(1)]); + let res = call_module_fn(&mut interp, &module, "cython_runtime_surface", &[s]); + + // _PyObject_GetDictPtr → NULL (WeavePy has no in-body tp_dictoffset). + assert_eq!(get_i64(&res, "dictptr_null"), 1); + // PyObject_GetOptionalAttrString: present (1) vs. missing (0). + assert_eq!(get_i64(&res, "opt_present"), 1); + assert_eq!(get_i64(&res, "opt_absent"), 0); + // _PyObject_GetMethod: returns 0 (bound) with a non-NULL handle. + assert_eq!(get_i64(&res, "get_method_rc"), 0); + assert_eq!(get_i64(&res, "get_method_ok"), 1); + // PyObject_CallMethodOneArg: s.__eq__(s) is truthy. + assert_eq!(get_i64(&res, "call_eq_true"), 1); + // _PyDict_NewPresized + PyMapping_GetOptionalItemString. + assert_eq!(get_i64(&res, "map_present"), 1); + assert_eq!(get_i64(&res, "map_value"), 99); + assert_eq!(get_i64(&res, "map_absent"), 0); + // PyLong_AsInt. + assert_eq!(get_i64(&res, "as_int"), 4242); +} diff --git a/crates/weavepy-capi/tests/capi_stockdatetime.rs b/crates/weavepy-capi/tests/capi_stockdatetime.rs new file mode 100644 index 0000000..7ee0e5f --- /dev/null +++ b/crates/weavepy-capi/tests/capi_stockdatetime.rs @@ -0,0 +1,262 @@ +//! Integration test: the RFC 0029 (wave 5) faithful-datetime ABI proof. +//! +//! `crates/weavepy-capi/build.rs` compiles `tests/capi_ext/_stockdatetime.c` +//! against the host's **stock CPython 3.13 headers** (including the real +//! `datetime.h`, so the datetime accessor macros and `PyDateTime_IMPORT` +//! are inlined exactly as Cython emits them inside pandas' `tslibs`) and +//! exports `WEAVEPY_CAPI_STOCKDATETIME_EXTENSION`. +//! +//! Here we `dlopen` that `.so` into WeavePy and drive it, asserting that: +//! * `PyDateTime_IMPORT` resolves the capsule and its type slots report +//! CPython's `tp_basicsize` (date 32, datetime 48, time 40, delta 40); +//! * a WeavePy `datetime`/`date`/`time`/`timedelta` handed to C reads +//! back correctly through the inlined `PyDateTime_GET_*` macros (the +//! pandas read path lands on WeavePy's byte-faithful instance bodies); +//! * the capsule constructors (`PyDate_FromDate`, …) round-trip; and +//! * `PyDate_Check`/`PyDateTime_Check`/`PyDelta_Check` (via the capsule +//! type slots) classify correctly — including `PyDate_Check(datetime)` +//! being true because `datetime` subclasses `date`. +//! +//! Skipped (passes) when the env var is unset (no CPython 3.13 headers or +//! `cc` on the build host), so CI on a bare machine still passes. + +use std::path::PathBuf; + +use weavepy_capi::loader::load_extension_module; +use weavepy_vm::object::Object; +use weavepy_vm::Interpreter; + +fn extension_path() -> Option { + option_env!("WEAVEPY_CAPI_STOCKDATETIME_EXTENSION").map(PathBuf::from) +} + +fn lookup(module: &Object, key: &str) -> Option { + let Object::Module(m) = module else { + return None; + }; + let d = m.dict.borrow(); + for (k, v) in d.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +fn load() -> Option<(Interpreter, Object)> { + let path = extension_path()?; + if !path.is_file() { + eprintln!( + "WEAVEPY_CAPI_STOCKDATETIME_EXTENSION points at missing file: {} — skipping", + path.display() + ); + return None; + } + weavepy_capi::force_link(); + let mut interp = Interpreter::default(); + let interp_ptr: *mut Interpreter = &raw mut interp; + match load_extension_module(interp_ptr, &path, "_stockdatetime") { + Ok(m) => Some((interp, m)), + Err(err) => { + eprintln!("dlopen of stock-datetime extension failed (treating as skip): {err}"); + None + } + } +} + +fn call(interp: &mut Interpreter, module: &Object, name: &str, args: &[Object]) -> Object { + let f = lookup(module, name).unwrap_or_else(|| panic!("module missing `{name}`")); + interp + .call_object(f, args, &[]) + .unwrap_or_else(|e| panic!("calling `{name}` failed: {e:?}")) +} + +/// Construct a `datetime`-module instance (`date`, `datetime`, …) by +/// importing the module and calling the class with integer args. +fn make(interp: &mut Interpreter, class: &str, args: &[i64]) -> Object { + let module = interp + .import_path("datetime") + .unwrap_or_else(|e| panic!("import datetime failed: {e:?}")); + let cls = lookup(&module, class) + .unwrap_or_else(|| panic!("datetime module missing `{class}`")); + let argv: Vec = args.iter().copied().map(Object::Int).collect(); + interp + .call_object(cls, &argv, &[]) + .unwrap_or_else(|e| panic!("datetime.{class}{args:?} failed: {e:?}")) +} + +fn int_tuple(o: Object) -> Vec { + match o { + Object::Tuple(t) => t + .iter() + .map(|x| match x { + Object::Int(i) => *i, + Object::Bool(b) => i64::from(*b), + other => panic!("non-int in tuple: {other:?}"), + }) + .collect(), + other => panic!("expected tuple, got {other:?}"), + } +} + +#[test] +fn stockdatetime_skipped_when_extension_missing() { + if extension_path().is_none() { + eprintln!("WEAVEPY_CAPI_STOCKDATETIME_EXTENSION not set — skipping datetime ABI proof"); + } +} + +/// `PyDateTime_IMPORT` resolves the capsule and its type slots carry the +/// faithful CPython 3.13 `tp_basicsize` values. +#[test] +fn stockdatetime_capsule_imports_with_faithful_sizes() { + let Some((_interp, module)) = load() else { + return; + }; + match lookup(&module, "imported") { + Some(Object::Int(n)) => assert_eq!(n, 1, "PyDateTime_IMPORT failed (capsule NULL)"), + other => panic!("imported flag wrong: {other:?}"), + } + let want = [ + ("cap_date_basicsize", 32), + ("cap_datetime_basicsize", 48), + ("cap_time_basicsize", 40), + ("cap_delta_basicsize", 40), + ]; + for (k, v) in want { + match lookup(&module, k) { + Some(Object::Int(n)) => assert_eq!(n, v, "{k} should be {v}"), + other => panic!("{k} wrong: {other:?}"), + } + } +} + +/// The `__Pyx_ImportType` size-check path: the `datetime` module's class +/// objects report CPython's `tp_basicsize` when read as `PyTypeObject*`. +#[test] +fn stockdatetime_module_size_check() { + let Some((mut interp, module)) = load() else { + return; + }; + let sizes = int_tuple(call(&mut interp, &module, "module_basicsizes", &[])); + assert_eq!(sizes, vec![32, 48, 40, 40], "datetime.* tp_basicsize"); +} + +/// A WeavePy `date` handed to C reads back through `PyDateTime_GET_*`. +#[test] +fn stockdatetime_read_date() { + let Some((mut interp, module)) = load() else { + return; + }; + let d = make(&mut interp, "date", &[2021, 3, 14]); + let got = int_tuple(call(&mut interp, &module, "read_date", &[d])); + assert_eq!(got, vec![2021, 3, 14]); +} + +/// A WeavePy `datetime` reads back through the inlined macros, including +/// the big-endian year and 3-byte microsecond fields. +#[test] +fn stockdatetime_read_datetime() { + let Some((mut interp, module)) = load() else { + return; + }; + let dt = make(&mut interp, "datetime", &[2021, 3, 14, 9, 30, 15, 123456]); + let got = int_tuple(call(&mut interp, &module, "read_datetime", &[dt.clone()])); + assert_eq!(got, vec![2021, 3, 14, 9, 30, 15, 123456, 0]); + // Naive datetime: PyDateTime_DATE_GET_TZINFO short-circuits to Py_None. + assert!(matches!( + call(&mut interp, &module, "datetime_tz_is_none", &[dt]), + Object::Bool(true) + )); +} + +/// A WeavePy `time` reads back through `PyDateTime_TIME_GET_*`. +#[test] +fn stockdatetime_read_time() { + let Some((mut interp, module)) = load() else { + return; + }; + let t = make(&mut interp, "time", &[9, 30, 15, 123456]); + let got = int_tuple(call(&mut interp, &module, "read_time", &[t])); + assert_eq!(got, vec![9, 30, 15, 123456, 0]); +} + +/// A WeavePy `timedelta` reads back through `PyDateTime_DELTA_GET_*`. +#[test] +fn stockdatetime_read_delta() { + let Some((mut interp, module)) = load() else { + return; + }; + let td = make(&mut interp, "timedelta", &[5, 3600, 250000]); + let got = int_tuple(call(&mut interp, &module, "read_delta", &[td])); + assert_eq!(got, vec![5, 3600, 250000]); +} + +/// The capsule constructors build faithful objects readable via macros. +#[test] +fn stockdatetime_capsule_constructors() { + let Some((mut interp, module)) = load() else { + return; + }; + let d = int_tuple(call( + &mut interp, + &module, + "construct_date", + &[Object::Int(1999), Object::Int(12), Object::Int(31)], + )); + assert_eq!(d, vec![1999, 12, 31]); + + let dt = int_tuple(call( + &mut interp, + &module, + "construct_datetime", + &[ + Object::Int(2000), + Object::Int(1), + Object::Int(2), + Object::Int(3), + Object::Int(4), + Object::Int(5), + Object::Int(6), + ], + )); + assert_eq!(dt, vec![2000, 1, 2, 3, 4, 5, 6]); + + let td = int_tuple(call( + &mut interp, + &module, + "construct_delta", + &[Object::Int(7), Object::Int(8), Object::Int(9)], + )); + assert_eq!(td, vec![7, 8, 9]); +} + +/// `PyDate_Check`/`PyDateTime_Check`/`PyDelta_Check` via the capsule type +/// slots, including `datetime` IS-A `date`. +#[test] +fn stockdatetime_type_checks() { + let Some((mut interp, module)) = load() else { + return; + }; + // (PyDate_Check, PyDate_CheckExact, PyDateTime_Check, PyDateTime_CheckExact, PyDelta_Check) + let date = make(&mut interp, "date", &[2021, 1, 1]); + assert_eq!( + int_tuple(call(&mut interp, &module, "checks", &[date])), + vec![1, 1, 0, 0, 0] + ); + + let dt = make(&mut interp, "datetime", &[2021, 1, 1, 0, 0, 0]); + // datetime subclasses date: PyDate_Check true, PyDate_CheckExact false. + assert_eq!( + int_tuple(call(&mut interp, &module, "checks", &[dt])), + vec![1, 0, 1, 1, 0] + ); + + let td = make(&mut interp, "timedelta", &[1, 2, 3]); + assert_eq!( + int_tuple(call(&mut interp, &module, "checks", &[td])), + vec![0, 0, 0, 0, 1] + ); +} diff --git a/crates/weavepy-capi/tests/capi_stocktype.rs b/crates/weavepy-capi/tests/capi_stocktype.rs new file mode 100644 index 0000000..2d341d4 --- /dev/null +++ b/crates/weavepy-capi/tests/capi_stocktype.rs @@ -0,0 +1,488 @@ +//! Integration test: the RFC 0044 wave-2 hermetic proof. +//! +//! `crates/weavepy-capi/build.rs` compiles `tests/capi_ext/_stocktype.c` +//! against the host's **stock CPython 3.13 headers** (full, non-limited +//! API → the genuine 416-byte `PyTypeObject` and real method-suite +//! structs) and exports `WEAVEPY_CAPI_STOCKTYPE_EXTENSION`. Here we +//! `dlopen` that `.so` into WeavePy and drive it, asserting that types +//! defined the **classic static `PyTypeObject` + `PyType_Ready`** way — +//! NOT `PyType_FromSpec` — dispatch correctly through WeavePy's VM: +//! +//! * number (`nb_add`/`nb_subtract`) + rich comparison; +//! * sequence (`sq_length`/`sq_item`) + mapping (`mp_subscript`) + +//! iteration (`tp_iter`/`tp_iternext`); +//! * calling (`tp_call`); +//! * the descriptor protocol (`tp_descr_get`/`tp_descr_set`); +//! * a `Py_TPFLAGS_HAVE_GC` type whose C-held child is collected +//! through the `tp_traverse`/`tp_clear` cycle-collector bridge. +//! +//! Skipped (passes) when the env var is unset — that happens when +//! CPython 3.13 dev headers (or `cc`) aren't available on the build +//! host, so CI on a bare machine still passes. + +use std::path::PathBuf; + +use weavepy_capi::loader::load_extension_module; +use weavepy_vm::error::RuntimeError; +use weavepy_vm::object::Object; +use weavepy_vm::Interpreter; + +fn extension_path() -> Option { + option_env!("WEAVEPY_CAPI_STOCKTYPE_EXTENSION").map(PathBuf::from) +} + +fn lookup(module: &Object, key: &str) -> Option { + let Object::Module(m) = module else { + return None; + }; + let d = m.dict.borrow(); + for (k, v) in d.iter() { + if let Object::Str(s) = &k.0 { + if &**s == key { + return Some(v.clone()); + } + } + } + None +} + +fn load() -> Option<(Interpreter, Object)> { + let path = extension_path()?; + if !path.is_file() { + eprintln!( + "WEAVEPY_CAPI_STOCKTYPE_EXTENSION points at missing file: {} — skipping", + path.display() + ); + return None; + } + weavepy_capi::force_link(); + let mut interp = Interpreter::default(); + let interp_ptr: *mut Interpreter = &raw mut interp; + match load_extension_module(interp_ptr, &path, "_stocktype") { + Ok(m) => Some((interp, m)), + Err(err) => { + eprintln!("dlopen of stock-type extension failed (treating as skip): {err}"); + None + } + } +} + +/// Construct an instance by calling the (readied) type object, exactly +/// as `T(...)` would from Python: drives `tp_new` + `tp_init`. +fn construct(interp: &mut Interpreter, ty: &Object, args: &[Object]) -> Object { + interp + .call_object(ty.clone(), args, &[]) + .unwrap_or_else(|e| panic!("constructing instance failed: {e:?}")) +} + +/// Look up a dunder/method on an instance's class MRO and call it with +/// `self` prepended. +fn call_method( + interp: &mut Interpreter, + instance: Object, + name: &str, + args: &[Object], +) -> Result { + let class = match &instance { + Object::Instance(inst) => inst.cls(), + other => panic!("expected instance, got {other:?}"), + }; + let method = class + .lookup(name) + .unwrap_or_else(|| panic!("method '{name}' not in MRO of {}", class.name)); + let mut full = Vec::with_capacity(args.len() + 1); + full.push(instance); + full.extend_from_slice(args); + interp.call_object(method, &full, &[]) +} + +/// Call a module-level function (a `METH_*` C function) by name. +fn call_module_fn( + interp: &mut Interpreter, + module: &Object, + name: &str, + args: &[Object], +) -> Object { + let f = lookup(module, name).unwrap_or_else(|| panic!("module fn `{name}` missing")); + interp + .call_object(f, args, &[]) + .unwrap_or_else(|e| panic!("calling `{name}` failed: {e:?}")) +} + +fn gc_counters(interp: &mut Interpreter, module: &Object) -> (i64, i64, i64) { + let f = lookup(module, "gc_counters").expect("gc_counters missing"); + match interp.call_object(f, &[], &[]).expect("gc_counters call") { + Object::Tuple(t) => { + assert_eq!(t.len(), 3, "gc_counters arity"); + let get = |o: &Object| match o { + Object::Int(n) => *n, + other => panic!("gc_counters element not int: {other:?}"), + }; + (get(&t[0]), get(&t[1]), get(&t[2])) + } + other => panic!("gc_counters returned non-tuple: {other:?}"), + } +} + +#[test] +fn stocktype_skipped_when_extension_missing() { + if extension_path().is_none() { + eprintln!("WEAVEPY_CAPI_STOCKTYPE_EXTENSION not set — skipping stock-type proof"); + } +} + +#[test] +fn stocktype_module_loads_with_types() { + let Some((_interp, module)) = load() else { + return; + }; + for name in ["Vec2", "Seq", "Adder", "Const", "Aw", "Proxy", "Node"] { + match lookup(&module, name) { + Some(Object::Type(_)) => {} + other => panic!("type `{name}` missing or not a type: {other:?}"), + } + } + match lookup(&module, "ABI") { + Some(Object::Str(s)) => assert_eq!(&*s, "cp313"), + other => panic!("ABI wrong: {other:?}"), + } +} + +/// A readied static type carries its `tp_methods` / dunders in the +/// bridged class dict (proves `PyType_Ready` harvested them). +#[test] +fn stocktype_ready_populates_class_dict() { + let Some((_interp, module)) = load() else { + return; + }; + let cls = lookup(&module, "Vec2").expect("Vec2"); + let dict = match &cls { + Object::Type(t) => t.dict.clone(), + _ => panic!("expected type"), + }; + let names: Vec = dict + .borrow() + .iter() + .filter_map(|(k, _)| match &k.0 { + Object::Str(s) => Some(s.to_string()), + _ => None, + }) + .collect(); + assert!( + names.iter().any(|s| s == "__add__"), + "missing __add__: {names:?}" + ); + assert!( + names.iter().any(|s| s == "__eq__"), + "missing __eq__: {names:?}" + ); +} + +/// `Vec2` number protocol (`nb_add`/`nb_subtract`) + `tp_richcompare`, +/// all dispatched through the VM's synthesised dunders. +#[test] +fn stocktype_number_and_richcompare() { + let Some((mut interp, module)) = load() else { + return; + }; + let vec2 = lookup(&module, "Vec2").expect("Vec2"); + let a = construct(&mut interp, &vec2, &[Object::Int(1), Object::Int(2)]); + let b = construct(&mut interp, &vec2, &[Object::Int(3), Object::Int(4)]); + + // a + b == Vec2(4, 6) + let sum = call_method(&mut interp, a.clone(), "__add__", &[b.clone()]).expect("__add__"); + let expect_sum = construct(&mut interp, &vec2, &[Object::Int(4), Object::Int(6)]); + assert!( + matches!( + call_method(&mut interp, sum, "__eq__", &[expect_sum]), + Ok(Object::Bool(true)) + ), + "a + b should equal Vec2(4, 6)" + ); + + // a - b == Vec2(-2, -2) + let diff = call_method(&mut interp, a.clone(), "__sub__", &[b.clone()]).expect("__sub__"); + let expect_diff = construct(&mut interp, &vec2, &[Object::Int(-2), Object::Int(-2)]); + assert!(matches!( + call_method(&mut interp, diff, "__eq__", &[expect_diff]), + Ok(Object::Bool(true)) + )); + + // Reflexive equality is true; a != b. + assert!(matches!( + call_method(&mut interp, a.clone(), "__eq__", &[a.clone()]), + Ok(Object::Bool(true)) + )); + assert!(matches!( + call_method(&mut interp, a, "__eq__", &[b]), + Ok(Object::Bool(false)) + )); +} + +/// `Seq` sequence + mapping + iteration slots. +#[test] +fn stocktype_sequence_mapping_iter() { + let Some((mut interp, module)) = load() else { + return; + }; + let seq_ty = lookup(&module, "Seq").expect("Seq"); + let s = construct(&mut interp, &seq_ty, &[Object::Int(5)]); + + // len(s) == 5 (sq_length / mp_length) + assert!(matches!( + call_method(&mut interp, s.clone(), "__len__", &[]), + Ok(Object::Int(5)) + )); + + // s[2] == 2 (mp_subscript → sq_item) + assert!(matches!( + call_method(&mut interp, s.clone(), "__getitem__", &[Object::Int(2)]), + Ok(Object::Int(2)) + )); + + // iter(s) walks 0..5 (tp_iter / tp_iternext) + let it = call_method(&mut interp, s, "__iter__", &[]).expect("__iter__"); + let mut seen = Vec::new(); + loop { + match call_method(&mut interp, it.clone(), "__next__", &[]) { + Ok(Object::Int(n)) => seen.push(n), + Ok(other) => panic!("__next__ yielded non-int: {other:?}"), + Err(_) => break, // StopIteration + } + assert!(seen.len() <= 5, "iterator failed to stop"); + } + assert_eq!(seen, vec![0, 1, 2, 3, 4]); +} + +/// `Adder` `tp_call`: calling the instance dispatches to the C slot. +#[test] +fn stocktype_call_protocol() { + let Some((mut interp, module)) = load() else { + return; + }; + let adder_ty = lookup(&module, "Adder").expect("Adder"); + let ad = construct(&mut interp, &adder_ty, &[Object::Int(10)]); + // ad(5) == 15 + let res = interp + .call_object(ad, &[Object::Int(5)], &[]) + .expect("calling Adder instance"); + assert!(matches!(res, Object::Int(15)), "got {res:?}"); +} + +/// `Const` descriptor protocol: `__get__` returns the stored constant +/// and `__set__` is observed through a module-global side effect. +#[test] +fn stocktype_descriptor_protocol() { + let Some((mut interp, module)) = load() else { + return; + }; + let const_ty = lookup(&module, "Const").expect("Const"); + let c = construct(&mut interp, &const_ty, &[Object::Int(99)]); + + // c.__get__(None, Const) == 99 (tp_descr_get) + let got = call_method( + &mut interp, + c.clone(), + "__get__", + &[Object::None, const_ty.clone()], + ) + .expect("__get__"); + assert!(matches!(got, Object::Int(99)), "got {got:?}"); + + // c.__set__(obj, 7) records 7 (tp_descr_set) + call_method(&mut interp, c, "__set__", &[Object::None, Object::Int(7)]).expect("__set__"); + let last = lookup(&module, "last_descr_set").expect("last_descr_set"); + match interp + .call_object(last, &[], &[]) + .expect("last_descr_set call") + { + Object::Int(7) => {} + other => panic!("Const.__set__ not observed: {other:?}"), + } +} + +/// Constructing a readied static type by *calling the type object from +/// C* through the call protocol (`PyObject_CallFunction`). `make_vec2` +/// does this at the top level of a C entry point; `Vec2.__add__` +/// (`vec2_build`) does the same thing **re-entrantly from inside a +/// slot**, so a green `stocktype_number_and_richcompare` additionally +/// covers the nested case. (RFC 0044 hardening.) +#[test] +fn stocktype_call_type_object_from_c() { + let Some((mut interp, module)) = load() else { + return; + }; + let vec2 = lookup(&module, "Vec2").expect("Vec2"); + + // make_vec2(3, 4) builds a Vec2 by calling the type object from C. + let made = call_module_fn( + &mut interp, + &module, + "make_vec2", + &[Object::Int(3), Object::Int(4)], + ); + match &made { + Object::Instance(i) => assert_eq!(i.cls().name, "Vec2", "make_vec2 wrong type"), + other => panic!("make_vec2 returned non-Vec2: {other:?}"), + } + + // It equals a Vec2 built the normal way (proves tp_new + tp_init ran). + let expect = construct(&mut interp, &vec2, &[Object::Int(3), Object::Int(4)]); + assert!( + matches!( + call_method(&mut interp, made.clone(), "__eq__", &[expect]), + Ok(Object::Bool(true)) + ), + "make_vec2(3, 4) should equal Vec2(3, 4)" + ); + + // And reprs faithfully (tp_repr reads the side core that tp_init set). + match call_method(&mut interp, made, "__repr__", &[]) { + Ok(Object::Str(s)) => assert_eq!(&*s, "Vec2(3, 4)"), + other => panic!("unexpected Vec2 repr: {other:?}"), + } +} + +/// `Aw` async protocol: the synthesised `__await__`/`__aiter__`/ +/// `__anext__` dunders reach the C `PyAsyncMethods` slots. A hermetic +/// *dispatch* proof (no event loop) — the awaitables are integer +/// sentinels. (RFC 0044 hardening, WS3 coverage.) +#[test] +fn stocktype_async_protocol() { + let Some((mut interp, module)) = load() else { + return; + }; + let aw_ty = lookup(&module, "Aw").expect("Aw"); + let aw = construct(&mut interp, &aw_ty, &[]); + + // __await__ → am_await (sentinel 11). + let awaited = call_method(&mut interp, aw.clone(), "__await__", &[]).expect("__await__"); + assert!( + matches!(awaited, Object::Int(11)), + "am_await not dispatched: {awaited:?}" + ); + + // __aiter__ → am_aiter returns the async-iterator (itself, an Aw). + let aiter = call_method(&mut interp, aw.clone(), "__aiter__", &[]).expect("__aiter__"); + match &aiter { + Object::Instance(i) => assert_eq!(i.cls().name, "Aw", "am_aiter wrong type"), + other => panic!("am_aiter returned non-Aw: {other:?}"), + } + + // __anext__ → am_anext (sentinel 7) and the C-side counter advances. + let before = call_module_fn(&mut interp, &module, "aw_anext_calls", &[]); + let nxt = call_method(&mut interp, aw, "__anext__", &[]).expect("__anext__"); + assert!( + matches!(nxt, Object::Int(7)), + "am_anext not dispatched: {nxt:?}" + ); + let after = call_module_fn(&mut interp, &module, "aw_anext_calls", &[]); + match (before, after) { + (Object::Int(b), Object::Int(a)) => { + assert_eq!(a, b + 1, "am_anext C slot did not run") + } + other => panic!("aw_anext_calls returned non-ints: {other:?}"), + } +} + +/// `Proxy` custom attribute access: `tp_getattro` synthesises a value +/// for the `magic` name and falls back to the generic instance-dict +/// lookup otherwise; `tp_setattro` records the write in a module global +/// and stores it so it round-trips back out. (RFC 0044 hardening.) +#[test] +fn stocktype_getattro_setattro() { + let Some((mut interp, module)) = load() else { + return; + }; + let proxy_ty = lookup(&module, "Proxy").expect("Proxy"); + let p = construct(&mut interp, &proxy_ty, &[]); + + // getattr(p, "magic") is synthesised in C → 4242 (tp_getattro). + let magic = call_method( + &mut interp, + p.clone(), + "__getattribute__", + &[Object::from_str("magic")], + ) + .expect("__getattribute__('magic')"); + assert!(matches!(magic, Object::Int(4242)), "got {magic:?}"); + + // setattr(p, "weight", 17) records (name, value) and stores normally. + call_method( + &mut interp, + p.clone(), + "__setattr__", + &[Object::from_str("weight"), Object::Int(17)], + ) + .expect("__setattr__('weight', 17)"); + match call_module_fn(&mut interp, &module, "last_setattr", &[]) { + Object::Tuple(t) => { + assert_eq!(t.len(), 2); + assert!( + matches!(&t[0], Object::Str(s) if &**s == "weight"), + "name: {:?}", + t[0] + ); + assert!(matches!(t[1], Object::Int(17)), "value: {:?}", t[1]); + } + other => panic!("last_setattr returned non-tuple: {other:?}"), + } + + // The stored value round-trips back out through the fallback path of + // tp_getattro (a non-"magic" name → PyObject_GenericGetAttr). + let weight = call_method( + &mut interp, + p, + "__getattribute__", + &[Object::from_str("weight")], + ) + .expect("__getattribute__('weight')"); + assert!( + matches!(weight, Object::Int(17)), + "round-trip got {weight:?}" + ); +} + +/// The headline GC proof: a two-node cycle whose edges live *only* in +/// C-managed memory is reclaimed by WeavePy's collector through the +/// `tp_traverse` / `tp_clear` bridge (RFC 0044, WS4). +#[test] +fn stocktype_gc_cycle_through_c_memory() { + let Some((mut interp, module)) = load() else { + return; + }; + let node_ty = lookup(&module, "Node").expect("Node"); + + let a = construct(&mut interp, &node_ty, &[]); + let b = construct(&mut interp, &node_ty, &[]); + + // Link a <-> b through the C side cores (invisible to the VM dict + // walker), forming a cycle reachable only via tp_traverse. + call_method(&mut interp, a.clone(), "set_child", &[b.clone()]).expect("a.set_child(b)"); + call_method(&mut interp, b.clone(), "set_child", &[a.clone()]).expect("b.set_child(a)"); + + // Both nodes are live and tracked. + let (_, _, live_before) = gc_counters(&mut interp, &module); + assert_eq!(live_before, 2, "expected 2 live nodes before collection"); + + // Drop every VM-visible reference. The cycle is now held alive only + // by the C-managed child pointers — unreachable to a dict-only walk. + drop(a); + drop(b); + + let collected = weavepy_vm::gc_trace::collect_all(); + + let (traverses, clears, live_after) = gc_counters(&mut interp, &module); + assert!( + traverses > 0, + "collector never invoked C tp_traverse (traverses={traverses})" + ); + assert!( + clears > 0, + "collector never invoked C tp_clear (clears={clears})" + ); + assert_eq!( + live_after, 0, + "nodes not reclaimed (live={live_after}, collected={collected})" + ); +} diff --git a/crates/weavepy-capi/tests/force_link_completeness.rs b/crates/weavepy-capi/tests/force_link_completeness.rs new file mode 100644 index 0000000..c8d0d89 --- /dev/null +++ b/crates/weavepy-capi/tests/force_link_completeness.rs @@ -0,0 +1,187 @@ +//! Force-link completeness guard (RFC 0047, wave 5). +//! +//! Every `#[no_mangle] extern "C" fn` this crate defines is part of the +//! CPython ABI surface a dlopen'd extension may bind against. On macOS +//! the linker dead-strips any such function that isn't rooted in +//! [`weavepy_capi::force_link_table`]; afterwards an extension that +//! calls the missing entry point jumps through an unbound PLT stub into +//! a NULL address and segfaults with no Rust frame on the stack. +//! +//! That is exactly how real numpy crashed: `numpy.random.SeedSequence` +//! runs `n //= 2**32`, Cython lowers it to `PyNumber_InPlaceFloorDivide`, +//! and that function — though defined here — had never been added to the +//! table, so it was stripped and the call faulted. +//! +//! Rather than trust a hand-maintained list, this test re-derives the +//! full set of defined entry points straight from the source tree and +//! asserts every one survives into the host binary's dynamic symbol +//! table. If anyone adds a `#[no_mangle]` C-API function without rooting +//! it, this fails the build with the precise list to fix. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Reference the force-link table so this very test binary links the +/// `#[used]` root array (and therefore every symbol it pins). Without +/// this, an integration test that touched no other crate symbol could +/// leave the table's object file out of the link entirely. +fn ensure_table_linked() -> usize { + weavepy_capi::force_link_table::touch() +} + +fn src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn collect_rs(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read_dir src") { + let path = entry.expect("dirent").path(); + if path.is_dir() { + collect_rs(&path, files); + } else if path.extension().and_then(|s| s.to_str()) == Some("rs") { + files.push(path); + } + } +} + +/// Extract `NAME` from a line containing `extern "C" fn NAME`. Returns +/// `None` for function-pointer *types* (`extern "C" fn(...)`, no space +/// before the paren) and for lines without the marker. +fn extern_c_fn_name(line: &str) -> Option { + let marker = "extern \"C\" fn "; + let idx = line.find(marker)?; + let rest = &line[idx + marker.len()..]; + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + (!name.is_empty()).then_some(name) +} + +/// Names of all `#[no_mangle] extern "C" fn` defined under `src/`, +/// skipping `#[cfg(...)]`-gated definitions (which may legitimately be +/// absent on this target). +fn defined_symbols() -> BTreeSet { + let mut files = Vec::new(); + collect_rs(&src_dir(), &mut files); + files.sort(); + + let mut out = BTreeSet::new(); + for file in files { + let text = std::fs::read_to_string(&file).expect("read src file"); + let mut no_mangle_at: Option = None; + let mut cfg_gated = false; + for (i, raw) in text.lines().enumerate() { + let line = raw.trim_start(); + // A blank line ends an attribute run: `#[no_mangle]` and the + // signature it decorates are always contiguous. + if line.is_empty() { + no_mangle_at = None; + cfg_gated = false; + continue; + } + if line.starts_with("//") { + continue; + } + if line.contains("no_mangle") { + no_mangle_at = Some(i); + cfg_gated = false; + } + if no_mangle_at.is_some() && line.contains("#[cfg(") { + cfg_gated = true; + } + if let Some(name) = extern_c_fn_name(raw) { + if let Some(start) = no_mangle_at { + if i - start <= 8 && !cfg_gated { + out.insert(name); + } + no_mangle_at = None; + cfg_gated = false; + } + } + } + } + out +} + +/// Defined, external symbols exported by the running test executable — +/// i.e. the set a dlopen'd extension could actually resolve against it. +fn exported_symbols() -> Option> { + let exe = std::env::current_exe().ok()?; + let macos = cfg!(target_os = "macos"); + + let mut cmd = Command::new("nm"); + if macos { + // External (`-g`), defined (`-U` = suppress undefined) symbols; + // macOS executables expose these to dlopen by default. + cmd.arg("-gU"); + } else { + // The ELF dynamic symbol table is the dlopen-visible export set + // (`crates/weavepy-capi/build.rs` adds `--export-dynamic`). + cmd.arg("-D").arg("--defined-only"); + } + let output = cmd.arg(&exe).output().ok()?; + if !output.status.success() { + return None; + } + + let mut set = BTreeSet::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + let (ty, name) = match cols.as_slice() { + [ty, name] => (*ty, *name), // undefined (no address) + [_addr, ty, name] => (*ty, *name), // defined + _ => continue, + }; + if ty.eq_ignore_ascii_case("u") { + continue; + } + // Mach-O prepends a single underscore to C symbol names. + let name = if macos { + name.strip_prefix('_').unwrap_or(name) + } else { + name + }; + set.insert(name.to_string()); + } + Some(set) +} + +#[test] +fn every_no_mangle_export_survives_dead_strip() { + let table_len = ensure_table_linked(); + assert!(table_len > 0, "force-link table is empty"); + + let defined = defined_symbols(); + assert!( + defined.len() > 400, + "source scan found only {} #[no_mangle] fns — the scanner is \ + probably broken, not the table", + defined.len() + ); + + let Some(exported) = exported_symbols() else { + eprintln!( + "warning: `nm` unavailable or failed; skipping force-link \ + completeness check" + ); + return; + }; + + let missing: Vec<&str> = defined + .iter() + .filter(|name| !exported.contains(*name)) + .map(String::as_str) + .collect(); + + assert!( + missing.is_empty(), + "\n{} C-API function(s) are defined `#[no_mangle]` but were \ + dead-stripped (absent from this binary's exports). Every one \ + will segfault a dlopen'd extension that calls it. Root each in \ + `src/force_link_table.rs`:\n {}\n", + missing.len(), + missing.join("\n ") + ); +} diff --git a/crates/weavepy-cli/src/main.rs b/crates/weavepy-cli/src/main.rs index 8cde0b2..e87b36d 100644 --- a/crates/weavepy-cli/src/main.rs +++ b/crates/weavepy-cli/src/main.rs @@ -305,7 +305,124 @@ The following implementation-specific options are available: -X int_max_str_digits : set sys.int_info.str_digits_check_threshold. "; +extern "C" { + fn signal(signum: i32, handler: usize) -> usize; + fn sigaction(signum: i32, act: *const SigActionC, old: *mut SigActionC) -> i32; + fn backtrace(array: *mut *mut std::ffi::c_void, size: i32) -> i32; + fn backtrace_symbols_fd(array: *const *mut std::ffi::c_void, size: i32, fd: i32); +} + +/// `struct sigaction` (macOS/BSD layout): an 8-byte handler pointer union, +/// a 4-byte `sigset_t` mask, and a 4-byte flags word. +#[repr(C)] +struct SigActionC { + sa_sigaction: usize, + sa_mask: u32, + sa_flags: i32, +} + +/// `SA_SIGINFO` — deliver the 3-argument handler so we can read `si_addr`. +const SA_SIGINFO: i32 = 0x0040; +/// Byte offset of `si_addr` within macOS `siginfo_t` +/// (`si_signo,si_errno,si_code,si_pid,si_uid,si_status` = 24 bytes precede it). +const SIGINFO_SI_ADDR_OFFSET: usize = 24; + +/// Byte offset of the `mcontext_t` pointer within macOS `ucontext_t` +/// (`uc_onstack,uc_sigmask,uc_stack,uc_link,uc_mcsize` precede it). +const UCONTEXT_MCONTEXT_OFFSET: usize = 48; +/// Byte offset of `__ss` (the ARM thread state) within macOS `mcontext64` +/// — it follows the 16-byte `__es` (ARM exception state). +const MCONTEXT_SS_OFFSET: usize = 16; +/// Byte offset of `tp_name` (a `const char *`) within `PyTypeObject`. +const PYTYPEOBJECT_TP_NAME_OFFSET: usize = 0x18; + +/// Read the C string at `p` (best-effort, capped) for signal-handler +/// diagnostics. Returns a lossy `String`; bails on an obviously-bad pointer +/// so we don't double-fault while already handling a crash. +unsafe fn read_c_str_lossy(p: *const u8, cap: usize) -> String { + if (p as usize) < 0x1000 { + return String::from(""); + } + let mut bytes = Vec::new(); + for i in 0..cap { + let b = unsafe { p.add(i).read() }; + if b == 0 { + break; + } + bytes.push(b); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +extern "C" fn weavepy_segv_backtrace(sig: i32, info: *const u8, ctx: *mut std::ffi::c_void) { + // The faulting memory address (`si_addr`) is the single most useful clue + // for a native crash in a dlopen'd extension: a small value (`0x0`, `0x8`, + // …) is a NULL-based field deref, a huge value a wild pointer. Printing it + // turns an opaque `PyArray_*` frame into an actionable diagnosis. + if !info.is_null() { + let si_addr = unsafe { info.add(SIGINFO_SI_ADDR_OFFSET).cast::().read() }; + eprintln!("\n=== WEAVEPY signal {sig} faulting address = 0x{si_addr:x} ==="); + } + // Faulting register file (arm64): `pc` pinpoints the exact instruction and + // `x0` is usually the receiver of a `Py_TYPE(x)->tp_field` chain. When the + // crash is a NULL `tp_mro`/`tp_dict`/… deref, `x0` is still the live type + // pointer, so decoding `x0->tp_name` names the offending type directly. + #[cfg(target_arch = "aarch64")] + if !ctx.is_null() { + unsafe { + let mctx = ctx.cast::().add(UCONTEXT_MCONTEXT_OFFSET).cast::<*const u8>().read(); + if !mctx.is_null() { + let ss = mctx.add(MCONTEXT_SS_OFFSET); + let x = |n: usize| ss.add(n * 8).cast::().read(); + let pc = ss.add(256).cast::().read(); + eprintln!( + "=== registers: pc=0x{pc:x} x0=0x{:x} x1=0x{:x} x8=0x{:x} x19=0x{:x} x20=0x{:x} ===", + x(0), x(1), x(8), x(19), x(20) + ); + // Heuristic: for a `tp_*` NULL-field crash the type pointer is + // in x0 (and often mirrored in x19/x20). Decode each as a + // candidate `PyTypeObject*` and print its `tp_name`. + for (reg, val) in [("x0", x(0)), ("x19", x(19)), ("x20", x(20))] { + let name_pp = (val as usize + PYTYPEOBJECT_TP_NAME_OFFSET) as *const *const u8; + if (val as usize) > 0x1000 { + let name = read_c_str_lossy(name_pp.read(), 64); + eprintln!("=== {reg} as PyTypeObject* -> tp_name = {name:?} ==="); + } + } + } + } + } + // Native (dladdr-based) backtrace first: it resolves frames inside a + // dlopen'd `.so` (e.g. a Cython extension's static helpers) to their + // real `module + symbol + offset`, which Rust's `std::backtrace` + // mis-attributes to the nearest exported libsystem symbol. + let mut frames: [*mut std::ffi::c_void; 96] = [std::ptr::null_mut(); 96]; + let n = unsafe { backtrace(frames.as_mut_ptr(), 96) }; + eprintln!("=== WEAVEPY signal {sig} native backtrace ==="); + unsafe { backtrace_symbols_fd(frames.as_ptr(), n, 2) }; + eprintln!("=== end native backtrace ==="); + let bt = std::backtrace::Backtrace::force_capture(); + eprintln!("=== WEAVEPY signal {sig} rust backtrace ===\n{bt}\n=== end backtrace ==="); + unsafe { + signal(sig, 0); + } + std::process::abort(); +} + fn main() -> ExitCode { + if std::env::var_os("WEAVEPY_SEGV_BT").is_some() { + // `SA_SIGINFO` so the handler receives `siginfo_t` and can report the + // faulting address; `signal()` alone would only pass the signal number. + let act = SigActionC { + sa_sigaction: weavepy_segv_backtrace as *const () as usize, + sa_mask: 0, + sa_flags: SA_SIGINFO, + }; + unsafe { + sigaction(11, &act, std::ptr::null_mut()); // SIGSEGV + sigaction(10, &act, std::ptr::null_mut()); // SIGBUS + } + } // Undo Rust's pre-`main` `sanitize_standard_fds` (which re-opens any closed // std fd onto `/dev/null`) so an inherited-closed stdin/stdout/stderr stays // closed, matching CPython (`test_posix.test_close_file`). Must run before @@ -340,6 +457,14 @@ fn run_on_large_stack(entry: fn() -> ExitCode) -> ExitCode { weavepy::vm::stdlib::signal_mod::block_async_signals_current_thread(); let vm_entry = move || -> ExitCode { + // TEMP: register the native crash handler + per-thread sigaltstack on + // the VM thread itself so a stack-overflow SIGSEGV can be caught here. + if std::env::var_os("WEAVEPY_CRASH_BT").is_some() { + extern "C" { + fn weavepy_install_crash_handler(); + } + unsafe { weavepy_install_crash_handler() }; + } weavepy::vm::stdlib::signal_mod::unblock_async_signals_current_thread(); // Arm SIGINT -> KeyboardInterrupt at startup (CPython does this during // interpreter init), so even scripts that never `import signal` raise diff --git a/crates/weavepy-parser/src/parser.rs b/crates/weavepy-parser/src/parser.rs index 059f3d8..bf57450 100644 --- a/crates/weavepy-parser/src/parser.rs +++ b/crates/weavepy-parser/src/parser.rs @@ -336,14 +336,10 @@ impl<'src> Parser<'src> { self.consume_stmt_end()?; let span = type_tok.span.merge(value.span); let target = Expr { - kind: ExprKind::Name(name), + kind: ExprKind::Name(name.clone()), span: name_tok.span, }; - let rhs = if type_params.is_empty() { - value - } else { - wrap_in_type_param_lambda(value, &type_params, name_tok.span) - }; + let rhs = build_lazy_type_alias(&name, value, &type_params, name_tok.span); Ok(Stmt { kind: StmtKind::Assign { targets: vec![target], @@ -4965,9 +4961,28 @@ fn big_to_i64(b: &num_bigint::BigInt) -> Option { } } -/// PEP 695 helper — wrap `body` in a `(lambda T, U: body)(TypeVar('T'), TypeVar('U'))` -/// call so type-parameter names bind locally to typevar instances. -fn wrap_in_type_param_lambda(body: Expr, names: &[String], span: Span) -> Expr { +/// PEP 695 helper — lower a `type` alias to a **lazy** `TypeAliasType` +/// constructor call so the alias body is *not* evaluated at definition +/// time (matching CPython 3.12+ `typing.TypeAliasType`): +/// +/// ```python +/// type Name[T, U] = body +/// # lowers to +/// Name = __weavepy_type_alias__('Name', ('T', 'U'), lambda T, U: body) +/// +/// type Name = body +/// # lowers to +/// Name = __weavepy_type_alias__('Name', (), lambda: body) +/// ``` +/// +/// The `__weavepy_type_alias__` intrinsic mints one `TypeVar`-shaped +/// placeholder per name, records them as `__type_params__`, and only +/// invokes the thunk (with those placeholders bound to the lambda's +/// parameters) the first time `Name.__value__` is read. Deferring the +/// body is what lets numpy's `_typing` aliases — e.g. +/// `type ArrayLike = Buffer | _DualArrayLike[np.dtype, …]` — be defined +/// without eagerly building unions / subscripting other aliases. +fn build_lazy_type_alias(name: &str, body: Expr, names: &[String], span: Span) -> Expr { let args = Arguments { posonlyargs: Vec::new(), args: names @@ -4984,34 +4999,36 @@ fn wrap_in_type_param_lambda(body: Expr, names: &[String], span: Span) -> Expr { kwarg: None, defaults: Vec::new(), }; - let lambda = Expr { + let thunk = Expr { kind: ExprKind::Lambda { args, body: Box::new(body), }, span, }; - let typevar_calls: Vec = names - .iter() - .map(|n| Expr { - kind: ExprKind::Call { - func: Box::new(Expr { - kind: ExprKind::Name("__weavepy_typevar__".to_owned()), - span, - }), - args: vec![Expr { + let name_str = Expr { + kind: ExprKind::Constant(Constant::Str(name.to_owned())), + span, + }; + let params_tuple = Expr { + kind: ExprKind::Tuple( + names + .iter() + .map(|n| Expr { kind: ExprKind::Constant(Constant::Str(n.clone())), span, - }], - keywords: Vec::new(), - }, - span, - }) - .collect(); + }) + .collect(), + ), + span, + }; Expr { kind: ExprKind::Call { - func: Box::new(lambda), - args: typevar_calls, + func: Box::new(Expr { + kind: ExprKind::Name("__weavepy_type_alias__".to_owned()), + span, + }), + args: vec![name_str, params_tuple, thunk], keywords: Vec::new(), }, span, diff --git a/crates/weavepy-vm/src/builtin_types.rs b/crates/weavepy-vm/src/builtin_types.rs index 66addb3..dacccfd 100644 --- a/crates/weavepy-vm/src/builtin_types.rs +++ b/crates/weavepy-vm/src/builtin_types.rs @@ -357,7 +357,7 @@ impl BuiltinTypes { } install_field_defaults(&attribute_error, &["name", "obj"]); install_field_defaults(&name_error, &["name"]); - install_field_defaults(&import_error, &["name", "path", "name_from"]); + install_field_defaults(&import_error, &["msg", "name", "path", "name_from"]); install_import_error_init(&import_error); let os_error = exc("OSError", exception.clone()); install_os_error_init(&os_error); @@ -652,6 +652,10 @@ impl BuiltinTypes { pair!(name_error, "NameError"), pair!(not_implemented_error, "NotImplementedError"), pair!(os_error, "OSError"), + // `IOError` and `EnvironmentError` are the same object as `OSError` + // in Python 3 (`IOError is OSError`), kept as builtin aliases. + pair!(os_error, "IOError"), + pair!(os_error, "EnvironmentError"), pair!(overflow_error, "OverflowError"), pair!(floating_point_error, "FloatingPointError"), pair!(runtime_error, "RuntimeError"), @@ -750,7 +754,7 @@ impl BuiltinTypes { "LookupError" => Some(self.lookup_error.clone()), "NameError" => Some(self.name_error.clone()), "NotImplementedError" => Some(self.not_implemented_error.clone()), - "OSError" => Some(self.os_error.clone()), + "OSError" | "IOError" | "EnvironmentError" => Some(self.os_error.clone()), "OverflowError" => Some(self.overflow_error.clone()), "FloatingPointError" => Some(self.floating_point_error.clone()), "RuntimeError" => Some(self.runtime_error.clone()), @@ -1239,6 +1243,27 @@ pub(crate) fn object_new(args: &[Object]) -> Result { return interp.type_call_default(&cls, &args[1..], &[]); } } + // CPython: a subclass of `types.GenericAlias` (collections.abc's + // `_CallableGenericAlias`, typing's private aliases, …) inherits + // `GenericAlias.tp_new`, so a delegating `super().__new__(cls, origin, + // args)` builds a parameterised alias rather than reaching the strict + // `object.__new__`. WeavePy keys alias construction on the *built-in* + // type's name in `type_call_default`, which a user subclass bypasses, so + // honour the inherited constructor here: `(origin, params)` becomes a + // duck-typed generic alias. (numpy's `_array_like` builds + // `collections.abc.Callable[..., Any]` exactly this way during import.) + { + let bt = builtin_types(); + if args.len() == 3 + && !Rc::ptr_eq(&cls, &bt.generic_alias_) + && cls.is_subclass_of(&bt.generic_alias_) + { + return Ok(crate::make_generic_alias_public( + args[1].clone(), + args[2].clone(), + )); + } + } // CPython `object_new` arity policy (bpo-31506): excess arguments // are an error unless exactly one of `__new__`/`__init__` is // overridden (the overriding side owns the signature). @@ -2851,10 +2876,16 @@ fn install_exception_str_repr(base_exception: &Rc) { [single] => { if is_key_error { Object::from_str(single.repr()) - } else if matches!(single, Object::Instance(_)) { - // A nested exception (or other instance) needs - // its own __str__ dispatched: CPython's - // BaseException.__str__ is `str(args[0])`. + } else if matches!(single, Object::Instance(_) | Object::Foreign(_)) { + // A nested exception, other instance, or a + // *foreign* extension object (e.g. a numpy + // scalar) needs its own `__str__`/`tp_str` + // dispatched: CPython's `BaseException.__str__` + // is `str(args[0])`, not `repr`. Without this a + // `OutOfBoundsTimedelta(np.timedelta64(...))` + // stringified to its repr + // (`np.timedelta64(...,'h')`) instead of + // `"... hours"`. Object::from_str( crate::builtins::str_reentrant(single) .unwrap_or_else(|| single.to_str()), @@ -3126,6 +3157,7 @@ pub fn make_exception_with_class(class: Rc, message: impl Into, message: impl Into, + name: &'static str, + f: fn(&[Object], &[(String, Object)]) -> Result, + ) { + let builtin = Object::Builtin(Rc::new(BuiltinFn { + name, + binds_instance: true, + call: Box::new(move |args| f(args, &[])), + call_kw: Some(Box::new(move |args, kwargs| f(args, kwargs))), + })); + let cm = Object::ClassMethod(MethodWrapper::new(builtin)); + ty.dict + .borrow_mut() + .insert(DictKey(Object::from_static(name)), cm); + } - install( - &bt.int_, - "from_bytes", - crate::builtins::b_int_from_bytes_cls, - ); + install_kw(&bt.int_, "from_bytes", crate::builtins::b_int_from_bytes_cls); install(&bt.bytes_, "fromhex", crate::builtins::b_bytes_fromhex_cls); install( &bt.bytearray_, @@ -4203,7 +4261,38 @@ fn install_numeric_class_methods(bt: &BuiltinTypes) { ))), } } + // CPython's `float.__int__` truncates toward zero, raising on non-finite + // values (`ValueError` for NaN, `OverflowError` for ±inf). Faithful via a + // bigint so magnitudes past the i64 range convert exactly. + fn float_as_int(args: &[Object]) -> Result { + let o = args + .first() + .ok_or_else(|| crate::error::type_error("__int__ requires an argument"))?; + let native = o.native_value(); + match native.as_ref().unwrap_or(o) { + Object::Float(f) => { + if f.is_nan() { + return Err(crate::error::value_error( + "cannot convert float NaN to integer", + )); + } + if f.is_infinite() { + return Err(crate::error::overflow_error( + "cannot convert float infinity to integer", + )); + } + Ok(Object::int_from_bigint(crate::object::bigint_from_f64_trunc( + f.trunc(), + ))) + } + other => Err(crate::error::type_error(format!( + "descriptor '__int__' requires a 'float' object but received a '{}'", + other.type_name() + ))), + } + } install_method(&bt.int_, "__int__", self_as_int); install_method(&bt.int_, "__index__", self_as_int); install_method(&bt.float_, "__float__", self_as_float); + install_method(&bt.float_, "__int__", float_as_int); } diff --git a/crates/weavepy-vm/src/builtins.rs b/crates/weavepy-vm/src/builtins.rs index c228e62..fcf9a5f 100644 --- a/crates/weavepy-vm/src/builtins.rs +++ b/crates/weavepy-vm/src/builtins.rs @@ -248,6 +248,22 @@ pub fn default_builtins() -> DictData { reg!("breakpoint", b_breakpoint); reg!("memoryview", b_memoryview); reg!("__weavepy_typevar__", b_typevar); + // PEP 695 `type X = …` lowers (in the parser) to a call to this + // name; it's a VM intrinsic (needs interpreter access to import + // `typing` and mint typevars) so the real work runs in + // `Interpreter::do_type_alias_call`. + { + let f = BuiltinFn { + name: "__vm:type_alias", + binds_instance: false, + call: Box::new(b_type_alias_unsupported), + call_kw: None, + }; + d.insert( + DictKey(Object::from_static("__weavepy_type_alias__")), + Object::Builtin(Rc::new(f)), + ); + } { let f = BuiltinFn { name: "__vm:input", @@ -459,6 +475,7 @@ pub fn lookup_method(obj: &Object, name: &str) -> Option { "isspace" => Some(method("isspace", str_isspace)), "isupper" => Some(method("isupper", str_isupper)), "islower" => Some(method("islower", str_islower)), + "istitle" => Some(method("istitle", str_istitle)), "isascii" => Some(method("isascii", str_isascii)), "isnumeric" => Some(method("isnumeric", str_isdigit)), "isdecimal" => Some(method("isdecimal", str_isdigit)), @@ -469,10 +486,10 @@ pub fn lookup_method(obj: &Object, name: &str) -> Option { "rjust" => Some(method("rjust", str_rjust)), "center" => Some(method("center", str_center)), "expandtabs" => Some(method("expandtabs", str_expandtabs)), - "encode" => Some(method("encode", str_encode)), + "encode" => Some(method_kw("encode", str_encode)), "removeprefix" => Some(method("removeprefix", str_removeprefix)), "removesuffix" => Some(method("removesuffix", str_removesuffix)), - "format" => Some(method(".format", str_format)), + "format" => Some(method_kw(".format", str_format_kw)), "format_map" => Some(method(".format_map", str_format_map)), "translate" => Some(method("translate", str_translate)), "maketrans" => Some(method("maketrans", str_maketrans)), @@ -542,6 +559,14 @@ pub fn lookup_method(obj: &Object, name: &str) -> Option { "__imul__" => Some(method("__imul__", list_imul)), _ => None, }, + // `range` is a genuine immutable sequence: CPython exposes `.index` + // and `.count` on it (pandas' `RangeIndex.get_loc` calls + // `self._range.index(int(key))` to locate a label). + Object::Range(_) => match name { + "index" => Some(method("index", range_index)), + "count" => Some(method("count", range_count)), + _ => None, + }, Object::Dict(_) => match name { "get" => Some(method("get", dict_get)), "keys" => Some(method("keys", dict_keys)), @@ -910,8 +935,8 @@ pub fn lookup_method(obj: &Object, name: &str) -> Option { Object::Int(_) | Object::Long(_) | Object::Bool(_) => match name { "bit_length" => Some(method("bit_length", int_bit_length)), "bit_count" => Some(method("bit_count", int_bit_count)), - "to_bytes" => Some(method("to_bytes", int_to_bytes)), - "from_bytes" => Some(method("from_bytes", int_from_bytes_method)), + "to_bytes" => Some(method_kw("to_bytes", int_to_bytes)), + "from_bytes" => Some(method_kw("from_bytes", int_from_bytes_method)), "is_integer" => Some(method("is_integer", int_is_integer)), "as_integer_ratio" => Some(method("as_integer_ratio", int_as_integer_ratio)), "conjugate" => Some(method("conjugate", int_conjugate)), @@ -933,6 +958,11 @@ pub fn lookup_method(obj: &Object, name: &str) -> Option { "fromhex" => Some(method("fromhex", float_fromhex)), "as_integer_ratio" => Some(method("as_integer_ratio", float_as_integer_ratio)), "conjugate" => Some(method("conjugate", float_conjugate)), + // CPython's `float` exposes `__int__` (truncation toward zero, + // raising on non-finite values). A C extension reaching `float`'s + // `nb_int` slot goes through the C-ABI bridge, but the Python-level + // dunder must exist too (`hasattr(float, "__int__")`). + "__int__" => Some(method("__int__", float_int)), "__trunc__" => Some(method("__trunc__", float_trunc)), "__floor__" => Some(method("__floor__", float_floor)), "__ceil__" => Some(method("__ceil__", float_ceil)), @@ -1594,7 +1624,7 @@ pub fn builtin_classmethod(type_name: &str, attr: &str) -> Option { let f = match (type_name, attr) { ("str", "maketrans") => Some(method("maketrans", str_maketrans)), ("bytes", "fromhex") | ("bytearray", "fromhex") => Some(method("fromhex", bytes_fromhex)), - ("int", "from_bytes") => Some(method("from_bytes", int_from_bytes_method)), + ("int", "from_bytes") => Some(method_kw("from_bytes", int_from_bytes_method)), ("float", "fromhex") => Some(method("fromhex", float_fromhex)), ("dict", "fromkeys") => Some(method("fromkeys", dict_fromkeys)), _ => None, @@ -2123,42 +2153,65 @@ pub(crate) fn seq_index_bound(o: &Object) -> Result { } pub(crate) fn coerce_index_i64(o: &Object) -> Result { + if let Some(res) = try_coerce_index_i64(o) { + return res; + } + Err(type_error(format!( + "'{}' object cannot be interpreted as an integer", + o.type_name() + ))) +} + +/// Like [`coerce_index_i64`], but distinguishes "has no `__index__`" (→ +/// `None`, so a caller can raise a context-specific message such as "tuple +/// indices must be integers") from "has `__index__`, here is its (possibly +/// failing) result" (→ `Some(..)`). +/// +/// Unlike the old `instance_method`-only lookup, this resolves `__index__` +/// through full attribute resolution, so a **C-slot** `nb_index` reached only +/// via the bridge — a `numpy` integer scalar (`np.intp`, used to index +/// `BlockManager.blocks[blknos[i]]`) — is honoured, matching CPython's +/// `PyNumber_Index`. +pub(crate) fn try_coerce_index_i64(o: &Object) -> Option> { if let Some(v) = o.as_i64() { - return Ok(v); + return Some(Ok(v)); } // A big integer is a valid `__index__` value, but it can't fit the C // ssize_t the caller wants → `OverflowError`, matching CPython // (`test_io.test_reconfigure_errors`: `line_buffering=2**1000`). if matches!(o, Object::Long(_)) { - return Err(crate::error::overflow_error( + return Some(Err(crate::error::overflow_error( "cannot fit 'int' into an index-sized integer", - )); + ))); } - if let Object::Instance(_) = o { - if let Some(method) = crate::instance_method(o, "__index__") { - if let Some(ptr) = crate::vm_singletons::current_interpreter_ptr() { - // SAFETY: the pointer was published by an enclosing VM frame - // still live on this thread; the GIL keeps the access exclusive. - let interp = unsafe { &mut *ptr }; - let globals = interp.builtins_dict(); - let r = interp.call_object_with_globals(&method, &[], &[], &globals)?; - if let Some(v) = r.as_i64() { - return Ok(v); - } - // `__index__` returned an int too large for an index-sized C - // integer (CPython raises `OverflowError`, not `TypeError`). - if matches!(r, Object::Long(_)) { - return Err(crate::error::overflow_error( - "cannot fit 'int' into an index-sized integer", - )); - } - } - } + if !matches!(o, Object::Instance(_) | Object::Foreign(_)) { + return None; } - Err(type_error(format!( - "'{}' object cannot be interpreted as an integer", - o.type_name() - ))) + let ptr = crate::vm_singletons::current_interpreter_ptr()?; + // SAFETY: the pointer was published by an enclosing VM frame still live on + // this thread; the GIL keeps the access exclusive. + let interp = unsafe { &mut *ptr }; + let Ok(method) = interp.load_attr_public(o, "__index__") else { + return None; + }; + let globals = interp.builtins_dict(); + Some((|| { + let r = interp.call_object_with_globals(&method, &[], &[], &globals)?; + if let Some(v) = r.as_i64() { + return Ok(v); + } + // `__index__` returned an int too large for an index-sized C integer + // (CPython raises `OverflowError`, not `TypeError`). + if matches!(r, Object::Long(_)) { + return Err(crate::error::overflow_error( + "cannot fit 'int' into an index-sized integer", + )); + } + Err(type_error(format!( + "__index__ returned non-int (type {})", + r.type_name() + ))) + })()) } /// Coerce `o` to an `f64` the way CPython's float-accepting C functions @@ -2206,6 +2259,22 @@ pub(crate) fn coerce_f64_opt(o: &Object) -> Result, RuntimeError> { } Ok(None) } + Object::Foreign(s) => { + // A foreign extension scalar (numpy `float64`/`int64`/…) exposes + // its value through the binary-ABI `nb_float`/`nb_index` slots, + // exactly as `float(x)` consumes it (`do_float_call`). Without this + // `math.isclose`/`math.dist`/`statistics.*` rejected `float64` + // operands ("must be a real number, not 'object'") that pandas' + // `assert_almost_equal`, `Series.cov`/`corr`, etc. feed them — + // CPython's `PyFloat_AsDouble` honours `nb_float` then `nb_index`. + match crate::foreign::as_float(s) { + Ok(v) => coerce_f64_opt(&v), + Err(_) => match crate::foreign::as_index(s) { + Ok(v) => coerce_f64_opt(&v), + Err(_) => Ok(None), + }, + } + } _ => Ok(None), } } @@ -3716,14 +3785,18 @@ fn int_as_integer_ratio(args: &[Object]) -> Result { Ok(Object::new_tuple(vec![v.clone(), Object::Int(1)])) } -fn int_to_bytes(args: &[Object]) -> Result { +// CPython signature: `int.to_bytes(length=1, byteorder='big', *, signed=False)`. +// `length`/`byteorder` are positional-or-keyword and `signed` is +// keyword-only, so this must accept keywords (pandas' offsets / hypothesis +// call `n.to_bytes(length, byteorder, signed=...)` by keyword). +fn int_to_bytes(args: &[Object], kwargs: &[(String, Object)]) -> Result { let n_obj = args .first() .ok_or_else(|| type_error("to_bytes() requires self"))?; let n = n_obj .as_bigint() .ok_or_else(|| type_error("to_bytes(): self is not an integer"))?; - let length = match args.get(1) { + let length = match arg_or_kw(args, 1, kwargs, "length") { Some(Object::Int(i)) if *i >= 0 => *i as usize, Some(Object::Bool(b)) => usize::from(*b), Some(Object::Long(b)) if !b.is_negative() => b @@ -3736,12 +3809,12 @@ fn int_to_bytes(args: &[Object]) -> Result { )) } }; - let byteorder = match args.get(2) { + let byteorder = match arg_or_kw(args, 2, kwargs, "byteorder") { Some(Object::Str(s)) => s.to_string(), None => "big".to_owned(), _ => return Err(type_error("byteorder must be a string")), }; - let signed = match args.get(3) { + let signed = match arg_or_kw(args, 3, kwargs, "signed") { Some(o) => o.is_truthy(), None => false, }; @@ -3749,7 +3822,12 @@ fn int_to_bytes(args: &[Object]) -> Result { Ok(Object::new_bytes(bytes)) } -fn int_from_bytes_method(args: &[Object]) -> Result { +// CPython signature: `int.from_bytes(bytes, byteorder='big', *, signed=False)`. +// `byteorder` is positional-or-keyword, `signed` keyword-only. +fn int_from_bytes_method( + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { // Bound-method form passes self as args[0] (the int class itself // in CPython). We treat any int-like first arg as the binding // receiver and ignore it. @@ -3781,12 +3859,12 @@ fn int_from_bytes_method(args: &[Object]) -> Result { }) }) .ok_or_else(|| type_error("from_bytes() requires bytes-like"))?; - let byteorder = match args.get(offset + 1) { + let byteorder = match arg_or_kw(args, offset + 1, kwargs, "byteorder") { Some(Object::Str(s)) => s.to_string(), None => "big".to_owned(), _ => return Err(type_error("byteorder must be a string")), }; - let signed = match args.get(offset + 2) { + let signed = match arg_or_kw(args, offset + 2, kwargs, "signed") { Some(o) => o.is_truthy(), None => false, }; @@ -3972,6 +4050,29 @@ fn float_trunc(args: &[Object]) -> Result { } } +/// `float.__int__(self)` — truncate toward zero, raising the same errors +/// CPython does for non-finite inputs (`ValueError` for NaN, `OverflowError` +/// for ±inf). Kept behaviourally identical to the type-dict `float.__int__` +/// so instance- and type-level access agree. +fn float_int(args: &[Object]) -> Result { + match one(args, "__int__")? { + Object::Float(f) => { + if f.is_nan() { + return Err(value_error("cannot convert float NaN to integer")); + } + if f.is_infinite() { + return Err(crate::error::overflow_error( + "cannot convert float infinity to integer", + )); + } + Ok(Object::int_from_bigint( + crate::object::bigint_from_f64_trunc(f.trunc()), + )) + } + _ => Err(type_error("__int__: float expected")), + } +} + fn float_floor(args: &[Object]) -> Result { match one(args, "__floor__")? { Object::Float(f) => float_int_part(f.floor()), @@ -4342,10 +4443,13 @@ fn finish_hex_tail(s: &[u8], mut i: usize, val: f64) -> Result Result { +pub(crate) fn b_int_from_bytes_cls( + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { // CPython's `int.from_bytes` calls `cls(result)` for subclasses so // e.g. `IntEnum.from_bytes(...)` resolves to the matching member. - let result = int_from_bytes_method(args)?; + let result = int_from_bytes_method(args, kwargs)?; fromhex_wrap_subclass(args.first(), "int", result) } @@ -4611,7 +4715,7 @@ fn b_bool(args: &[Object]) -> Result { Ok(Object::Bool(args[0].is_truthy())) } -pub(crate) fn b_complex(args: &[Object]) -> Result { +pub fn b_complex(args: &[Object]) -> Result { if args.is_empty() { return Ok(Object::new_complex(0.0, 0.0)); } @@ -5932,6 +6036,7 @@ pub(crate) fn make_unbound_super(class: Rc) -> Object slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }; Object::Instance(Rc::new(inst)) } @@ -6001,6 +6106,7 @@ pub(crate) fn build_super_proxy( slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }; Object::Instance(Rc::new(inst)) } @@ -6347,6 +6453,19 @@ pub fn class_of(obj: &Object) -> crate::sync::Rc { } Object::Frame(_) => bt.frame_.clone(), Object::Traceback(_) => bt.traceback_.clone(), + // A C-API capsule is an opaque cpyext token with no dedicated + // VM type; report the base `object` type (it never reaches a + // Python-level `type()` in practice — capsules flow C -> module + // dict -> C). See RFC 0045. + Object::Capsule(_) => bt.object_.clone(), + // A foreign cpyext object's true type is its (often un-bridged) + // C `PyTypeObject`. When the extension's type is bridged the + // foreign `get_type` hook yields an `Object::Type`; otherwise we + // report the base `object` (RFC 0046, wave 4). + Object::Foreign(s) => match crate::foreign::get_type(s) { + Object::Type(t) => t, + _ => bt.object_.clone(), + }, } } @@ -6455,6 +6574,10 @@ fn object_identity(obj: &Object) -> i64 { Object::Cell(c) => Rc::as_ptr(c) as usize as i64, Object::Iter(i) => Rc::as_ptr(i) as usize as i64, Object::LazyIter(l) => Rc::as_ptr(l) as usize as i64, + Object::Capsule(c) => Rc::as_ptr(c) as usize as i64, + // `id()` of a foreign proxy is the underlying `PyObject*` — the + // cpyext identity, consistent with `is`/`eq` (RFC 0046). + Object::Foreign(s) => s.ptr as i64, Object::Int(i) => i.wrapping_mul(0x9E37_79B9_7F4A_7C15u64 as i64), Object::Float(f) => (f.to_bits() as i64) ^ 0x0123_4567_89AB_CDEFu64 as i64, Object::Bool(b) => { @@ -6817,6 +6940,15 @@ fn b_input_unsupported(_args: &[Object]) -> Result { Err(runtime_error("input() must be called through the VM")) } +/// Placeholder for the `__weavepy_type_alias__` PEP 695 intrinsic; the +/// VM intercepts it (it needs interpreter state to import `typing` and +/// mint typevars), so reaching this body means the dispatcher missed it. +fn b_type_alias_unsupported(_args: &[Object]) -> Result { + Err(runtime_error( + "__weavepy_type_alias__() must be called through the VM", + )) +} + /// `pow(base, exp[, mod])` — modular exponentiation when `mod` is /// given, otherwise `base ** exp`. Mirrors CPython's three-arg /// `pow` including the negative-exponent + mod case (the modular @@ -7005,7 +7137,7 @@ fn b_breakpoint(_args: &[Object]) -> Result { /// `def f[T](...)`, and `class C[T]:`. Behaves enough like /// `typing.TypeVar` that consumers can subscript / index / repr it /// without importing `typing`. -fn b_typevar(args: &[Object]) -> Result { +pub(crate) fn b_typevar(args: &[Object]) -> Result { let name = match args.first() { Some(Object::Str(s)) => s.to_string(), Some(other) => other.to_str(), @@ -7044,6 +7176,19 @@ pub fn b_memoryview(args: &[Object]) -> Result { .expect("shared_buffer present per guard"); crate::object::PyMemoryView::from_shared(buf) } + // A foreign buffer exporter (numpy's `ndarray`, a Cython `cdef + // class` with `__getbuffer__`, …). Route through the cpyext + // bridge, which drives `PyObject_GetBuffer` and returns a + // faithfully-typed memoryview (`format`/`itemsize`/`shape`). + Object::Foreign(soul) => return crate::foreign::get_buffer(soul), + // A faithful C instance that exports the buffer protocol crosses as + // `Object::Instance` wearing its real type (numpy's `ndarray` is built + // by its own `tp_new`, not proxied as `Foreign`). Drive its + // `bf_getbuffer` through the cpyext bridge. A non-exporter instance + // surfaces the C-side `TypeError`, matching CPython. + Object::Instance(_) if crate::foreign::is_installed() => { + return crate::foreign::get_buffer_obj(arg); + } other => { return Err(type_error(format!( "memoryview: a bytes-like object is required, not '{}'", @@ -8091,6 +8236,35 @@ fn str_islower(args: &[Object]) -> Result { Ok(Object::Bool(has_cased)) } +fn str_istitle(args: &[Object]) -> Result { + // CPython `unicode_istitle_impl`: a titlecased string has each cased run + // starting with an upper/titlecase char, and there is >=1 cased char. + // (Titlecase Lt chars are treated as uppercase for the run-start test; + // Rust's std lacks a general-category API, so uppercase covers Lu — the + // ASCII and dominant Unicode case, matching our other `is*` helpers.) + let s = str_self(args)?; + let mut cased = false; + let mut prev_cased = false; + for c in s.chars() { + if c.is_uppercase() { + if prev_cased { + return Ok(Object::Bool(false)); + } + prev_cased = true; + cased = true; + } else if c.is_lowercase() { + if !prev_cased { + return Ok(Object::Bool(false)); + } + prev_cased = true; + cased = true; + } else { + prev_cased = false; + } + } + Ok(Object::Bool(cased)) +} + fn str_isascii(args: &[Object]) -> Result { Ok(Object::Bool(str_self(args)?.is_ascii())) } @@ -8242,7 +8416,10 @@ fn str_expandtabs(args: &[Object]) -> Result { Ok(str_result(args, out)) } -fn str_encode(args: &[Object]) -> Result { +// CPython signature: `str.encode(encoding='utf-8', errors='strict')`; both +// are positional-or-keyword, so this must accept keywords (pandas' interchange +// buffer path and much stdlib call `s.encode(encoding=..., errors=...)`). +fn str_encode(args: &[Object], kwargs: &[(String, Object)]) -> Result { // Accept both `str` and surrogate-bearing `WStr` receivers so // `chr(0xD800).encode('utf-8', 'surrogatepass')` works. let recv = args @@ -8251,12 +8428,12 @@ fn str_encode(args: &[Object]) -> Result { if !recv.is_str() { return Err(type_error("expected str method receiver")); } - let encoding = match args.get(1) { + let encoding = match arg_or_kw(args, 1, kwargs, "encoding") { Some(Object::Str(e)) => e.to_string(), None => "utf-8".to_owned(), _ => return Err(type_error("encode() expected str")), }; - let errors = match args.get(2) { + let errors = match arg_or_kw(args, 2, kwargs, "errors") { Some(Object::Str(e)) => e.to_string(), None => "strict".to_owned(), _ => "strict".to_owned(), @@ -8299,11 +8476,17 @@ fn str_removesuffix(args: &[Object]) -> Result { Ok(str_result(args, out)) } -fn str_format(args: &[Object]) -> Result { +/// Keyword-aware `str.format`. The VM's dispatch loop special-cases the +/// `.format` method and threads kwargs through `do_str_format`, so this body +/// only runs when the *bound method object* is invoked through the C-API +/// (`PyObject_Call` with a kwargs dict) — e.g. Cython building an error +/// message with `TEMPLATE.format(cls=..., own_freq=..., other_freq=...)` +/// (pandas' `DIFFERENT_FREQ`/`IncompatibleFrequency`). Without a `call_kw` +/// the C-API dispatch raised a spurious "format() takes no keyword arguments". +fn str_format_kw(args: &[Object], kwargs: &[(String, Object)]) -> Result { let template = str_self(args)?.into_owned(); let rest = &args[1..]; - let kwargs: Vec<(String, Object)> = Vec::new(); - crate::str_format_impl(&template, rest, &kwargs).map(|s| str_result(args, s)) + crate::str_format_impl(&template, rest, kwargs).map(|s| str_result(args, s)) } fn str_format_map(args: &[Object]) -> Result { @@ -8763,6 +8946,108 @@ fn list_count(args: &[Object]) -> Result { Ok(Object::Int(n)) } +// ---- range.index / range.count ------------------------------------------- +// +// CPython's `range_index`/`range_count` take an arithmetic fast path for +// real integers (`PyLong`/`bool`) and fall back to a linear +// `_PySequence_IterSearch` for anything else (a float that equals an int, a +// numpy scalar). We mirror both. + +fn range_self(args: &[Object], meth: &str) -> Result, RuntimeError> { + match args.first() { + Some(Object::Range(r)) => Ok(r.clone()), + _ => Err(type_error(format!( + "descriptor '{meth}' for 'range' objects doesn't apply to the given object" + ))), + } +} + +fn range_len_i128(r: &crate::object::Range) -> i128 { + if r.step > 0 { + if r.stop > r.start { + (r.stop - r.start + r.step - 1) / r.step + } else { + 0 + } + } else if r.stop < r.start { + (r.start - r.stop + (-r.step) - 1) / (-r.step) + } else { + 0 + } +} + +/// The 0-based position of integer `v` within `r`, or `None` if `v` is not a +/// member. Mirrors CPython's `range_contains_long` + index arithmetic. +fn range_position(r: &crate::object::Range, v: i128) -> Option { + if r.step > 0 { + if v < r.start || v >= r.stop { + return None; + } + } else if v > r.start || v <= r.stop { + return None; + } + let diff = v - r.start; + if diff % r.step != 0 { + return None; + } + Some(diff / r.step) +} + +fn int_like_to_i128(o: &Object) -> Option { + match o { + Object::Int(i) => Some(i128::from(*i)), + Object::Bool(b) => Some(i128::from(*b)), + Object::Long(b) => b.to_i128(), + _ => None, + } +} + +fn range_index(args: &[Object]) -> Result { + let r = range_self(args, "index")?; + let value = args + .get(1) + .ok_or_else(|| type_error("index() takes exactly one argument (0 given)"))?; + if let Some(v) = int_like_to_i128(value) { + if let Some(idx) = range_position(&r, v) { + return Ok(crate::object::int_from_i128(idx)); + } + return Err(value_error(format!("{} is not in range", value.repr()))); + } + // Non-integer: linear search comparing each element with `__eq__` + // (CPython `_PySequence_IterSearch(..., PY_ITERSEARCH_INDEX)`). + let n = range_len_i128(&r); + let mut k: i128 = 0; + while k < n { + let elem = crate::object::int_from_i128(r.start + k * r.step); + if crate::object::member_eq(&elem, value)? { + return Ok(crate::object::int_from_i128(k)); + } + k += 1; + } + Err(value_error(format!("{} is not in range", value.repr()))) +} + +fn range_count(args: &[Object]) -> Result { + let r = range_self(args, "count")?; + let value = args + .get(1) + .ok_or_else(|| type_error("count() takes exactly one argument (0 given)"))?; + if let Some(v) = int_like_to_i128(value) { + return Ok(Object::Int(i64::from(range_position(&r, v).is_some()))); + } + let n = range_len_i128(&r); + let mut cnt: i64 = 0; + let mut k: i128 = 0; + while k < n { + let elem = crate::object::int_from_i128(r.start + k * r.step); + if crate::object::member_eq(&elem, value)? { + cnt += 1; + } + k += 1; + } + Ok(Object::Int(cnt)) +} + fn list_sort(args: &[Object]) -> Result { let l = list_self(args)?; let mut err: Option = None; diff --git a/crates/weavepy-vm/src/descr_registry.rs b/crates/weavepy-vm/src/descr_registry.rs index 241ccad..2aaa8a3 100644 --- a/crates/weavepy-vm/src/descr_registry.rs +++ b/crates/weavepy-vm/src/descr_registry.rs @@ -20,7 +20,7 @@ //! addresses are stable keys. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use crate::object::Object; @@ -92,6 +92,73 @@ pub fn module_of_builtin(b: &Rc) -> Option<&'static st BUILTIN_MODULE.read().get(&k).copied() } +/// Pointers of the `BuiltinFn`s that back a harvested C descriptor's +/// getter/setter (a `tp_getset` computed attribute or a `tp_members` +/// struct field, decoded in `weavepy-capi`'s `getset` module). +/// +/// These accessor closures are `Object::Builtin`s named after the C +/// attribute — and that name can collide with a real `builtins` function +/// (numpy's `dtype.str` getset getter is a `BuiltinFn { name: "str" }`). +/// The dispatch loop's by-name builtin fast-paths key purely on +/// `BuiltinFn::name`, so without this marker `dtype.str` would be hijacked +/// by the `str(obj)` fast-path — which calls the dtype's `tp_str` +/// (numpy's `_dtype.__str__`, which itself reads `dtype.str`) and spins +/// into unbounded recursion. Descriptor invocation consults this set and +/// calls the accessor's own closure directly, never the name fast-path. +/// +/// PROCESS-GLOBAL for the same reason as [`BUILTIN_MODULE`]: a bridged +/// type harvested on the import thread is shared across every interpreter +/// thread through the module cache, and its descriptors may be read from +/// any of them. +static NATIVE_DESCR_ACCESSOR: LazyLock>> = + LazyLock::new(|| parking_lot::RwLock::new(HashSet::new())); + +/// Tag `obj` as the getter/setter closure of a harvested C descriptor, so +/// [`is_native_descr_accessor`] recognizes it and the dispatch loop routes +/// the call to its own closure instead of a same-named builtin fast-path. +pub fn mark_native_descr_accessor(obj: &Object) { + if let Object::Builtin(b) = obj { + let k = Rc::as_ptr(b).cast::<()>() as usize; + NATIVE_DESCR_ACCESSOR.write().insert(k); + } +} + +/// True when `b` backs a harvested C getset/member descriptor (tagged via +/// [`mark_native_descr_accessor`]). Such a builtin must be invoked through +/// its own closure, bypassing the by-name builtin fast-paths. +pub fn is_native_descr_accessor(b: &Rc) -> bool { + let k = Rc::as_ptr(b).cast::<()>() as usize; + NATIVE_DESCR_ACCESSOR.read().contains(&k) +} + +thread_local! { + /// Writable `__module__` for a `builtin_function_or_method` (RFC 0046, + /// wave 4). CPython's `PyCFunctionObject` exposes `m_module` as a + /// writable member, and extensions assign it at import time — numpy's + /// `multiarray.py` does `_reconstruct.__module__ = 'numpy._core.multiarray'` + /// so the reconstructor pickles by reference. We store the assigned + /// object keyed by the builtin's `Rc` identity (stable for the process + /// lifetime) and let [`module_of`]'s static attribution remain the + /// fallback. Thread-local: extension import runs on one interpreter + /// thread, matching [`DESCR_META`]. + static BUILTIN_WRITABLE_MODULE: RefCell> = + RefCell::new(HashMap::new()); +} + +/// Record a runtime `__module__` assignment on a builtin function. +/// Returns `false` if `obj` is not a taggable representation. +pub fn set_builtin_module(obj: &Object, value: Object) -> bool { + let Some(k) = key(obj) else { return false }; + BUILTIN_WRITABLE_MODULE.with(|m| m.borrow_mut().insert(k, value)); + true +} + +/// A runtime `__module__` assigned via [`set_builtin_module`], if any. +pub fn builtin_module_value(obj: &Object) -> Option { + let k = key(obj)?; + BUILTIN_WRITABLE_MODULE.with(|m| m.borrow().get(&k).cloned()) +} + /// The pointer key for a descriptor object, or `None` if `obj` is not a /// representation we ever tag. fn key(obj: &Object) -> Option { diff --git a/crates/weavepy-vm/src/foreign.rs b/crates/weavepy-vm/src/foreign.rs new file mode 100644 index 0000000..3d3d3f7 --- /dev/null +++ b/crates/weavepy-vm/src/foreign.rs @@ -0,0 +1,287 @@ +//! Foreign (cpyext-style) object proxy — RFC 0046, wave 4. +//! +//! WeavePy's binary-ABI layer ([`weavepy-capi`]) mints layout-faithful +//! mirrors and `PyObjectBox`es for values that *originate in the VM*. +//! A real C extension such as numpy, however, also creates objects of +//! its **own** — a builtin `numpy.zeros` function, a static +//! `PyArray_Descr`, an `ndarray` instance, the `numpy._core` type +//! objects — by allocating them itself (often as static C storage or +//! via `PyObject_Malloc` + `PyObject_Init`, bypassing WeavePy's +//! allocator entirely). The VM cannot interpret those bytes: they are +//! not a `PyObjectBox`, not a mirror, not a capsule. +//! +//! Following PyPy's `cpyext`, such a pointer crosses into the VM as a +//! **foreign proxy**: an opaque, identity-stable handle ([`Object::Foreign`]) +//! that holds the raw `*mut PyObject` and routes every operation +//! (`repr`, call, attribute access, the number protocol, …) back +//! through the binary-ABI layer via the function-pointer table +//! installed here at interpreter start ([`install`]). The VM never +//! dereferences the pointer; the cpyext layer owns its lifetime. +//! +//! The hook table is empty in a pure-VM build (no extension can run, so +//! no foreign object is ever created), so this module is inert unless +//! `weavepy-capi` has installed its bridge. + +use std::sync::OnceLock; + +use weavepy_compiler::{BinOpKind, CompareKind}; + +use crate::error::RuntimeError; +use crate::object::Object; +use crate::sync::Rc; + +/// VM-side soul of a foreign `PyObject` (see [`Object::Foreign`]). +/// +/// `ptr` is stored as a `usize` (not a pointer) so [`Object`] stays +/// `Send + Sync` — exactly like [`crate::object::PyCapsuleSoul`]. The +/// VM never dereferences it; it is only ever handed back to the cpyext +/// layer through the [`ForeignHooks`]. +pub struct PyForeignSoul { + /// The raw `*mut PyObject`, as an integer. + pub ptr: usize, + /// The *bare* type name (the tail of `tp_name` after the last `.`), i.e. + /// what Python's `type(x).__name__` reports (`float64`, `Nano`). Cached so + /// `repr` fallbacks and `__name__` need no C round-trip. + pub type_name: Rc, + /// The full, unmodified `Py_TYPE(ptr)->tp_name` (`numpy.float64`, + /// `pandas._libs.tslibs.offsets.Nano`, but bare `Timestamp` when the C + /// type itself sets no module prefix). This is exactly the string CPython + /// interpolates into `tp_name`-based `TypeError` messages + /// (`unsupported operand type(s) for /: 'float' and 'X'`, `'X' object is + /// not iterable`, …), so error text must use *this*, never `type_name`. + pub tp_name: Rc, +} + +impl std::fmt::Debug for PyForeignSoul { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "", self.type_name, self.ptr) + } +} + +impl Drop for PyForeignSoul { + fn drop(&mut self) { + if let Some(h) = HOOKS.get() { + (h.decref)(self.ptr); + } + } +} + +/// Bridge installed by `weavepy-capi` at interpreter start. Every entry +/// receives/returns plain VM types; the cpyext side performs the +/// `Object <-> *mut PyObject` marshalling and turns a pending C +/// exception into a [`RuntimeError`]. +#[derive(Debug)] +pub struct ForeignHooks { + /// `Py_INCREF(ptr)` — pin a fresh reference (used when a foreign + /// pointer is wrapped into a new soul). + pub incref: fn(usize), + /// `Py_DECREF(ptr)` — release the reference a soul held. + pub decref: fn(usize), + /// `PyObject_Repr(ptr)`. + pub repr: fn(usize) -> Result, + /// `PyObject_Str(ptr)`. + pub str: fn(usize) -> Result, + /// `PyObject_Hash(ptr)`. + pub hash: fn(usize) -> Result, + /// `PyObject_IsTrue(ptr)`. + pub is_true: fn(usize) -> Result, + /// `PyObject_Call(ptr, args, kwargs)`. + pub call: fn(usize, &[Object], &[(String, Object)]) -> Result, + /// `PyObject_GetAttrString(ptr, name)`. + pub getattr: fn(usize, &str) -> Result, + /// `PyObject_SetAttrString(ptr, name, value)` (value `None` ⇒ delete). + pub setattr: fn(usize, &str, Option<&Object>) -> Result<(), RuntimeError>, + /// `PyObject_GetItem(ptr, key)`. + pub getitem: fn(usize, &Object) -> Result, + /// `PyObject_SetItem` / `PyObject_DelItem` (value `None` ⇒ delete). + pub setitem: fn(usize, &Object, Option<&Object>) -> Result<(), RuntimeError>, + /// `PyObject_Length(ptr)`. + pub length: fn(usize) -> Result, + /// `PyObject_GetIter(ptr)`. + pub iter: fn(usize) -> Result, + /// `PyIter_Next(ptr)` — `Ok(None)` at exhaustion. + pub iternext: fn(usize) -> Result, RuntimeError>, + /// `PyNumber_*`/sequence binary op. Either operand may be foreign; + /// returns the VM `NotImplemented` singleton when C declines so the + /// VM's dispatcher can keep looking. + pub binop: fn(BinOpKind, &Object, &Object) -> Result, + /// `PyObject_RichCompare`. Returns `NotImplemented` when C declines. + pub compare: fn(CompareKind, &Object, &Object) -> Result, + /// Resolve `type(ptr)` to a VM object (an [`Object::Type`] when the + /// type is bridged; falls back to a foreign proxy of the type). + pub get_type: fn(usize) -> Object, + /// `PyNumber_Float(ptr)` — drive the foreign type's `nb_float` + /// (then `nb_index`) conversion. Returns an [`Object::Float`]. Lets + /// `float(np.float64(x))` and friends round-trip a numpy scalar + /// without WeavePy having to interpret its bytes. + pub as_float: fn(usize) -> Result, + /// `PyNumber_Long(ptr)` — drive `nb_int` (then `nb_index`). Returns an + /// `Object::Int`/`Long`/`Bool` (`int(np.uint32(x))`). + pub as_int: fn(usize) -> Result, + /// `PyNumber_Index(ptr)` — drive `nb_index` (loss-less integer view). + /// Returns an `Object::Int`/`Long`/`Bool`; errors when the type has no + /// `nb_index` (e.g. a float scalar used as an index). + pub as_index: fn(usize) -> Result, + /// `memoryview(ptr)` — acquire the foreign object's buffer + /// (`PyObject_GetBuffer` with `PyBUF_FULL_RO`) and wrap it in a VM + /// [`Object::MemoryView`] that faithfully carries the exporter's + /// `format`/`itemsize`/`shape`/`strides` (e.g. numpy's `'O'`/8 object + /// arrays). Errors when the type does not export the buffer protocol. + pub get_buffer: fn(usize) -> Result, + /// `memoryview(obj)` for an arbitrary VM object that crosses into C with + /// its own buffer protocol — a numpy `ndarray` crosses as a faithful + /// [`Object::Instance`] (wearing its real C type), not an + /// [`Object::Foreign`], so it has no raw soul pointer. The bridge marshals + /// the object to a `*mut PyObject` ([`crate::object::into_owned`]) and + /// drives `PyMemoryView_FromObject` on it. Errors (with the C-side + /// `TypeError`) when the type does not export the buffer protocol. + pub get_buffer_obj: fn(&Object) -> Result, +} + +static HOOKS: OnceLock = OnceLock::new(); + +/// Install the cpyext bridge. Idempotent; a second call is ignored. +pub fn install(hooks: ForeignHooks) { + let _ = HOOKS.set(hooks); +} + +/// True once the binary-ABI layer has installed its bridge. +pub fn is_installed() -> bool { + HOOKS.get().is_some() +} + +fn hooks() -> Result<&'static ForeignHooks, RuntimeError> { + HOOKS + .get() + .ok_or_else(|| RuntimeError::Internal("foreign-object bridge not installed".to_owned())) +} + +/// Construct a foreign proxy soul for `ptr`, pinning one reference. +/// `type_name` is the *bare* tail; `tp_name` is the full C `tp_name`. +/// Returns the raw soul; the caller wraps it in [`Object::Foreign`]. +pub fn wrap(ptr: usize, type_name: Rc, tp_name: Rc) -> Rc { + if let Some(h) = HOOKS.get() { + (h.incref)(ptr); + } + Rc::new(PyForeignSoul { + ptr, + type_name, + tp_name, + }) +} + +// --- VM-facing operations (thin wrappers that surface a clean error +// when the bridge is absent). --- + +pub fn repr(s: &PyForeignSoul) -> Result { + match hooks() { + Ok(h) => (h.repr)(s.ptr), + Err(_) => Ok(format!("<{} object at 0x{:x}>", s.type_name, s.ptr)), + } +} + +pub fn str_(s: &PyForeignSoul) -> Result { + match hooks() { + Ok(h) => (h.str)(s.ptr), + Err(_) => repr(s), + } +} + +pub fn hash(s: &PyForeignSoul) -> Result { + (hooks()?.hash)(s.ptr) +} + +pub fn is_true(s: &PyForeignSoul) -> bool { + match hooks() { + Ok(h) => (h.is_true)(s.ptr).unwrap_or(true), + Err(_) => true, + } +} + +/// `PyObject_IsTrue(ptr)` that *propagates* a pending C exception as a +/// `RuntimeError` rather than swallowing it to `true`. CPython truth-tests +/// with `PyObject_IsTrue`, and a *multi-element* numpy array's `nb_bool` +/// raises `ValueError` ("The truth value of an array with more than one +/// element is ambiguous"). Every boolean context that can surface an error +/// — `PyObject_RichCompareBool` (membership / `list.index` / equality +/// containment), `any`/`all`/`filter` — must see that raise; the infallible +/// [`is_true`] above is only for the short-circuit sites (`if`, `and`/`or`) +/// whose ambient error check catches the pending exception separately. +pub fn is_true_checked(s: &PyForeignSoul) -> Result { + (hooks()?.is_true)(s.ptr) +} + +pub fn call( + s: &PyForeignSoul, + args: &[Object], + kwargs: &[(String, Object)], +) -> Result { + (hooks()?.call)(s.ptr, args, kwargs) +} + +pub fn getattr(s: &PyForeignSoul, name: &str) -> Result { + (hooks()?.getattr)(s.ptr, name) +} + +pub fn setattr(s: &PyForeignSoul, name: &str, value: Option<&Object>) -> Result<(), RuntimeError> { + (hooks()?.setattr)(s.ptr, name, value) +} + +pub fn getitem(s: &PyForeignSoul, key: &Object) -> Result { + (hooks()?.getitem)(s.ptr, key) +} + +pub fn setitem( + s: &PyForeignSoul, + key: &Object, + value: Option<&Object>, +) -> Result<(), RuntimeError> { + (hooks()?.setitem)(s.ptr, key, value) +} + +pub fn length(s: &PyForeignSoul) -> Result { + (hooks()?.length)(s.ptr) +} + +pub fn iter(s: &PyForeignSoul) -> Result { + (hooks()?.iter)(s.ptr) +} + +pub fn iternext(s: &PyForeignSoul) -> Result, RuntimeError> { + (hooks()?.iternext)(s.ptr) +} + +pub fn binop(op: BinOpKind, a: &Object, b: &Object) -> Result { + (hooks()?.binop)(op, a, b) +} + +pub fn compare(op: CompareKind, a: &Object, b: &Object) -> Result { + (hooks()?.compare)(op, a, b) +} + +pub fn get_type(s: &PyForeignSoul) -> Object { + match hooks() { + Ok(h) => (h.get_type)(s.ptr), + Err(_) => Object::None, + } +} + +pub fn as_float(s: &PyForeignSoul) -> Result { + (hooks()?.as_float)(s.ptr) +} + +pub fn as_int(s: &PyForeignSoul) -> Result { + (hooks()?.as_int)(s.ptr) +} + +pub fn as_index(s: &PyForeignSoul) -> Result { + (hooks()?.as_index)(s.ptr) +} + +pub fn get_buffer(s: &PyForeignSoul) -> Result { + (hooks()?.get_buffer)(s.ptr) +} + +pub fn get_buffer_obj(obj: &Object) -> Result { + (hooks()?.get_buffer_obj)(obj) +} diff --git a/crates/weavepy-vm/src/gc_trace.rs b/crates/weavepy-vm/src/gc_trace.rs index 0a31dfd..8b3df14 100644 --- a/crates/weavepy-vm/src/gc_trace.rs +++ b/crates/weavepy-vm/src/gc_trace.rs @@ -1716,6 +1716,10 @@ pub fn traverse_object(obj: &Object, visit: &mut dyn FnMut(&Object)) { if let Some(native) = &i.native { traverse_object(native, visit); } + // A C extension type (RFC 0044) may hold child references in + // C-managed memory invisible to the dict walk above; give its + // registered `tp_traverse` bridge a chance to surface them. + run_external_traverse(obj, visit); } Object::Module(m) => { let Ok(dict) = m.dict.try_borrow() else { @@ -1852,12 +1856,28 @@ pub fn traverse_object(obj: &Object, visit: &mut dyn FnMut(&Object)) { /// Sync` and lives in a `OnceLock`. Each thread sees the same /// table — registrations are a global, additive operation. fn run_external_traverse(obj: &Object, visit: &mut dyn FnMut(&Object)) { - let table = TRAVERSE_TABLE.get_or_init(|| parking_lot::Mutex::new(Vec::new())); - let entries = table.lock(); - for entry in entries.iter() { - if (entry.matches)(obj) { - (entry.traverse)(obj, visit); - } + let Some(table) = TRAVERSE_TABLE.get() else { + return; + }; + // Snapshot the matching traverse fns, then *release* the table lock + // before invoking them. A C extension's `tp_traverse` calls our + // visitproc, which recurses back through the collector + // (`exc_has_finalizable` → `traverse_object`) and can re-enter + // `run_external_traverse` for a *nested* foreign object on the same + // thread — e.g. collecting a cycle through pandas' `BaseOffset`. + // `parking_lot::Mutex` is not reentrant, so holding the lock across the + // callback self-deadlocks. The table is registration-only and its + // entries are plain `fn` pointers, so a cheap snapshot is sound. + let matched: Vec = { + let entries = table.lock(); + entries + .iter() + .filter(|e| (e.matches)(obj)) + .map(|e| e.traverse) + .collect() + }; + for traverse in matched { + traverse(obj, visit); } } @@ -1880,6 +1900,48 @@ pub fn register_traverse( table.lock().push(TraverseEntry { matches, traverse }); } +#[allow(missing_debug_implementations)] +struct ClearEntry { + matches: fn(&Object) -> bool, + clear: fn(&Object), +} + +static CLEAR_TABLE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// Called from `clear_object_fields` to let a type whose child +/// references live in module-private (or C-managed) memory break its +/// cycles during the collector's clear phase. The companion of +/// [`register_traverse`] (RFC 0044, WS4). +fn run_external_clear(obj: &Object) { + let Some(table) = CLEAR_TABLE.get() else { + return; + }; + // Snapshot then release the lock before invoking, mirroring + // `run_external_traverse`: a C extension's `tp_clear` can re-enter the + // collector and thus this function for a nested foreign object on the + // same thread, which would self-deadlock on the non-reentrant mutex. + let matched: Vec = { + let entries = table.lock(); + entries + .iter() + .filter(|e| (e.matches)(obj)) + .map(|e| e.clear) + .collect() + }; + for clear in matched { + clear(obj); + } +} + +/// Register a clear callback, mirroring [`register_traverse`]. Invoked +/// during the collector's clear phase so a matching object can drop the +/// child references it holds outside the VM's view. +pub fn register_clear(matches: fn(&Object) -> bool, clear: fn(&Object)) { + let table = CLEAR_TABLE.get_or_init(|| parking_lot::Mutex::new(Vec::new())); + table.lock().push(ClearEntry { matches, clear }); +} + /// Drain a container's child references in place. Used during /// the GC's clear phase to break cycles. pub fn clear_object_fields(obj: &Object) { @@ -1904,6 +1966,13 @@ pub fn clear_object_fields(obj: &Object) { } } Object::Instance(i) => { + // Drop any C-held child references (RFC 0044) *first*: a readied + // extension type's `tp_clear` breaks cycles routed through + // C-managed memory that the dict/slots clears below can't see, and + // it typically reads its identity (`self._id`, …) back out of the + // instance dict to find its side-table slot — so it must run while + // that dict is still intact. + run_external_clear(obj); if let Ok(mut m) = i.dict.try_borrow_mut() { m.clear(); } @@ -2015,6 +2084,14 @@ pub fn track(obj: Object) { with_state(|s| s.track(obj)); } +/// Convenience: stop tracking `obj` (by identity) in the shared, +/// process-global GC. The inverse of [`track`]; backs the C-API +/// `PyObject_GC_UnTrack` (RFC 0044, WS4). +pub fn untrack(obj: &Object) { + let id = crate::weakref_registry::id_of(obj); + with_state(|s| s.untrack_id(id)); +} + /// Reinitialise the process-global cycle collector's locks in a `fork(2)` /// child. See [`GcState::reinit_after_fork_in_child`]. /// diff --git a/crates/weavepy-vm/src/lib.rs b/crates/weavepy-vm/src/lib.rs index ea58159..b5f7e5b 100644 --- a/crates/weavepy-vm/src/lib.rs +++ b/crates/weavepy-vm/src/lib.rs @@ -30,6 +30,7 @@ pub mod builtins; pub mod descr_registry; pub mod error; pub mod ext_loader; +pub mod foreign; pub mod frozen_code_cache; pub mod gc_trace; pub mod gil; @@ -842,6 +843,102 @@ impl Interpreter { self.repr_of(v, &globals) } + /// Public `str(v)` entry point for the C-API (`PyObject_Str`). Dispatches + /// the full `__str__` protocol — including a `__str__` *inherited* from a + /// Python base class — exactly as the `str()` builtin would, so a C + /// extension calling `PyObject_Str` on a VM instance (e.g. Cython's + /// `str(tz)` on a pytz `DstTzInfo`) sees the object's real string form + /// rather than the `` placeholder. + pub fn str_object(&mut self, v: &Object) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let globals = self.builtins.clone(); + self.stringify(v, &globals) + } + + /// Public `format(v, spec)` entry point for the C-API + /// (`PyObject_Format`). Dispatches the full `__format__` protocol — a + /// user `__format__`, a built-in subclass's inherited native formatting, + /// a foreign scalar's `__format__`, and the numeric/`str` format + /// mini-language — exactly as the `FORMAT_VALUE` bytecode and the + /// `format()` builtin do. A Cython f-string such as `f'{self.month:02d}'` + /// lowers to `PyObject_Format(int, "02d")`; without this the spec was + /// dropped and pandas' `Timestamp` repr rendered `2014-3-7 0:0:0`. + pub fn format_public(&mut self, v: &Object, spec: &str) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let globals = self.builtins.clone(); + self.format_obj_str(v, spec, &globals) + } + + /// C-API entry point for `PyObject_IsInstance`. Routes through the full + /// `isinstance()` protocol — tuples of classinfos, metaclass + /// `__instancecheck__` (the ABCMeta virtual-subclass mechanism behind + /// `numbers`, `collections.abc`, pandas' `ABCSeries`/`ABCIndex`), PEP 604 + /// unions and the legacy `__bases__` protocol — exactly like CPython's + /// `PyObject_IsInstance` in `Objects/abstract.c`. The bare structural + /// `is_subclass_of` MRO walk the C-API used before never fired + /// `__instancecheck__`, so Cython code such as pandas' `NAType.__add__` + /// (`isinstance(other, numbers.Number)`) saw plain `int`/`float` as + /// non-numbers and returned `NotImplemented`, surfacing as spurious + /// `unsupported operand type(s)` errors for `pd.NA + 1`, `pd.NA ** 2`, …. + pub fn isinstance_public( + &mut self, + obj: &Object, + classinfo: &Object, + ) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let globals = self.builtins.clone(); + Ok(self + .do_isinstance_call(obj, classinfo, &globals)? + .is_truthy()) + } + + /// C-API entry point for `PyObject_IsSubclass`; the class-membership + /// analogue of [`Self::isinstance_public`], honouring a metaclass + /// `__subclasscheck__` (ABCMeta) and tuples/unions of classinfos. + pub fn issubclass_public( + &mut self, + cls: &Object, + classinfo: &Object, + ) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let globals = self.builtins.clone(); + Ok(self + .do_issubclass_call(cls, classinfo, &globals)? + .is_truthy()) + } + + /// CPython `_PyErr_NormalizeException` for the C-API error bridge: turn a + /// pending `(type, value)` pair — set by `PyErr_SetObject` — into a real + /// exception *instance* by running the type's own constructor. When + /// `value` is a tuple it is splatted (`type(*value)`), `None` means no + /// args (`type()`), and anything else is a single argument + /// (`type(value)`). Running the true `__new__`/`__init__` is what lets a + /// C extension exception with a custom constructor keep its attributes: + /// numpy raises `_UFuncBinaryResolutionError` via `PyErr_SetObject(exc, + /// (ufunc, (dt0, dt1)))`, and only `UFuncTypeError.__init__` stores the + /// `self.ufunc`/`self.dtypes` its `__str__` later reads. Stringifying the + /// tuple into a single message (the old bridge behaviour) left `str(exc)` + /// raising `AttributeError: … has no attribute 'ufunc'`. + pub fn construct_exception_from_capi( + &mut self, + class: Rc, + value: Object, + ) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let globals = self.builtins.clone(); + let callable = Object::Type(class); + match value { + Object::Tuple(items) => self.call(&callable, &items, &[], &globals), + Object::None => self.call(&callable, &[], &[], &globals), + other => self.call(&callable, std::slice::from_ref(&other), &[], &globals), + } + } + /// Apply a binary operator to two objects, dispatching the full /// `__op__`/`__rop__` protocol exactly as the `BINARY_OP` bytecode would. /// Backs the `_operator` accelerator (`operator.add`, …) so those @@ -900,6 +997,22 @@ impl Interpreter { } }; } + // A faithful foreign numeric scalar (numpy `int64`/`float64`) reaches + // its unary op only through the bridged C-slot dunder; `unary_op` knows + // only VM-native scalars. + if matches!(v, Object::Foreign(_)) + && matches!(op, UnaryKind::Neg | UnaryKind::Pos | UnaryKind::Invert) + { + let globals = self.builtins.clone(); + let dunder = match op { + UnaryKind::Neg => "__neg__", + UnaryKind::Pos => "__pos__", + _ => "__invert__", + }; + if let Ok(method) = self.load_attr(v, dunder) { + return self.call(&method, &[], &[], &globals); + } + } unary_op(v, op) } @@ -932,6 +1045,20 @@ impl Interpreter { } } + /// Public `len(o)` entry point. Mirrors CPython's `PyObject_Length` + /// slot dispatch (`__len__` → native length → bridged C length slot). + /// Used by `PyObject_Length` in the C-API so that a `list`/`dict`/… + /// *subclass* instance resolves its length through the interpreter — + /// its C mirror's generic `sq_length` bridge would otherwise call + /// straight back into `PyObject_Length` and recurse forever + /// (`np.array(FrozenList(...))` after `PySequence_Check` succeeds). + pub fn len_object(&mut self, value: &Object) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let _handles = self.activate_thread_handles(); + self.accel_len(value) + } + /// Public iterator-construction entry point. Mirrors `iter(o)`. /// Used by `PyObject_GetIter` in the C-API. pub fn iter_object(&mut self, value: Object) -> Result { @@ -942,6 +1069,29 @@ impl Interpreter { self.make_iter(&value, &globals) } + /// Build a CPython `seqiterobject` over `seq` — the lazy + /// `__getitem__`-indexed iterator that the C-API's `PySeqIter_New` + /// returns. Used by `PySeqIter_New` in the C-API. + /// + /// Crucially this does **not** consult `seq.__iter__`: numpy's + /// `array_iter` (the ndarray `tp_iter`) returns `PySeqIter_New(self)`, + /// so routing through `__iter__` here would recurse forever + /// (`__iter__` → `array_iter` → `PySeqIter_New` → `__iter__` …) and + /// overflow the native stack. The indexing iterator instead drives + /// `seq[0]`, `seq[1]`, … until `IndexError`, exactly like CPython. + pub fn seq_iter_object(&mut self, seq: Object) -> Result { + let _interp_guard = + crate::vm_singletons::publish_interpreter_ptr(std::ptr::from_mut::(self)); + let _handles = self.activate_thread_handles(); + let globals = self.builtins.clone(); + match self.make_seq_iterator(&seq, &globals)? { + Some(it) => Ok(it), + None => Err(type_error( + "PySeqIter_New: sequence-iterator helper unavailable", + )), + } + } + /// Pull the next value out of an iterator (`next(it)`), returning /// `Ok(None)` for `StopIteration`. Used by `PyIter_Next` in the /// C-API. @@ -1149,6 +1299,29 @@ impl Interpreter { if !Self::is_refcount_dead(&dropped, 1) { return; } + // RFC 0045 (wave 5): if the dead subgraph contains any instance + // that has escaped into a C extension (owns a faithful inline + // body), do not reap it here. The reaper's deadness test is + // `Rc`-refcount only and cannot see a *borrowed* C reference — a + // raw body pointer an extension holds across a re-entrant call + // (pandas caches `Index._engine` and reads it back through + // `PyDict_GetItem` as a borrowed ref; an `Index`/`DataFrame` holds + // its `ndarray`/`BlockManager` the same way). The reaper reclaims + // the *pure-Python* holder (`Index`, whose own `c_body` is 0) + // whose `_cache`/`__dict__` transitively owns the escaped body; + // freeing it drops the body, the allocator hands the slot to the + // next `ndarray`, and the extension's borrowed pointer becomes a + // type-confused use-after-free (`'ndarray' has no attribute + // 'is_unique'` in `Index.unique` during `merge`). Leaving the + // subgraph to the tracing cycle collector is always safe — prompt + // reaping is only a `__del__`-timing optimisation, and a tracked + // object stays alive through its GC handle until C's transient + // borrows are gone. Every cascade child lives in `dropped`'s + // subgraph, so this single scan covers the whole walk. Inert for + // pure-Python programs (no instance ever owns a body). + if Self::subgraph_contains_escaped(&dropped) { + return; + } // RFC 0039 (WS4): cascade through the dead *acyclic* subgraph, // emulating CPython's refcount-driven `tp_dealloc` chain. Freeing // `dropped` drops the last reference to its tracked children, @@ -1653,6 +1826,112 @@ impl Interpreter { gc_trace::strong_count_for(obj) <= local_refs + registry_holds + weak_clones } + /// Has `obj` escaped into a C extension — does it own a faithful + /// inline instance body (RFC 0045, `c_body != 0`)? Such an instance's + /// pointer may be held *borrowed* (uncounted) by a C extension across + /// a re-entrant call into the VM, so the prompt reaper — whose + /// deadness test sees only VM `Rc` references — must not reclaim it + /// (that would free the body and let the allocator reuse it under the + /// extension's live pointer). The tracing cycle collector reclaims it + /// later, once C's transient borrows are gone. A single `Cell` read; + /// `false` for every pure-Python instance and non-instance object. + #[inline] + fn instance_escaped_to_c(obj: &Object) -> bool { + matches!(obj, Object::Instance(inst) if inst.c_body.get() != 0) + } + + /// Does the acyclic subgraph rooted at `root` contain any instance + /// that has escaped into a C extension (see [`instance_escaped_to_c`])? + /// Used by the prompt reaper to leave such subgraphs to the tracing + /// cycle collector (a C extension may hold a borrowed pointer into one + /// of the bodies). A bounded breadth-first walk over the same edges + /// `gc_trace::traverse_object` exposes, deduplicated by object id and + /// capped so a pathological structure can never make frame exit + /// quadratic; hitting the cap conservatively reports `true` (skip the + /// reap — always safe). Returns immediately for the overwhelmingly + /// common all-scalar / pure-Python subgraph. + fn subgraph_contains_escaped(root: &Object) -> bool { + // Fast path: a root that can hold no references (scalar, string, + // …) trivially has no escaped instance in its subgraph. This + // covers the overwhelming majority of reaped temporaries at zero + // allocation cost. + if Self::instance_escaped_to_c(root) { + return true; + } + if !Self::object_can_hold_refs(root) { + return false; + } + // Bounded DFS reusing a thread-local scratch stack + visited set so + // the hot reap path allocates nothing. Cap chosen well above + // pandas' shallow `_cache`/`__dict__` fan-out; on overflow we + // proceed with the reap (return `false`) — the only structures big + // enough to hit it are large escaped-free graphs, and a pandas + // escaped body is always shallow. + const VISIT_CAP: usize = 2048; + thread_local! { + static SCRATCH: std::cell::RefCell<( + Vec, + std::collections::HashSet, + )> = std::cell::RefCell::new((Vec::new(), std::collections::HashSet::new())); + } + SCRATCH.with(|cell| { + let Ok(mut guard) = cell.try_borrow_mut() else { + // Re-entrant (a finalizer walked here): fall back to a + // conservative "skip the reap" rather than risk aliasing + // the scratch buffers. + return true; + }; + let (stack, seen) = &mut *guard; + stack.clear(); + seen.clear(); + gc_trace::traverse_object(root, &mut |child| stack.push(child.clone())); + let mut visited = 0usize; + let mut found = false; + while let Some(obj) = stack.pop() { + if !seen.insert(crate::weakref_registry::id_of(&obj)) { + continue; + } + if Self::instance_escaped_to_c(&obj) { + found = true; + break; + } + visited += 1; + if visited > VISIT_CAP { + break; + } + if Self::object_can_hold_refs(&obj) { + gc_trace::traverse_object(&obj, &mut |child| stack.push(child.clone())); + } + } + stack.clear(); + seen.clear(); + found + }) + } + + /// Cheap discriminator: can `obj` hold references to other objects + /// (and therefore possibly reach an escaped instance)? `false` for + /// scalars/strings/bytes and other leaves, letting + /// [`subgraph_contains_escaped`] skip the walk entirely. + #[inline] + fn object_can_hold_refs(obj: &Object) -> bool { + matches!( + obj, + Object::List(_) + | Object::Tuple(_) + | Object::Dict(_) + | Object::Set(_) + | Object::FrozenSet(_) + | Object::MappingProxy(_) + | Object::SimpleNamespace(_) + | Object::Instance(_) + | Object::Cell(_) + | Object::BoundMethod(_) + | Object::Function(_) + | Object::Type(_) + ) + } + /// Emulate CPython deleting a terminating thread's thread-state /// dict: drop `ident`'s slot from every `_threading_local.local`. /// Run from the native worker teardown (`spawn_python_worker`) the @@ -4164,6 +4443,20 @@ impl Interpreter { } else { self.binary_subscr(&v, &i)? } + } else if matches!(&v, Object::Foreign(_)) { + // A foreign extension object (e.g. numpy's `flatiter`) + // reads through its own `__getitem__` slot wrapper — the + // mirror of the `Foreign` `STORE_SUBSCR` arm. `binary_subscr` + // only knows VM-native containers. + match self.load_attr(&v, "__getitem__") { + Ok(method) => self.call( + &method, + std::slice::from_ref(&i), + &[], + &frame.globals.clone(), + )?, + Err(_) => self.binary_subscr(&v, &i)?, + } } else { self.binary_subscr(&v, &i)? }; @@ -4174,7 +4467,7 @@ impl Interpreter { let target = frame.pop()?; let value = frame.pop()?; let g = frame.globals.clone(); - if let Object::Instance(_) = &target { + if let Object::Instance(inst) = &target { if let Some(method) = instance_method(&target, "__setitem__") { // A native-backed builtin subclass (`class C(list)`, // `class C(bytearray)`, …) that doesn't *override* @@ -4185,7 +4478,15 @@ impl Interpreter { // `store_subscr` instead — it collects the RHS via the // full VM protocol (`collect_iterable`) and splices the // unwrapped native payload, exactly as for a bare list. + // This detour only applies to instances carrying a VM + // native payload (`inst.native`); a faithful inline + // foreign instance (numpy `ndarray`, `native == None`) + // whose bridged `__setitem__` is *also* an + // `Object::Builtin` must dispatch its own C slot — that + // slot handles strided slice assignment correctly, + // which `store_subscr` (VM containers only) cannot. if matches!(i, Object::Slice(_)) + && inst.native.is_some() && bound_is_native_builtin(&method, "__setitem__") { self.store_subscr(&target, &i, value, &g)?; @@ -4195,6 +4496,25 @@ impl Interpreter { } else { self.store_subscr(&target, &i, value, &g)?; } + } else if matches!(&target, Object::Foreign(_)) { + // A foreign extension object (e.g. numpy's `flatiter`, + // whose type numpy does not expose for `PyType_Ready`, so + // it crosses as `Object::Foreign` rather than an + // `Object::Instance` like `ndarray`) assigns through its + // own `__setitem__` slot wrapper — the same bound method + // an explicit `obj.__setitem__(k, v)` resolves. `store_subscr` + // only knows VM-native containers, and `PyObject_SetItem` + // does not forward to the object's C `mp_ass_subscript`; + // dispatching the method reaches numpy's slot directly. + // This is what makes `numpy.eye` (`m.flat[i::M+1] = 1`) work. + match self.load_attr(&target, "__setitem__") { + Ok(method) => { + self.call(&method, &[i.clone(), value], &[], &g)?; + } + // No `__setitem__`: fall back for the canonical + // "does not support item assignment" TypeError. + Err(_) => self.store_subscr(&target, &i, value, &g)?, + } } else { self.store_subscr(&target, &i, value, &g)?; } @@ -4262,6 +4582,31 @@ impl Interpreter { } } } + } else if matches!(v, Object::Foreign(_)) + && matches!( + kind, + UnaryKind::Neg | UnaryKind::Pos | UnaryKind::Invert + ) + { + // A faithful foreign numeric scalar (numpy `int64`/`float64`) + // reaches its unary op only through the bridged C-slot dunder + // (`nb_negative`/`nb_positive`/`nb_invert`); `unary_op` knows + // only VM-native scalars. Resolve the slot wrapper and + // dispatch it, falling back to the native payload (if any) + // when the type lacks the dunder so the canonical + // "bad operand type for unary …" TypeError still surfaces. + let g = frame.globals.clone(); + let dunder = match kind { + UnaryKind::Neg => "__neg__", + UnaryKind::Pos => "__pos__", + _ => "__invert__", + }; + match self.load_attr(&v, dunder) { + Ok(method) => self.call(&method, &[], &[], &g)?, + Err(_) => { + unary_op(&v.native_value().unwrap_or_else(|| v.clone()), kind)? + } + } } else { unary_op(&v, kind)? }; @@ -4526,6 +4871,7 @@ impl Interpreter { | Object::Generator(_) | Object::Instance(_) | Object::LazyIter(_) + | Object::Foreign(_) ) { let fresh = self.make_iter(&it_obj, &frame.globals)?; if let Some(slot) = frame.stack.last_mut() { @@ -4574,6 +4920,13 @@ impl Interpreter { } } } + // A foreign/extension iterator (numpy array iterator, + // Cython generator, any C `tp_iternext`) advances via its + // `__next__`; `StopIteration` terminates the loop. + Object::Foreign(_) => { + let g = frame.globals.clone(); + self.foreign_iter_next(&it_obj, &g)? + } _ => { return Err(RuntimeError::Internal( "FOR_ITER expects iterator on stack".to_owned(), @@ -5942,6 +6295,44 @@ impl Interpreter { } } + /// Attach a traceback to a C-raised exception *instance* so that + /// `exc.__traceback__` is a real (non-None) traceback object. + /// + /// CPython attaches a traceback as an exception unwinds through Cython + /// C code (`__Pyx_AddTraceback` → `PyTraceBack_Here`). WeavePy raises + /// C-side exceptions purely through the pending-exception cell, so the + /// instance would otherwise carry `__traceback__ = None`. That breaks + /// Cython's `except SomeError:` handler: its `__Pyx__GetException` + /// fetches the traceback via `PyException_GetTraceback` and, on handler + /// exit, does an **unguarded** `Py_DECREF` on it — dereferencing NULL + /// and SIGSEGV'ing (seen catching `OutOfBoundsTimedelta` inside + /// `pandas` `_Timedelta.__hash__`). Seeding a real traceback pointing at + /// the current Python frame restores CPython's invariant. No-op when the + /// instance already has a traceback or no Python frame is on the stack. + pub fn attach_c_traceback(&self, exc: &Object) { + let Object::Instance(inst) = exc else { + return; + }; + let key = DictKey(Object::from_static("__traceback__")); + if let Some(existing) = inst.dict.borrow().get(&key) { + if !matches!(existing, Object::None) { + return; + } + } + let Some(py_frame) = self.frame_stack.borrow().last().cloned() else { + return; + }; + let new_tb = Rc::new(PyTraceback { + lineno: py_frame.last_line.get().unwrap_or(1), + lasti: py_frame.lasti.get(), + frame: py_frame, + next: RefCell::new(None), + }); + inst.dict + .borrow_mut() + .insert(key, Object::Traceback(new_tb)); + } + /// If the most-recent handled exception is still active when /// `raise X` runs, attach it as the new exception's `__context__` /// so chained tracebacks render `During handling of the above @@ -6465,6 +6856,22 @@ impl Interpreter { name ))), }, + // RFC 0046 (wave 4): a foreign extension object (numpy descr, + // scalar, …) resolves attributes through its bridged type's + // descriptors first — `PyType_Ready` harvested the C + // `tp_getset`/`tp_members`/`tp_methods` into the type dict, so + // `descr.type`, `descr.kind`, array methods, … apply the normal + // descriptor protocol with the foreign object as `self`. Names + // the bridged type does not carry fall back to the extension's + // own `tp_getattro` (instance `__dict__`, computed attributes), + // which dispatches to the object's C slot — not back into this + // arm — so there is no recursion. + Object::Foreign(s) => { + if let Some(res) = self.resolve_foreign_via_type(obj, name) { + return res; + } + crate::foreign::getattr(s, name) + } Object::Instance(inst) => self.load_attr_instance(inst, obj, name), Object::Type(ty) => self.load_attr_type(ty, name), Object::Property(p) => match name { @@ -6913,9 +7320,17 @@ impl Interpreter { "__name__" | "__qualname__" => { Ok(Object::from_static(builtin_display_name(b.name))) } - "__module__" => Ok(Object::from_static( - crate::descr_registry::module_of(obj).unwrap_or("builtins"), - )), + "__module__" => { + // RFC 0046 (wave 4): a runtime `func.__module__ = …` + // assignment (numpy's `_reconstruct`) wins over the + // static attribution. + if let Some(v) = crate::descr_registry::builtin_module_value(obj) { + return Ok(v); + } + Ok(Object::from_static( + crate::descr_registry::module_of(obj).unwrap_or("builtins"), + )) + } "__doc__" => Ok(builtin_doc(b.name) .map(Object::from_static) .unwrap_or(Object::None)), @@ -7171,6 +7586,19 @@ impl Interpreter { _ => {} } } + // `range.start` / `.stop` / `.step` read-only data + // attributes (CPython's `range` members). Unlike `slice` + // these are always concrete ints. pandas' `range_to_ndarray` + // reads `rng.start/stop/step` when building a frame column + // from a `range`. + if let Object::Range(r) = obj { + match name { + "start" => return Ok(crate::object::int_from_i128(r.start)), + "stop" => return Ok(crate::object::int_from_i128(r.stop)), + "step" => return Ok(crate::object::int_from_i128(r.step)), + _ => {} + } + } // Plain iterators expose the iterator protocol as real // attributes (`hasattr(it, '__next__')` gates e.g. // `dataclasses._get_slots`' iterator rejection). Wrapped in @@ -7264,6 +7692,17 @@ impl Interpreter { instance_obj: &Object, name: &str, ) -> Result { + if (name == "is_unique" || name == "unique") && std::env::var_os("WEAVEPY_ISU_DIAG").is_some() + { + eprintln!( + "[ISU] getattr name={} inst=0x{:x} cls={} c_body=0x{:x} strong={}", + name, + Rc::as_ptr(inst) as usize, + inst.cls().name, + inst.c_body.get(), + Rc::strong_count(inst), + ); + } let result = if let Some(getattribute) = self.user_getattribute(&inst.cls()) { // `dispatch` (not `new`): a `__getattribute__` that is itself a // descriptor (`__getattribute__ = SomeDescriptor()`) must have @@ -7901,6 +8340,57 @@ impl Interpreter { /// Run the descriptor protocol against `attr` (already resolved /// from a class MRO). `instance` is `Object::None` when accessed /// directly on the class (e.g. `Foo.bar`). + /// Resolve `name` on a foreign extension object through its bridged + /// type's descriptors (RFC 0046, wave 4), applying the descriptor + /// protocol with `obj` as `self`. Returns `None` when the bridged type + /// does not carry the name (the caller then consults the extension's + /// own `tp_getattro`); `Some(Ok|Err)` once the bridged type owns the + /// resolution. Must *not* itself fall back to the foreign getattr hook + /// — that would recurse through the C bridge. + pub fn resolve_foreign_via_type( + &mut self, + obj: &Object, + name: &str, + ) -> Option> { + let t = crate::builtins::class_of(obj); + let attr = t.lookup(name)?; + let owner = Object::Type(t); + Some(self.descriptor_get(&attr, obj, &owner)) + } + + /// Invoke a descriptor's getter/setter closure. For a harvested C + /// getset/member accessor (tagged in [`crate::descr_registry`]), call + /// its own closure directly, bypassing the dispatch loop's by-name + /// builtin fast-paths: the accessor is an `Object::Builtin` named after + /// the C attribute, and a name such as `"str"`/`"int"`/`"len"` would + /// otherwise be hijacked by the matching builtin fast-path. numpy's + /// `dtype.str` getset getter (`BuiltinFn { name: "str" }`) is the motivating + /// case — the `str(obj)` fast-path calls the dtype's `tp_str` + /// (`_dtype.__str__`, which reads `dtype.str`) and recurses without bound. + fn call_descriptor_accessor( + &mut self, + accessor: &Object, + args: &[Object], + kwargs: &[(String, Object)], + globals: &Rc>, + ) -> Result { + if let Object::Builtin(b) = accessor { + if crate::descr_registry::is_native_descr_accessor(b) { + if let Some(call_kw) = b.call_kw.as_ref() { + return call_kw(args, kwargs); + } + if !kwargs.is_empty() { + return Err(type_error(format!( + "builtin '{}' does not accept keyword arguments", + b.name + ))); + } + return (b.call)(args); + } + } + self.call(accessor, args, kwargs, globals) + } + pub(crate) fn descriptor_get( &mut self, attr: &Object, @@ -7915,7 +8405,7 @@ impl Interpreter { if matches!(prop.fget, Object::None) { return Err(attribute_error("unreadable attribute")); } - self.call( + self.call_descriptor_accessor( &prop.fget, std::slice::from_ref(instance), &[], @@ -8509,6 +8999,38 @@ impl Interpreter { Err(unsupported_format_string(value)) }; } + // A foreign extension scalar (numpy's `np.uint32`, …) overrides + // `__format__` — its scalars delegate to the matching `int`/`float` + // formatter — so dispatch through the binary-ABI bridge rather than + // the native mini-language. This is what makes `format(np.uint32(x), + // 'd')` and `str(ndarray)` (numpy formats each element with a spec) + // work; `object.__format__`'s native fallback would reject the spec. + if let Object::Foreign(s) = value { + // A numpy *scalar* (`np.uint32`, …) overrides `__format__`; use it. + // A numpy `dtype`/`ndarray` (and most extension objects) instead + // inherit `object.__format__`, which the foreign getattr bridge + // can't synthesise — so a missing slot falls back to + // `object.__format__` semantics (str(self) for an empty spec, a + // TypeError otherwise), never a spurious AttributeError. pandas' + // `invalid_comparison` builds `f"...dtype={left.dtype}..."`. + match crate::foreign::getattr(s, "__format__") { + Ok(method) => { + let r = self.call(&method, &[Object::from_str(spec)], &[], globals)?; + return Ok(match r { + Object::Str(s) => s.to_string(), + other => other.to_str(), + }); + } + Err(_) => { + let text = self.stringify(value, globals)?; + return if spec.is_empty() { + Ok(text) + } else { + Err(unsupported_format_string(value)) + }; + } + } + } format_via_spec(value, spec) } @@ -8549,7 +9071,30 @@ impl Interpreter { ))), }; } - Ok(Object::Int(v.len()? as i64)) + match v.len() { + Ok(n) => Ok(Object::Int(n as i64)), + Err(e) => { + // A foreign C-extension instance (e.g. a `numpy` dtype) exposes + // `__len__` only through its bridged type's C length slot + // (`mp_length`/`sq_length`), which the lightweight + // `instance_method` lookup above does not see — but full + // attribute resolution synthesises it from the slot. Consult + // that before giving up so `len(np.dtype('f8'))` answers `0` + // (pandas' `Series.__init__` does `if len(data.dtype):` to + // detect compound dtypes). + if let Ok(lenf) = self.load_attr_public(v, "__len__") { + let r = self.call(&lenf, &[], &[], globals)?; + return match r { + Object::Int(i) => Ok(Object::Int(i)), + other => Err(type_error(format!( + "'__len__' should return int, not '{}'", + other.type_name() + ))), + }; + } + Err(e) + } + } } /// `abs(x)` — dispatch `__abs__` for class instances (CPython calls @@ -8565,6 +9110,19 @@ impl Interpreter { if let Some(method) = instance_method(v, "__abs__") { return self.call(&method, &[], &[], globals); } + // A faithful foreign numeric scalar (numpy `int64`/`float64`, and + // numpy arrays) exposes its magnitude only through the bridged + // C-slot dunder (`nb_absolute` → `__abs__`); `b_abs` knows only + // VM-native scalars. Mirror the `UnaryOp` dispatch: resolve + // `__abs__` via normal attribute lookup and call it before falling + // back. Without this, `abs(np.float64(x))` (e.g. pandas' own + // `abs(a - b)` reduction checks) tripped `b_abs`'s guard with the + // useless "bad operand type for abs(): 'object'". + if matches!(v, Object::Foreign(_)) { + if let Ok(method) = self.load_attr(v, "__abs__") { + return self.call(&method, &[], &[], globals); + } + } // A built-in numeric subclass with no `__abs__` override (e.g. // `class CS(complex)`) unwraps to its native payload so `abs()` // applies the base type's magnitude rather than tripping @@ -8587,6 +9145,18 @@ impl Interpreter { let extra: &[Object] = if args.len() >= 2 { &args[1..2] } else { &[] }; return self.call(&method, extra, &[], globals); } + // A faithful foreign numeric scalar (numpy `float64`/`int64`) + // exposes `__round__` only through its bridged type; `b_round` + // knows only VM-native scalars. Mirror `do_abs_call`: resolve + // `__round__` by normal attribute lookup and call it, so + // `round(np.float64(x), n)` (pandas' `.corr()`/`.cov()` results + // are numpy scalars) works instead of tripping the guard below. + if matches!(value, Object::Foreign(_)) { + if let Ok(method) = self.load_attr(value, "__round__") { + let extra: &[Object] = if args.len() >= 2 { &args[1..2] } else { &[] }; + return self.call(&method, extra, &[], globals); + } + } } // Unwrap a built-in numeric subclass with no `__round__` override // to its native payload (`class MyFloat(float)` → the float) so @@ -8623,6 +9193,39 @@ impl Interpreter { return Ok(r); } } + // A *foreign* numeric scalar (numpy `int64`/`float64`) carries + // `divmod` in its C `nb_divmod` slot, invisible to the VM's + // `instance_method` dunder lookup above. Mirror `do_round_call`'s + // foreign arm: resolve `__divmod__`/`__rdivmod__` on the bridged + // type and call it, then — if the slot declines or is absent — fall + // back to `(a // b, a % b)` through the *full* operator dispatch + // (`op_binary` routes foreign operands through the C number suite, + // the same path standalone `//`/`%` already take). The bare + // `b_divmod` used below cannot see foreign slots and would misreport + // the failure as an `unsupported operand type(s) for //`. This is + // what `datetime.fromordinal(np.int64(...))`'s `_ord2ymd` — a + // `divmod(ordinal, _DI400Y)` on a numpy-seeded ordinal — needs. + if matches!(a, Object::Foreign(_)) || matches!(b, Object::Foreign(_)) { + if matches!(a, Object::Foreign(_)) { + if let Ok(method) = self.load_attr(a, "__divmod__") { + let r = self.call(&method, std::slice::from_ref(b), &[], globals)?; + if !r.is_same(¬_impl) { + return Ok(r); + } + } + } + if matches!(b, Object::Foreign(_)) { + if let Ok(method) = self.load_attr(b, "__rdivmod__") { + let r = self.call(&method, std::slice::from_ref(a), &[], globals)?; + if !r.is_same(¬_impl) { + return Ok(r); + } + } + } + let q = self.op_binary(a, b, weavepy_compiler::BinOpKind::FloorDiv)?; + let r = self.op_binary(a, b, weavepy_compiler::BinOpKind::Mod)?; + return Ok(Object::new_tuple(vec![q, r])); + } // The dunder protocol is exhausted. For a user instance with no // native numeric payload, raise the canonical `divmod()` TypeError // (falling through to the primitive path would misreport it as `//`). @@ -8736,6 +9339,66 @@ impl Interpreter { ) })); } + if let Object::Foreign(s) = a { + // A foreign extension scalar (numpy `complex64`/`complex128`/ + // `float64`/…) exposes `__complex__`/`__float__`/`__index__` + // through the binary-ABI bridge. Mirror the `Object::Instance` + // path above so `complex(np.complex128(..))` and + // `cmath.isclose(c64, c64)` work — CPython's `complex_new` / + // `PyComplex_AsCComplex` honour the same hooks (`__complex__` + // first for the real arg, then `PyNumber_Float`). + if allow_complex { + // CPython `complex_new` fast path: an operand that *is* a + // complex yields its stored value directly — no `__complex__` + // dispatch, no warning. A numpy scalar that *subclasses* the + // builtin (`complex128 <: complex`) inherits `__complex__` + // from the WeavePy base in its MRO, but the C `tp_getattro` + // the foreign getattr hook uses walks only numpy's own C dict + // and misses it — so `complex(np.complex128(1+2j))` otherwise + // fell through to `__float__` and dropped the imaginary part + // (`(1+0j)`). Read the scalar's `real`/`imag` components to + // rebuild the faithful native complex. (The non-subclass + // `complex64` is *not* an instance of `complex`, so it skips + // this and uses its own `__complex__` below.) + let complex_ty = + Object::Type(crate::builtin_types::builtin_types().complex_.clone()); + if self.do_isinstance_call(a, &complex_ty, globals)?.is_truthy() { + let re = + crate::builtins::coerce_f64_opt(&self.load_attr_public(a, "real")?)?; + let im = + crate::builtins::coerce_f64_opt(&self.load_attr_public(a, "imag")?)?; + if let (Some(re), Some(im)) = (re, im) { + return Ok(Object::new_complex(re, im)); + } + } + if let Ok(method) = crate::foreign::getattr(s, "__complex__") { + let r = self.call(&method, &[], &[], globals)?; + return self.check_complex_result(r); + } + } + if let Ok(v) = crate::foreign::as_float(s) { + return Ok(v); + } + if let Ok(v) = crate::foreign::as_index(s) { + if long_overflows_f64(&v) { + return Err(overflow_error("int too large to convert to float")); + } + if let Some(f) = v.as_f64() { + return Ok(Object::Float(f)); + } + } + return Err(type_error(if allow_complex { + format!( + "complex() first argument must be a string or a number, not '{}'", + a.type_name_owned() + ) + } else { + format!( + "complex() second argument must be a number, not '{}'", + a.type_name_owned() + ) + })); + } Ok(a.native_value().unwrap_or_else(|| a.clone())) } @@ -8967,6 +9630,13 @@ impl Interpreter { v: &Object, globals: &Rc>, ) -> Result { + // A *foreign* value drives its own `nb_bool` (CPython + // `PyObject_IsTrue`): a multi-element numpy array raises "truth value + // ... is ambiguous" rather than testing truthy-by-default. Propagate + // that error; the infallible `Object::is_truthy` swallows it to `true`. + if let Object::Foreign(s) = v { + return crate::foreign::is_true_checked(s); + } if let Object::Instance(_) = v { if let Some(method) = instance_method(v, "__bool__") { let r = self.call(&method, &[], &[], globals)?; @@ -9071,6 +9741,13 @@ impl Interpreter { // subclass inherits the base type's value-returning `__int__`, // so this also covers `int(IntSubclass())`. if !has_base { + // A foreign extension scalar (numpy's `np.int64`, …) is + // opaque to `instance_method`; drive its `nb_int`/`nb_index` + // slot through the binary-ABI bridge (CPython's + // `PyNumber_Long`). `int(np.uint32(x))` reaches here. + if let Object::Foreign(s) = other { + return self.check_int_result(other, "__int__", crate::foreign::as_int(s)?); + } if let Some(method) = instance_method(other, "__int__") { let r = self.call(&method, &[], &[], globals)?; return self.check_int_result(other, "__int__", r); @@ -9226,6 +9903,14 @@ impl Interpreter { | Object::ByteArray(_) => builtins::b_float_compat(args), Object::MemoryView(_) => builtins::b_float_compat(args), other => { + // A foreign extension scalar (numpy's `np.float64`, the result + // of `array.sum()`/`.mean()`, …) is opaque to `instance_method`; + // drive its `nb_float`/`nb_index` slot through the binary-ABI + // bridge (CPython's `PyNumber_Float`). `float(a.sum())` reaches + // here. + if let Object::Foreign(s) = other { + return self.check_float_result(other, crate::foreign::as_float(s)?); + } // CPython's `PyNumber_Float`: try `__float__` (which must // return a float; a strict subclass is accepted with a // DeprecationWarning), then `__index__` (an int, converted with @@ -9673,13 +10358,14 @@ impl Interpreter { let items = self.collect_iterable(&args[1], globals)?; let mut out = Vec::new(); for item in items { - let keep = if use_pred { + let verdict = if use_pred { self.call(&func, std::slice::from_ref(&item), &[], globals)? - .is_truthy() } else { - item.is_truthy() + item.clone() }; - if keep { + // `PyObject_IsTrue` on the predicate result / element (a foreign + // multi-element array raises "truth value ... ambiguous"). + if self.obj_truthy(&verdict, globals)? { out.push(item); } } @@ -9817,7 +10503,10 @@ impl Interpreter { let want_any = name == "any"; let it = self.make_iter(&args[0], globals)?; while let Some(x) = self.iter_next(&it, globals)? { - if x.is_truthy() { + // `PyObject_IsTrue` per element (a foreign multi-element array + // raises "truth value ... ambiguous"): route through the faithful + // truthiness, not the infallible `is_truthy`. + if self.obj_truthy(&x, globals)? { if want_any { return Ok(Object::Bool(true)); } @@ -9951,7 +10640,13 @@ impl Interpreter { // we'd compute by default, so skip it to avoid recursion. if !Rc::ptr_eq(&meta, &builtin_types().type_) { if let Some(hook) = meta.lookup("__instancecheck__") { - let bound = Object::BoundMethod(Rc::new(BoundMethod::new( + // `dispatch` (not `new`) so a descriptor-wrapped hook + // honours its `__get__`: pandas builds `ABCSeries` etc. + // from a metaclass whose `__instancecheck__` is a + // `@classmethod`, and CPython's `_PyObject_LookupSpecial` + // binds it via the descriptor protocol. A plain-function + // hook still falls through to the receiver-prepend path. + let bound = Object::BoundMethod(Rc::new(BoundMethod::dispatch( Object::Type(cls.clone()), hook, ))); @@ -10125,7 +10820,10 @@ impl Interpreter { let meta = info_cls.metaclass_or_type(); if !Rc::ptr_eq(&meta, &builtin_types().type_) { if let Some(hook) = meta.lookup("__subclasscheck__") { - let bound = Object::BoundMethod(Rc::new(BoundMethod::new( + // `dispatch` so a `@classmethod` (pandas' ABC shims) or + // other descriptor-wrapped hook honours its `__get__`; + // plain functions still take the receiver-prepend path. + let bound = Object::BoundMethod(Rc::new(BoundMethod::dispatch( Object::Type(info_cls.clone()), hook, ))); @@ -10792,18 +11490,245 @@ impl Interpreter { } } - /// Crate-visible attribute load for builtins that need full - /// dispatch (e.g. weakproxy forwarding). - pub(crate) fn load_attr_public( + /// Attribute load with full `LOAD_ATTR` dispatch, for builtins and + /// the C-API bridge (`PyObject_GetAttr*`) that need the same + /// resolution the bytecode path performs (descriptors, function/ + /// generator slots, `__getattr__`, foreign objects, …). + pub fn load_attr_public(&mut self, obj: &Object, name: &str) -> Result { + self.load_attr(obj, name) + } + + /// Public binary-operator entry for the C-API bridge + /// (`PyNumber_Add`/…). Dispatches the full `__op__`/`__rop__` protocol + /// exactly as the `BINARY_OP` bytecode would — so `str % args` + /// formatting, sequence concatenation/repetition, and user-class + /// operator overloads all resolve identically to the interpreter. + pub fn binary_op_public( + &mut self, + a: &Object, + b: &Object, + op: weavepy_compiler::BinOpKind, + ) -> Result { + self.op_binary(a, b, op) + } + + /// Public unary-operator entry for the C-API bridge + /// (`PyNumber_Negative`/`Positive`/`Absolute`/`Invert`). Dispatches the + /// full `__neg__`/`__pos__`/`__invert__`/`__abs__` protocol exactly as the + /// `UNARY_OP` bytecode would — crucially routing a *foreign* extension + /// operand through `load_attr` (its type's method table), which is how + /// scalar `-td` on a Cython `pandas.Timedelta` resolves. numpy's + /// object-dtype unary ufunc loop (`np.negative(arr)`) calls + /// `PyNumber_Negative` per element; without this bridge a foreign operand + /// with no mirrored `nb_negative` slot produced a NULL/`TypeError` instead + /// of the negated value. + pub fn op_unary_public( + &mut self, + v: &Object, + op: UnaryKind, + ) -> Result { + self.op_unary(v, op) + } + + /// Public `abs()` entry for the C-API bridge (`PyNumber_Absolute`). + /// `abs` is a builtin rather than a `UNARY_OP`, so it is not covered by + /// [`op_unary`]; this mirrors that method's structure — a foreign / + /// user operand dispatches through `__abs__` (`load_attr` + call, the + /// same route scalar `abs(td)` on a Cython object takes), everything + /// else through the native [`crate::builtins::b_abs`]. Prevents the + /// C-API's old `_ => null` arm from planting NULLs in a numpy + /// `object` array under `np.abs`. + pub fn abs_public(&mut self, v: &Object) -> Result { + if matches!(v, Object::Instance(_) | Object::Foreign(_)) { + let globals = self.builtins.clone(); + if let Ok(method) = self.load_attr(v, "__abs__") { + return self.call(&method, &[], &[], &globals); + } + } + crate::builtins::b_abs(std::slice::from_ref(v)) + } + + /// Public rich-comparison entry for the C-API bridge + /// (`PyObject_RichCompare`). Dispatches the full `do_richcompare` + /// protocol exactly as the `COMPARE_OP` bytecode would — forward then + /// reflected `__lt__`/`__eq__`/… dunders, `__ne__`-from-`__eq__`, + /// foreign `tp_richcompare` slots, and the recursive per-element + /// comparison for native containers (tuple/list ordering, the case + /// Cython's import-time `(major, minor)` version checks hit). The + /// capi's own `compare_objects` only knew built-in scalars, so + /// `tuple >= tuple` was a spurious `TypeError`. + pub fn rich_compare_public( + &mut self, + a: &Object, + b: &Object, + op: CompareKind, + ) -> Result { + let globals = self.builtins.clone(); + self.rich_compare_obj(a, b, op, &globals) + } + + /// Public hash entry for the C-API bridge (`PyObject_Hash`). Routes + /// through the same `do_hash_call` the `hash()` builtin uses, so a + /// value hashed from inside a C extension (Cython's + /// `hash(tuple(self._items))`) lands on the *identical* CPython-faithful + /// hash the VM computes — the capi's old `DefaultHasher`-on-`DictKey` + /// produced a divergent value, breaking cross-path hash equality. + pub fn hash_public(&mut self, obj: &Object) -> Result { + let globals = self.builtins.clone(); + match self.do_hash_call(obj, &globals)? { + Object::Int(i) => Ok(i), + Object::Bool(b) => Ok(i64::from(b)), + Object::Long(b) => Ok(crate::object::py_hash_long_bigint(&b)), + other => Err(type_error(format!( + "__hash__ method should return an integer, not '{}'", + other.type_name() + ))), + } + } + + /// Public subscript-read entry for the C-API bridge + /// (`PyObject_GetItem` / `PyMapping_GetItem`). Dispatches the full + /// `__getitem__` protocol exactly as the `BINARY_SUBSCR` bytecode would: + /// a user-defined instance `__getitem__` (honouring `__missing__` for + /// `dict` subclasses), a metaclass `__getitem__` then `__class_getitem__` + /// / PEP 585 alias for a class operand, and a foreign extension object's + /// own `__getitem__` slot wrapper (numpy's `flatiter`, an `ndarray`'s + /// `mp_subscript`). The capi's own `get_item` only knew native + /// containers, so subscripting any of these crossed back as a spurious + /// "not subscriptable" `TypeError`. + pub fn subscr_get_public(&mut self, v: &Object, i: &Object) -> Result { + let g = self.builtins.clone(); + if let Object::Instance(inst) = v { + let user_getitem = inst + .cls() + .lookup_with_owner("__getitem__") + .filter(|(_, owner)| !owner.flags.is_builtin || inst.native.is_none()) + .map(|(m, _)| m); + if let Some(m) = user_getitem { + let method = Object::BoundMethod(Rc::new(BoundMethod::dispatch(v.clone(), m))); + return self.call(&method, std::slice::from_ref(i), &[], &g); + } + return match self.binary_subscr(v, i) { + Err(RuntimeError::PyException(exc)) + if exc.type_name() == "KeyError" + && matches!(v.native_value(), Some(Object::Dict(_))) => + { + match instance_method(v, "__missing__") { + Some(miss) => self.call(&miss, std::slice::from_ref(i), &[], &g), + None => Err(RuntimeError::PyException(exc)), + } + } + r => r, + }; + } + if let Object::Type(ty) = v { + let meta = ty.metaclass_or_type(); + let bt = builtin_types(); + let meta_getitem = if Rc::ptr_eq(&meta, &bt.type_) { + None + } else { + meta.lookup("__getitem__") + }; + if let Some(method) = meta_getitem { + let bound = + Object::BoundMethod(Rc::new(BoundMethod::new(Object::Type(ty.clone()), method))); + return self.call(&bound, std::slice::from_ref(i), &[], &g); + } + if let Some(method) = ty.lookup("__class_getitem__") { + let callable = match method { + Object::ClassMethod(inner) => inner.func(), + Object::StaticMethod(inner) => inner.func(), + other => other, + }; + return self.call(&callable, &[Object::Type(ty.clone()), i.clone()], &[], &g); + } + if ty.flags.is_builtin && !ty.flags.is_exception { + return Ok(make_generic_alias(Object::Type(ty.clone()), i.clone())); + } + return self.binary_subscr(v, i); + } + if matches!(v, Object::Foreign(_)) { + return match self.load_attr(v, "__getitem__") { + Ok(method) => self.call(&method, std::slice::from_ref(i), &[], &g), + Err(_) => self.binary_subscr(v, i), + }; + } + self.binary_subscr(v, i) + } + + /// Public subscript-store entry for the C-API bridge + /// (`PyObject_SetItem` / `PyMapping_SetItem`). Mirrors `STORE_SUBSCR`: + /// an instance `__setitem__` (with the native-slice fast path for a + /// builtin-backed subclass), a foreign object's `__setitem__` slot + /// wrapper (numpy `m.flat[i::M+1] = 1`), else the native container + /// store. The capi previously only handled `dict`/`list`, so assigning + /// into a numpy array crossed back as "object does not support item + /// assignment". + pub fn subscr_set_public( &mut self, - obj: &Object, - name: &str, - ) -> Result { - self.load_attr(obj, name) + target: &Object, + i: &Object, + value: Object, + ) -> Result<(), RuntimeError> { + let g = self.builtins.clone(); + if let Object::Instance(inst) = target { + if let Some(method) = instance_method(target, "__setitem__") { + // Slice assignment detours to `store_subscr` only for a VM + // native-backed builtin subclass (`inst.native` set); a + // faithful inline foreign instance (numpy `ndarray`) dispatches + // its own bridged C `__setitem__` slot, which handles strided + // slice assignment that `store_subscr` cannot. + if matches!(i, Object::Slice(_)) + && inst.native.is_some() + && bound_is_native_builtin(&method, "__setitem__") + { + return self.store_subscr(target, i, value, &g); + } + self.call(&method, &[i.clone(), value], &[], &g)?; + return Ok(()); + } + return self.store_subscr(target, i, value, &g); + } + if matches!(target, Object::Foreign(_)) { + return match self.load_attr(target, "__setitem__") { + Ok(method) => { + self.call(&method, &[i.clone(), value], &[], &g)?; + Ok(()) + } + Err(_) => self.store_subscr(target, i, value, &g), + }; + } + self.store_subscr(target, i, value, &g) + } + + /// Public subscript-delete entry for the C-API bridge + /// (`PyObject_DelItem` / `PyMapping_DelItem`). Mirrors `DELETE_SUBSCR`: + /// an instance `__delitem__`, a foreign object's `__delitem__` slot + /// wrapper, else the native container delete. + pub fn subscr_del_public(&mut self, target: &Object, i: &Object) -> Result<(), RuntimeError> { + let g = self.builtins.clone(); + if let Object::Instance(_) = target { + if let Some(method) = instance_method(target, "__delitem__") { + self.call(&method, std::slice::from_ref(i), &[], &g)?; + return Ok(()); + } + return self.delete_subscr(target, i); + } + if matches!(target, Object::Foreign(_)) { + return match self.load_attr(target, "__delitem__") { + Ok(method) => { + self.call(&method, std::slice::from_ref(i), &[], &g)?; + Ok(()) + } + Err(_) => self.delete_subscr(target, i), + }; + } + self.delete_subscr(target, i) } - /// Crate-visible attribute store (weakproxy `__setattr__` forwarding). - pub(crate) fn store_attr_public( + /// Attribute store mirroring `STORE_ATTR` (weakproxy `__setattr__` + /// forwarding; the C-API `PyObject_SetAttr` bridge in `weavepy-capi`). + pub fn store_attr_public( &mut self, obj: &Object, name: &str, @@ -10812,8 +11737,9 @@ impl Interpreter { self.store_attr(obj, name, value) } - /// Crate-visible attribute delete (weakproxy `__delattr__` forwarding). - pub(crate) fn delete_attr_public( + /// Attribute delete mirroring `DELETE_ATTR` (weakproxy `__delattr__` + /// forwarding; the C-API `PyObject_SetAttr(o, n, NULL)` bridge). + pub fn delete_attr_public( &mut self, obj: &Object, name: &str, @@ -10871,6 +11797,14 @@ impl Interpreter { } return self.repr_of(v, globals); } + // RFC 0046 (wave 4): a foreign extension object's `str` comes from its + // own `tp_str` (falling back to `tp_repr`), not the VM's repr-only + // `to_str`. numpy's `str(dtype)` is `'float64'` (its `tp_str`), + // distinct from `repr(dtype)` = `dtype('float64')`; routing through + // `repr` here collapsed the two. + if let Object::Foreign(s) = v { + return crate::foreign::str_(s); + } if let Object::Long(b) = v { crate::builtins::long_str_limit_check(b)?; } @@ -10982,6 +11916,40 @@ impl Interpreter { Ok(false) } + /// CPython `PySequence_Contains` / the `in` operator, exposed for the + /// C-API bridge. Mirrors the `ContainsOp` bytecode handler: an + /// instance/metaclass `__contains__` wins; a built-in-container subclass + /// tests through its wrapped native payload; a pure-Python class falls + /// back to iteration (`_PySequence_IterSearch`); otherwise the native + /// [`Object::contains`] (covering dict / set / mappingproxy / range / + /// bytes / …). The C-API `PySequence_Contains` previously listed only a + /// handful of container shapes and returned a silent `-1` (error with no + /// exception) for everything else — a dict in particular, which is how + /// Cython compiles `val in module_global_dict` (pandas' `_try_infer_map`). + pub fn py_contains( + &mut self, + container: &Object, + item: &Object, + ) -> Result { + if let Some(method) = instance_method(container, "__contains__") + .or_else(|| metaclass_method(container, "__contains__")) + { + let g = self.builtins.clone(); + let r = self.call(&method, std::slice::from_ref(item), &[], &g)?; + return Ok(r.is_truthy()); + } + if let Object::Instance(inst) = container { + return match inst.native.clone() { + Some(native) => native.contains(item), + None => { + let g = self.builtins.clone(); + self.contains_via_iter(container, item, &g) + } + }; + } + container.contains(item) + } + /// `item in ` with CPython `PySequence_Contains` /// semantics: identity first, then rich `==` per element. The fast /// native equality is used unless a user-defined `__eq__` could be @@ -10997,8 +11965,17 @@ impl Interpreter { if x.is_same(item) { return Ok(true); } - let needs_dispatch = - matches!(x, Object::Instance(_)) || matches!(item, Object::Instance(_)); + // Escalate to the full rich-comparison protocol whenever either + // operand can carry custom `__eq__` semantics the native + // `eq_value` fast path doesn't know about: pure-Python instances + // AND foreign (C-extension) objects. A numpy `dtype`/scalar is an + // `Object::Foreign`, and `dtype == "int8"` is `True` via + // `dispatch_compare_op` (reflected to the foreign operand) — so + // `dtype in ["int8", ...]` must reach that same path rather than + // `eq_value`, which only compares native structural values and + // reports every foreign-vs-primitive pair unequal. + let needs_dispatch = matches!(x, Object::Instance(_) | Object::Foreign(_)) + || matches!(item, Object::Instance(_) | Object::Foreign(_)); if needs_dispatch { if self.dispatch_compare_op(x, item, CompareKind::Eq, globals)? { return Ok(true); @@ -11099,6 +12076,24 @@ impl Interpreter { v.type_name_owned() ))) } + // A foreign/extension object (numpy array, a Cython generator, + // any C type exposing `tp_iter`) becomes iterable through the + // Python iterator protocol: call `__iter__` and hand back the + // resulting iterator (frequently the object itself). `FOR_ITER` + // / `iter_next` then drive it via `__next__`. Without this, + // `for x in ` — e.g. pandas' + // `libinternals.get_blkno_placements`, reached by + // `merge`/`reindex`/`drop` — raised a spurious + // "'object' object is not iterable". + Object::Foreign(_) => { + if let Ok(method) = self.load_attr(v, "__iter__") { + return self.call(&method, &[], &[], globals); + } + Err(type_error(format!( + "'{}' object is not iterable", + v.type_name_owned() + ))) + } Object::Type(ty) => { // Iterating a class consults the metaclass for // `__iter__` — that's how `list(MyEnum)` works. @@ -11389,6 +12384,10 @@ impl Interpreter { } Err(e) => Err(e), }, + // A foreign/extension iterator (numpy array iterator, Cython + // generator, any C `tp_iternext`) is advanced through its + // `__next__`, with `StopIteration` meaning exhaustion. + Object::Foreign(_) => self.foreign_iter_next(iter, globals), _ => Err(type_error(format!( "'{}' object is not an iterator", iter.type_name_owned() @@ -11396,6 +12395,32 @@ impl Interpreter { } } + /// Advance a foreign/extension iterator by dispatching its `__next__` + /// through normal attribute lookup, translating `StopIteration` into + /// exhaustion (`Ok(None)`). This is the generic bridge that lets any C + /// iterator — numpy array iterators, Cython generators + /// (`yield`-functions compiled to C), user extension types — drive + /// `for` loops, `list()`, `tuple()`, comprehensions, etc. + fn foreign_iter_next( + &mut self, + iter: &Object, + globals: &Rc>, + ) -> Result, RuntimeError> { + match self.load_attr(iter, "__next__") { + Ok(method) => match self.call(&method, &[], &[], globals) { + Ok(v) => Ok(Some(v)), + Err(RuntimeError::PyException(exc)) if exc.type_name() == "StopIteration" => { + Ok(None) + } + Err(e) => Err(e), + }, + Err(_) => Err(type_error(format!( + "'{}' object is not an iterator", + iter.type_name_owned() + ))), + } + } + /// Advance a native lazy itertools adapter ([`Object::LazyIter`]). /// Runs at the interpreter level because the wrapped source can be /// any VM iterable (generator, user `__next__`, native iterator) — @@ -13604,6 +14629,22 @@ impl Interpreter { globals: &Rc>, ) -> Result { let (dunder, rdunder) = binop_dunders(op); + // RFC 0047 (wave 5): a *foreign* operand carries its arithmetic in + // its C `tp_as_number` slots, invisible to the VM dunder lookup + // below. Route through the foreign binop hook (CPython's slot + // protocol over the C number suite — `float32 - float32` from + // numpy's import-time `getlimits` math lands here). The hook raises + // the canonical TypeError when C declines for both operands; only + // swallow *that* so a Python `__op__`/`__rop__` on a non-foreign + // partner still gets its turn, and propagate any other error. + if matches!(a, Object::Foreign(_)) || matches!(b, Object::Foreign(_)) { + match crate::foreign::binop(op, a, b) { + Ok(r) if !r.is_same(&crate::vm_singletons::not_implemented()) => return Ok(r), + Ok(_) => {} + Err(e) if is_type_error(&e) => {} + Err(e) => return Err(e), + } + } // CPython's `binary_op1`: try `a.__op__(b)`, then `b.__rop__(a)`. // Either may *decline* by returning `NotImplemented`, in which case // we must keep looking rather than propagate the sentinel — a @@ -13639,7 +14680,15 @@ impl Interpreter { } } } - if let Some(method) = instance_method(a, dunder) { + // A *class* operand carries its operator on its metaclass + // (`c_int * 4` is `type(c_int).__mul__(c_int, 4)`; ctypes builds + // array types this way). `instance_method` only walks the operand's + // own MRO, so fall back to the metaclass dunder for `Type` operands + // (`metaclass_method` returns `None` for the plain `type` metaclass, + // so built-in classes like `int`/`str` are unaffected). + if let Some(method) = + instance_method(a, dunder).or_else(|| metaclass_method(a, dunder)) + { let r = self.call(&method, std::slice::from_ref(b), &[], globals)?; if !r.is_same(¬_impl) { return Ok(r); @@ -13647,7 +14696,9 @@ impl Interpreter { a_declined = true; } if !reflected_tried { - if let Some(method) = instance_method(b, rdunder) { + if let Some(method) = + instance_method(b, rdunder).or_else(|| metaclass_method(b, rdunder)) + { let r = self.call(&method, std::slice::from_ref(a), &[], globals)?; if !r.is_same(¬_impl) { return Ok(r); @@ -13863,7 +14914,7 @@ impl Interpreter { if matches!(fget, Object::None) { return None; } - self.call(&fget, std::slice::from_ref(obj), &[], globals) + self.call_descriptor_accessor(&fget, std::slice::from_ref(obj), &[], globals) .ok() } Object::Instance(desc) if desc.cls().lookup("__get__").is_some() => { @@ -13894,7 +14945,14 @@ impl Interpreter { op: CompareKind, globals: &Rc>, ) -> Result { - Ok(self.rich_compare_obj(a, b, op, globals)?.is_truthy()) + // CPython's `PyObject_RichCompareBool` truth-tests the comparison + // *result* with `PyObject_IsTrue`, so `arr == x` yielding a + // multi-element numpy bool array raises "truth value ... ambiguous" + // (membership, `list.index`, `array_equivalent`). Route through the + // faithful truthiness rather than the infallible `is_truthy`, which + // reports every foreign result truthy-by-default. + let r = self.rich_compare_obj(a, b, op, globals)?; + self.obj_truthy(&r, globals) } /// Rich comparison returning the *raw* object a dunder produced, as @@ -13919,6 +14977,19 @@ impl Interpreter { // and only if *both* decline fall through to the native default // (identity for ==/!=, `TypeError` for an ordering). let not_impl = crate::vm_singletons::not_implemented(); + // RFC 0047 (wave 5): a foreign operand compares through its C + // `tp_richcompare` slot, not the VM dunders below. Route through the + // foreign compare hook (CPython's `do_richcompare`). A + // `NotImplemented` decline falls through to the VM path so `==`/`!=` + // still get the identity / `__eq__`-derived defaults. + if matches!(a, Object::Foreign(_)) || matches!(b, Object::Foreign(_)) { + match crate::foreign::compare(op, a, b) { + Ok(r) if !r.is_same(¬_impl) => return Ok(r), + Ok(_) => {} + Err(e) if is_type_error(&e) => {} + Err(e) => return Err(e), + } + } if let Some(method) = self.cmp_method(a, dunder, globals) { let r = self.call(&method, std::slice::from_ref(b), &[], globals)?; if !r.is_same(¬_impl) { @@ -13973,6 +15044,31 @@ impl Interpreter { Ok(Object::Bool(compare_op(a, b, op)?)) } + /// CPython's `object.__ne__` (`object_richcompare` with `Py_NE`): invoke + /// `a`'s *forward* `__eq__` slot and invert its truthiness, propagating a + /// `NotImplemented` decline so the reflected `__ne__` still gets its turn. + /// + /// The old default short-circuited on identity (`a is b -> False`), which is + /// wrong for any type whose `__eq__` returns `False` for equal/identical + /// operands: `Decimal("NaN") != Decimal("NaN")` must be `True`, and + /// `pandas.isna(Decimal("NaN"))` depends on exactly that via + /// `libmissing.checknull` (`isinstance(v, Decimal) and v != v`). + pub(crate) fn object_default_ne( + &mut self, + a: &Object, + b: &Object, + globals: &Rc>, + ) -> Result { + let not_impl = crate::vm_singletons::not_implemented(); + if let Some(method) = self.cmp_method(a, "__eq__", globals) { + let r = self.call(&method, std::slice::from_ref(b), &[], globals)?; + if !r.is_same(¬_impl) { + return Ok(Object::Bool(!r.is_truthy())); + } + } + Ok(not_impl) + } + // ---------- RFC 0021 specialized fast paths ---------- /// Run the `BINARY_OP` cache machinery. Returns `Ok(true)` if a @@ -15368,6 +16464,24 @@ impl Interpreter { Ok(()) } }, + // RFC 0046 (wave 4): a `builtin_function_or_method` carries a + // single writable member, `__module__` (CPython's `m_module`). + // Extensions assign it at import (numpy's `_reconstruct`); every + // other attribute is a read-only getset / absent. + Object::Builtin(_) => match name { + "__module__" => { + crate::descr_registry::set_builtin_module(obj, value); + Ok(()) + } + "__doc__" | "__name__" | "__qualname__" | "__self__" | "__text_signature__" => { + Err(attribute_error(format!( + "attribute '{name}' of 'builtin_function_or_method' objects is not writable" + ))) + } + _ => Err(attribute_error(format!( + "'builtin_function_or_method' object has no attribute '{name}'" + ))), + }, _ => Err(type_error(format!( "'{}' object has no attribute '{}'", obj.type_name(), @@ -15412,6 +16526,63 @@ impl Interpreter { self.generic_setattr_instance(inst, obj, name, value) } + /// CPython `PyObject_GenericGetAttr` — the `object.__getattribute__` slot + /// body, exposed for the C-API bridge. Resolves data descriptors → + /// instance dict → class attrs *without* re-dispatching a + /// `__getattribute__` / `tp_getattro` override and *without* consulting + /// `__getattr__` (matching CPython, where the slot wrapper, not the + /// generic body, runs the hook). + /// + /// A C extension type whose `tp_getattro` is set to + /// `PyObject_GenericGetAttr` — the universal CPython idiom, e.g. a proxy + /// that special-cases one name then defers to the generic fallback — + /// otherwise recurses without bound: the bridge's full `LOAD_ATTR` would + /// re-enter the type's own `tp_getattro`, which calls + /// `PyObject_GenericGetAttr` again. This entry point breaks the cycle. + pub fn generic_getattr_public( + &mut self, + obj: &Object, + name: &str, + ) -> Result { + match obj { + Object::Instance(inst) => { + let inst = inst.clone(); + self.load_attr_instance_default(&inst, obj, name) + } + // Other shapes have no `tp_*attro` shim that re-enters the bridge, + // so the full lookup is both safe and the correct generic result. + _ => self.load_attr(obj, name), + } + } + + /// CPython `PyObject_GenericSetAttr` — the `object.__setattr__` / + /// `object.__delattr__` slot body, exposed for the C-API bridge. A `value` + /// of `None` is the delete form (`PyObject_GenericSetAttr(o, name, NULL)`). + /// Never re-dispatches a user `__setattr__` / `__delattr__` / + /// `tp_setattro`, so a C type whose `tp_setattro` calls + /// `PyObject_GenericSetAttr` does not recurse (see + /// [`Interpreter::generic_getattr_public`]). + pub fn generic_setattr_public( + &mut self, + obj: &Object, + name: &str, + value: Option, + ) -> Result<(), RuntimeError> { + match obj { + Object::Instance(inst) => { + let inst = inst.clone(); + match value { + Some(v) => self.generic_setattr_instance(&inst, obj, name, v), + None => self.generic_delattr_instance(&inst, obj, name), + } + } + _ => match value { + Some(v) => self.store_attr_public(obj, name, v), + None => self.delete_attr_public(obj, name), + }, + } + } + /// CPython `PyObject_GenericSetAttr` — the `object.__setattr__` /// slot body: honours data descriptors, `__class__`/`__dict__` /// special handling and `__slots__` enforcement, but does *not* @@ -15569,7 +16740,12 @@ impl Interpreter { ))); } let setter = prop.fset.clone(); - self.call(&setter, &[obj.clone(), value], &[], &self.builtins.clone())?; + self.call_descriptor_accessor( + &setter, + &[obj.clone(), value], + &[], + &self.builtins.clone(), + )?; return Ok(()); } Object::SlotDescriptor(_) => { @@ -16023,11 +17199,19 @@ impl Interpreter { coerced_index = Object::Slice(Rc::new(resolve_slice_ints(s)?)); &coerced_index } - inst @ Object::Instance(_) - if is_sequence && instance_method(inst, "__index__").is_some() => - { - coerced_index = Object::Int(crate::builtins::coerce_index_i64(inst)?); - &coerced_index + idx @ (Object::Instance(_) | Object::Foreign(_)) if is_sequence => { + // Honour the full `__index__` protocol (CPython's + // `PyNumber_Index`), including a C-slot `nb_index` reached only + // through the bridge — a `numpy` integer scalar indexing a + // tuple/list (`BlockManager.blocks[blknos[i]]`). A value with no + // `__index__` falls through to the sequence-specific TypeError. + match crate::builtins::try_coerce_index_i64(idx) { + Some(res) => { + coerced_index = Object::Int(res?); + &coerced_index + } + None => index, + } } Object::Long(_) if is_sequence => { return Err(index_error("cannot fit 'int' into an index-sized integer")) @@ -16292,11 +17476,19 @@ impl Interpreter { coerced_index = Object::Slice(Rc::new(resolve_slice_ints(s)?)); &coerced_index } - inst @ Object::Instance(_) - if is_sequence && instance_method(inst, "__index__").is_some() => - { - coerced_index = Object::Int(crate::builtins::coerce_index_i64(inst)?); - &coerced_index + idx @ (Object::Instance(_) | Object::Foreign(_)) if is_sequence => { + // Honour the full `__index__` protocol (CPython's + // `PyNumber_Index`), including a C-slot `nb_index` reached only + // through the bridge — a `numpy` integer scalar indexing a + // tuple/list (`BlockManager.blocks[blknos[i]]`). A value with no + // `__index__` falls through to the sequence-specific TypeError. + match crate::builtins::try_coerce_index_i64(idx) { + Some(res) => { + coerced_index = Object::Int(res?); + &coerced_index + } + None => index, + } } Object::Long(_) if is_sequence => { return Err(index_error("cannot fit 'int' into an index-sized integer")) @@ -16534,11 +17726,19 @@ impl Interpreter { coerced_index = Object::Slice(Rc::new(resolve_slice_ints(s)?)); &coerced_index } - inst @ Object::Instance(_) - if is_sequence && instance_method(inst, "__index__").is_some() => - { - coerced_index = Object::Int(crate::builtins::coerce_index_i64(inst)?); - &coerced_index + idx @ (Object::Instance(_) | Object::Foreign(_)) if is_sequence => { + // Honour the full `__index__` protocol (CPython's + // `PyNumber_Index`), including a C-slot `nb_index` reached only + // through the bridge — a `numpy` integer scalar indexing a + // tuple/list (`BlockManager.blocks[blknos[i]]`). A value with no + // `__index__` falls through to the sequence-specific TypeError. + match crate::builtins::try_coerce_index_i64(idx) { + Some(res) => { + coerced_index = Object::Int(res?); + &coerced_index + } + None => index, + } } _ => index, }; @@ -16924,11 +18124,26 @@ impl Interpreter { // and Python-callable iterables work (e.g. // `ipaddress`'s `map(cls._parse_octet, octets)`), then // hand the native helper a concrete sequence. + // + // `byteorder=`/`signed=` are almost always passed by + // keyword (`pickle`'s `decode_long` does + // `int.from_bytes(data, byteorder='little', signed=True)`). + // Forward kwargs through `call_kw`; the old + // `(b.call)(…)` dropped them, so every little-endian + // decode silently fell back to big-endian — which + // corrupted every large-int (`>= 2**31`) round-trip + // through pickle protocol 2+. let off = usize::from( args.first() .map(|o| o.is_int_like() || matches!(o, Object::Type(_))) .unwrap_or(false), ); + let invoke = |a: &[Object]| -> Result { + match b.call_kw.as_ref() { + Some(ckw) => ckw(a, kwargs), + None => (b.call)(a), + } + }; if let Some(data) = args.get(off) { let bytes_like = data.as_bytes_view().is_some() || matches!( @@ -16939,10 +18154,10 @@ impl Interpreter { let items = self.collect_iterable(data, outer_globals)?; let mut new_args = args.to_vec(); new_args[off] = Object::new_tuple(items); - return (b.call)(&new_args); + return invoke(&new_args); } } - return (b.call)(args); + return invoke(args); } if b.name == "map" && args.len() >= 2 { return self.do_map_call(args, outer_globals); @@ -17082,6 +18297,9 @@ impl Interpreter { if b.name == "__vm:input" { return self.do_input_call(args, outer_globals); } + if b.name == "__vm:type_alias" { + return self.do_type_alias_call(args, outer_globals); + } if b.name == "__vm:__import__" { // ``__import__(name, globals=None, locals=None, // fromlist=(), level=0)`` — mirror @@ -17572,6 +18790,14 @@ impl Interpreter { return call_kw(args, kwargs); } if !kwargs.is_empty() { + if std::env::var_os("WEAVEPY_TRACE_INIT").is_some() { + let keys: Vec<&str> = kwargs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!( + "[BLTKW] builtin {:?} rejected kwargs {keys:?}\n{}", + b.name, + std::backtrace::Backtrace::force_capture() + ); + } return Err(type_error(format!( "builtin '{}' does not accept keyword arguments", b.name @@ -18224,6 +19450,27 @@ impl Interpreter { resolved_bases.push(b.clone()); continue; } + // PEP 585 generic alias base — `class PrettyDict(dict[_KT, _VT])`. + // CPython's `GenericAlias.__mro_entries__` substitutes the origin + // class, so the real base is `dict`. Our alias is a + // `SimpleNamespace` carrying `__origin__` with no `__mro_entries__` + // attribute; left unresolved it stays a base, and because its class + // is `GenericAlias` it then *wins* the metaclass race and class + // creation miscalls `GenericAlias(name, bases, ns)` ("GenericAlias + // expected 2 arguments, got 3"). Resolve it to its origin here. + if is_generic_alias(b) { + if let Object::SimpleNamespace(d) = b { + if let Some(origin) = d + .borrow() + .get(&DictKey(Object::from_static("__origin__"))) + .cloned() + { + resolved_bases.push(origin); + bases_replaced = true; + continue; + } + } + } // CPython's `map`/`filter`/`zip`/`enumerate` are real types and // subclassable. WeavePy dispatches *calls* to them through fast // builtin functions, but a class statement naming one as a base @@ -18841,6 +20088,15 @@ impl Interpreter { .collect::>()?, _ => return Err(type_error("type() arg 2 must be tuple of bases")), }; + if std::env::var_os("WEAVEPY_TRACE_SUPER").is_some() + && (name == "TimeRE" || name == "TimeRE") + { + let bn: Vec = bases + .iter() + .map(|b| format!("{}({:?})", b.name, b.mro.borrow().iter().map(|t| t.name.clone()).collect::>())) + .collect(); + eprintln!("[MKCLASS] name={name} bases={bn:?}"); + } let ns_dict_obj = args[2].clone(); let mut ns = match &args[2] { Object::Dict(d) => d.borrow().clone(), @@ -18896,6 +20152,10 @@ impl Interpreter { } } let ty = TypeObject::new_user(&name, effective_bases.clone(), ns)?; + if std::env::var_os("WEAVEPY_TRACE_SUPER").is_some() && name == "TimeRE" { + let m: Vec = ty.mro.borrow().iter().map(|t| t.name.clone()).collect(); + eprintln!("[MKCLASS2] created TimeRE ptr={:p} mro={m:?}", Rc::as_ptr(&ty)); + } ty.set_metaclass(metaclass.clone()); if let Some(Object::Cell(cell)) = classcell { *cell.borrow_mut() = Object::Type(ty.clone()); @@ -20298,6 +21558,16 @@ impl Interpreter { Object::Builtin(b) if b.name == "__init__" && b.call_kw.is_none() ); let init_kwargs: &[(String, Object)] = if drop_init_kwargs { &[] } else { kwargs }; + if !init_kwargs.is_empty() + && matches!(&init, Object::Builtin(b) if b.name == "__init__" && b.call_kw.is_none()) + && std::env::var_os("WEAVEPY_TRACE_INIT").is_some() + { + let keys: Vec<&str> = init_kwargs.iter().map(|(k, _)| k.as_str()).collect(); + eprintln!( + "[INIT] cls={} is_object_new={is_object_new} init=builtin __init__ kwargs={keys:?}", + cls.name + ); + } let bound = Object::BoundMethod(Rc::new(BoundMethod::new(instance.clone(), init))); let result = self.call( &bound, @@ -21166,6 +22436,59 @@ impl Interpreter { } } + /// PEP 695 `type Name[T, U] = body` runtime constructor. + /// + /// The parser lowers the statement to + /// `__weavepy_type_alias__('Name', ('T', 'U'), lambda T, U: body)`. + /// We mint one TypeVar-shaped placeholder per declared parameter and + /// build a lazy `typing.TypeAliasType`, so the body thunk runs only + /// when `Name.__value__` is first read — matching CPython, and + /// crucially letting numpy's `_typing` aliases (`type ArrayLike = + /// Buffer | _DualArrayLike[np.dtype, …]`) be *defined* without + /// eagerly building unions or subscripting sibling aliases. + fn do_type_alias_call( + &mut self, + args: &[Object], + outer_globals: &Rc>, + ) -> Result { + let name = match args.first() { + Some(s @ Object::Str(_)) => s.clone(), + Some(other) => Object::from_str(other.to_str()), + None => return Err(type_error("__weavepy_type_alias__() missing name")), + }; + let param_names: Vec = match args.get(1) { + Some(Object::Tuple(t)) => t.iter().cloned().collect(), + Some(Object::None) | None => Vec::new(), + Some(other) => { + return Err(type_error(format!( + "__weavepy_type_alias__() type-params must be a tuple, not '{}'", + other.type_name() + ))) + } + }; + let thunk = match args.get(2) { + Some(obj) => obj.clone(), + None => { + return Err(type_error( + "__weavepy_type_alias__() missing lazy-value thunk", + )) + } + }; + let mut typevars: Vec = Vec::with_capacity(param_names.len()); + for pn in ¶m_names { + typevars.push(crate::builtins::b_typevar(std::slice::from_ref(pn))?); + } + let ctor = self.module_attr("typing", "TypeAliasType").ok_or_else(|| { + runtime_error("typing.TypeAliasType unavailable for PEP 695 `type` alias") + })?; + self.call( + &ctor, + &[name, Object::new_tuple(typevars), thunk], + &[], + outer_globals, + ) + } + /// Parse `source`, replaying any tokenizer-collected invalid-escape /// `SyntaxWarning`s through the `warnings` machinery, then map a parse /// failure to a located `SyntaxError`. The single funnel for the @@ -21403,6 +22726,63 @@ impl Interpreter { } } + /// CPython `meth_reduce`: reduce a `builtin_function_or_method` (and the + /// `method_descriptor`/`wrapper_descriptor`/`method-wrapper` siblings) by + /// reference. Returns `None` for anything that is not such a callable, so + /// the normal object reduction proceeds unchanged. + fn maybe_reduce_builtin_callable( + &mut self, + recv: &Object, + ) -> Result, RuntimeError> { + let ty_name = crate::builtins::class_of(recv).name.clone(); + if !matches!( + ty_name.as_str(), + "builtin_function_or_method" + | "method_descriptor" + | "wrapper_descriptor" + | "method-wrapper" + | "method_wrapper" + ) { + return Ok(None); + } + // Bare name (`ml_name`), matching `meth_reduce` — *not* the dotted + // `__qualname__`: the getattr path fetches the attribute off `self` + // by its short name, and the string path lets pickle re-resolve it + // through `__module__`. + let name = match self.load_attr(recv, "__name__") { + Ok(Object::Str(s)) => s.to_string(), + _ => return Ok(None), + }; + // A NULL / module `__self__` means module-level → pickle by name. + let self_obj = self.load_attr(recv, "__self__").ok(); + let module_level = match &self_obj { + None | Some(Object::None) => true, + Some(Object::Module(_)) => true, + Some(o) => crate::builtins::class_of(o).name == "module", + }; + if module_level { + return Ok(Some(Object::from_str(name))); + } + // Bound to an instance → `(getattr, (self, name))`. `getattr` is + // fetched from the live `builtins` so it round-trips through any + // unpickler by name. + let self_obj = self_obj.expect("module_level=false implies Some"); + let key = DictKey(Object::from_static("getattr")); + let getattr_fn = self + .cache + .get("builtins") + .and_then(|m| match m { + Object::Module(m) => m.dict.borrow().get(&key).cloned(), + _ => None, + }) + .or_else(|| self.builtins.borrow().get(&key).cloned()) + .ok_or_else(|| runtime_error("builtin 'getattr' unavailable"))?; + Ok(Some(Object::new_tuple(vec![ + getattr_fn, + Object::new_tuple(vec![self_obj, Object::from_str(name)]), + ]))) + } + /// The default object reduction, delegated to the verbatim-ported /// `copyreg` helpers so the (subtle) per-protocol rules live in one /// place: `_reduce_newobj` for protocol 2+, `_reduce_ex` (CPython's @@ -21413,6 +22793,20 @@ impl Interpreter { proto: i64, globals: &Rc>, ) -> Result { + // Built-in functions and methods (`builtin_function_or_method`) + // reduce by *reference*, exactly like CPython's `meth_reduce`: a + // module-level function (`__self__` is NULL or a module) pickles as + // the bare name string — pickle then re-imports it via the object's + // `__module__` — while a method bound to an instance pickles as + // `(getattr, (self, name))`. Without this, pickling *any* object + // whose reduce names a C function fails with + // "cannot pickle 'builtin_function_or_method' object"; the canonical + // victim is every numpy array, whose `__reduce__` references + // `numpy._core.multiarray._reconstruct` — so Index/Series/DataFrame + // and every dtype that wraps an ndarray were unpicklable. + if let Some(reduced) = self.maybe_reduce_builtin_callable(recv)? { + return Ok(reduced); + } let helper_name = if proto >= 2 { "_reduce_newobj" } else { @@ -21987,7 +23381,18 @@ impl Interpreter { } } let sub_name = format!("{absolute}.{s}"); - let _ = self.import_path(&sub_name); + if let Err(e) = self.import_path(&sub_name) { + if std::env::var_os("WEAVEPY_DEBUG_FROMLIST").is_some() { + match &e { + RuntimeError::PyException(pe) => eprintln!( + "[fromlist] {sub_name}: {}: {}", + pe.type_name(), + pe.message() + ), + other => eprintln!("[fromlist] {sub_name}: {other}"), + } + } + } } } } @@ -22325,6 +23730,10 @@ impl Interpreter { } fn meta_path_import_inner(&mut self, full: &str) -> Result, RuntimeError> { + // Tag the `_weave_import_fallback` helper uses for a module it built and + // registered itself (PEP 451 create/exec, no code object). Kept byte + // identical to `_LIVE_MODULE` in `_weave_import_fallback.py`. + const LIVE_MODULE_SENTINEL: &str = "\u{0}weave-live-module"; let helper = match self.import_path("_weave_import_fallback") { Ok(Object::Module(m)) => m, _ => return Ok(None), @@ -22352,6 +23761,25 @@ impl Interpreter { Object::Tuple(t) => t, _ => return Ok(None), }; + // A finder that builds its *own* module (PEP 451 + // create_module/exec_module, no code object) — six's + // `_SixMetaPathImporter` for the virtual `six.moves`, consumed by + // `dateutil.tz`'s `from six.moves import _thread`. The helper has + // already constructed, registered (`sys.modules[full]`) and executed + // it; cache and return the live object verbatim. It may be a + // `types.ModuleType` *subclass* instance (six's `_MovedItems`), not a + // native `Object::Module`, so attribute access on it goes through the + // normal descriptor/`__getattr__` path rather than module-dict lookup. + if let Some(Object::Str(tag)) = payload.first() { + if tag.as_ref() == LIVE_MODULE_SENTINEL { + let module_obj = payload.get(1).cloned().unwrap_or(Object::None); + if matches!(module_obj, Object::None) { + return Ok(None); + } + self.cache.insert(full, module_obj.clone()); + return Ok(Some(module_obj)); + } + } let Some(Object::Code(code_rc)) = payload.first().cloned() else { return Ok(None); }; @@ -22507,7 +23935,14 @@ impl Interpreter { self.cache.remove(full); return Err(e); } - Ok(module_obj) + // CPython `_load_unlocked`: after the body runs the module is + // re-read from `sys.modules` (`module = sys.modules.pop(name)`), + // because the body may have *replaced* its own entry. `decimal.py` + // does exactly this when the C `_decimal` accelerator is absent — + // `import _pydecimal; sys.modules[__name__] = _pydecimal` — so the + // public `decimal` module must become `_pydecimal` (with all 80+ + // names such as `InvalidOperation`), not the 7-key shell we seeded. + Ok(self.cache.get(full).unwrap_or(module_obj)) } /// Read, parse, compile, and execute the module's source. @@ -22578,7 +24013,11 @@ impl Interpreter { self.cache.remove(full); return Err(e); } - Ok(module_obj) + // CPython `_load_unlocked` re-reads `sys.modules[name]` after the + // body runs: a module may replace its own entry (e.g. `decimal.py` + // sets `sys.modules[__name__] = _pydecimal`). Hand back whatever is + // bound now, not the shell we seeded before execution. + Ok(self.cache.get(full).unwrap_or(module_obj)) } /// `IMPORT_FROM` runtime side. Looks up `name` on the module on @@ -22642,6 +24081,33 @@ impl Interpreter { } Err(err) } + // Not a *native* `Object::Module`, but `sys.modules` may legitimately + // hold any object — most importantly a `types.ModuleType` *subclass* + // instance. six's virtual `six.moves` is such a `_MovedItems`, and + // `dateutil.tz` does `from six.moves import _thread`. Mirror + // CPython's `import_from`: try a plain attribute access first (six + // resolves `_thread`/`range`/… through a lazy class descriptor that + // imports the real target on `__get__`), then fall back to a + // `{pkg}.{name}` submodule the fromlist machinery may have already + // placed in `sys.modules` (or that the finder can build on demand). + Object::Instance(_) => { + match self.load_attr(module, name) { + Ok(v) => Ok(v), + Err(e) if self.is_attribute_error(&e) => { + if let Ok(Object::Str(pkg)) = self.load_attr(module, "__name__") { + let candidate = format!("{pkg}.{name}"); + if let Some(sub) = self.cache.get(&candidate) { + return Ok(sub); + } + if let Ok(sub) = self.load_one(&candidate) { + return Ok(sub); + } + } + Err(import_error(format!("cannot import name '{name}'"))) + } + Err(e) => Err(e), + } + } other => Err(type_error(format!( "IMPORT_FROM on non-module: '{}'", other.type_name() @@ -23120,12 +24586,21 @@ pub(crate) fn slice_bound_index(o: &Object) -> Result, RuntimeError> i64::MAX }))) } - Object::Instance(_) => Ok(Some(crate::builtins::coerce_index_i64(o).map_err( - |_| type_error("slice indices must be integers or None or have an __index__ method"), - )?)), - _ => Err(type_error( - "slice indices must be integers or None or have an __index__ method", - )), + // Any other object is a valid slice bound iff it implements + // `__index__` (CPython's `_PyEval_SliceIndex` → `PyNumber_Index`). + // This covers user classes (`Object::Instance`) *and* C-extension + // integer scalars reached through the bridge (`Object::Foreign` — + // e.g. a numpy `intp` from `BlockManager.blknos[loc]`, sliced as + // `old_blocks[:blkno]` in pandas' outer-merge block split). + // `try_coerce_index_i64` returns `Some(Err(..))` when `__index__` + // exists but raises (propagate that real error) and `None` when the + // type has no `__index__` (raise the slice `TypeError`). + _ => match crate::builtins::try_coerce_index_i64(o) { + Some(res) => Ok(Some(res?)), + None => Err(type_error( + "slice indices must be integers or None or have an __index__ method", + )), + }, } } @@ -24301,10 +25776,35 @@ fn sort_key_needs_dunder_lt_depth(o: &Object, depth: u32) -> bool { return false; } match o { - Object::Instance(inst) => matches!( - inst.cls().lookup("__lt__"), - Some(Object::Function(_) | Object::BoundMethod(_)) - ), + Object::Instance(inst) => { + // A user-defined Python `__lt__`/bound method always needs the + // Python `<` path (the historical case: `pprint`'s `_safe_key`). + if matches!( + inst.cls().lookup("__lt__"), + Some(Object::Function(_) | Object::BoundMethod(_)) + ) { + return true; + } + // CPython's `list.sort`/`sorted` *always* order through `<` + // (`PyObject_RichCompareBool(x, y, Py_LT)`); WeavePy's native + // `Object::cmp` is only a safe fast path when it yields the + // identical answer. That holds for a plain value subclass + // (`class E(int)`) — ordered by its `native_value()` payload — + // but NOT for a Cython/C extension class surfaced as an + // `Object::Instance` (pandas `Period`/`Timestamp`, + // `decimal.Decimal`) whose `__lt__` is a C slot wrapper + // (`builtin_function_or_method`) and which carries no native + // scalar for `Object::cmp` to order. Detect exactly that: an + // ordering dunder is present, yet `native_value()` is `None`, so + // the native path would raise a spurious "'<' not supported". + o.native_value().is_none() + && (inst.cls().lookup("__lt__").is_some() || inst.cls().lookup("__gt__").is_some()) + } + // A genuinely foreign extension object (a bare numpy scalar not + // surfaced as an `Object::Instance`, …) orders through its + // `tp_richcompare` slot, which the native `Object::cmp` total order + // cannot reach — so it must sort through Python's `<`. + Object::Foreign(_) => true, Object::Tuple(items) => items .iter() .any(|x| sort_key_needs_dunder_lt_depth(x, depth + 1)), @@ -25385,6 +26885,14 @@ pub(crate) fn percent_format_with( // any real number here (`'%.*g' % (p, float_subclass)`). let real = match &item { Object::Instance(_) => item.native_value().unwrap_or_else(|| item.clone()), + // A numpy float/int scalar (`'%g' % np.float64(x)`, + // pandas' `to_csv` decimal path) is foreign; CPython + // runs it through `PyFloat_AsDouble` (its `nb_float` + // slot), so coerce here rather than raise "must be real + // number". On failure fall back so the error below fires. + Object::Foreign(s) => { + crate::foreign::as_float(s).unwrap_or_else(|_| item.clone()) + } _ => item.clone(), }; if !percent_is_real(&real) { @@ -26836,6 +28344,19 @@ fn binary_op(a: &Object, b: &Object, op: BinOpKind) -> Result return Ok(O::Bool(*x & *y)), + B::BitOr => return Ok(O::Bool(*x | *y)), + B::BitXor => return Ok(O::Bool(*x ^ *y)), + _ => {} + } + } // Promote bool → int where appropriate. let (a, b) = (promote_bool(&a), promote_bool(&b)); @@ -27171,6 +28692,34 @@ fn binary_op(a: &Object, b: &Object, op: BinOpKind) -> Result { + let (seq, count) = if is_repeatable_sequence(&a) { + (&a, &b) + } else { + (&b, &a) + }; + match crate::builtins::try_coerce_index_i64(count) { + Some(n) => binary_op(seq, &O::Int(n?), B::Mult), + None => Err(type_error(format!( + "can't multiply sequence by non-int of type '{}'", + count.type_name_owned() + ))), + } + } + _ => Err(type_error(format!( "unsupported operand type(s) for {}: '{}' and '{}'", op.as_str(), @@ -27180,6 +28729,22 @@ fn binary_op(a: &Object, b: &Object, op: BinOpKind) -> Resultsq_repeat` slot participates in the +/// `seq * int` / `int * seq` repeat protocol (CPython's `PyNumber_Multiply` +/// fallback). Numbers, `dict`, `set`, `range`, and `memoryview` are +/// excluded — none define `sq_repeat`. +fn is_repeatable_sequence(o: &Object) -> bool { + matches!( + o, + Object::List(_) + | Object::Tuple(_) + | Object::Str(_) + | Object::WStr(_) + | Object::Bytes(_) + | Object::ByteArray(_) + ) +} + /// Validate a sequence-repeat count before allocating: CPython raises /// `OverflowError` when `len * n` exceeds `PY_SSIZE_T_MAX` (e.g. /// `b"abc" * sys.maxsize`) — without this, `Vec::with_capacity` aborts @@ -27223,6 +28788,18 @@ fn is_union_eligible(obj: &Object) -> bool { matches!(obj, Object::Type(_) | Object::None) || is_pep604_union(obj).is_some() || is_generic_alias(obj) + || is_typevar_like(obj) +} + +/// True if `obj` is a PEP 695 / `typing.TypeVar`-shaped placeholder (a +/// `SimpleNamespace` carrying the `__weavepy_typevar__` sentinel that +/// [`crate::builtins::b_typevar`] stamps). CPython's `TypeVar` defines +/// `__or__`/`__ror__`, so a type parameter is a valid PEP 604 union arg — +/// numpy's `_typing` builds `…[DTypeT] | … | BuiltinT | …` over bare type +/// parameters when its PEP 695 `type` aliases are evaluated. +fn is_typevar_like(obj: &Object) -> bool { + matches!(obj, Object::SimpleNamespace(d) + if d.borrow().get(&DictKey(Object::from_static("__weavepy_typevar__"))).is_some()) } /// A PEP 604 union can only be *initiated* by an operand that carries @@ -27231,7 +28808,10 @@ fn is_union_eligible(obj: &Object) -> bool { /// but cannot start one, so `None | None` raises `TypeError` like /// CPython. fn is_union_initiator(obj: &Object) -> bool { - matches!(obj, Object::Type(_)) || is_pep604_union(obj).is_some() || is_generic_alias(obj) + matches!(obj, Object::Type(_)) + || is_pep604_union(obj).is_some() + || is_generic_alias(obj) + || is_typevar_like(obj) } /// Detect whether `obj` is a PEP 604 union. Returns the flattened @@ -27937,11 +29517,49 @@ pub(crate) fn compare_op(a: &Object, b: &Object, op: CompareKind) -> Result Ok(a.cmp(b)?.is_lt()), - CompareKind::LtE => Ok(a.cmp(b)?.is_le()), - CompareKind::Gt => Ok(a.cmp(b)?.is_gt()), - CompareKind::GtE => Ok(a.cmp(b)?.is_ge()), + CompareKind::Lt | CompareKind::LtE | CompareKind::Gt | CompareKind::GtE => { + let ord = a.cmp(b).map_err(|e| relabel_unorderable(e, op))?; + Ok(match op { + CompareKind::Lt => ord.is_lt(), + CompareKind::LtE => ord.is_le(), + CompareKind::Gt => ord.is_gt(), + CompareKind::GtE => ord.is_ge(), + _ => unreachable!(), + }) + } + } +} + +/// `Object::cmp` is operator-agnostic, so its "not supported between instances +/// of …" `TypeError` always names `<`. When the comparison originated from an +/// explicit `<=` / `>` / `>=`, rewrite the symbol to the operator the user +/// actually wrote — CPython reports the original operator, and pandas asserts on +/// the exact text (`slice_locs` does `step >= 0`, and +/// `test_convert_almost_null_slice` matches +/// `"'>=' not supported between instances of 'str' and 'int'"`). For nested +/// containers this still matches CPython, which applies the outer operator to +/// the first differing element. +fn relabel_unorderable(err: RuntimeError, op: CompareKind) -> RuntimeError { + let sym = match op { + CompareKind::LtE => "<=", + CompareKind::Gt => ">", + CompareKind::GtE => ">=", + // `<` (and the non-ordering ops) already match `Object::cmp`'s wording. + _ => return err, + }; + if is_type_error(&err) { + if let RuntimeError::PyException(pe) = &err { + if let Some(rest) = pe + .message() + .strip_prefix("'<' not supported between instances of ") + { + return type_error(format!( + "'{sym}' not supported between instances of {rest}" + )); + } + } } + err } /// True when `o` is (or wraps) a NaN float — used to short-circuit the diff --git a/crates/weavepy-vm/src/object.rs b/crates/weavepy-vm/src/object.rs index a1b23e9..da1d242 100644 --- a/crates/weavepy-vm/src/object.rs +++ b/crates/weavepy-vm/src/object.rs @@ -213,6 +213,67 @@ pub enum Object { /// which `traceback.walk_stack`'s hardcoded `f_back` hop count /// relies on. LazyIter(Rc), + /// A C-API `PyCapsule` (RFC 0029 / RFC 0045, wave 3). + /// + /// A capsule is a *cpyext* concept — pure-Python code never mints one — + /// but it routinely lives in a place the VM owns: a module dict + /// (`module._API`, the numpy `import_array()` idiom) or an attribute + /// (`obj.__array_struct__`). The VM therefore must hold *some* value + /// for it. This is that value: an opaque, identity-stable token whose + /// payload the VM never interprets. The binary-ABI layer + /// (`weavepy-capi`) owns the capsule's C storage and registers a free + /// callback (via [`register_capsule_free`]) that runs when the last + /// reference here drops. See [`PyCapsuleSoul`]. + Capsule(Rc), + /// A "foreign" `PyObject` minted by a C extension itself (a numpy + /// builtin function, a static `PyArray_Descr`, a module-defined type + /// object, …) rather than by WeavePy's binary-ABI allocator. The VM + /// holds it as an opaque, identity-stable proxy and routes every + /// operation back through the cpyext bridge (RFC 0046, wave 4). See + /// [`crate::foreign::PyForeignSoul`]. + Foreign(Rc), +} + +/// VM-side soul of a C-API `PyCapsule` (see [`Object::Capsule`]). +/// +/// The capsule's real state (the wrapped `void*`, its name, context, and +/// destructor) lives in `weavepy-capi`'s heap-allocated capsule box. This +/// struct is only the VM's *handle* onto it, so a capsule can round-trip +/// `C -> VM (module dict / attribute) -> C` as the **same** pointer: the +/// cpyext layer retains the box while a soul is alive and hands the very +/// same pointer back out on each crossing. +/// +/// `handle` is stored as a `usize` (not a pointer) on purpose: it keeps +/// `Object: Send + Sync` (the compile-time assertion above), matching the +/// faithful-instance-body hook. The VM never dereferences it. +#[derive(Debug)] +pub struct PyCapsuleSoul { + /// Capsule name (e.g. `"_stockarray._ARRAY_API"`), used only for + /// `repr`. A capsule may be unnamed, hence `Option`. + pub name: Option>, + /// Opaque handle owned by the cpyext layer (the retained capsule + /// `PyObject*`, as an integer). Never dereferenced by the VM. + pub handle: usize, +} + +impl Drop for PyCapsuleSoul { + fn drop(&mut self) { + if let Some(free) = CAPSULE_FREE.get() { + free(self.handle); + } + } +} + +/// Free hook for a capsule's cpyext-side storage, registered by +/// `weavepy-capi` during interpreter init (the same additive-hook pattern +/// as [`crate::types::register_instance_body_free`]). Inert in a pure-VM +/// build, where no capsule is ever created. +static CAPSULE_FREE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Register the capsule free hook (RFC 0045, wave 3). Idempotent — a +/// second registration is ignored. +pub fn register_capsule_free(f: fn(usize)) { + let _ = CAPSULE_FREE.set(f); } impl fmt::Debug for Object { @@ -284,6 +345,11 @@ impl fmt::Debug for Object { m.finish() } Object::LazyIter(l) => write!(f, "<{} object>", l.type_name()), + Object::Capsule(c) => match &c.name { + Some(n) => write!(f, "", c.handle), + None => write!(f, "", c.handle), + }, + Object::Foreign(s) => write!(f, "", s.type_name, s.ptr), } } } @@ -360,6 +426,30 @@ impl fmt::Debug for PyFrame { } } +impl Drop for PyFrame { + fn drop(&mut self) { + // Iteratively tear down the `back` chain. A plain recursive drop + // would drop this frame's `back` Arc, whose own drop drops the + // *next* frame's `back`, and so on — one native stack frame per + // link. A deep traceback that pins the whole call chain (e.g. a + // `RecursionError` raised ~1000 frames down) then overflows the + // native stack and `abort()`s the process the moment the + // exception is finally released. Walking the list here keeps + // teardown O(depth) in heap but O(1) in native stack: each frame + // we unwrap has its own `back` detached *before* it falls out of + // scope, so its Drop sees an empty chain and never recurses. + let mut link = self.back.borrow_mut().take(); + while let Some(frame) = link { + match Rc::try_unwrap(frame) { + Ok(inner) => link = inner.back.borrow_mut().take(), + // Still referenced elsewhere (an alive generator frame, a + // retained `f_back`): that owner keeps the tail alive. + Err(_) => break, + } + } + } +} + impl PyFrame { pub fn current_lineno(&self) -> u32 { if let Some(v) = self.override_lineno.get() { @@ -477,6 +567,22 @@ pub struct PyTraceback { pub next: RefCell>>, } +impl Drop for PyTraceback { + fn drop(&mut self) { + // Same unbounded-recursion hazard as `PyFrame::drop`: a traceback + // is a `tb_next` linked list one node per stack level, so a + // ~1000-deep traceback (a `RecursionError`) would drop recursively + // and overflow the native stack. Tear the chain down iteratively. + let mut link = self.next.borrow_mut().take(); + while let Some(tb) = link { + match Rc::try_unwrap(tb) { + Ok(inner) => link = inner.next.borrow_mut().take(), + Err(_) => break, + } + } + } +} + // ---- bytearray buffer-export accounting ---- // // CPython forbids resizing a `bytearray` while its buffer is exported @@ -1272,6 +1378,25 @@ pub fn int_from_i128(v: i128) -> Object { } } +/// Number of elements a `range` yields, as an `i128` (never negative; +/// `0` for an empty or zero-step range). Mirrors CPython's +/// `compute_range_length` and is shared by `range` equality *and* +/// hashing so the two always agree (`hash` must match `==`). +pub(crate) fn range_len_i128(r: &Range) -> i128 { + let span = if r.step > 0 { + (r.stop - r.start).max(0) + } else if r.step < 0 { + (r.start - r.stop).max(0) + } else { + return 0; + }; + if span == 0 { + return 0; + } + let step = r.step.unsigned_abs() as i128; + (span + step - 1) / step +} + /// Run any tripped Python signal handler on the main thread, mirroring /// `os::service_pending_signals`. Used by the buffered-write flush so an /// `EINTR` from a blocking `write(2)` runs the handler before retrying @@ -1592,7 +1717,18 @@ pub(crate) fn member_eq(a: &Object, b: &Object) -> Result { if a.is_same(b) { return Ok(true); } - if instance_has_custom_dunder(a, "__eq__") || instance_has_custom_dunder(b, "__eq__") { + // Dispatch the full Python comparison whenever an operand can carry + // custom `__eq__` semantics: a pure-Python instance with a user dunder, + // OR a foreign (C-extension) object whose `tp_richcompare` the native + // `eq_value` fast path cannot see. Without the foreign case, `dtype in + // (a, b)` / `[dtype].count(other)` compare a numpy dtype/scalar against a + // primitive via `eq_value` (always unequal) even though `dtype == "int8"` + // is `True` through the reflected richcompare. + if instance_has_custom_dunder(a, "__eq__") + || instance_has_custom_dunder(b, "__eq__") + || matches!(a, Object::Foreign(_)) + || matches!(b, Object::Foreign(_)) + { if let Some(ptr) = crate::vm_singletons::current_interpreter_ptr() { // SAFETY: see `current_interp_eq`. let interp = unsafe { &mut *ptr }; @@ -1681,7 +1817,15 @@ impl PartialEq for DictKey { // so a class defining `__eq__`/`__hash__` works as a `set`/`dict` // key. Plain instances (no custom `__eq__`) keep identity semantics, // already decided by the `eq_value` fast path above. - if instance_has_custom_dunder(&self.0, "__eq__") + // + // A foreign extension scalar (numpy `int64`/…) compares to the equal + // Python scalar only through its `tp_richcompare` — `eq_value` above + // knows only native pairs — so route it through the interpreter too. + // With the shared `tp_hash` (see `py_hash_value`) this is what lets + // `d[np.int64(0)]` find the `0` key (numpy's `np.roll` shifts dict). + if matches!(self.0, Object::Foreign(_)) + || matches!(other.0, Object::Foreign(_)) + || instance_has_custom_dunder(&self.0, "__eq__") || instance_has_custom_dunder(&other.0, "__eq__") { if let Some(eq) = current_interp_eq(&self.0, &other.0) { @@ -5187,7 +5331,11 @@ impl Object { | Object::ClassMethod(_) | Object::SlotDescriptor(_) | Object::Frame(_) - | Object::Traceback(_) => true, + | Object::Traceback(_) + | Object::Capsule(_) => true, + // Foreign proxies route truthiness through the extension's + // `nb_bool`/`sq_length` (numpy: a 0-d/1-elem array's truth). + Object::Foreign(s) => crate::foreign::is_true(s), Object::MemoryView(mv) => mv.len.get() > 0, Object::MappingProxy(d) => !d.borrow().is_empty(), Object::DictView(v) => !v.dict.borrow().is_empty(), @@ -5279,6 +5427,10 @@ impl Object { (Object::DictView(a), Object::DictView(b)) => Rc::ptr_eq(a, b), (Object::SimpleNamespace(a), Object::SimpleNamespace(b)) => Rc::ptr_eq(a, b), (Object::LazyIter(a), Object::LazyIter(b)) => Rc::ptr_eq(a, b), + (Object::Capsule(a), Object::Capsule(b)) => Rc::ptr_eq(a, b), + // Two foreign proxies are the *same* object iff they wrap the + // same `PyObject*` (cpyext identity), even across distinct souls. + (Object::Foreign(a), Object::Foreign(b)) => a.ptr == b.ptr, (Object::Unbound, Object::Unbound) => true, _ => false, } @@ -5453,6 +5605,30 @@ impl Object { (Object::DictView(a), Object::DictView(b)) => { Rc::ptr_eq(a, b) || dict_view_set_eq(a, b) } + // `range` compares by the *sequence it generates*, not the raw + // `(start, stop, step)` triple (CPython `range_equals`): equal + // iff the lengths match, and — when non-empty — the starts match, + // and — when length > 1 — the steps match. So `range(0, 3, 2) == + // range(0, 4, 2)` (both yield `[0, 2]`) and every empty range + // compares equal (`range(0) == range(5, 5)`), while `range(3) != + // range(4)`. `pandas.RangeIndex.equals` (and thus `DataFrame` + // round-trip equality) rides on this. Kept bit-for-bit consistent + // with the `range` hash in `py_hash_value`. + (Object::Range(a), Object::Range(b)) => { + if Rc::ptr_eq(a, b) { + return true; + } + let la = range_len_i128(a); + if la != range_len_i128(b) { + false + } else if la == 0 { + true + } else if a.start != b.start { + false + } else { + la == 1 || a.step == b.step + } + } // CPython's default `tp_richcompare` (no user `__eq__`) falls // back to *identity*: `x == x` is True and `x == y` is False // for distinct objects. This covers reference types without @@ -5527,11 +5703,21 @@ impl Object { let b = b.borrow(); seq_cmp(&a, &b) } - _ => Err(type_error(format!( - "'<' not supported between instances of '{}' and '{}'", - self.type_name_owned(), - other.type_name_owned() - ))), + _ => { + if std::env::var_os("WEAVEPY_CMP_BT").is_some() { + eprintln!( + "[CMP_BT vm-lt] '{}' vs '{}'\n{:?}", + self.type_name_owned(), + other.type_name_owned(), + std::backtrace::Backtrace::force_capture() + ); + } + Err(type_error(format!( + "'<' not supported between instances of '{}' and '{}'", + self.type_name_owned(), + other.type_name_owned() + ))) + } } } @@ -5822,9 +6008,51 @@ impl Object { // partial consumption (`zip(range(n), it)`) must leave `it` // positioned at the first unconsumed element. Object::Iter(it) => Ok(PyIterator::Shared(it.clone())), + // Iterables the object-level factory can't build a `PyIterator` + // for on its own — they need the running interpreter to drive + // their `__iter__`/`__next__` (or `tp_iter`/`tp_iternext`): + // * `Instance` — a user/extension class (incl. a bridged + // Cython generator or generator-expression, which crosses + // into the VM as an `Instance` of its bridged type). + // * `Foreign` — an opaque C object (numpy array iterator, + // any C `tp_iter` type). + // * `Generator` / `Coroutine` — VM generators driven by + // `send`. + // A builtin invoked from C reaches here (Cython compiles + // `sum(stop - start for start, stop in slices)` in pandas' + // `get_blkno_indexers`, reached by `merge`/`reindex`/`drop`, to + // a C `sum()` over a generator object). Drive it through the + // active interpreter's iterator protocol and materialise the + // elements — every builtin routed here (`sum`/`min`/`max`/ + // `sorted`/`list`/`tuple`/`any`/`all`/…) consumes the iterable + // in full, so eager collection matches their semantics. + Object::Instance(_) + | Object::Foreign(_) + | Object::Generator(_) + | Object::Coroutine(_) => { + let ptr = crate::vm_singletons::current_interpreter_ptr().ok_or_else(|| { + type_error(format!( + "'{}' object is not iterable", + self.type_name_owned() + )) + })?; + // SAFETY: published by the enclosing VM frame still live on + // this thread; the GIL keeps the access exclusive. Same + // reentrant-callback pattern as `current_interp_eq` above. + let interp = unsafe { &mut *ptr }; + let iter = interp.iter_object(self.clone())?; + let mut items = Vec::new(); + while let Some(v) = interp.iter_next_object(iter.clone())? { + items.push(v); + } + Ok(PyIterator::List { + items: Rc::new(RefCell::new(items)), + index: 0, + }) + } _ => Err(type_error(format!( "'{}' object is not iterable", - self.type_name() + self.type_name_owned() ))), } } @@ -5878,6 +6106,10 @@ impl Object { Object::DictView(v) => v.kind.type_name(), Object::SimpleNamespace(_) => "SimpleNamespace", Object::LazyIter(l) => l.type_name(), + Object::Capsule(_) => "PyCapsule", + // The real (dynamic) `tp_name` is only available via + // type_name_owned; this static placeholder mirrors Instance. + Object::Foreign(_) => "object", } } @@ -5886,7 +6118,22 @@ impl Object { pub fn type_name_owned(&self) -> String { match self { Object::Instance(inst) => inst.cls().name.clone(), - Object::Type(t) => format!("type[{}]", t.name), + // `type_name_owned` is `type(obj).__name__`. For a *class* object + // that is its metaclass (`type` for a plain class, `ABCMeta`/an + // `EnumMeta`, … otherwise) — never a `type[]` alias. CPython's + // error text ("'<' not supported between instances of 'type' and + // 'type'", which pandas' groupby idxmax/idxmin over an object column + // matches on) depends on this being the bare metaclass name. + Object::Type(t) => t + .metaclass + .try_borrow() + .ok() + .and_then(|m| m.as_ref().map(|mc| mc.name.clone())) + .unwrap_or_else(|| "type".to_string()), + // `type_name_owned` feeds CPython-style `TypeError` text, which + // uses the raw `tp_name` (`'numpy.float64'`, + // `'pandas._libs.tslibs.offsets.Nano'`) — not the bare `__name__`. + Object::Foreign(s) => s.tp_name.to_string(), other => other.type_name().to_owned(), } } @@ -6295,6 +6542,14 @@ impl Object { Rc::as_ptr(l) as usize ) } + Object::Capsule(c) => match &c.name { + Some(n) => format!("", c.handle), + None => format!("", c.handle), + }, + // Infallible fallback; the VM's `repr_of` routes a foreign + // proxy through the extension's `tp_repr` (numpy array repr). + Object::Foreign(s) => crate::foreign::repr(s) + .unwrap_or_else(|_| format!("<{} object at 0x{:x}>", s.type_name, s.ptr)), Object::SimpleNamespace(d) => { let dict = d.borrow(); // PEP 585/604 runtime forms repr as type expressions @@ -6670,6 +6925,19 @@ fn py_hash_bytes_slice(bytes: &[u8]) -> i64 { } } +/// Interpreter-independent hash of a `str` — the exact value `hash(s)` +/// yields (the same one [`py_hash_value`] computes for `Object::Str`). +/// +/// The C-API layer mirrors this into a faithful `str`'s `hash` field so +/// that macro-heavy Cython, which reads `((PyASCIIObject*)s)->hash` +/// *directly* off the struct to match keyword arguments +/// (`__Pyx_MatchKeywordArg_str`), sees the same value WeavePy would +/// compute — without it every Cython call with a keyword argument fails +/// with a spurious "unexpected keyword argument". +pub fn py_str_hash(s: &str) -> i64 { + py_hash_bytes_slice(s.as_bytes()) +} + /// Identity-based hash for objects that hash by allocation identity in /// CPython (functions, types, modules, plain instances without a custom /// `__hash__`, …). Mirrors CPython's pointer hash: rotate so the low @@ -6726,6 +6994,11 @@ pub(crate) fn identity_hash(obj: &Object) -> i64 { Object::MemoryView(r) => rot(Rc::as_ptr(r).cast()), Object::SimpleNamespace(r) => rot(Rc::as_ptr(r).cast()), Object::LazyIter(r) => rot(Rc::as_ptr(r).cast()), + Object::Capsule(r) => rot(Rc::as_ptr(r).cast()), + // Identity hash keyed on the foreign `PyObject*` (matches the + // `is`-identity used by `eq`/`id`). A value-hashable foreign + // object (numpy scalar) is handled earlier via the hash hook. + Object::Foreign(s) => rot((s.ptr as *const u8).cast()), // Value-hashable variants never reach here (handled by // `py_hash_value`); anything else gets a stable constant. _ => 0, @@ -6870,6 +7143,36 @@ pub(crate) fn py_hash_value(obj: &Object) -> Option { // to `identity_hash` at the call site. current_interp_hash(obj) } + // A foreign extension scalar (numpy `int64`/`float64`/`bool_`, …) + // hashes through its own `tp_hash` slot so it collides with the equal + // Python scalar in a dict/set — CPython guarantees + // `hash(np.int64(0)) == hash(0)`. numpy's `np.roll` keys its `shifts` + // dict by axis with numpy ints, then looks it up with the Python-int + // axis; without the shared hash the lookup raises `KeyError`. The + // `no_gil_handoff` guard mirrors `current_interp_hash`: this may run + // while a container holds its cell mutex during a probe. + Object::Foreign(s) => { + let _no_yield = crate::gil::no_gil_handoff(); + crate::foreign::hash(s).ok() + } + // `range` hashes as CPython's `range_hash`: the hash of the + // `(length, start | None, step | None)` triple. Collapsing start/step + // to `None` for empty / length-1 ranges keeps the hash in lock-step + // with `eq_value` above — equal ranges (same generated sequence) share + // a hash and can therefore key a `dict`/`set` (or a pandas index). + Object::Range(r) => { + let len = range_len_i128(r); + let (start_obj, step_obj) = if len == 0 { + (Object::None, Object::None) + } else if len == 1 { + (int_from_i128(r.start), Object::None) + } else { + (int_from_i128(r.start), int_from_i128(r.step)) + }; + let triple = + Object::Tuple(Rc::from(vec![int_from_i128(len), start_obj, step_obj])); + py_hash_value(&triple) + } _ => None, } } diff --git a/crates/weavepy-vm/src/stdlib/ctypes_native.rs b/crates/weavepy-vm/src/stdlib/ctypes_native.rs new file mode 100644 index 0000000..90e215f --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/ctypes_native.rs @@ -0,0 +1,432 @@ +//! `_ctypes_native` — the low-level primitive layer behind WeavePy's +//! frozen `_ctypes` reimplementation (which in turn backs the verbatim +//! CPython `ctypes` package). +//! +//! CPython's `_ctypes` is a *core-built* C extension (it links against +//! `_PyRuntime` and other private interpreter internals), so the host +//! `_ctypes.cpython-313-*.so` cannot be `dlopen`'d into WeavePy the way a +//! stable-ABI wheel (numpy/pandas) can. We therefore reimplement the +//! `_ctypes` contract natively. The split mirrors CPython's own +//! `Lib/ctypes` (Python) over `_ctypes` (C): +//! +//! * **This module** owns the genuinely-native pieces: the platform C type +//! sizes/alignments, raw memory peek/poke, `dlopen`/`dlsym`, the libc +//! `memmove`/`memset`/`string_at` block helpers, the ctypes private +//! errno, and (RFC: wave 5 FFI) the libffi call/closure bridge. +//! * The frozen `python/_ctypes.py` builds the `_SimpleCData`/`Structure`/ +//! `Union`/`Array`/`_Pointer`/`CFuncPtr` type system + metaclasses on top +//! of these primitives, exposing exactly the names `ctypes/__init__.py` +//! imports. +//! +//! Memory model: a ctypes object's storage is a Python `bytearray` (owned, +//! GC'd, address-stable while its length is fixed — ctypes objects never +//! resize except via `resize()`); views (struct fields, array elements, +//! `from_buffer`) share that `bytearray` at an offset. External memory +//! (`from_address`, pointer deref, FFI return pointers) is addressed by a +//! raw integer through [`read_mem`]/[`write_mem`]. `addressof_buffer` +//! returns the `bytearray`'s stable data pointer so the two worlds unify +//! on a single `void *`. + +use std::cell::Cell; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; + +use crate::error::{type_error, value_error, RuntimeError}; +use crate::import::ModuleCache; +use crate::object::{BuiltinFn, DictData, DictKey, Object, PyModule}; +use crate::sync::Rc; +use crate::sync::RefCell; + +mod ffi; + +// ---------------------------------------------------------------- +// Argument helpers +// ---------------------------------------------------------------- + +fn arg(args: &[Object], i: usize) -> Result<&Object, RuntimeError> { + args.get(i) + .ok_or_else(|| type_error(format!("_ctypes: missing argument {i}"))) +} + +fn arg_usize(args: &[Object], i: usize) -> Result { + arg(args, i)? + .as_usize() + .ok_or_else(|| type_error(format!("_ctypes: argument {i} must be a non-negative int"))) +} + +fn arg_i64(args: &[Object], i: usize) -> Result { + arg(args, i)? + .as_i64() + .ok_or_else(|| type_error(format!("_ctypes: argument {i} must be an int"))) +} + +fn arg_str(args: &[Object], i: usize) -> Result { + match arg(args, i)? { + Object::Str(s) => Ok(s.to_string()), + other => Err(type_error(format!( + "_ctypes: argument {i} must be str (got '{}')", + other.type_name() + ))), + } +} + +/// Build a Python int from a (possibly > i64::MAX) machine address. +fn addr_obj(v: usize) -> Object { + Object::int_from_i128(v as i128) +} + +// ---------------------------------------------------------------- +// Platform C type sizes / alignments +// ---------------------------------------------------------------- + +/// `(size, align)` for a ctypes `_type_` format code, using the real +/// platform C ABI (so a `Structure` laid out here matches what a loaded +/// extension's C struct expects). Returns `None` for an unknown code. +fn code_info(code: char) -> Option<(usize, usize)> { + use std::mem::{align_of, size_of}; + let p = (size_of::<*const c_void>(), align_of::<*const c_void>()); + Some(match code { + // signed/unsigned char, bool, char + 'c' | 'b' | 'B' | '?' => (1, 1), + // short + 'h' | 'H' => (size_of::(), align_of::()), + // int + 'i' | 'I' => (size_of::(), align_of::()), + // long + 'l' | 'L' => (size_of::(), align_of::()), + // long long + 'q' | 'Q' => ( + size_of::(), + align_of::(), + ), + // float / double + 'f' => (size_of::(), align_of::()), + 'd' => (size_of::(), align_of::()), + // long double — platform dependent. Apple silicon and 32-bit ARM + // use 64-bit long double (== double); x86 uses the 80-bit extended + // type stored in 12 (i386) / 16 (x86-64) bytes. + 'g' => long_double_info(), + // pointers: void*, char*, wchar_t*, py_object (PyObject*) + 'P' | 'z' | 'Z' | 'O' => p, + // wchar_t: 4 bytes on POSIX, 2 on Windows. + 'u' => wchar_info(), + _ => return None, + }) +} + +#[cfg(target_arch = "x86_64")] +fn long_double_info() -> (usize, usize) { + (16, 16) +} +#[cfg(all(target_arch = "x86", not(target_arch = "x86_64")))] +fn long_double_info() -> (usize, usize) { + (12, 4) +} +#[cfg(not(any(target_arch = "x86_64", target_arch = "x86")))] +fn long_double_info() -> (usize, usize) { + // aarch64 (incl. Apple silicon), arm, etc.: long double == double. + (8, 8) +} + +#[cfg(windows)] +fn wchar_info() -> (usize, usize) { + (2, 2) +} +#[cfg(not(windows))] +fn wchar_info() -> (usize, usize) { + (4, 4) +} + +fn b_sizeof_code(args: &[Object]) -> Result { + let code = arg_str(args, 0)?; + let c = code.chars().next().ok_or_else(|| value_error("empty type code"))?; + let (size, _) = code_info(c).ok_or_else(|| value_error(format!("unknown type code {c:?}")))?; + Ok(Object::Int(size as i64)) +} + +fn b_alignment_code(args: &[Object]) -> Result { + let code = arg_str(args, 0)?; + let c = code.chars().next().ok_or_else(|| value_error("empty type code"))?; + let (_, align) = code_info(c).ok_or_else(|| value_error(format!("unknown type code {c:?}")))?; + Ok(Object::Int(align as i64)) +} + +// ---------------------------------------------------------------- +// Raw memory +// ---------------------------------------------------------------- + +/// Stable data pointer of a `bytearray`'s backing buffer. The buffer does +/// not move while its length is fixed, so the returned address is valid as +/// long as the `bytearray` is alive and unresized. +fn b_addressof_buffer(args: &[Object]) -> Result { + match arg(args, 0)? { + Object::ByteArray(rc) => { + let ptr = rc.borrow().as_ptr() as usize; + Ok(addr_obj(ptr)) + } + Object::Bytes(b) => Ok(addr_obj(b.as_ptr() as usize)), + other => Err(type_error(format!( + "addressof_buffer: expected bytearray (got '{}')", + other.type_name() + ))), + } +} + +/// `read_mem(addr, n) -> bytes` — copy `n` bytes from raw memory. +fn b_read_mem(args: &[Object]) -> Result { + let addr = arg_usize(args, 0)?; + let n = arg_usize(args, 1)?; + if addr == 0 { + return Err(value_error("read_mem: NULL pointer access")); + } + let slice = unsafe { std::slice::from_raw_parts(addr as *const u8, n) }; + Ok(Object::new_bytes(slice.to_vec())) +} + +/// `write_mem(addr, data)` — copy `data` into raw memory. +fn b_write_mem(args: &[Object]) -> Result { + let addr = arg_usize(args, 0)?; + let data = arg(args, 1)? + .as_bytes_view() + .ok_or_else(|| type_error("write_mem: data must be bytes-like"))?; + if addr == 0 && !data.is_empty() { + return Err(value_error("write_mem: NULL pointer access")); + } + unsafe { + std::ptr::copy(data.as_ptr(), addr as *mut u8, data.len()); + } + Ok(Object::None) +} + +fn b_memmove(args: &[Object]) -> Result { + let dst = arg_usize(args, 0)?; + let src = arg_usize(args, 1)?; + let n = arg_usize(args, 2)?; + unsafe { + libc::memmove(dst as *mut c_void, src as *const c_void, n); + } + Ok(addr_obj(dst)) +} + +fn b_memset(args: &[Object]) -> Result { + let dst = arg_usize(args, 0)?; + let c = arg_i64(args, 1)? as c_int; + let n = arg_usize(args, 2)?; + unsafe { + libc::memset(dst as *mut c_void, c, n); + } + Ok(addr_obj(dst)) +} + +/// `string_at(addr, size=-1) -> bytes`. With `size < 0`, reads up to the +/// first NUL (C string semantics). +fn b_string_at(args: &[Object]) -> Result { + let addr = arg_usize(args, 0)?; + let size = args.get(1).and_then(Object::as_i64).unwrap_or(-1); + if addr == 0 { + return Err(value_error("string_at: NULL pointer access")); + } + let bytes = if size < 0 { + let c = unsafe { CStr::from_ptr(addr as *const c_char) }; + c.to_bytes().to_vec() + } else { + let slice = unsafe { std::slice::from_raw_parts(addr as *const u8, size as usize) }; + slice.to_vec() + }; + Ok(Object::new_bytes(bytes)) +} + +/// `wstring_at(addr, size=-1) -> str`. `wchar_t` is 4 bytes on POSIX. +fn b_wstring_at(args: &[Object]) -> Result { + let addr = arg_usize(args, 0)?; + let size = args.get(1).and_then(Object::as_i64).unwrap_or(-1); + if addr == 0 { + return Err(value_error("wstring_at: NULL pointer access")); + } + let (wsize, _) = wchar_info(); + let mut s = String::new(); + let mut p = addr; + let mut count = 0i64; + loop { + if size >= 0 && count >= size { + break; + } + let cp: u32 = if wsize == 4 { + unsafe { *(p as *const u32) } + } else { + unsafe { u32::from(*(p as *const u16)) } + }; + if size < 0 && cp == 0 { + break; + } + if let Some(ch) = char::from_u32(cp) { + s.push(ch); + } else { + s.push('\u{fffd}'); + } + p += wsize; + count += 1; + } + Ok(Object::from_str(s)) +} + +// ---------------------------------------------------------------- +// dlopen / dlsym +// ---------------------------------------------------------------- + +fn b_dlopen(args: &[Object]) -> Result { + let mode = args.get(1).and_then(Object::as_i64).unwrap_or(libc::RTLD_LOCAL as i64) as c_int; + let handle = match arg(args, 0)? { + Object::None => unsafe { libc::dlopen(std::ptr::null(), mode) }, + Object::Str(s) => { + let cname = CString::new(s.as_bytes()) + .map_err(|_| value_error("dlopen: embedded NUL in name"))?; + unsafe { libc::dlopen(cname.as_ptr(), mode) } + } + other => { + return Err(type_error(format!( + "dlopen: name must be str or None (got '{}')", + other.type_name() + ))) + } + }; + if handle.is_null() { + let msg = last_dlerror().unwrap_or_else(|| "dlopen failed".to_owned()); + return Err(os_error(msg)); + } + Ok(addr_obj(handle as usize)) +} + +fn b_dlsym(args: &[Object]) -> Result { + let handle = arg_usize(args, 0)?; + let name = arg_str(args, 1)?; + let cname = CString::new(name.as_bytes()) + .map_err(|_| value_error("dlsym: embedded NUL in name"))?; + // Clear any stale error first (dlsym returning NULL is ambiguous). + unsafe { libc::dlerror() }; + let sym = unsafe { libc::dlsym(handle as *mut c_void, cname.as_ptr()) }; + if sym.is_null() { + if let Some(err) = last_dlerror() { + return Err(value_error(format!("{name}: symbol not found: {err}"))); + } + } + Ok(addr_obj(sym as usize)) +} + +fn b_dlclose(args: &[Object]) -> Result { + let handle = arg_usize(args, 0)?; + let rc = unsafe { libc::dlclose(handle as *mut c_void) }; + Ok(Object::Int(rc as i64)) +} + +fn b_dlerror(_args: &[Object]) -> Result { + match last_dlerror() { + Some(s) => Ok(Object::from_str(s)), + None => Ok(Object::None), + } +} + +fn last_dlerror() -> Option { + let p = unsafe { libc::dlerror() }; + if p.is_null() { + None + } else { + Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()) + } +} + +fn os_error(msg: impl Into) -> RuntimeError { + RuntimeError::PyException(crate::error::PyException::from_builtin("OSError", msg.into())) +} + +// ---------------------------------------------------------------- +// ctypes private errno (per RFC: swapped around USE_ERRNO calls) +// ---------------------------------------------------------------- + +thread_local! { + static CTYPES_ERRNO: Cell = const { Cell::new(0) }; +} + +fn b_get_errno(_args: &[Object]) -> Result { + Ok(Object::Int(CTYPES_ERRNO.with(|e| e.get()) as i64)) +} + +fn b_set_errno(args: &[Object]) -> Result { + let new = arg_i64(args, 0)? as i32; + let old = CTYPES_ERRNO.with(|e| e.replace(new)); + Ok(Object::Int(old as i64)) +} + +/// Atomically read-and-replace ctypes' private per-thread errno, returning +/// the previous value. Used by the libffi bridge's `USE_ERRNO` swap +/// (see `ffi::swap_ctypes_errno`). +pub(super) fn ctypes_errno_replace(new: i32) -> i32 { + CTYPES_ERRNO.with(|e| e.replace(new)) +} + +// ---------------------------------------------------------------- +// Registration +// ---------------------------------------------------------------- + +fn register( + d: &mut DictData, + name: &'static str, + body: impl Fn(&[Object]) -> Result + Send + Sync + 'static, +) { + d.insert( + DictKey(Object::from_static(name)), + Object::Builtin(Rc::new(BuiltinFn { + name, + binds_instance: false, + call: Box::new(body), + call_kw: None, + })), + ); +} + +pub fn build(_cache: &ModuleCache) -> Rc { + let dict = Rc::new(RefCell::new(DictData::new())); + { + let mut d = dict.borrow_mut(); + d.insert( + DictKey(Object::from_static("__name__")), + Object::from_static("_ctypes_native"), + ); + // Platform constants. + for (n, v) in [ + ("RTLD_LOCAL", libc::RTLD_LOCAL as i64), + ("RTLD_GLOBAL", libc::RTLD_GLOBAL as i64), + ("RTLD_NOW", libc::RTLD_NOW as i64), + ("RTLD_LAZY", libc::RTLD_LAZY as i64), + ("SIZEOF_TIME_T", std::mem::size_of::() as i64), + ("SIZEOF_VOID_P", std::mem::size_of::<*const c_void>() as i64), + ] { + d.insert(DictKey(Object::from_static(n)), Object::Int(v)); + } + register(&mut d, "sizeof_code", b_sizeof_code); + register(&mut d, "alignment_code", b_alignment_code); + register(&mut d, "addressof_buffer", b_addressof_buffer); + register(&mut d, "read_mem", b_read_mem); + register(&mut d, "write_mem", b_write_mem); + register(&mut d, "memmove", b_memmove); + register(&mut d, "memset", b_memset); + register(&mut d, "string_at", b_string_at); + register(&mut d, "wstring_at", b_wstring_at); + register(&mut d, "dlopen", b_dlopen); + register(&mut d, "dlsym", b_dlsym); + register(&mut d, "dlclose", b_dlclose); + register(&mut d, "dlerror", b_dlerror); + register(&mut d, "get_errno", b_get_errno); + register(&mut d, "set_errno", b_set_errno); + // FFI bridge (libffi) — defined in the `ffi` submodule. All three + // are positional (the frozen `_ctypes.py` calls them positionally). + register(&mut d, "call_function", ffi::b_call_function); + register(&mut d, "create_closure", ffi::b_create_closure); + register(&mut d, "free_closure", ffi::b_free_closure); + } + Rc::new(PyModule { + name: "_ctypes_native".to_owned(), + filename: None, + dict, + }) +} diff --git a/crates/weavepy-vm/src/stdlib/ctypes_native/ffi.rs b/crates/weavepy-vm/src/stdlib/ctypes_native/ffi.rs new file mode 100644 index 0000000..d8f9f12 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/ctypes_native/ffi.rs @@ -0,0 +1,677 @@ +//! FFI bridge for `_ctypes_native`. +//! +//! This is the genuinely-FFI half of ctypes: turning a resolved function +//! address + ctypes type codes into a real C ABI call (and the reverse, +//! for Python callbacks passed to C). It is implemented on top of a small, +//! self-contained native back-end ([`native`]) — a hand-written call gate +//! and a pool of closure trampolines — so it has no external C build +//! dependency (no `libffi`). +//! +//! The frozen `python/_ctypes.py` marshals every foreign-function call +//! down to two primitives: +//! +//! * `call_function(addr, rcode, codes, payloads, flags)` — invoke the C +//! function at `addr`. `rcode` is the return type's ctypes format code +//! (or `None` for `void`); `codes[i]`/`payloads[i]` are the format code +//! and already-coerced Python value for argument `i`; `flags` carries +//! the `FUNCFLAG_*` bits (only `USE_ERRNO` is honoured here). +//! * `create_closure(callable, rcode, argcodes)` — build a C-callable +//! trampoline that, when invoked from C, marshals the C arguments back +//! into Python, calls `callable`, and marshals the result out. Returns +//! the trampoline's code address (what a `CFUNCTYPE(py_callable)` stores +//! as its function pointer). +//! +//! The format codes are the standard `struct`/ctypes single-character +//! codes: `b B h H i I l L q Q` (ints), `f d g` (float/double/long +//! double), `c ?` (char/bool), `u` (wchar), and `P z Z O` (pointers: +//! `void*`, `char*`, `wchar_t*`, `PyObject*`). Aggregates and pointers +//! are always marshalled by address (`P`) on the Python side, so the +//! bridge only ever sees scalars and pointers — never a by-value struct. +//! +//! ## ABI placement +//! +//! [`native`] works purely in terms of a register-file image (up to 8 +//! integer + 8 FP registers, plus overflow stack words). This module owns +//! the calling-convention decision of *which* slot each argument lands in +//! ([`assign_slots`]) and the scalar <-> register bit marshalling, keeping +//! the platform ABI knowledge in one place shared by both the call and the +//! callback direction. + +use std::os::raw::c_void; + +use crate::error::{type_error, value_error, PyException, RuntimeError}; +use crate::object::Object; + +mod native; + +// ---------------------------------------------------------------- +// Type-code classification +// ---------------------------------------------------------------- + +/// The ABI class a ctypes format code marshals to. `size` for `Int` is +/// the platform C width (so `l` is 8 on LP64, 4 on Windows), matching the +/// sizes `_ctypes_native::code_info` reports. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Cls { + Int { size: usize, signed: bool }, + F32, + F64, + Ptr, + Void, +} + +fn wchar_size() -> usize { + super::wchar_info().0 +} + +/// `long double` is platform-dependent. On AArch64/ARM it is identical to +/// `double` (8 bytes), so we can marshal it as `f64`. On x86 it is the +/// 80-bit extended type, which cannot round-trip through a Python float, +/// so we decline it (callers get a clear error). +fn classify_longdouble() -> Option { + #[cfg(any(target_arch = "aarch64", target_arch = "arm"))] + { + Some(Cls::F64) + } + #[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))] + { + None + } +} + +fn classify(code: char) -> Option { + use std::mem::size_of; + let cls = match code { + 'b' => Cls::Int { size: 1, signed: true }, + 'B' | 'c' | '?' => Cls::Int { size: 1, signed: false }, + 'h' => Cls::Int { size: size_of::(), signed: true }, + 'H' => Cls::Int { size: size_of::(), signed: false }, + 'i' => Cls::Int { size: size_of::(), signed: true }, + 'I' => Cls::Int { size: size_of::(), signed: false }, + 'l' => Cls::Int { size: size_of::(), signed: true }, + 'L' => Cls::Int { size: size_of::(), signed: false }, + 'q' => Cls::Int { size: size_of::(), signed: true }, + 'Q' => Cls::Int { size: size_of::(), signed: false }, + 'f' => Cls::F32, + 'd' => Cls::F64, + 'g' => return classify_longdouble(), + 'u' => Cls::Int { size: wchar_size(), signed: false }, + 'P' | 'z' | 'Z' | 'O' => Cls::Ptr, + _ => return None, + }; + Some(cls) +} + +// ---------------------------------------------------------------- +// ABI slot assignment (shared by the call and callback directions) +// ---------------------------------------------------------------- + +/// Where an argument is passed: an integer register, an FP register, or an +/// overflow stack word. Indices are 0-based within each file. +#[derive(Clone, Copy)] +enum Slot { + Gpr(usize), + Fpr(usize), + Stack(usize), +} + +/// Assign every argument to an ABI slot, mirroring the platform C calling +/// convention: integer/pointer args fill the general registers then spill +/// to the stack; float/double args fill the FP registers then spill. This +/// single function is used both to *place* outgoing arguments and to +/// *recover* incoming ones in a closure, guaranteeing the two directions +/// agree. +fn assign_slots(classes: &[Cls]) -> Vec { + let mut ngrn = 0usize; // next general register number + let mut nsrn = 0usize; // next SIMD/FP register number + let mut nstk = 0usize; // next stack word + let mut out = Vec::with_capacity(classes.len()); + for &c in classes { + let slot = match c { + Cls::F32 | Cls::F64 => { + if nsrn < native::NFPR_ARG { + let s = Slot::Fpr(nsrn); + nsrn += 1; + s + } else { + let s = Slot::Stack(nstk); + nstk += 1; + s + } + } + // Int / Ptr / (Void never reaches here as an argument). + _ => { + if ngrn < native::NGPR_ARG { + let s = Slot::Gpr(ngrn); + ngrn += 1; + s + } else { + let s = Slot::Stack(nstk); + nstk += 1; + s + } + } + }; + out.push(slot); + } + out +} + +// ---------------------------------------------------------------- +// Scalar <-> register-bits marshalling +// ---------------------------------------------------------------- + +/// Sign/zero-extend a `size`-byte integer held in the low bytes of `v` to +/// a full 64-bit register image, as the C ABI requires for sub-word args. +fn widen_int(v: u64, size: usize, signed: bool) -> u64 { + if size >= 8 { + return v; + } + let bits = size * 8; + if signed { + let shift = 64 - bits; + (((v << shift) as i64) >> shift) as u64 + } else { + v & ((1u64 << bits) - 1) + } +} + +/// Reinterpret a Python value as the raw 64-bit register image of an +/// integer/char/bool argument. Negative values keep their two's-complement +/// bits. Handles big-int addresses (`Object::Long`) too. +fn payload_as_u64(o: &Object) -> Option { + match o { + Object::Bool(b) => Some(u64::from(*b)), + Object::None => Some(0), + _ => o + .as_i64() + .map(|i| i as u64) + .or_else(|| o.as_usize().map(|u| u as u64)), + } +} + +/// Build a Python int from the `size`-byte integer held in the low bytes +/// of `bits`, sign-extending when `signed`. +fn int_object_from_bits(bits: u64, size: usize, signed: bool) -> Object { + if signed { + let shift = 64 - size * 8; + let v = ((bits << shift) as i64) >> shift; + Object::Int(v) + } else { + let v = if size >= 8 { + bits + } else { + bits & ((1u64 << (size * 8)) - 1) + }; + if v <= i64::MAX as u64 { + Object::Int(v as i64) + } else { + Object::int_from_i128(v as i128) + } + } +} + +/// Resolve a pointer-class argument to a machine address, allocating a +/// NUL-terminated temporary for `char*`/`wchar_t*` bytes/str payloads and +/// stashing it in `keep` so it outlives the call. +fn pointer_payload( + code: char, + payload: &Object, + keep: &mut Vec>, +) -> Result { + match payload { + Object::None => Ok(0), + Object::Bytes(_) | Object::ByteArray(_) if code == 'z' => { + let mut buf = payload.as_bytes_view().unwrap_or_default(); + buf.push(0); // C-string NUL terminator + let ptr = buf.as_ptr() as usize; + keep.push(buf); + Ok(ptr) + } + Object::Str(s) if code == 'Z' => { + let wsize = wchar_size(); + let mut buf: Vec = Vec::with_capacity((s.chars().count() + 1) * wsize); + for ch in s.chars() { + let cp = ch as u32; + buf.extend_from_slice(&cp.to_ne_bytes()[..wsize]); + } + buf.extend_from_slice(&0u32.to_ne_bytes()[..wsize]); + let ptr = buf.as_ptr() as usize; + keep.push(buf); + Ok(ptr) + } + _ => payload + .as_usize() + .or_else(|| payload.as_i64().map(|i| i as usize)) + .ok_or_else(|| { + type_error(format!( + "call_function: cannot convert {} to a pointer argument", + payload.type_name() + )) + }), + } +} + +/// Compute the 64-bit register image for one outgoing argument. +fn arg_bits( + cls: Cls, + code: char, + payload: &Object, + keep: &mut Vec>, +) -> Result { + Ok(match cls { + Cls::Int { size, signed } => { + let v = payload_as_u64(payload).ok_or_else(|| { + type_error(format!( + "call_function: cannot convert {} to an integer argument (code {code:?})", + payload.type_name() + )) + })?; + widen_int(v, size, signed) + } + Cls::F32 => { + let v = payload + .as_f64() + .ok_or_else(|| type_error("call_function: float argument expected"))?; + u64::from((v as f32).to_bits()) + } + Cls::F64 => { + let v = payload + .as_f64() + .ok_or_else(|| type_error("call_function: float argument expected"))?; + v.to_bits() + } + Cls::Ptr => pointer_payload(code, payload, keep)? as u64, + Cls::Void => return Err(type_error("call_function: void is not a valid argument type")), + }) +} + +/// Marshal the raw result registers into a Python object per the return +/// class. Integer/pointer results are read from the GPR result; float and +/// double results from the FP result (its low 32 / 64 bits). +fn marshal_ret(ret: Cls, ret_gpr: u64, ret_fpr: u64) -> Object { + match ret { + Cls::Void => Object::None, + Cls::F32 => Object::Float(f64::from(f32::from_bits(ret_fpr as u32))), + Cls::F64 => Object::Float(f64::from_bits(ret_fpr)), + Cls::Ptr => super::addr_obj(ret_gpr as usize), + Cls::Int { size, signed } => int_object_from_bits(ret_gpr, size, signed), + } +} + +// ---------------------------------------------------------------- +// List extraction +// ---------------------------------------------------------------- + +fn list_items(o: Option<&Object>) -> Result, RuntimeError> { + match o { + None | Some(Object::None) => Ok(Vec::new()), + Some(Object::List(rc)) => Ok(rc.borrow().clone()), + Some(Object::Tuple(rc)) => Ok(rc.to_vec()), + Some(other) => Err(type_error(format!( + "call_function: expected a list (got '{}')", + other.type_name() + ))), + } +} + +fn list_chars(o: Option<&Object>) -> Result, RuntimeError> { + let mut out = Vec::new(); + for it in list_items(o)? { + match it { + Object::Str(s) => out.push( + s.chars() + .next() + .ok_or_else(|| value_error("call_function: empty type code"))?, + ), + other => { + return Err(type_error(format!( + "call_function: type codes must be str (got '{}')", + other.type_name() + ))) + } + } + } + Ok(out) +} + +fn return_class(o: Option<&Object>) -> Result { + match o { + None | Some(Object::None) => Ok(Cls::Void), + Some(Object::Str(s)) => { + let c = s + .chars() + .next() + .ok_or_else(|| value_error("call_function: empty return type code"))?; + classify(c) + .ok_or_else(|| value_error(format!("call_function: unsupported return code {c:?}"))) + } + Some(other) => Err(type_error(format!( + "call_function: return code must be str or None (got '{}')", + other.type_name() + ))), + } +} + +// ---------------------------------------------------------------- +// ctypes private errno swap (FUNCFLAG_USE_ERRNO) +// ---------------------------------------------------------------- + +#[cfg(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "openbsd", + target_os = "netbsd" +))] +fn errno_location() -> *mut i32 { + unsafe { libc::__error() } +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn errno_location() -> *mut i32 { + unsafe { libc::__errno_location() } +} + +#[cfg(not(any( + target_os = "macos", + target_os = "ios", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "openbsd", + target_os = "netbsd", + target_os = "linux", + target_os = "android" +)))] +fn errno_location() -> *mut i32 { + // No known errno symbol for this target: fall back to a dummy cell so + // the swap is a harmless no-op rather than UB. + thread_local! { static DUMMY: std::cell::Cell = const { std::cell::Cell::new(0) }; } + DUMMY.with(|c| c.as_ptr()) +} + +/// Swap the C library `errno` with ctypes' private per-thread errno. Called +/// symmetrically before and after the FFI call when `USE_ERRNO` is set, so +/// the real `errno` reflects the caller's saved value across the call and +/// the callee's `errno` lands back in the private slot (CPython's exact +/// `_ctypes_callproc` protocol). +fn swap_ctypes_errno() { + let loc = errno_location(); + let real = unsafe { *loc }; + let saved = super::ctypes_errno_replace(real); + unsafe { *loc = saved }; +} + +// ---------------------------------------------------------------- +// call_function +// ---------------------------------------------------------------- + +pub(super) fn b_call_function(args: &[Object]) -> Result { + let addr = super::arg_usize(args, 0)?; + if addr == 0 { + return Err(value_error("call_function: attempt to call NULL function pointer")); + } + if !native::SUPPORTED { + return Err(value_error( + "call_function: native FFI is not implemented for this architecture", + )); + } + let ret_cls = return_class(args.get(1))?; + let codes = list_chars(args.get(2))?; + let payloads = list_items(args.get(3))?; + if codes.len() != payloads.len() { + return Err(type_error(format!( + "call_function: {} type code(s) but {} argument(s)", + codes.len(), + payloads.len() + ))); + } + let flags = args.get(4).and_then(Object::as_i64).unwrap_or(0); + const FUNCFLAG_USE_ERRNO: i64 = 0x8; + let use_errno = (flags & FUNCFLAG_USE_ERRNO) != 0; + + let n = codes.len(); + let mut classes = Vec::with_capacity(n); + for &c in &codes { + classes.push( + classify(c) + .ok_or_else(|| value_error(format!("call_function: unsupported arg code {c:?}")))?, + ); + } + let slots = assign_slots(&classes); + + let mut gpr = [0u64; 8]; + let mut fpr = [0u64; 8]; + let mut stack: Vec = Vec::new(); + let mut nfpr: u64 = 0; + // Temporaries (NUL-terminated string buffers) that must stay alive for + // the duration of the call. + let mut keep: Vec> = Vec::new(); + for i in 0..n { + let bits = arg_bits(classes[i], codes[i], &payloads[i], &mut keep)?; + match slots[i] { + Slot::Gpr(r) => gpr[r] = bits, + Slot::Fpr(r) => { + fpr[r] = bits; + nfpr = nfpr.max(r as u64 + 1); + } + Slot::Stack(_) => stack.push(bits), + } + } + + let (ret_gpr, ret_fpr) = unsafe { + if use_errno { + swap_ctypes_errno(); + } + let r = native::raw_call(addr, &gpr, &fpr, &stack, nfpr); + if use_errno { + swap_ctypes_errno(); + } + r + }; + // Keep the argument backing storage alive until the call has returned. + drop(keep); + Ok(marshal_ret(ret_cls, ret_gpr, ret_fpr)) +} + +// ---------------------------------------------------------------- +// create_closure / free_closure (Python callable -> C function ptr) +// ---------------------------------------------------------------- + +/// Immutable environment bound to a closure trampoline slot. Boxed and +/// handed to [`native::alloc_trampoline`] as the slot's user-data; freed by +/// [`b_free_closure`] (or leaked for the process lifetime if the frozen +/// `_ctypes` never frees it, matching ctypes' "closure lives with the +/// CFUNCTYPE object" lifetime). +struct ClosureData { + callable: Object, + arg_codes: Vec, + arg_classes: Vec, + ret: Cls, +} + +/// Read a NUL-terminated C string at `addr` into bytes. +/// +/// # Safety +/// `addr` must be a valid, NUL-terminated C string pointer. +unsafe fn read_cstr(addr: usize) -> Vec { + unsafe { std::ffi::CStr::from_ptr(addr as *const std::os::raw::c_char) } + .to_bytes() + .to_vec() +} + +/// Read a NUL-terminated `wchar_t` string at `addr` into a `String`. +/// +/// # Safety +/// `addr` must be a valid, NUL-terminated `wchar_t` string pointer. +unsafe fn read_wstr(addr: usize) -> String { + let wsize = wchar_size(); + let mut out = String::new(); + let mut p = addr; + loop { + let cp: u32 = unsafe { + if wsize == 4 { + *(p as *const u32) + } else { + u32::from(*(p as *const u16)) + } + }; + if cp == 0 { + break; + } + out.push(char::from_u32(cp).unwrap_or('\u{fffd}')); + p += wsize; + } + out +} + +/// Marshal one incoming closure argument (already loaded into a 64-bit +/// register image) into a Python object. +/// +/// # Safety +/// For pointer classes, `bits` must be a valid address of the declared +/// kind (`z`/`Z` are dereferenced as C/`wchar_t` strings). +unsafe fn bits_to_object(cls: Cls, code: char, bits: u64) -> Object { + match cls { + Cls::Int { size, signed } => int_object_from_bits(bits, size, signed), + Cls::F32 => Object::Float(f64::from(f32::from_bits(bits as u32))), + Cls::F64 => Object::Float(f64::from_bits(bits)), + Cls::Ptr => { + let addr = bits as usize; + match code { + 'z' if addr != 0 => Object::new_bytes(unsafe { read_cstr(addr) }), + 'Z' if addr != 0 => Object::from_str(unsafe { read_wstr(addr) }), + 'z' | 'Z' => Object::None, + _ => super::addr_obj(addr), + } + } + Cls::Void => Object::None, + } +} + +/// Write a closure's Python return value into the result registers. Integer +/// results go to the GPR result register; float/double to the FP result +/// register (its low 32 / 64 bits). +/// +/// # Safety +/// `ret_gpr`/`ret_fpr` must point to the trampoline frame's result cells. +unsafe fn write_ret(ret_gpr: *mut u64, ret_fpr: *mut u64, ret: Cls, value: &Object) { + match ret { + Cls::Void => {} + Cls::Int { .. } => unsafe { *ret_gpr = payload_as_u64(value).unwrap_or(0) }, + Cls::Ptr => { + let a = value + .as_usize() + .or_else(|| value.as_i64().map(|i| i as usize)) + .unwrap_or(0); + unsafe { *ret_gpr = a as u64 }; + } + Cls::F32 => unsafe { + *ret_fpr = u64::from((value.as_f64().unwrap_or(0.0) as f32).to_bits()) + }, + Cls::F64 => unsafe { *ret_fpr = value.as_f64().unwrap_or(0.0).to_bits() }, + } +} + +/// The Rust side of a closure trampoline: runs whenever the trampoline's +/// code pointer is invoked from C. Reconstructs the Python arguments from +/// the register-file snapshot, re-enters the interpreter published on this +/// thread (the same reentrancy hook the C-API uses), calls the Python +/// callable, and writes the marshalled result back into the result cells. +fn closure_dispatch(userdata: *mut c_void, regs: &native::ClosureRegs) { + if userdata.is_null() { + // Should not happen (a live trampoline always has data); leave the + // result cells as-is. + return; + } + let data: &ClosureData = unsafe { &*(userdata as *const ClosureData) }; + + let slots = assign_slots(&data.arg_classes); + let mut py_args: Vec = Vec::with_capacity(slots.len()); + for (i, (&cls, &code)) in data.arg_classes.iter().zip(data.arg_codes.iter()).enumerate() { + let bits = unsafe { + match slots[i] { + Slot::Gpr(r) => regs.gpr(r), + Slot::Fpr(r) => regs.fpr(r), + Slot::Stack(r) => regs.stack(r), + } + }; + py_args.push(unsafe { bits_to_object(cls, code, bits) }); + } + + let outcome = match crate::vm_singletons::current_interpreter_ptr() { + Some(ptr) if !ptr.is_null() => { + let vm = unsafe { &mut *ptr }; + vm.call_object(data.callable.clone(), &py_args, &[]) + } + _ => Err(value_error( + "ctypes callback invoked with no active interpreter on this thread", + )), + }; + + let value = match outcome { + Ok(v) => v, + Err(e) => { + // A C caller cannot receive a Python exception; CPython prints + // it via the unraisable hook and returns 0. We do the safe + // thing: report (with the exception detail) and fall back to a + // zero/default result so the C caller keeps running. + eprintln!("Exception ignored on calling ctypes callback function: {e}"); + Object::None + } + }; + unsafe { write_ret(regs.ret_gpr, regs.ret_fpr, data.ret, &value) }; +} + +pub(super) fn b_create_closure(args: &[Object]) -> Result { + if !native::SUPPORTED { + // The frozen `_ctypes.py` catches NotImplementedError and degrades + // to "callable from Python only". + return Err(RuntimeError::PyException(PyException::from_builtin( + "NotImplementedError", + "ctypes closures are not implemented for this architecture", + ))); + } + let callable = super::arg(args, 0)?.clone(); + let ret = return_class(args.get(1))?; + let codes = list_chars(args.get(2))?; + + let mut classes = Vec::with_capacity(codes.len()); + for &c in &codes { + classes.push( + classify(c) + .ok_or_else(|| value_error(format!("create_closure: unsupported arg code {c:?}")))?, + ); + } + + let data = Box::into_raw(Box::new(ClosureData { + callable, + arg_codes: codes, + arg_classes: classes, + ret, + })); + match native::alloc_trampoline(data as *mut c_void) { + Some(code) => Ok(super::addr_obj(code)), + None => { + // Pool exhausted: reclaim the box we just allocated. + drop(unsafe { Box::from_raw(data) }); + Err(RuntimeError::PyException(PyException::from_builtin( + "RuntimeError", + "ctypes: closure trampoline pool exhausted", + ))) + } + } +} + +pub(super) fn b_free_closure(args: &[Object]) -> Result { + // The frozen `_ctypes.py` currently never calls this (closures live for + // the process), but honour it if it ever does: reclaim the slot and the + // boxed `ClosureData`. + if let Some(addr) = args.first().and_then(Object::as_usize) { + if let Some(prev) = native::free_trampoline(addr) { + drop(unsafe { Box::from_raw(prev as *mut ClosureData) }); + } + } + Ok(Object::None) +} diff --git a/crates/weavepy-vm/src/stdlib/ctypes_native/ffi/native.rs b/crates/weavepy-vm/src/stdlib/ctypes_native/ffi/native.rs new file mode 100644 index 0000000..3abae21 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/ctypes_native/ffi/native.rs @@ -0,0 +1,496 @@ +//! Self-contained native FFI back-end for the `_ctypes` bridge. +//! +//! This replaces the external `libffi` dependency (whose vendored assembly +//! does not build under the current Apple `clang` toolchain) with a small, +//! hand-written call gate and closure trampoline pool. It provides exactly +//! two mechanisms, both expressed in terms of a register-file image: +//! +//! * [`raw_call`] — given a resolved function address and the general/FP +//! register file plus any overflow stack words, performs a real C ABI +//! call and returns the integer (`x0`/`rax`) and floating (`d0`/`xmm0`) +//! result registers. The caller ([`super`]) places each argument into the +//! correct register/stack slot per the platform calling convention +//! (driven by [`super::assign_slots`], using [`NGPR_ARG`]/[`NFPR_ARG`]). +//! +//! * [`alloc_trampoline`]/[`free_trampoline`] — hand out a stable, C-callable +//! code address backed by a fixed pool of assembly stubs. Each stub records +//! its own address, jumps to a shared spill routine that snapshots the +//! argument registers, and calls back into Rust ([`wp_cl_dispatch`] -> +//! [`super::closure_dispatch`]) with the register-file image. Because the +//! stubs live in the normal `.text` segment there is no runtime code +//! generation and therefore no W^X / `MAP_JIT` handling to worry about. +//! +//! Only `aarch64` and `x86_64` have an ABI implementation; on any other +//! target [`SUPPORTED`] is `false` and both entry points degrade cleanly +//! (the frozen `_ctypes.py` treats a missing closure back-end as "callbacks +//! are Python-callable only"). + +#![allow(dead_code)] + +use std::os::raw::c_void; + +/// Argument/return register file handed to the assembly call gate. Layout +/// is load-bearing: the field byte offsets are hard-coded in the gate asm. +#[repr(C)] +struct RawCall { + fnptr: *const c_void, // +0 + gpr: *const u64, // +8 -> up to 8 integer/pointer registers + fpr: *const u64, // +16 -> up to 8 FP registers (low 64 bits each) + stack: *const u64, // +24 -> overflow stack words (may be null if 0) + stack_words: u64, // +32 + nfpr: u64, // +40 -> # FP regs used (x86-64 variadic `al`) + ret_gpr: *mut u64, // +48 -> receives x0 / rax + ret_fpr: *mut u64, // +56 -> receives d0 / xmm0 +} + +/// A snapshot of the argument registers as seen by a closure trampoline, +/// handed to [`super::closure_dispatch`]. All pointers reference stack +/// storage owned by the trampoline frame and are valid only for the +/// duration of the dispatch call. +pub(super) struct ClosureRegs { + gpr: *const u64, + fpr: *const u64, + stack: *const u64, + pub(super) ret_gpr: *mut u64, + pub(super) ret_fpr: *mut u64, +} + +impl ClosureRegs { + /// Read integer/pointer argument register `i` (0-based). + /// + /// # Safety + /// `i` must be a register index the trampoline actually spilled. + pub(super) unsafe fn gpr(&self, i: usize) -> u64 { + unsafe { *self.gpr.add(i) } + } + /// Read FP argument register `i` (low 64 bits). + /// + /// # Safety + /// `i` must be a register index the trampoline actually spilled. + pub(super) unsafe fn fpr(&self, i: usize) -> u64 { + unsafe { *self.fpr.add(i) } + } + /// Read overflow stack word `i` (0 == first stacked argument). + /// + /// # Safety + /// `i` must index a word the caller actually pushed. + pub(super) unsafe fn stack(&self, i: usize) -> u64 { + unsafe { *self.stack.add(i) } + } +} + +// ================================================================ +// aarch64 (AAPCS64, incl. Apple silicon) +// ================================================================ + +#[cfg(target_arch = "aarch64")] +mod abi { + /// Integer/pointer argument registers: x0..x7. + pub(super) const NGPR: usize = 8; + /// Bytes between consecutive trampoline stubs (`adr`+`b` = 8 bytes). + pub(super) const STUB_SIZE: usize = 8; + /// `adr x17, .` yields the stub base itself, so no bias. + pub(super) const LEA_BIAS: usize = 0; +} + +#[cfg(target_arch = "aarch64")] +core::arch::global_asm!( + ".p2align 2", + ".globl {gate}", + "{gate}:", + " stp x29, x30, [sp, #-32]!", + " mov x29, sp", + " stp x19, x20, [sp, #16]", + " mov x19, x0", // x19 = &RawCall + " ldr x9, [x19, #32]", // stack_words + " add x10, x9, #1", // round up to even for 16-byte stack alignment + " and x10, x10, #0xfffffffffffffffe", + " lsl x10, x10, #3", // -> bytes + " sub sp, sp, x10", + " ldr x11, [x19, #24]", // stack src + " cbz x9, 2f", + " mov x12, #0", + "1:", + " ldr x13, [x11, x12, lsl #3]", + " str x13, [sp, x12, lsl #3]", + " add x12, x12, #1", + " cmp x12, x9", + " b.lo 1b", + "2:", + " ldr x14, [x19, #16]", // fpr src + " ldp d0, d1, [x14, #0]", + " ldp d2, d3, [x14, #16]", + " ldp d4, d5, [x14, #32]", + " ldp d6, d7, [x14, #48]", + " ldr x20, [x19, #0]", // fnptr + " ldr x16, [x19, #8]", // gpr src + " ldp x0, x1, [x16, #0]", + " ldp x2, x3, [x16, #16]", + " ldp x4, x5, [x16, #32]", + " ldp x6, x7, [x16, #48]", + " blr x20", + " ldr x9, [x19, #48]", // ret_gpr + " str x0, [x9]", + " ldr x9, [x19, #56]", // ret_fpr + " str d0, [x9]", + " mov sp, x29", + " ldp x19, x20, [sp, #16]", + " ldp x29, x30, [sp], #32", + " ret", + gate = sym wp_ffi_call_gate, +); + +#[cfg(target_arch = "aarch64")] +core::arch::global_asm!( + ".p2align 4", + ".globl {pool}", + "{pool}:", + ".rept {n}", + " adr x17, .", // x17 = this stub's address + " b 9f", // -> shared spill routine + ".endr", + ".p2align 2", + "9:", + " stp x29, x30, [sp, #-16]!", + " mov x29, sp", + " sub sp, sp, #160", + " stp x0, x1, [sp, #0]", // spill integer args + " stp x2, x3, [sp, #16]", + " stp x4, x5, [sp, #32]", + " stp x6, x7, [sp, #48]", + " stp d0, d1, [sp, #64]", // spill FP args + " stp d2, d3, [sp, #80]", + " stp d4, d5, [sp, #96]", + " stp d6, d7, [sp, #112]", + " mov x0, x17", // stub_addr + " add x1, sp, #0", // gpr + " add x2, sp, #64", // fpr + " add x3, x29, #16", // incoming stack args + " add x4, sp, #128", // ret_gpr + " add x5, sp, #136", // ret_fpr + " bl {dispatch}", + " ldr x0, [sp, #128]", + " ldr d0, [sp, #136]", + " add sp, sp, #160", + " ldp x29, x30, [sp], #16", + " ret", + pool = sym wp_cl_pool, + dispatch = sym wp_cl_dispatch, + n = const POOL_SIZE, +); + +// ================================================================ +// x86-64 (System V AMD64) — Intel syntax (Rust's default). +// ================================================================ + +#[cfg(target_arch = "x86_64")] +mod abi { + /// Integer/pointer argument registers: rdi, rsi, rdx, rcx, r8, r9. + pub(super) const NGPR: usize = 6; + /// Bytes between consecutive trampoline stubs (padded to 16). + pub(super) const STUB_SIZE: usize = 16; + /// `lea r11, [rip]` yields the address *after* the 7-byte `lea`. + pub(super) const LEA_BIAS: usize = 7; +} + +#[cfg(target_arch = "x86_64")] +core::arch::global_asm!( + ".p2align 4", + ".globl {gate}", + "{gate}:", + " push rbp", + " mov rbp, rsp", + " push rbx", + " push r12", + " mov rbx, rdi", // rbx = &RawCall + " mov r12, [rbx+32]", // stack_words + " lea rax, [r12*8]", + " add rax, 15", + " and rax, -16", // 16-byte-aligned overflow area + " sub rsp, rax", + " mov rcx, [rbx+24]", // stack src + " xor r11, r11", + "3:", + " cmp r11, r12", + " jae 4f", + " mov rax, [rcx + r11*8]", + " mov [rsp + r11*8], rax", + " add r11, 1", + " jmp 3b", + "4:", + " mov rax, [rbx+16]", // fpr src + " movsd xmm0, [rax+0]", + " movsd xmm1, [rax+8]", + " movsd xmm2, [rax+16]", + " movsd xmm3, [rax+24]", + " movsd xmm4, [rax+32]", + " movsd xmm5, [rax+40]", + " movsd xmm6, [rax+48]", + " movsd xmm7, [rax+56]", + " mov r10, [rbx+8]", // gpr src + " mov rdi, [r10+0]", + " mov rsi, [r10+8]", + " mov rdx, [r10+16]", + " mov rcx, [r10+24]", + " mov r8, [r10+32]", + " mov r9, [r10+40]", + " mov rax, [rbx+40]", // nfpr -> al (variadic) + " mov r11, [rbx+0]", // fnptr + " call r11", + " mov r10, [rbx+48]", // ret_gpr + " mov [r10], rax", + " mov r10, [rbx+56]", // ret_fpr + " movsd [r10], xmm0", + " lea rsp, [rbp-16]", + " pop r12", + " pop rbx", + " pop rbp", + " ret", + gate = sym wp_ffi_call_gate, +); + +#[cfg(target_arch = "x86_64")] +core::arch::global_asm!( + ".p2align 4", + ".globl {pool}", + "{pool}:", + ".rept {n}", + " .p2align 4", // fixed 16-byte stub stride regardless of jmp encoding + " lea r11, [rip]", // r11 = stub base + 7 + " jmp 9f", + ".endr", + ".p2align 4", + "9:", + " push rbp", + " mov rbp, rsp", + " sub rsp, 128", + " mov [rsp+0], rdi", // spill integer args + " mov [rsp+8], rsi", + " mov [rsp+16], rdx", + " mov [rsp+24], rcx", + " mov [rsp+32], r8", + " mov [rsp+40], r9", + " movsd [rsp+48], xmm0", // spill FP args + " movsd [rsp+56], xmm1", + " movsd [rsp+64], xmm2", + " movsd [rsp+72], xmm3", + " movsd [rsp+80], xmm4", + " movsd [rsp+88], xmm5", + " movsd [rsp+96], xmm6", + " movsd [rsp+104], xmm7", + " mov rdi, r11", // stub_addr + " lea rsi, [rsp+0]", // gpr + " lea rdx, [rsp+48]", // fpr + " lea rcx, [rbp+16]", // incoming stack args + " lea r8, [rsp+112]", // ret_gpr + " lea r9, [rsp+120]", // ret_fpr + " call {dispatch}", + " mov rax, [rsp+112]", + " movsd xmm0, [rsp+120]", + " mov rsp, rbp", + " pop rbp", + " ret", + pool = sym wp_cl_pool, + dispatch = sym wp_cl_dispatch, + n = const POOL_SIZE, +); + +// ================================================================ +// Shared machinery (aarch64 + x86-64) +// ================================================================ + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +use core::sync::atomic::{AtomicPtr, Ordering}; +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +use std::sync::Mutex; + +/// Number of pre-allocated closure trampolines. ctypes callbacks are few in +/// practice; freed slots are recycled ([`free_trampoline`]) so this bounds +/// *live* callbacks, not total ever created. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) const POOL_SIZE: usize = 1024; + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) const NGPR_ARG: usize = abi::NGPR; +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) const NFPR_ARG: usize = 8; +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) const SUPPORTED: bool = true; + +// Defined by the `global_asm!` blocks above (their `sym` operands resolve +// these names in this module's scope). +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +extern "C" { + fn wp_ffi_call_gate(call: *const RawCall); + fn wp_cl_pool(); +} + +/// Execute a real C ABI call. `gpr`/`fpr` hold the integer and FP register +/// files (only the ABI-relevant prefix is consumed); `stack` holds any +/// overflow words; `nfpr` is the FP-register count for x86-64 variadic +/// calls. Returns `(x0/rax, d0/xmm0)`. +/// +/// # Safety +/// `fnptr` must be a valid function whose real C signature matches the +/// register/stack placement the caller performed; pointer arguments must +/// outlive the call. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) unsafe fn raw_call( + fnptr: usize, + gpr: &[u64; 8], + fpr: &[u64; 8], + stack: &[u64], + nfpr: u64, +) -> (u64, u64) { + let mut ret_gpr = 0u64; + let mut ret_fpr = 0u64; + let call = RawCall { + fnptr: fnptr as *const c_void, + gpr: gpr.as_ptr(), + fpr: fpr.as_ptr(), + stack: if stack.is_empty() { + std::ptr::null() + } else { + stack.as_ptr() + }, + stack_words: stack.len() as u64, + nfpr, + ret_gpr: &mut ret_gpr, + ret_fpr: &mut ret_fpr, + }; + unsafe { wp_ffi_call_gate(&call) }; + (ret_gpr, ret_fpr) +} + +/// Per-slot user-data pointers (leaked `ClosureData`), read lock-free by the +/// trampoline dispatch path. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +static SLOT_DATA: [AtomicPtr; POOL_SIZE] = + [const { AtomicPtr::new(std::ptr::null_mut()) }; POOL_SIZE]; + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +struct AllocState { + next: usize, + free: Vec, +} +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +static ALLOC: Mutex = Mutex::new(AllocState { + next: 0, + free: Vec::new(), +}); + +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +fn pool_base() -> usize { + wp_cl_pool as *const () as usize +} + +/// Bind `userdata` to a free trampoline slot and return its C-callable code +/// address, or `None` if the pool is exhausted. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) fn alloc_trampoline(userdata: *mut c_void) -> Option { + let slot = { + let mut st = ALLOC.lock().unwrap(); + if let Some(s) = st.free.pop() { + s + } else if st.next < POOL_SIZE { + let s = st.next; + st.next += 1; + s + } else { + return None; + } + }; + SLOT_DATA[slot].store(userdata, Ordering::Release); + Some(pool_base() + slot * abi::STUB_SIZE) +} + +/// Release the trampoline at `code_addr`, returning the `userdata` pointer +/// previously bound (so the caller can reclaim it). Returns `None` if the +/// address is not a live trampoline in this pool. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +pub(super) fn free_trampoline(code_addr: usize) -> Option<*mut c_void> { + let base = pool_base(); + if code_addr < base { + return None; + } + let off = code_addr - base; + if off % abi::STUB_SIZE != 0 { + return None; + } + let slot = off / abi::STUB_SIZE; + if slot >= POOL_SIZE { + return None; + } + let prev = SLOT_DATA[slot].swap(std::ptr::null_mut(), Ordering::AcqRel); + if prev.is_null() { + return None; + } + ALLOC.lock().unwrap().free.push(slot); + Some(prev) +} + +/// Trampoline dispatch entry (called from the shared spill routine). Recovers +/// the slot from the stub's self-reported address, loads the bound +/// `ClosureData`, and hands the register-file image to the Rust marshaller. +#[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] +extern "C" fn wp_cl_dispatch( + stub_addr: usize, + gpr: *const u64, + fpr: *const u64, + stack: *const u64, + ret_gpr: *mut u64, + ret_fpr: *mut u64, +) { + let base = pool_base(); + let slot = stub_addr + .wrapping_sub(base) + .wrapping_sub(abi::LEA_BIAS) + / abi::STUB_SIZE; + let userdata = if slot < POOL_SIZE { + SLOT_DATA[slot].load(Ordering::Acquire) + } else { + std::ptr::null_mut() + }; + let regs = ClosureRegs { + gpr, + fpr, + stack, + ret_gpr, + ret_fpr, + }; + super::closure_dispatch(userdata, ®s); +} + +// ================================================================ +// Unsupported architectures: clean, no-asm fallbacks. +// ================================================================ + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) const NGPR_ARG: usize = 8; +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) const NFPR_ARG: usize = 8; +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) const SUPPORTED: bool = false; + +/// # Safety +/// Never called: [`SUPPORTED`] is `false`, so every call site is guarded. +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) unsafe fn raw_call( + _fnptr: usize, + _gpr: &[u64; 8], + _fpr: &[u64; 8], + _stack: &[u64], + _nfpr: u64, +) -> (u64, u64) { + (0, 0) +} + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) fn alloc_trampoline(_userdata: *mut c_void) -> Option { + None +} + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +pub(super) fn free_trampoline(_code_addr: usize) -> Option<*mut c_void> { + None +} diff --git a/crates/weavepy-vm/src/stdlib/lzma_mod.rs b/crates/weavepy-vm/src/stdlib/lzma_mod.rs index b9560ff..3beba73 100644 --- a/crates/weavepy-vm/src/stdlib/lzma_mod.rs +++ b/crates/weavepy-vm/src/stdlib/lzma_mod.rs @@ -509,7 +509,7 @@ impl Coder { let before_out = s.total_out(); let status = s .process(input, output, action) - .map_err(|e| lzma_error(&format!("{e:?}")))?; + .map_err(|e| lzma_stream_error(&e))?; Ok(( (s.total_in() - before_in) as usize, (s.total_out() - before_out) as usize, @@ -700,6 +700,27 @@ fn lzma_error(msg: &str) -> RuntimeError { RuntimeError::PyException(PyException::new(inst)) } +/// Translate an `xz2`/liblzma stream error into the exact `LZMAError` message +/// CPython's `_lzma` module produces (`Modules/_lzmamodule.c: catch_lzma_error`). +/// pandas' `read_xml` compression tests assert on the wording verbatim — e.g. +/// decoding a non-XZ stream as `xz` must read +/// `"Input format not supported by decoder"`, not xz2's terse `Debug` (`Format`). +fn lzma_stream_error(e: &xz2::stream::Error) -> RuntimeError { + use xz2::stream::Error; + let msg = match e { + Error::Mem => "Cannot allocate memory", + Error::MemLimit => "Memory usage limit exceeded", + Error::Format => "Input format not supported by decoder", + Error::Options => "Invalid or unsupported options", + Error::Data => "Corrupt input data", + Error::Program => "Internal error", + // `NoCheck` / `UnsupportedCheck` (and any future variants) surface as + // the generic liblzma error text. + _ => "Internal error", + }; + lzma_error(msg) +} + fn eof_error(msg: &str) -> RuntimeError { RuntimeError::PyException(PyException::from_builtin("EOFError", msg)) } diff --git a/crates/weavepy-vm/src/stdlib/mmap_mod.rs b/crates/weavepy-vm/src/stdlib/mmap_mod.rs index fb6d554..505809b 100644 --- a/crates/weavepy-vm/src/stdlib/mmap_mod.rs +++ b/crates/weavepy-vm/src/stdlib/mmap_mod.rs @@ -122,6 +122,22 @@ fn mmap_type() -> Rc { })), ); } + // `mmap(fileno, length, access=..., flags=..., prot=..., offset=...)` is + // routinely constructed with keyword arguments — pandas' memory-mapped + // reader does `mmap.mmap(fileno, 0, access=mmap.ACCESS_READ)`. Override + // the positional-only `__init__` inserted above with a kwargs-aware entry + // that folds the documented keywords into the positional slots `mm_init` + // reads, so the call no longer trips "`__init__` does not accept keyword + // arguments". + td.insert( + DictKey(Object::from_static("__init__")), + Object::Builtin(Rc::new(BuiltinFn { + name: "__init__", + binds_instance: true, + call: Box::new(mm_init), + call_kw: Some(Box::new(mm_init_kw)), + })), + ); TypeObject::new_with_flags( "mmap", vec![bt.object_.clone()], @@ -335,6 +351,32 @@ fn mm_init(args: &[Object]) -> Result { Ok(Object::None) } +fn mm_init_kw(args: &[Object], kwargs: &[(String, Object)]) -> Result { + // Fold the CPython `mmap()` keyword arguments into the positional layout + // `mm_init` consumes (`[self, fileno, length, access]`). `flags`/`prot`/ + // `offset`/`tagname` are accepted for signature parity but not consulted + // by this shim, which derives the file view purely from `access`. + let mut pos: Vec = args.to_vec(); + for (k, val) in kwargs { + let slot = match k.as_str() { + "fileno" => 1, + "length" => 2, + "access" => 3, + "flags" | "prot" | "offset" | "tagname" => continue, + _ => { + return Err(type_error(format!( + "'{k}' is an invalid keyword argument for mmap()" + ))) + } + }; + while pos.len() <= slot { + pos.push(Object::None); + } + pos[slot] = val.clone(); + } + mm_init(&pos) +} + fn mmap_bytes(state: &MmapState) -> &[u8] { state.region.as_slice() } diff --git a/crates/weavepy-vm/src/stdlib/mod.rs b/crates/weavepy-vm/src/stdlib/mod.rs index fc2f78f..98bcc91 100644 --- a/crates/weavepy-vm/src/stdlib/mod.rs +++ b/crates/weavepy-vm/src/stdlib/mod.rs @@ -65,13 +65,13 @@ pub mod time; pub mod tracemalloc_real; mod unicode_decomp_data; pub mod unicodedata_mod; -pub mod uuid_mod; pub mod weakref_mod; pub mod zlib_mod; // RFC 0023 — drop-in stdlib parity. pub mod abc_mod; pub mod atexit_mod; pub mod contextvars_mod; +pub mod ctypes_native; pub mod https_mod; pub mod io_full; pub mod locale_mod; @@ -137,7 +137,10 @@ pub fn register_all(cache: &ModuleCache) { cache.register_builtin("_statistics", statistics_accel::build); cache.register_builtin("binascii", binascii_mod::build); cache.register_builtin("secrets", secrets_mod::build); - cache.register_builtin("uuid", uuid_mod::build); + // `uuid` is CPython's verbatim pure-Python `Lib/uuid.py` (registered as a + // frozen source below), NOT a native dict shim — the shim's fake UUID + // (a `dict`) could not carry a real `__str__`, so `str(uuid.uuid4())` + // returned a dict repr. See `frozen_sources()`. cache.register_builtin("_tempfile", tempfile_mod::build); cache.register_builtin("_shutil", shutil_mod::build); cache.register_builtin("_functools", functools_mod::build); @@ -191,6 +194,12 @@ pub fn register_all(cache: &ModuleCache) { cache.register_builtin("_locale", locale_mod::build); cache.register_builtin("_abc", abc_mod::build); cache.register_builtin("_contextvars", contextvars_mod::build); + // RFC 0046 (wave 5): native primitive layer behind the frozen `_ctypes` + // reimplementation (memory peek/poke, dlopen/dlsym, platform C type + // sizes, libffi call bridge) that backs the verbatim CPython `ctypes` + // package. The host `_ctypes.*.so` is core-built (links `_PyRuntime`), + // so it cannot be dlopen'd like a stable-ABI wheel — we reimplement it. + cache.register_builtin("_ctypes_native", ctypes_native::build); cache.register_builtin("atexit", atexit_mod::build); cache.register_builtin("_https", https_mod::build); // RFC 0026 — POSIX-flavoured stdlib that user code (and the @@ -208,7 +217,38 @@ pub fn register_all(cache: &ModuleCache) { cache.register_builtin("_xxsubinterpreters", interpreters_mod::build); // Frozen Python sources (pure-Python stdlib). + // + // RFC 0046 (wave 4): `numpy`/`_numpy_pure` are a pure-Python compatibility + // shim that, being frozen, would otherwise shadow a real numpy installed on + // `sys.path`. Setting `WEAVEPY_NO_NUMPY_SHIM` suppresses the shim so the + // binary-ABI loader imports the genuine `numpy._core._multiarray_umath` + // extension instead. + let suppress_numpy_shim = std::env::var_os("WEAVEPY_NO_NUMPY_SHIM").is_some(); + // Mirror of `WEAVEPY_NO_NUMPY_SHIM` for the frozen `pytest`/`pluggy`/ + // `iniconfig` shims: suppressing them lets a real pytest installed on + // `sys.path` load instead (or an editable copy of our shim during + // development), rather than being shadowed by the frozen source. + let suppress_pytest_shim = std::env::var_os("WEAVEPY_NO_PYTEST_SHIM").is_some(); + // General-purpose escape hatch (comma-separated module names) so a frozen + // module can be shadowed by an editable copy on `sys.path` during + // development — the same idea as the two shims above, but for arbitrary + // modules while iterating on their pure-Python source without a rebuild. + let suppress_list = std::env::var("WEAVEPY_SUPPRESS_FROZEN").unwrap_or_default(); + let suppressed: std::collections::HashSet<&str> = suppress_list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); for src in frozen_sources() { + if suppress_numpy_shim && matches!(src.name, "numpy" | "_numpy_pure") { + continue; + } + if suppress_pytest_shim && matches!(src.name, "pytest" | "pluggy" | "iniconfig") { + continue; + } + if suppressed.contains(src.name) { + continue; + } cache.register_frozen(*src); } } @@ -220,6 +260,58 @@ fn frozen_sources() -> &'static [FrozenSource] { source: include_str!("python/builtins.py"), is_package: false, }, + // RFC 0046 (wave 5): `ctypes`. The verbatim CPython `ctypes` package + // runs over our frozen `_ctypes` reimplementation (CPython's real + // `_ctypes` is a core-built C extension linking `_PyRuntime`, so it + // can't be dlopen'd like a stable-ABI wheel). `_ctypes` in turn sits + // on the native `_ctypes_native` primitive module (memory, dlopen, + // platform C type sizes, libffi). pandas imports `ctypes` + // unconditionally (`pandas.errors`). + FrozenSource { + name: "_ctypes", + source: include_str!("python/_ctypes.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes", + source: include_str!("python/ctypes/__init__.py"), + is_package: true, + }, + FrozenSource { + name: "ctypes._endian", + source: include_str!("python/ctypes/_endian.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes.util", + source: include_str!("python/ctypes/util.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes.wintypes", + source: include_str!("python/ctypes/wintypes.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes.macholib", + source: include_str!("python/ctypes/macholib/__init__.py"), + is_package: true, + }, + FrozenSource { + name: "ctypes.macholib.dyld", + source: include_str!("python/ctypes/macholib/dyld.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes.macholib.dylib", + source: include_str!("python/ctypes/macholib/dylib.py"), + is_package: false, + }, + FrozenSource { + name: "ctypes.macholib.framework", + source: include_str!("python/ctypes/macholib/framework.py"), + is_package: false, + }, // RFC 0040 WS1 — upgrades the native `os` module's `environ`/ // `environb` to CPython's write-through `_Environ` mappings. Imported // for its side effect immediately after the native `os` module is @@ -264,6 +356,18 @@ fn frozen_sources() -> &'static [FrozenSource] { source: include_str!("python/keyword.py"), is_package: false, }, + // `shlex` — verbatim CPython lexical analyzer (`split`/`quote`/`join`). + // Pure-Python over `os`/`re`/`sys`/`collections.deque`/`io.StringIO`, + // all of which WeavePy provides. Without it pandas' `tests/io/conftest.py` + // dies at its first line (`import shlex`) and `_load_conftests` silently + // swallows the `ModuleNotFoundError`, dropping every io-conftest fixture + // (`compression_to_extension`, `tips_file`, the s3/moto fixtures, …) so + // dozens of io tests fail with a spurious "missing positional argument". + FrozenSource { + name: "shlex", + source: include_str!("python/shlex.py"), + is_package: false, + }, // `random` — verbatim CPython distribution layer over the // Rust `_random` MT19937 core (RFC 0037: `random.Random(42)` // is stream-identical to CPython). @@ -272,6 +376,21 @@ fn frozen_sources() -> &'static [FrozenSource] { source: include_str!("python/random_mod.py"), is_package: false, }, + // `uuid` — verbatim CPython `Lib/uuid.py`. The full `UUID` class + // (immutable, `__slots__`-backed, `object.__setattr__` bypass, + // `__str__`/`__repr__`/`__hash__`/`__eq__`, the `bytes`/`hex`/`urn`/ + // `version`/`fields` properties) is required by real code — pandas' + // `_testing.ensure_clean()` builds temp filenames from + // `str(uuid.uuid4())`, so a dict masquerading as a UUID produced a + // dict-repr filename (`{'bytes': …}`) and `ENAMETOOLONG`. `uuid4()` + // needs only `os.urandom`; the optional `_uuid` C ext is guarded by + // `try/except ImportError`, and `getnode()`'s subprocess helpers are + // never reached by the random/hash-based generators. + FrozenSource { + name: "uuid", + source: include_str!("python/uuid.py"), + is_package: false, + }, // Internal: `_SeqIter`, the lazy legacy-`__getitem__` iterator // `iter(obj)` returns when *obj* has no `__iter__` (CPython's // built-in `iterator`/seqiterobject). Kept out of `builtins` to @@ -1206,7 +1325,12 @@ fn frozen_sources() -> &'static [FrozenSource] { source: include_str!("python/smtplib.py"), is_package: false, }, - // `xml` package and submodules — only `etree.ElementTree`. + // `xml` package + submodules. `etree` and `dom` are now the *verbatim* + // CPython implementations running over WeavePy's native `pyexpat` + // (namespace-aware `ParserCreate(encoding, "}")`, `_elementtree` C + // accelerator absent so the pure-Python path is taken). This replaces + // the earlier hand-rolled `xml_etree.py`, which was namespace-naive and + // failed pandas' `read_xml`/`to_xml` namespace + prefix round-trips. FrozenSource { name: "xml", source: "", @@ -1214,12 +1338,73 @@ fn frozen_sources() -> &'static [FrozenSource] { }, FrozenSource { name: "xml.etree", - source: "", + source: include_str!("python/xml/etree/__init__.py"), is_package: true, }, + FrozenSource { + name: "xml.etree.ElementPath", + source: include_str!("python/xml/etree/ElementPath.py"), + is_package: false, + }, FrozenSource { name: "xml.etree.ElementTree", - source: include_str!("python/xml_etree.py"), + source: include_str!("python/xml/etree/ElementTree.py"), + is_package: false, + }, + // `xml.parsers.expat` — verbatim CPython over WeavePy's native + // `pyexpat`. Registering the package + this thin `from pyexpat import *` + // shim is what lets the verbatim `xml.dom` package below drive the + // native parser (`xml.parsers.expat` was otherwise unresolved even + // though `pyexpat` imports fine). + FrozenSource { + name: "xml.parsers", + source: include_str!("python/xml/parsers/__init__.py"), + is_package: true, + }, + FrozenSource { + name: "xml.parsers.expat", + source: include_str!("python/xml/parsers/expat.py"), + is_package: false, + }, + // `xml.dom` + `xml.dom.minidom` (and the builders they need) — verbatim + // CPython. pandas' `DataFrame.to_xml(pretty_print=True)` does + // `xml.dom.minidom.parseString(out_xml).toprettyxml(indent=" ")`; the + // verbatim `minidom` guarantees byte-identical pretty-printed output, + // and `expatbuilder` runs over the native `pyexpat` (non-namespace mode, + // qualified names verbatim — matching how the input was serialized). + FrozenSource { + name: "xml.dom", + source: include_str!("python/xml/dom/__init__.py"), + is_package: true, + }, + FrozenSource { + name: "xml.dom.minicompat", + source: include_str!("python/xml/dom/minicompat.py"), + is_package: false, + }, + FrozenSource { + name: "xml.dom.domreg", + source: include_str!("python/xml/dom/domreg.py"), + is_package: false, + }, + FrozenSource { + name: "xml.dom.NodeFilter", + source: include_str!("python/xml/dom/NodeFilter.py"), + is_package: false, + }, + FrozenSource { + name: "xml.dom.xmlbuilder", + source: include_str!("python/xml/dom/xmlbuilder.py"), + is_package: false, + }, + FrozenSource { + name: "xml.dom.expatbuilder", + source: include_str!("python/xml/dom/expatbuilder.py"), + is_package: false, + }, + FrozenSource { + name: "xml.dom.minidom", + source: include_str!("python/xml/dom/minidom.py"), is_package: false, }, // RFC 0018 — introspection, test infrastructure, exception groups. @@ -1582,6 +1767,11 @@ fn frozen_sources() -> &'static [FrozenSource] { source: include_str!("python/pickle.py"), is_package: false, }, + FrozenSource { + name: "_compat_pickle", + source: include_str!("python/_compat_pickle.py"), + is_package: false, + }, FrozenSource { name: "shelve", source: include_str!("python/shelve.py"), diff --git a/crates/weavepy-vm/src/stdlib/multiprocessing_mod.rs b/crates/weavepy-vm/src/stdlib/multiprocessing_mod.rs index e2e1e7d..1e89426 100644 --- a/crates/weavepy-vm/src/stdlib/multiprocessing_mod.rs +++ b/crates/weavepy-vm/src/stdlib/multiprocessing_mod.rs @@ -518,6 +518,7 @@ fn make_semlock_instance(inner: Arc) -> Object { slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }); Object::Instance(inst) } diff --git a/crates/weavepy-vm/src/stdlib/operator_accel.rs b/crates/weavepy-vm/src/stdlib/operator_accel.rs index d923d1d..0eea955 100644 --- a/crates/weavepy-vm/src/stdlib/operator_accel.rs +++ b/crates/weavepy-vm/src/stdlib/operator_accel.rs @@ -184,9 +184,13 @@ fn inplace(args: &[Object], op: BinOpKind, name: &str) -> Result Result { let (a, b) = two_args(args, name)?; - Ok(Object::Bool(with_interp(|interp| { - interp.op_compare(a, b, op) - })?)) + // `operator.gt(a, b)` is the *expression* `a > b`, not `bool(a > b)`: it + // must return the raw rich-comparison result, so a foreign object (a numpy + // `ndarray`) yields its element-wise bool array rather than a coerced + // scalar. pandas' `comparison_op` dispatches `Series > x` through + // `operator.gt`; a scalar result there makes pandas treat the whole + // comparison as invalid (`invalid_comparison`) and boolean masks break. + with_interp(|interp| interp.rich_compare_public(a, b, op)) } fn unary(args: &[Object], op: UnaryKind, name: &str) -> Result { diff --git a/crates/weavepy-vm/src/stdlib/pyexpat_mod.rs b/crates/weavepy-vm/src/stdlib/pyexpat_mod.rs index 8317047..a7477e1 100644 --- a/crates/weavepy-vm/src/stdlib/pyexpat_mod.rs +++ b/crates/weavepy-vm/src/stdlib/pyexpat_mod.rs @@ -646,6 +646,9 @@ fn parse_method(args: &[Object]) -> Result { // instance after creation; honour the instance attributes. let buffer_text = buffer_text || flag_attr(&inst, "buffer_text"); let ordered = ordered || flag_attr(&inst, "ordered_attributes"); + // expat's `XML_SetReturnNSTriplet`: when set, prefixed names gain a trailing + // `prefix` field. `minidom` (ExpatBuilderNS) turns this on. + let return_prefix = flag_attr(&inst, "namespace_prefixes"); let line_starts = compute_line_starts(&buffer); { @@ -660,6 +663,7 @@ fn parse_method(args: &[Object]) -> Result { namespace_sep.as_deref(), buffer_text, ordered, + return_prefix, )?; Ok(Object::Int(1)) } @@ -795,6 +799,7 @@ fn run_parse( namespace_sep: Option<&str>, buffer_text: bool, ordered: bool, + return_prefix: bool, ) -> Result<(), RuntimeError> { let mut reader = Reader::from_reader(buffer); let config = reader.config_mut(); @@ -805,6 +810,23 @@ fn run_parse( let mut ns_stack: Vec = Vec::new(); let mut pending_text: Option = None; let mut buf: Vec = Vec::new(); + // Well-formedness tracking. A conforming XML document has exactly one + // top-level ("document") element. expat rejects two classes of input that + // `quick-xml` (a lenient fragment-friendly parser) accepts silently, and + // pandas' `read_xml` asserts on the exact expat wording: + // * no root element at all -> "no element found" (3) + // * a second top-level element/junk -> "junk after document element" (9) + // `root_seen` flips true when the first depth-0 element opens; once the + // root has closed (`depth` back to 0 with `root_seen`) any further element + // start or non-whitespace text in the epilog is junk. + let mut root_seen = false; + // Element nesting depth. Character data at depth 0 is prolog/epilog + // whitespace, which expat routes through the Default handler rather than + // `CharacterDataHandler` (CPython: the `\n` after `` is a `default` + // event, not `char`). Getting this wrong made `minidom.toprettyxml` insert + // blank lines between the declaration and the root element, breaking every + // pandas `DataFrame.to_xml(pretty_print=True)` byte-comparison. + let mut depth: i32 = 0; loop { let event_pos = reader.buffer_position() as usize; @@ -812,7 +834,7 @@ fn run_parse( match ev { Ok(Event::Eof) => break, Ok(Event::Decl(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; let version = e .version() .ok() @@ -848,7 +870,11 @@ fn run_parse( )?; } Ok(Event::Start(e)) => { - flush_text(inst, &mut pending_text)?; + if depth == 0 && root_seen { + let (line, col) = line_col(line_starts, event_pos); + return Err(make_expat_error(inst, 9, line, col, event_pos as i64)); + } + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); handle_start( inst, @@ -856,12 +882,21 @@ fn run_parse( &e, namespace_sep, ordered, + return_prefix, &mut ns_stack, false, )?; + if depth == 0 { + root_seen = true; + } + depth += 1; } Ok(Event::Empty(e)) => { - flush_text(inst, &mut pending_text)?; + if depth == 0 && root_seen { + let (line, col) = line_col(line_starts, event_pos); + return Err(make_expat_error(inst, 9, line, col, event_pos as i64)); + } + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); handle_start( inst, @@ -869,40 +904,60 @@ fn run_parse( &e, namespace_sep, ordered, + return_prefix, &mut ns_stack, true, )?; + if depth == 0 { + root_seen = true; + } } Ok(Event::End(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); - let name = expand_end_name(&reader, e.name().as_ref(), namespace_sep, &ns_stack)?; + let name = expand_end_name( + &reader, + e.name().as_ref(), + namespace_sep, + &ns_stack, + return_prefix, + )?; call_handler(inst, "EndElementHandler", &[Object::from_str(name)])?; pop_ns_scope(inst, namespace_sep, &mut ns_stack)?; + depth -= 1; } Ok(Event::Text(e)) => { let text = e .unescape() .map_err(|err| escape_err(inst, line_starts, event_pos, &err.to_string()))? .into_owned(); - accumulate_text(inst, &mut pending_text, text, buffer_text)?; + // Non-whitespace character data outside the root element is not + // well-formed. Once the root has closed, expat reports it as + // "junk after document element"; whitespace epilog is allowed. + if depth == 0 && root_seen && !text.trim().is_empty() { + let (line, col) = line_col(line_starts, event_pos); + return Err(make_expat_error(inst, 9, line, col, event_pos as i64)); + } + accumulate_text(inst, &mut pending_text, text, buffer_text, depth > 0)?; } Ok(Event::CData(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); let text = decode_cow(&reader, e.as_ref())?; call_handler(inst, "StartCdataSectionHandler", &[])?; - emit_char_data(inst, &text)?; + // CDATA is only well-formed inside the root element, so it is + // always reportable character data. + emit_char_data(inst, &text, true)?; call_handler(inst, "EndCdataSectionHandler", &[])?; } Ok(Event::Comment(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); let text = decode_cow(&reader, e.as_ref())?; call_handler(inst, "CommentHandler", &[Object::from_str(text)])?; } Ok(Event::PI(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); let raw = decode_cow(&reader, e.as_ref())?; let (target, rest) = match raw.split_once(char::is_whitespace) { @@ -916,7 +971,7 @@ fn run_parse( )?; } Ok(Event::DocType(e)) => { - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; update_position(inst, line_starts, event_pos); let text = decode_cow(&reader, e.as_ref())?; let name = text.split_whitespace().next().unwrap_or("").to_owned(); @@ -941,7 +996,14 @@ fn run_parse( } buf.clear(); } - flush_text(inst, &mut pending_text)?; + flush_text(inst, &mut pending_text, depth > 0)?; + // A document with no top-level element ("", whitespace-only, prolog-only) + // is ill-formed: expat raises "no element found" at the final position. + if !root_seen { + let end = buffer.len(); + let (line, col) = line_col(line_starts, end); + return Err(make_expat_error(inst, 3, line, col, end as i64)); + } Ok(()) } @@ -950,30 +1012,47 @@ fn accumulate_text( pending: &mut Option, text: String, buffer_text: bool, + in_element: bool, ) -> Result<(), RuntimeError> { if buffer_text { pending.get_or_insert_with(String::new).push_str(&text); Ok(()) } else { - emit_char_data(inst, &text) + emit_char_data(inst, &text, in_element) } } -fn flush_text(inst: &Rc, pending: &mut Option) -> Result<(), RuntimeError> { +fn flush_text( + inst: &Rc, + pending: &mut Option, + in_element: bool, +) -> Result<(), RuntimeError> { if let Some(text) = pending.take() { if !text.is_empty() { - emit_char_data(inst, &text)?; + emit_char_data(inst, &text, in_element)?; } } Ok(()) } -fn emit_char_data(inst: &Rc, text: &str) -> Result<(), RuntimeError> { - let fired = call_handler( - inst, - "CharacterDataHandler", - &[Object::from_str(text.to_owned())], - )?; +fn emit_char_data( + inst: &Rc, + text: &str, + in_element: bool, +) -> Result<(), RuntimeError> { + // Only content *inside* the root element is reportable character data. + // Whitespace in the prolog/epilog (depth 0) is never handed to + // `CharacterDataHandler` by expat — it goes straight to the Default + // handler, exactly like any other markup with no specific handler. + let fired = if in_element { + call_handler( + inst, + "CharacterDataHandler", + &[Object::from_str(text.to_owned())], + )? + } else { + false + }; if !fired { // expat routes unhandled data through the Default handlers. if handler_of(inst, "DefaultHandlerExpand").is_some() { @@ -996,6 +1075,7 @@ fn handle_start( e: &quick_xml::events::BytesStart<'_>, namespace_sep: Option<&str>, ordered: bool, + return_prefix: bool, ns_stack: &mut Vec, empty: bool, ) -> Result<(), RuntimeError> { @@ -1033,13 +1113,20 @@ fn handle_start( ns_stack.push(scope); } - let name = expand_name(reader, e.name().as_ref(), namespace_sep, ns_stack, true)?; + let name = expand_name( + reader, + e.name().as_ref(), + namespace_sep, + ns_stack, + true, + return_prefix, + )?; // Build the expanded attribute names. let mut attrs: Vec<(String, String)> = Vec::with_capacity(raw_attrs.len()); for (k, v) in raw_attrs { let nk = if namespace_sep.is_some() { - expand_name(reader, k.as_bytes(), namespace_sep, ns_stack, false)? + expand_name(reader, k.as_bytes(), namespace_sep, ns_stack, false, return_prefix)? } else { k }; @@ -1112,12 +1199,22 @@ fn lookup_ns(ns_stack: &[NsScope], prefix: &str) -> Option { /// Expand an element/attribute name in namespace mode to expat's /// `urilocalname` form. `is_element` controls default-namespace /// application (attributes are not in the default namespace). +/// +/// When `return_prefix` is set (expat's `XML_SetReturnNSTriplet`, driven by the +/// Python-visible `namespace_prefixes` flag), a *prefixed* name additionally +/// carries the original prefix as a third separator-delimited field +/// (`urilocalprefix`). `xml.dom.minidom` turns this on and relies on +/// the trailing prefix to reconstruct the qualified name — without it every +/// `doc:tag` round-tripped through `minidom.toprettyxml` collapses to `tag`, +/// which is exactly what broke pandas' `to_xml(prefix=…, pretty_print=True)`. +/// A default-namespace name (no prefix) stays two-field, matching expat. fn expand_name( reader: &Reader<&[u8]>, raw: &[u8], namespace_sep: Option<&str>, ns_stack: &[NsScope], is_element: bool, + return_prefix: bool, ) -> Result { let name = decode_cow(reader, raw)?; let Some(sep) = namespace_sep else { @@ -1125,6 +1222,9 @@ fn expand_name( }; if let Some((prefix, local)) = name.split_once(':') { if let Some(uri) = lookup_ns(ns_stack, prefix) { + if return_prefix { + return Ok(format!("{uri}{sep}{local}{sep}{prefix}")); + } return Ok(format!("{uri}{sep}{local}")); } return Ok(name); @@ -1144,8 +1244,9 @@ fn expand_end_name( raw: &[u8], namespace_sep: Option<&str>, ns_stack: &[NsScope], + return_prefix: bool, ) -> Result { - expand_name(reader, raw, namespace_sep, ns_stack, true) + expand_name(reader, raw, namespace_sep, ns_stack, true, return_prefix) } fn decode_cow(reader: &Reader<&[u8]>, bytes: &[u8]) -> Result { diff --git a/crates/weavepy-vm/src/stdlib/python/_collections.py b/crates/weavepy-vm/src/stdlib/python/_collections.py index 23c0824..a734e27 100644 --- a/crates/weavepy-vm/src/stdlib/python/_collections.py +++ b/crates/weavepy-vm/src/stdlib/python/_collections.py @@ -13,6 +13,11 @@ __all__ = ["deque", "defaultdict", "_count_elements"] +# CPython's C `deque`/`defaultdict` expose `__class_getitem__` so PEP 585 +# subscription (`deque[int]`) yields a `types.GenericAlias`. `types` only +# imports `sys`, so this is import-cycle safe from this low-level module. +from types import GenericAlias as _GenericAlias + def _count_elements(mapping, iterable): """Tally elements from the iterable (Counter's inner loop).""" @@ -75,6 +80,8 @@ def __ror__(self, other): new.update(self) return new + __class_getitem__ = classmethod(_GenericAlias) + class deque: """list-like container with fast appends and pops on either end. @@ -270,6 +277,8 @@ def __ge__(self, other): __hash__ = None + __class_getitem__ = classmethod(_GenericAlias) + def __reduce__(self): return type(self), (list(self._data), self._maxlen) diff --git a/crates/weavepy-vm/src/stdlib/python/_compat_pickle.py b/crates/weavepy-vm/src/stdlib/python/_compat_pickle.py new file mode 100644 index 0000000..439f8c0 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/_compat_pickle.py @@ -0,0 +1,251 @@ +# This module is used to map the old Python 2 names to the new names used in +# Python 3 for the pickle module. This needed to make pickle streams +# generated with Python 2 loadable by Python 3. + +# This is a copy of lib2to3.fixes.fix_imports.MAPPING. We cannot import +# lib2to3 and use the mapping defined there, because lib2to3 uses pickle. +# Thus, this could cause the module to be imported recursively. +IMPORT_MAPPING = { + '__builtin__' : 'builtins', + 'copy_reg': 'copyreg', + 'Queue': 'queue', + 'SocketServer': 'socketserver', + 'ConfigParser': 'configparser', + 'repr': 'reprlib', + 'tkFileDialog': 'tkinter.filedialog', + 'tkSimpleDialog': 'tkinter.simpledialog', + 'tkColorChooser': 'tkinter.colorchooser', + 'tkCommonDialog': 'tkinter.commondialog', + 'Dialog': 'tkinter.dialog', + 'Tkdnd': 'tkinter.dnd', + 'tkFont': 'tkinter.font', + 'tkMessageBox': 'tkinter.messagebox', + 'ScrolledText': 'tkinter.scrolledtext', + 'Tkconstants': 'tkinter.constants', + 'ttk': 'tkinter.ttk', + 'Tkinter': 'tkinter', + 'markupbase': '_markupbase', + '_winreg': 'winreg', + 'thread': '_thread', + 'dummy_thread': '_dummy_thread', + 'dbhash': 'dbm.bsd', + 'dumbdbm': 'dbm.dumb', + 'dbm': 'dbm.ndbm', + 'gdbm': 'dbm.gnu', + 'xmlrpclib': 'xmlrpc.client', + 'SimpleXMLRPCServer': 'xmlrpc.server', + 'httplib': 'http.client', + 'htmlentitydefs' : 'html.entities', + 'HTMLParser' : 'html.parser', + 'Cookie': 'http.cookies', + 'cookielib': 'http.cookiejar', + 'BaseHTTPServer': 'http.server', + 'test.test_support': 'test.support', + 'commands': 'subprocess', + 'urlparse' : 'urllib.parse', + 'robotparser' : 'urllib.robotparser', + 'urllib2': 'urllib.request', + 'anydbm': 'dbm', + '_abcoll' : 'collections.abc', +} + + +# This contains rename rules that are easy to handle. We ignore the more +# complex stuff (e.g. mapping the names in the urllib and types modules). +# These rules should be run before import names are fixed. +NAME_MAPPING = { + ('__builtin__', 'xrange'): ('builtins', 'range'), + ('__builtin__', 'reduce'): ('functools', 'reduce'), + ('__builtin__', 'intern'): ('sys', 'intern'), + ('__builtin__', 'unichr'): ('builtins', 'chr'), + ('__builtin__', 'unicode'): ('builtins', 'str'), + ('__builtin__', 'long'): ('builtins', 'int'), + ('itertools', 'izip'): ('builtins', 'zip'), + ('itertools', 'imap'): ('builtins', 'map'), + ('itertools', 'ifilter'): ('builtins', 'filter'), + ('itertools', 'ifilterfalse'): ('itertools', 'filterfalse'), + ('itertools', 'izip_longest'): ('itertools', 'zip_longest'), + ('UserDict', 'IterableUserDict'): ('collections', 'UserDict'), + ('UserList', 'UserList'): ('collections', 'UserList'), + ('UserString', 'UserString'): ('collections', 'UserString'), + ('whichdb', 'whichdb'): ('dbm', 'whichdb'), + ('_socket', 'fromfd'): ('socket', 'fromfd'), + ('_multiprocessing', 'Connection'): ('multiprocessing.connection', 'Connection'), + ('multiprocessing.process', 'Process'): ('multiprocessing.context', 'Process'), + ('multiprocessing.forking', 'Popen'): ('multiprocessing.popen_fork', 'Popen'), + ('urllib', 'ContentTooShortError'): ('urllib.error', 'ContentTooShortError'), + ('urllib', 'getproxies'): ('urllib.request', 'getproxies'), + ('urllib', 'pathname2url'): ('urllib.request', 'pathname2url'), + ('urllib', 'quote_plus'): ('urllib.parse', 'quote_plus'), + ('urllib', 'quote'): ('urllib.parse', 'quote'), + ('urllib', 'unquote_plus'): ('urllib.parse', 'unquote_plus'), + ('urllib', 'unquote'): ('urllib.parse', 'unquote'), + ('urllib', 'url2pathname'): ('urllib.request', 'url2pathname'), + ('urllib', 'urlcleanup'): ('urllib.request', 'urlcleanup'), + ('urllib', 'urlencode'): ('urllib.parse', 'urlencode'), + ('urllib', 'urlopen'): ('urllib.request', 'urlopen'), + ('urllib', 'urlretrieve'): ('urllib.request', 'urlretrieve'), + ('urllib2', 'HTTPError'): ('urllib.error', 'HTTPError'), + ('urllib2', 'URLError'): ('urllib.error', 'URLError'), +} + +PYTHON2_EXCEPTIONS = ( + "ArithmeticError", + "AssertionError", + "AttributeError", + "BaseException", + "BufferError", + "BytesWarning", + "DeprecationWarning", + "EOFError", + "EnvironmentError", + "Exception", + "FloatingPointError", + "FutureWarning", + "GeneratorExit", + "IOError", + "ImportError", + "ImportWarning", + "IndentationError", + "IndexError", + "KeyError", + "KeyboardInterrupt", + "LookupError", + "MemoryError", + "NameError", + "NotImplementedError", + "OSError", + "OverflowError", + "PendingDeprecationWarning", + "ReferenceError", + "RuntimeError", + "RuntimeWarning", + # StandardError is gone in Python 3, so we map it to Exception + "StopIteration", + "SyntaxError", + "SyntaxWarning", + "SystemError", + "SystemExit", + "TabError", + "TypeError", + "UnboundLocalError", + "UnicodeDecodeError", + "UnicodeEncodeError", + "UnicodeError", + "UnicodeTranslateError", + "UnicodeWarning", + "UserWarning", + "ValueError", + "Warning", + "ZeroDivisionError", +) + +try: + WindowsError +except NameError: + pass +else: + PYTHON2_EXCEPTIONS += ("WindowsError",) + +for excname in PYTHON2_EXCEPTIONS: + NAME_MAPPING[("exceptions", excname)] = ("builtins", excname) + +MULTIPROCESSING_EXCEPTIONS = ( + 'AuthenticationError', + 'BufferTooShort', + 'ProcessError', + 'TimeoutError', +) + +for excname in MULTIPROCESSING_EXCEPTIONS: + NAME_MAPPING[("multiprocessing", excname)] = ("multiprocessing.context", excname) + +# Same, but for 3.x to 2.x +REVERSE_IMPORT_MAPPING = dict((v, k) for (k, v) in IMPORT_MAPPING.items()) +assert len(REVERSE_IMPORT_MAPPING) == len(IMPORT_MAPPING) +REVERSE_NAME_MAPPING = dict((v, k) for (k, v) in NAME_MAPPING.items()) +assert len(REVERSE_NAME_MAPPING) == len(NAME_MAPPING) + +# Non-mutual mappings. + +IMPORT_MAPPING.update({ + 'cPickle': 'pickle', + '_elementtree': 'xml.etree.ElementTree', + 'FileDialog': 'tkinter.filedialog', + 'SimpleDialog': 'tkinter.simpledialog', + 'DocXMLRPCServer': 'xmlrpc.server', + 'SimpleHTTPServer': 'http.server', + 'CGIHTTPServer': 'http.server', + # For compatibility with broken pickles saved in old Python 3 versions + 'UserDict': 'collections', + 'UserList': 'collections', + 'UserString': 'collections', + 'whichdb': 'dbm', + 'StringIO': 'io', + 'cStringIO': 'io', +}) + +REVERSE_IMPORT_MAPPING.update({ + '_bz2': 'bz2', + '_dbm': 'dbm', + '_functools': 'functools', + '_gdbm': 'gdbm', + '_pickle': 'pickle', +}) + +NAME_MAPPING.update({ + ('__builtin__', 'basestring'): ('builtins', 'str'), + ('exceptions', 'StandardError'): ('builtins', 'Exception'), + ('UserDict', 'UserDict'): ('collections', 'UserDict'), + ('socket', '_socketobject'): ('socket', 'SocketType'), +}) + +REVERSE_NAME_MAPPING.update({ + ('_functools', 'reduce'): ('__builtin__', 'reduce'), + ('tkinter.filedialog', 'FileDialog'): ('FileDialog', 'FileDialog'), + ('tkinter.filedialog', 'LoadFileDialog'): ('FileDialog', 'LoadFileDialog'), + ('tkinter.filedialog', 'SaveFileDialog'): ('FileDialog', 'SaveFileDialog'), + ('tkinter.simpledialog', 'SimpleDialog'): ('SimpleDialog', 'SimpleDialog'), + ('xmlrpc.server', 'ServerHTMLDoc'): ('DocXMLRPCServer', 'ServerHTMLDoc'), + ('xmlrpc.server', 'XMLRPCDocGenerator'): + ('DocXMLRPCServer', 'XMLRPCDocGenerator'), + ('xmlrpc.server', 'DocXMLRPCRequestHandler'): + ('DocXMLRPCServer', 'DocXMLRPCRequestHandler'), + ('xmlrpc.server', 'DocXMLRPCServer'): + ('DocXMLRPCServer', 'DocXMLRPCServer'), + ('xmlrpc.server', 'DocCGIXMLRPCRequestHandler'): + ('DocXMLRPCServer', 'DocCGIXMLRPCRequestHandler'), + ('http.server', 'SimpleHTTPRequestHandler'): + ('SimpleHTTPServer', 'SimpleHTTPRequestHandler'), + ('http.server', 'CGIHTTPRequestHandler'): + ('CGIHTTPServer', 'CGIHTTPRequestHandler'), + ('_socket', 'socket'): ('socket', '_socketobject'), +}) + +PYTHON3_OSERROR_EXCEPTIONS = ( + 'BrokenPipeError', + 'ChildProcessError', + 'ConnectionAbortedError', + 'ConnectionError', + 'ConnectionRefusedError', + 'ConnectionResetError', + 'FileExistsError', + 'FileNotFoundError', + 'InterruptedError', + 'IsADirectoryError', + 'NotADirectoryError', + 'PermissionError', + 'ProcessLookupError', + 'TimeoutError', +) + +for excname in PYTHON3_OSERROR_EXCEPTIONS: + REVERSE_NAME_MAPPING[('builtins', excname)] = ('exceptions', 'OSError') + +PYTHON3_IMPORTERROR_EXCEPTIONS = ( + 'ModuleNotFoundError', +) + +for excname in PYTHON3_IMPORTERROR_EXCEPTIONS: + REVERSE_NAME_MAPPING[('builtins', excname)] = ('exceptions', 'ImportError') +del excname diff --git a/crates/weavepy-vm/src/stdlib/python/_ctypes.py b/crates/weavepy-vm/src/stdlib/python/_ctypes.py new file mode 100644 index 0000000..0dc11f1 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/_ctypes.py @@ -0,0 +1,1441 @@ +"""WeavePy reimplementation of the CPython ``_ctypes`` extension module. + +CPython ships ``_ctypes`` as a *core-built* C extension: it links against +private interpreter internals (``_PyRuntime`` & friends), so unlike a +stable-ABI wheel (numpy, pandas) its compiled ``_ctypes*.so`` cannot be +``dlopen``'d into WeavePy. We therefore reimplement the exact public +surface that CPython's verbatim ``Lib/ctypes/__init__.py`` imports, layered +on the native :mod:`_ctypes_native` primitive module. + +The split mirrors CPython's own ``Lib/ctypes`` (Python) over ``_ctypes`` +(C): + +* :mod:`_ctypes_native` (Rust) owns the genuinely-native pieces — platform C + type sizes/alignments, raw memory peek/poke, ``dlopen``/``dlsym``, the + libc ``memmove``/``memset``/``string_at`` helpers, the ctypes private + errno, and the libffi call/closure bridge. +* This module builds the ``_SimpleCData`` / ``Structure`` / ``Union`` / + ``Array`` / ``_Pointer`` / ``CFuncPtr`` type system and its metaclasses + on top of those primitives. + +Memory model +------------ +A ctypes object's storage is a Python ``bytearray`` (owned, GC'd, and +address-stable while its length is fixed — ctypes objects never resize +except via :func:`resize`). Views — ``Structure`` fields, ``Array`` +elements, :meth:`from_buffer` — share that ``bytearray`` at an offset. +External memory (:meth:`from_address`, pointer dereference, FFI return +pointers) is addressed by a raw integer. Every object therefore resolves to +a single ``void *`` via :func:`addressof`, exactly like CPython's +``CDataObject.b_ptr``. +""" + +import sys as _sys +import _ctypes_native as _nat + +__version__ = "1.1.0" + +# --------------------------------------------------------------------------- +# Platform constants (re-exported by ctypes/__init__.py) +# --------------------------------------------------------------------------- + +RTLD_LOCAL = _nat.RTLD_LOCAL +RTLD_GLOBAL = _nat.RTLD_GLOBAL +SIZEOF_TIME_T = _nat.SIZEOF_TIME_T + +_PTR = _nat.SIZEOF_VOID_P +_WCHAR = _nat.sizeof_code("u") +_BO = _sys.byteorder +_FP = "<" if _BO == "little" else ">" +# Opposite-endian byte order / struct prefix, used by the byte-swapping +# ``__ctype_be__`` / ``__ctype_le__`` variant types below. +_BO_SWAP = "big" if _BO == "little" else "little" +_FP_SWAP = ">" if _FP == "<" else "<" +# Multi-byte numeric ``_type_`` codes get a distinct opposite-endian sibling +# type (CPython names it ``_be`` on a little-endian host); single-byte +# codes alias ``__ctype_le__``/``__ctype_be__`` to the type itself; all other +# codes (bool ``?``, the pointer/string ``P z Z O`` codes, ``u``) expose +# neither attribute — faithfully matching CPython's ``_ctypes``. +_SWAP_CODES = frozenset("hHiIlLqQfd") +_SELF_ENDIAN_CODES = frozenset("bBc") +_ENDIAN_SUFFIX = "_be" if _BO == "little" else "_le" + +# Function-pointer calling-convention / behaviour flags. Values match +# CPython's ``Modules/_ctypes/ctypes.h`` so ctypes/__init__.py's bit math is +# faithful. +FUNCFLAG_CDECL = 0x1 +FUNCFLAG_HRESULT = 0x2 +FUNCFLAG_PYTHONAPI = 0x4 +FUNCFLAG_USE_ERRNO = 0x8 +FUNCFLAG_USE_LASTERROR = 0x10 +# stdcall is Windows-only; defined so the constant exists everywhere. +FUNCFLAG_STDCALL = 0x0 + +# Type-flag bits used by from_param's "PARAMFLAG" plumbing. +TYPEFLAG_ISPOINTER = 0x100 +TYPEFLAG_HASPOINTER = 0x200 + + +class ArgumentError(Exception): + """Raised when a foreign function call gets an argument it can't + convert (CPython exposes this from ``_ctypes``).""" + + +def get_errno(): + return _nat.get_errno() + + +def set_errno(value): + return _nat.set_errno(value) + + +# --------------------------------------------------------------------------- +# Low-level value codecs for the simple ``_type_`` format codes +# --------------------------------------------------------------------------- + +# Integer codes -> (size, signed). +_INT_CODES = { + "b": (1, True), + "B": (1, False), + "h": (_nat.sizeof_code("h"), True), + "H": (_nat.sizeof_code("H"), False), + "i": (_nat.sizeof_code("i"), True), + "I": (_nat.sizeof_code("i"), False), + "l": (_nat.sizeof_code("l"), True), + "L": (_nat.sizeof_code("l"), False), + "q": (_nat.sizeof_code("q"), True), + "Q": (_nat.sizeof_code("q"), False), +} + + +def _read_at(obj, off, n): + """Read ``n`` bytes from ``obj``'s memory at relative offset ``off``.""" + buf = obj._b_buffer + if buf is not None: + start = obj._b_offset + off + return bytes(buf[start:start + n]) + return _nat.read_mem(obj._b_addr + off, n) + + +def _write_at(obj, off, data): + buf = obj._b_buffer + if buf is not None: + start = obj._b_offset + off + buf[start:start + len(data)] = data + else: + _nat.write_mem(obj._b_addr + off, data) + + +def _simple_get(code, obj, off=0, swap=False): + # ``swap`` selects the opposite-endian decode for a ``__ctype_be__`` / + # ``__ctype_le__`` variant field/scalar (no-op for single-byte codes). + bo = _BO_SWAP if swap else _BO + fp = _FP_SWAP if swap else _FP + if code in _INT_CODES: + size, signed = _INT_CODES[code] + v = int.from_bytes(_read_at(obj, off, size), bo) + # Apply two's-complement sign manually: CPython makes ``signed`` a + # keyword-only arg, which not every host int.from_bytes honours. + if signed and v >= (1 << (size * 8 - 1)): + v -= 1 << (size * 8) + return v + if code == "f": + import struct as _struct + return _struct.unpack(fp + "f", _read_at(obj, off, 4))[0] + if code in ("d", "g"): + import struct as _struct + sz = _nat.sizeof_code(code) + if sz == 8: + return _struct.unpack(fp + "d", _read_at(obj, off, 8))[0] + # 80-bit / 128-bit long double: not yet decoded to a Python float. + raise NotImplementedError("long double (>8 bytes) not supported yet") + if code == "c": + return _read_at(obj, off, 1) + if code == "?": + return _read_at(obj, off, 1)[0] != 0 + if code == "u": + cp = int.from_bytes(_read_at(obj, off, _WCHAR), _BO) + return chr(cp) + if code == "P": + v = int.from_bytes(_read_at(obj, off, _PTR), _BO) + return v if v else None + if code == "z": + v = int.from_bytes(_read_at(obj, off, _PTR), _BO) + return _nat.string_at(v, -1) if v else None + if code == "Z": + v = int.from_bytes(_read_at(obj, off, _PTR), _BO) + return _nat.wstring_at(v, -1) if v else None + if code == "O": + # py_object: the live Python object is held on the keepalive list; + # the buffer stores its id() purely as a presence marker. + v = int.from_bytes(_read_at(obj, off, _PTR), _BO) + if not v: + raise ValueError("PyObject is NULL") + ka = obj._b_objects + if ka: + for kept in ka: + if id(kept) == v: + return kept + raise ValueError("PyObject is NULL") + raise TypeError("unknown type code %r" % code) + + +def _simple_set(code, obj, value, off=0, swap=False): + # ``swap`` selects the opposite-endian encode for a ``__ctype_be__`` / + # ``__ctype_le__`` variant field/scalar (no-op for single-byte codes). + bo = _BO_SWAP if swap else _BO + fp = _FP_SWAP if swap else _FP + if code in _INT_CODES: + size, signed = _INT_CODES[code] + iv = int(value) & ((1 << (size * 8)) - 1) + _write_at(obj, off, iv.to_bytes(size, bo)) + return + if code == "f": + import struct as _struct + _write_at(obj, off, _struct.pack(fp + "f", float(value))) + return + if code in ("d", "g"): + import struct as _struct + sz = _nat.sizeof_code(code) + if sz == 8: + _write_at(obj, off, _struct.pack(fp + "d", float(value))) + return + raise NotImplementedError("long double (>8 bytes) not supported yet") + if code == "c": + if isinstance(value, int): + b = bytes([value & 0xFF]) + elif isinstance(value, (bytes, bytearray)) and len(value) == 1: + b = bytes(value) + else: + raise TypeError("one character bytes, bytearray or integer expected") + _write_at(obj, off, b) + return + if code == "?": + _write_at(obj, off, b"\x01" if value else b"\x00") + return + if code == "u": + if not isinstance(value, str) or len(value) != 1: + raise TypeError("unicode string expected instead of %s instance" + % type(value).__name__) + _write_at(obj, off, ord(value).to_bytes(_WCHAR, _BO)) + return + if code == "P": + iv = _as_address(value) + if isinstance(value, _CData): + obj._keep(value) + _write_at(obj, off, iv.to_bytes(_PTR, _BO)) + return + if code == "z": + if value is None: + iv = 0 + elif isinstance(value, int): + iv = value + elif isinstance(value, (bytes, bytearray)): + kb = bytearray(value) + kb.append(0) # NUL-terminate + obj._keep(kb) + iv = _nat.addressof_buffer(kb) + elif isinstance(value, _CData): + obj._keep(value) + iv = addressof(value) + else: + raise TypeError("bytes or integer address expected instead of %s instance" + % type(value).__name__) + _write_at(obj, off, iv.to_bytes(_PTR, _BO)) + return + if code == "Z": + if value is None: + iv = 0 + elif isinstance(value, int): + iv = value + elif isinstance(value, str): + kb = bytearray() + for ch in value: + kb += ord(ch).to_bytes(_WCHAR, _BO) + kb += (0).to_bytes(_WCHAR, _BO) + obj._keep(kb) + iv = _nat.addressof_buffer(kb) + else: + raise TypeError("unicode string or integer address expected") + _write_at(obj, off, iv.to_bytes(_PTR, _BO)) + return + if code == "O": + obj._keep(value) + _write_at(obj, off, id(value).to_bytes(_PTR, _BO)) + return + raise TypeError("unknown type code %r" % code) + + +def _as_address(x): + """Coerce a Python value to an integer machine address.""" + if x is None: + return 0 + if isinstance(x, bool): + return int(x) + if isinstance(x, int): + return x + if isinstance(x, _CArgObject): + return x._address() + if isinstance(x, _CData): + return addressof(x) + raise TypeError("cannot convert %r to an address" % (type(x).__name__,)) + + +# --------------------------------------------------------------------------- +# Metaclasses +# --------------------------------------------------------------------------- + + +class _CDataType(type): + """Common metaclass behaviour shared by every ctypes type. + + Methods defined here are ``CDataType`` methods in CPython: they live on + the metaclass, so they are callable on the *class* object (e.g. + ``c_int.from_address(...)``, ``Point * 4``). + """ + + def __mul__(cls, length): + return _create_array_type(cls, length) + + def __rmul__(cls, length): + return _create_array_type(cls, length) + + # -- construction from existing memory ------------------------------- + + def from_address(cls, address): + inst = _blank(cls) + inst._b_buffer = None + inst._b_offset = 0 + inst._b_addr = int(address) + inst._b_base = None + inst._b_objects = None + return inst + + def from_buffer(cls, source, offset=0): + ba = _writable_buffer(source) + size = sizeof(cls) + if offset < 0: + raise ValueError("offset cannot be negative") + if len(ba) - offset < size: + raise ValueError( + "Buffer size too small (%d instead of at least %d bytes)" + % (len(ba), size + offset) + ) + inst = _blank(cls) + inst._b_buffer = ba + inst._b_offset = offset + inst._b_base = source + inst._b_objects = None + return inst + + def from_buffer_copy(cls, source, offset=0): + size = sizeof(cls) + data = bytes(source) + if offset < 0: + raise ValueError("offset cannot be negative") + if len(data) - offset < size: + raise ValueError( + "Buffer size too small (%d instead of at least %d bytes)" + % (len(data), size + offset) + ) + inst = _alloc_instance(cls) + _write_at(inst, 0, data[offset:offset + size]) + return inst + + def in_dll(cls, dll, name): + addr = _nat.dlsym(dll._handle, name) + if not addr: + raise ValueError("symbol %r not found" % name) + return cls.from_address(addr) + + def from_param(cls, value): + return _default_from_param(cls, value) + + def __call__(cls, *args, **kw): + # Identical to type.__call__, but kept explicit so the data-model + # `__new__`/`__init__` flow is unambiguous for the C-style types. + return type.__call__(cls, *args, **kw) + + +def _make_swapped_simple(base_name, base_cls, code): + """Create the opposite-endian sibling of a multi-byte numeric type. + + On a little-endian host this is ``_be`` (CPython's exact name); it is + a plain ``_SimpleCData`` subclass carrying the same ``_type_`` code plus a + private ``_swapped_`` marker so its ``.value`` / field access byte-swaps — + its raw storage is therefore big-endian while its Python value matches. + """ + swapped = _SimpleType( + base_name + _ENDIAN_SUFFIX, + (_SimpleCData,), + {"_type_": code, "_swapped_": True, "__module__": "ctypes"}, + ) + # The sibling's own endian aliases: the native-order one points back at the + # native base, the opposite-order one is itself. + if _BO == "little": + type.__setattr__(swapped, "__ctype_le__", base_cls) + type.__setattr__(swapped, "__ctype_be__", swapped) + else: + type.__setattr__(swapped, "__ctype_be__", base_cls) + type.__setattr__(swapped, "__ctype_le__", swapped) + return swapped + + +class _SimpleType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + code = namespace.get("_type_", None) + if code is None: + code = getattr(cls, "_type_", None) + if code is not None: + if not isinstance(code, str) or len(code) != 1: + raise ValueError( + "class must define a '_type_' string attribute of length 1" + ) + cls._b_size_ = _nat.sizeof_code(code) + cls._b_align_ = _nat.alignment_code(code) + else: + cls._b_size_ = 0 + cls._b_align_ = 1 + # Install the CPython ``__ctype_le__`` / ``__ctype_be__`` endian + # aliases (numpy's ``ctypeslib`` reaches for these when a dtype has an + # explicit byte order — pandas' dataframe-interchange path does). The + # generated ``_swapped_`` sibling must skip this to avoid recursion. + if code is not None and not namespace.get("_swapped_"): + if code in _SWAP_CODES: + swapped = _make_swapped_simple(name, cls, code) + if _BO == "little": + type.__setattr__(cls, "__ctype_le__", cls) + type.__setattr__(cls, "__ctype_be__", swapped) + else: + type.__setattr__(cls, "__ctype_be__", cls) + type.__setattr__(cls, "__ctype_le__", swapped) + elif code in _SELF_ENDIAN_CODES: + type.__setattr__(cls, "__ctype_le__", cls) + type.__setattr__(cls, "__ctype_be__", cls) + + +class _StructType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + _init_aggregate(cls, namespace, union=False) + + def __setattr__(cls, key, value): + if key == "_fields_": + type.__setattr__(cls, key, value) + _layout_aggregate(cls, value, union=False) + else: + type.__setattr__(cls, key, value) + + +class _UnionType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + _init_aggregate(cls, namespace, union=True) + + def __setattr__(cls, key, value): + if key == "_fields_": + type.__setattr__(cls, key, value) + _layout_aggregate(cls, value, union=True) + else: + type.__setattr__(cls, key, value) + + +class _ArrayType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + etype = getattr(cls, "_type_", None) + length = getattr(cls, "_length_", None) + if etype is not None and length is not None: + cls._b_size_ = sizeof(etype) * length + cls._b_align_ = alignment(etype) + else: + cls._b_size_ = 0 + cls._b_align_ = 1 + + +class _PointerType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + cls._b_size_ = _PTR + cls._b_align_ = _PTR + + def set_type(cls, t): + cls._type_ = t + + +class _FuncPtrType(_CDataType): + def __init__(cls, name, bases, namespace, **kw): + super().__init__(name, bases, namespace) + cls._b_size_ = _PTR + cls._b_align_ = _PTR + + +# --------------------------------------------------------------------------- +# Instance allocation helpers +# --------------------------------------------------------------------------- + + +def _blank(cls): + """A bare instance of ``cls`` with no memory set up yet (callers fill in + the ``_b_*`` slots). Bypasses ``_CData.__new__`` allocation.""" + return object.__new__(cls) + + +def _alloc_instance(cls): + """An instance of ``cls`` backed by fresh zeroed owned memory.""" + inst = object.__new__(cls) + inst._b_buffer = bytearray(sizeof(cls)) + inst._b_offset = 0 + inst._b_addr = 0 + inst._b_base = None + inst._b_objects = None + return inst + + +def _field_view(parent, ftype, field_offset): + """A sub-object of ``ftype`` aliasing ``parent``'s memory at an offset.""" + inst = _blank(ftype) + if parent._b_buffer is not None: + inst._b_buffer = parent._b_buffer + inst._b_offset = parent._b_offset + field_offset + inst._b_addr = 0 + else: + inst._b_buffer = None + inst._b_offset = 0 + inst._b_addr = parent._b_addr + field_offset + inst._b_base = parent + inst._b_objects = None + return inst + + +# --------------------------------------------------------------------------- +# Base data classes +# --------------------------------------------------------------------------- + + +class _CData(metaclass=_CDataType): + _b_size_ = 0 + _b_align_ = 1 + + def __new__(cls, *args, **kw): + return _alloc_instance(cls) + + def __init__(self, *args, **kw): + pass + + # -- memory helpers -------------------------------------------------- + + def _addr(self): + if self._b_buffer is not None: + return _nat.addressof_buffer(self._b_buffer) + self._b_offset + return self._b_addr + + def _keep(self, obj): + if self._b_objects is None: + self._b_objects = [] + self._b_objects.append(obj) + + def _read(self, off, n): + return _read_at(self, off, n) + + def _write(self, off, data): + _write_at(self, off, data) + + def __ctypes_from_outparam__(self): + return self + + +class _SimpleCData(_CData, metaclass=_SimpleType): + _type_ = None + + def __init__(self, value=None): + if value is not None: + self.value = value + + @property + def value(self): + t = type(self) + return _simple_get(t._type_, self, 0, getattr(t, "_swapped_", False)) + + @value.setter + def value(self, v): + t = type(self) + _simple_set(t._type_, self, v, 0, getattr(t, "_swapped_", False)) + + def __repr__(self): + return "%s(%r)" % (type(self).__name__, self.value) + + def __bool__(self): + return any(self._read(0, sizeof(type(self)))) + + def __eq__(self, other): + if isinstance(other, _SimpleCData): + return self.value == other.value + return self.value == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.value) + + +# -- Structure / Union ------------------------------------------------------- + + +class _Field: + __slots__ = ("name", "ftype", "offset", "size", "bits", "bit_offset") + + def __init__(self, name, ftype, offset, size, bits=None, bit_offset=0): + self.name = name + self.ftype = ftype + self.offset = offset + self.size = size + self.bits = bits + self.bit_offset = bit_offset + + +def _round_up(n, align): + if align <= 1: + return n + rem = n % align + return n if rem == 0 else n + (align - rem) + + +def _init_aggregate(cls, namespace, union): + # Inherit a base aggregate's already-computed layout, then (if this + # class body supplied `_fields_`) extend it. + base_layout = None + for base in cls.__mro__[1:]: + bl = base.__dict__.get("_b_layout_") + if bl is not None: + base_layout = bl + break + if base_layout is not None and "_b_layout_" not in cls.__dict__: + cls._b_layout_ = dict(base_layout) + cls._b_size_ = base.__dict__.get("_b_size_", 0) + cls._b_align_ = base.__dict__.get("_b_align_", 1) + else: + cls._b_layout_ = {} + cls._b_size_ = 0 + cls._b_align_ = 1 + if "_fields_" in namespace: + _layout_aggregate(cls, namespace["_fields_"], union) + + +def _layout_aggregate(cls, fields, union): + layout = {} + # Start after any inherited base fields (structs append; unions overlay). + base_size = 0 + base_align = 1 + for base in cls.__mro__[1:]: + bs = base.__dict__.get("_b_size_") + if bs: + base_size = bs + base_align = base.__dict__.get("_b_align_", 1) + layout.update(base.__dict__.get("_b_layout_", {})) + break + + pack = getattr(cls, "_pack_", 0) + offset = 0 if union else base_size + total_align = base_align + max_size = base_size + + for item in fields: + fname = item[0] + ftype = item[1] + bits = item[2] if len(item) > 2 else None + if not isinstance(ftype, _CDataType): + raise TypeError("second item in _fields_ tuple (index 0) must be a C type") + fsize = sizeof(ftype) + falign = alignment(ftype) + if pack: + falign = min(falign, pack) + if union: + foffset = base_size # all union members overlay at the base offset + max_size = max(max_size, fsize) + else: + offset = _round_up(offset, falign) + foffset = offset + offset += fsize + total_align = max(total_align, falign) + fld = _Field(fname, ftype, foffset, fsize, bits) + layout[fname] = fld + type.__setattr__(cls, fname, _make_field_descriptor(fld)) + + if union: + total = _round_up(max_size, total_align) + else: + total = _round_up(offset, total_align) + + cls._b_layout_ = layout + type.__setattr__(cls, "_b_size_", total) + type.__setattr__(cls, "_b_align_", total_align) + + +def _is_char_type(t): + return ( + isinstance(t, _CDataType) + and issubclass(t, _SimpleCData) + and getattr(t, "_type_", None) == "c" + ) + + +def _is_wchar_type(t): + return ( + isinstance(t, _CDataType) + and issubclass(t, _SimpleCData) + and getattr(t, "_type_", None) == "u" + ) + + +def _make_field_descriptor(fld): + ftype = fld.ftype + offset = fld.offset + simple = issubclass(ftype, _SimpleCData) + swap = getattr(ftype, "_swapped_", False) + + def getter(self): + if simple: + return _simple_get(ftype._type_, self, offset, swap) + return _field_view(self, ftype, offset) + + def setter(self, value): + if simple: + _simple_set(ftype._type_, self, value, offset, swap) + return + # Aggregate / pointer / array field: copy the value's bytes in. + if isinstance(value, _CData): + _write_at(self, offset, value._read(0, sizeof(ftype))) + if value._b_objects: + for k in value._b_objects: + self._keep(k) + else: + tmp = ftype(value) if not isinstance(value, (list, tuple)) else ftype(*value) + _write_at(self, offset, tmp._read(0, sizeof(ftype))) + if tmp._b_objects: + for k in tmp._b_objects: + self._keep(k) + + return property(getter, setter) + + +class Structure(_CData, metaclass=_StructType): + def __init__(self, *args, **kw): + layout = type(self)._b_layout_ + names = list(layout.keys()) + for i, val in enumerate(args): + if i >= len(names): + raise TypeError("too many initializers") + setattr(self, names[i], val) + for key, val in kw.items(): + if key not in layout: + raise AttributeError( + "'%s' is not a valid field name" % key + ) + setattr(self, key, val) + + +class Union(_CData, metaclass=_UnionType): + def __init__(self, *args, **kw): + layout = type(self)._b_layout_ + names = list(layout.keys()) + for i, val in enumerate(args): + if i >= len(names): + raise TypeError("too many initializers") + setattr(self, names[i], val) + for key, val in kw.items(): + if key not in layout: + raise AttributeError("'%s' is not a valid field name" % key) + setattr(self, key, val) + + +# -- Array ------------------------------------------------------------------- + +_array_cache = {} + + +def _create_array_type(element_type, length): + if isinstance(length, bool) or not isinstance(length, int): + raise TypeError("can't multiply a ctypes type by a non-integer") + if length < 0: + raise ValueError("Array length must be >= 0, not %d" % length) + if not isinstance(element_type, _CDataType): + raise TypeError("Expected a ctypes type") + key = (element_type, length) + cached = _array_cache.get(key) + if cached is not None: + return cached + name = "%s_Array_%d" % (element_type.__name__, length) + arr = _ArrayType(name, (Array,), {"_type_": element_type, "_length_": length}) + _array_cache[key] = arr + return arr + + +class Array(_CData, metaclass=_ArrayType): + def __init__(self, *args): + if args: + for i, val in enumerate(args): + self[i] = val + + def __len__(self): + return type(self)._length_ + + def _check_index(self, index): + n = type(self)._length_ + if index < 0: + index += n + if not (0 <= index < n): + raise IndexError("invalid index") + return index + + def __getitem__(self, index): + etype = type(self)._type_ + esize = sizeof(etype) + if isinstance(index, slice): + return [self[i] for i in range(*index.indices(len(self)))] + index = self._check_index(index) + if issubclass(etype, _SimpleCData): + return _simple_get( + etype._type_, self, index * esize, getattr(etype, "_swapped_", False) + ) + return _field_view(self, etype, index * esize) + + def __setitem__(self, index, value): + etype = type(self)._type_ + esize = sizeof(etype) + if isinstance(index, slice): + for i, v in zip(range(*index.indices(len(self))), value): + self[i] = v + return + index = self._check_index(index) + if issubclass(etype, _SimpleCData): + _simple_set( + etype._type_, self, value, index * esize, + getattr(etype, "_swapped_", False), + ) + return + if isinstance(value, _CData): + _write_at(self, index * esize, value._read(0, esize)) + else: + tmp = etype(value) + _write_at(self, index * esize, tmp._read(0, esize)) + + def __iter__(self): + for i in range(len(self)): + yield self[i] + + @property + def value(self): + etype = type(self)._type_ + if _is_char_type(etype): + data = self._read(0, sizeof(type(self))) + nul = data.find(b"\x00") + return data if nul < 0 else data[:nul] + if _is_wchar_type(etype): + chars = [] + for i in range(len(self)): + ch = self[i] + if ch == "\x00": + break + chars.append(ch) + return "".join(chars) + raise AttributeError("value") + + @value.setter + def value(self, val): + etype = type(self)._type_ + if _is_char_type(etype): + if not isinstance(val, (bytes, bytearray)): + raise TypeError("bytes expected instead of %s" % type(val).__name__) + if len(val) > len(self): + raise ValueError("bytes too long") + data = bytes(val) + _write_at(self, 0, data) + if len(data) < len(self): + _write_at(self, len(data), b"\x00") + return + if _is_wchar_type(etype): + if not isinstance(val, str): + raise TypeError("unicode expected") + for i, ch in enumerate(val): + self[i] = ch + if len(val) < len(self): + self[len(val)] = "\x00" + return + raise AttributeError("value") + + @property + def raw(self): + if not _is_char_type(type(self)._type_): + raise AttributeError("raw") + return self._read(0, sizeof(type(self))) + + @raw.setter + def raw(self, val): + if not _is_char_type(type(self)._type_): + raise AttributeError("raw") + data = bytes(val) + if len(data) > len(self): + raise ValueError("bytes too long") + _write_at(self, 0, data) + + +# -- Pointer ----------------------------------------------------------------- + +_pointer_type_cache = {} + + +class _Pointer(_CData, metaclass=_PointerType): + _type_ = None + + def __init__(self, value=None): + if value is not None: + self.contents = value + + def _target_addr(self): + return int.from_bytes(self._read(0, _PTR), _BO) + + @property + def contents(self): + addr = self._target_addr() + if addr == 0: + raise ValueError("NULL pointer access") + tgt = type(self)._type_ + view = tgt.from_address(addr) + view._b_base = self + return view + + @contents.setter + def contents(self, value): + if not isinstance(value, _CData): + raise TypeError("expected a ctypes instance") + self._write(0, addressof(value).to_bytes(_PTR, _BO)) + self._keep(value) + + def __getitem__(self, index): + tgt = type(self)._type_ + esize = sizeof(tgt) + base = self._target_addr() + if base == 0: + raise ValueError("NULL pointer access") + if issubclass(tgt, _SimpleCData): + tmp = tgt.from_address(base + index * esize) + return _simple_get(tgt._type_, tmp, 0, getattr(tgt, "_swapped_", False)) + view = tgt.from_address(base + index * esize) + view._b_base = self + return view + + def __setitem__(self, index, value): + tgt = type(self)._type_ + esize = sizeof(tgt) + base = self._target_addr() + if base == 0: + raise ValueError("NULL pointer access") + dst = tgt.from_address(base + index * esize) + if issubclass(tgt, _SimpleCData): + _simple_set(tgt._type_, dst, value, 0, getattr(tgt, "_swapped_", False)) + elif isinstance(value, _CData): + _write_at(dst, 0, value._read(0, esize)) + else: + tmp = tgt(value) + _write_at(dst, 0, tmp._read(0, esize)) + + def __bool__(self): + return self._target_addr() != 0 + + +def POINTER(cls): + try: + return _pointer_type_cache[cls] + except KeyError: + pass + if cls is None: + # CPython allows POINTER(None) -> a not-yet-typed pointer; the cache + # is later seeded with `_pointer_type_cache[None] = c_void_p` by + # ctypes._reset_cache(). + name = "LP_None" + ptr = _PointerType(name, (_Pointer,), {"_type_": None}) + _pointer_type_cache[cls] = ptr + return ptr + name = "LP_%s" % cls.__name__ + ptr = _PointerType(name, (_Pointer,), {"_type_": cls}) + _pointer_type_cache[cls] = ptr + return ptr + + +def pointer(obj): + if not isinstance(obj, _CData): + raise TypeError("_type_ must have storage info") + ptr_type = POINTER(type(obj)) + p = ptr_type() + p.contents = obj + return p + + +# -- CFuncPtr ---------------------------------------------------------------- + + +class CFuncPtr(_CData, metaclass=_FuncPtrType): + _argtypes_ = None + _restype_ = None + _flags_ = FUNCFLAG_CDECL + _closure = None + _errcheck_ = None + + # `restype`/`argtypes`/`errcheck` are per-instance configuration on a + # foreign function (e.g. `libc.strlen.restype = c_size_t`). They shadow + # the class defaults (`_restype_` defaults to `c_int` on a CDLL's + # `_FuncPtr`; `_argtypes_` defaults to `None`), so normal attribute + # resolution in `__call__` picks the instance value when set and falls + # back to the class otherwise. This mirrors CPython's getset descriptors + # over the C-level slots. + @property + def restype(self): + return self._restype_ + + @restype.setter + def restype(self, value): + self._restype_ = value + + @restype.deleter + def restype(self): + try: + del self.__dict__["_restype_"] + except KeyError: + pass + + @property + def argtypes(self): + return self._argtypes_ + + @argtypes.setter + def argtypes(self, value): + self._argtypes_ = None if value is None else tuple(value) + + @argtypes.deleter + def argtypes(self): + try: + del self.__dict__["_argtypes_"] + except KeyError: + pass + + @property + def errcheck(self): + return self._errcheck_ + + @errcheck.setter + def errcheck(self, value): + if value is not None and not callable(value): + raise TypeError("the errcheck attribute must be callable") + self._errcheck_ = value + + def __init__(self, arg=0, *extra): + self._handle_addr = 0 + self._callable = None + self._name = None + if isinstance(arg, int): + self._set_address(arg) + elif isinstance(arg, tuple): + name_or_ord, dll = arg + addr = _resolve_dll_symbol(dll, name_or_ord) + self._set_address(addr) + if not isinstance(name_or_ord, int): + self._name = name_or_ord + elif callable(arg): + self._callable = arg + closure_addr = _make_closure(self, arg) + self._set_address(closure_addr) + else: + raise TypeError( + "argument must be callable or integer function address" + ) + + def _set_address(self, addr): + self._handle_addr = int(addr) + self._write(0, int(addr).to_bytes(_PTR, _BO)) + + def __call__(self, *args): + handle = self._handle_addr + thunk = _INTERNAL_THUNKS.get(handle) + if thunk is not None: + return thunk(*args) + if self._callable is not None and handle == 0: + return self._callable(*args) + # Instance attributes (set via the `restype`/`argtypes` descriptors) + # shadow the class defaults; plain attribute lookup gives instance + # value when set, else the class default (`_restype_` -> `c_int`, + # `_argtypes_` -> `None`). + argtypes = self._argtypes_ + restype = self._restype_ + flags = type(self)._flags_ + result = _ffi_invoke(handle, restype, argtypes, flags, args) + errcheck = self._errcheck_ + if errcheck is not None: + result = errcheck(result, self, args) + return result + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + +class _CArgObject: + """Lightweight pass-by-reference wrapper produced by :func:`byref`.""" + + __slots__ = ("_obj", "_offset") + + def __init__(self, obj, offset): + self._obj = obj + self._offset = offset + + def _address(self): + return addressof(self._obj) + self._offset + + def __repr__(self): + return "" % ("P", self._address()) + + +def byref(obj, offset=0): + if not isinstance(obj, _CData): + raise TypeError("byref() argument must be a ctypes instance, not '%s'" + % type(obj).__name__) + return _CArgObject(obj, offset) + + +def sizeof(type_or_obj): + if isinstance(type_or_obj, _CDataType): + return type_or_obj._b_size_ + if isinstance(type_or_obj, _CData): + return type(type_or_obj)._b_size_ + raise TypeError("this type has no size") + + +def alignment(type_or_obj): + if isinstance(type_or_obj, _CDataType): + return type_or_obj._b_align_ + if isinstance(type_or_obj, _CData): + return type(type_or_obj)._b_align_ + raise TypeError("no alignment info") + + +def addressof(obj): + if not isinstance(obj, _CData): + raise TypeError("invalid type") + return obj._addr() + + +def resize(obj, size): + if not isinstance(obj, _CData): + raise TypeError("expected ctypes instance") + min_size = type(obj)._b_size_ + if size < min_size: + raise ValueError("minimum size is %d" % min_size) + if obj._b_buffer is None: + raise ValueError("Memory cannot be resized because this object doesn't own it") + cur = obj._b_buffer + if size > len(cur): + cur.extend(b"\x00" * (size - len(cur))) + + +def _default_from_param(cls, value): + if value is None: + return None + if isinstance(value, cls): + return value + if isinstance(value, _CArgObject): + return value + # Try to construct; mirrors CPython's "exact type or convertible". + try: + return cls(value) + except (TypeError, ValueError): + raise TypeError( + "expected %s instance instead of %s" + % (cls.__name__, type(value).__name__) + ) + + +def _writable_buffer(source): + if isinstance(source, bytearray): + return source + if isinstance(source, _CData): + # Share the owning bytearray if there is one. + if source._b_buffer is not None: + return source._b_buffer + if isinstance(source, memoryview) and not source.readonly: + return bytearray(source) # NOTE: copy; true zero-copy needs buffer API + raise TypeError("underlying buffer is not writable") + + +def _resolve_dll_symbol(dll, name_or_ord): + if isinstance(name_or_ord, int): + raise TypeError("ordinal lookup is only supported on Windows") + handle = dll._handle + addr = _nat.dlsym(handle, name_or_ord) + if not addr: + raise AttributeError( + "function %r not found" % (name_or_ord,) + ) + return addr + + +# --------------------------------------------------------------------------- +# dlopen (posix) — re-exported by ctypes/__init__.py as `_dlopen` +# --------------------------------------------------------------------------- + + +def dlopen(name, mode=RTLD_LOCAL): + return _nat.dlopen(name, mode) + + +def dlclose(handle): + return _nat.dlclose(handle) + + +def dlsym(handle, name): + return _nat.dlsym(handle, name) + + +# --------------------------------------------------------------------------- +# Internal thunks for the addr-wrapped helpers ctypes/__init__.py builds +# (`memmove`, `memset`, `cast`, `string_at`, `wstring_at`). CPython exposes +# these as C function addresses and ctypes wraps them in CFUNCTYPE; we route +# the sentinel "addresses" back to native/Python implementations because two +# of them (cast / string_at) have PyObject semantics that can't be a plain C +# call. They are only ever *invoked* at runtime, never at import. +# --------------------------------------------------------------------------- + +_INTERNAL_THUNKS = {} +_next_thunk_id = 1 + + +def _register_thunk(fn): + global _next_thunk_id + addr = _next_thunk_id + _next_thunk_id += 1 + _INTERNAL_THUNKS[addr] = fn + return addr + + +def _thunk_memmove(dst, src, count): + return _nat.memmove(_as_address(dst), _as_address(src), int(count)) + + +def _thunk_memset(dst, c, count): + return _nat.memset(_as_address(dst), int(c), int(count)) + + +def _thunk_string_at(ptr, size=-1): + return _nat.string_at(_as_address(ptr), int(size)) + + +def _thunk_wstring_at(ptr, size=-1): + return _nat.wstring_at(_as_address(ptr), int(size)) + + +def _thunk_cast(ptr, obj, typ): + # cast(obj, typ): reinterpret obj's memory as `typ`, keeping obj alive. + if isinstance(obj, _CData): + addr = addressof(obj) + else: + addr = _as_address(ptr) + result = typ.from_address(addr) + result._b_base = obj + return result + + +_memmove_addr = _register_thunk(_thunk_memmove) +_memset_addr = _register_thunk(_thunk_memset) +_string_at_addr = _register_thunk(_thunk_string_at) +_wstring_at_addr = _register_thunk(_thunk_wstring_at) +_cast_addr = _register_thunk(_thunk_cast) + + +# --------------------------------------------------------------------------- +# Foreign function invocation + callbacks (libffi bridge) +# --------------------------------------------------------------------------- + + +def _type_code_for_ffi(t): + """Map a ctypes type (or None) to the format code the native libffi + bridge understands.""" + if t is None: + return None # void + if isinstance(t, _CDataType): + if issubclass(t, _SimpleCData): + return t._type_ + if issubclass(t, (_Pointer, Array, Structure, Union)): + return "P" + if issubclass(t, CFuncPtr): + return "P" + raise TypeError("unsupported ctypes type in FFI signature: %r" % (t,)) + + +def _arg_to_ffi(value): + """Marshal a Python/ctypes argument to a (code, payload) the native + bridge can push onto the call. Payload is an int (address/scalar), a + float, or bytes.""" + if isinstance(value, _CArgObject): + return ("P", value._address()) + if isinstance(value, (Array, Structure, Union)): + # Aggregates are passed by the address of their own storage. + return ("P", addressof(value)) + if isinstance(value, (_Pointer, CFuncPtr)): + # Pointer-like scalars are passed *by value*: the callee receives the + # address they hold (a target/code pointer), not the wrapper address. + return ("P", int.from_bytes(value._read(0, _PTR), _BO)) + if isinstance(value, _SimpleCData): + return (value._type_, value.value) + if value is None: + return ("P", 0) + if isinstance(value, bool): + return ("i", int(value)) + if isinstance(value, int): + return ("P", value) if False else ("q", value) + if isinstance(value, float): + return ("d", value) + if isinstance(value, bytes): + return ("z", value) + if isinstance(value, str): + return ("Z", value) + raise TypeError("cannot pass %r to a foreign function" % (type(value).__name__,)) + + +def _ffi_invoke(addr, restype, argtypes, flags, args): + if addr == 0: + raise ValueError("attempt to call NULL function pointer") + # Determine per-argument codes. + codes = [] + payloads = [] + if argtypes: + if len(args) != len(argtypes): + raise TypeError( + "this function takes %d argument(s) (%d given)" + % (len(argtypes), len(args)) + ) + for at, val in zip(argtypes, args): + conv = at.from_param(val) if hasattr(at, "from_param") else val + code = _type_code_for_ffi(at) + payload = _coerce_payload(code, conv if conv is not None else val) + codes.append(code) + payloads.append(payload) + else: + for val in args: + code, payload = _arg_to_ffi(val) + codes.append(code) + payloads.append(payload) + rcode = _type_code_for_ffi(restype) + raw = _nat.call_function(addr, rcode, codes, payloads, int(flags)) + if restype is None: + return None + return _wrap_result(restype, raw) + + +def _coerce_payload(code, value): + if code in ("P", "z", "Z", "O"): + if isinstance(value, _CData): + # Aggregates (struct/union/array) and py_object are passed by their + # own address. Pointer-like scalars (c_char_p/c_wchar_p/c_void_p, + # POINTER(...) and function pointers) are passed *by value*: the + # callee receives the address they store, not the address of the + # Python wrapper holding it. + if code == "O" or isinstance(value, (Structure, Union, Array)): + return addressof(value) + return int.from_bytes(value._read(0, _PTR), _BO) + if isinstance(value, _CArgObject): + return value._address() + if value is None: + return 0 + if code == "z" and isinstance(value, (bytes, bytearray)): + return value + if code == "Z" and isinstance(value, str): + return value + return int(value) + if code in _INT_CODES or code in ("c", "?"): + if isinstance(value, _SimpleCData): + return value.value + if isinstance(value, (bytes, bytearray)) and code == "c": + return value[0] + return int(value) + if code in ("f", "d", "g"): + if isinstance(value, _SimpleCData): + return float(value.value) + return float(value) + if isinstance(value, _SimpleCData): + return value.value + return value + + +def _wrap_result(restype, raw): + if isinstance(restype, _CDataType) and issubclass(restype, _SimpleCData): + # Native bridge returns a Python scalar already in `raw`. + obj = restype() + obj.value = raw + return obj.value + if isinstance(restype, _CDataType) and issubclass(restype, _Pointer): + p = restype() + p._write(0, int(raw).to_bytes(_PTR, _BO)) + return p + return raw + + +def _from_closure_arg(argtype, raw): + """Rebuild the declared ctypes argument a Python callback expects from the + primitive the native trampoline delivers. + + The native bridge only knows single-character format codes, so a pointer + argument arrives as a bare machine address (int), a ``char*``/``wchar_t*`` + as ``bytes``/``str``, and scalars as ``int``/``float``. For a typed + pointer (``POINTER(T)``) CPython hands the callback a live pointer object, + so reconstruct one over that address; every other declared type already + matches the primitive the bridge produced. + """ + if ( + argtype is not None + and isinstance(argtype, _CDataType) + and issubclass(argtype, _Pointer) + ): + p = argtype() + p._write(0, int(raw).to_bytes(_PTR, _BO)) + return p + return raw + + +def _to_closure_result(restype, result): + """Reduce a callback's Python return value to the primitive the native + trampoline writes back into the result register.""" + if restype is None: + return None + if isinstance(result, _SimpleCData): + return result.value + if isinstance(result, _Pointer): + return int.from_bytes(result._read(0, _PTR), _BO) + if isinstance(result, _CData): + return addressof(result) + return result + + +def _make_closure(funcptr, callable_): + # A real C-callable closure is created by the native bridge. The native + # trampoline can only marshal primitives, so wrap the user callable so it + # (a) rebuilds each declared argtype (e.g. POINTER(c_int)) from the raw + # primitive before the call and (b) reduces the return value back to a + # primitive afterwards -- mirroring CPython's per-argument converters. + functype = type(funcptr) + argtypes = tuple(functype._argtypes_ or ()) + restype = functype._restype_ + argcodes = [_type_code_for_ffi(t) for t in argtypes] + rcode = _type_code_for_ffi(restype) + + def _closure_entry(*raw): + conv = [_from_closure_arg(at, val) for at, val in zip(argtypes, raw)] + if len(raw) > len(argtypes): + conv.extend(raw[len(argtypes):]) + return _to_closure_result(restype, callable_(*conv)) + + try: + return _nat.create_closure(_closure_entry, rcode, argcodes) + except NotImplementedError: + return 0 diff --git a/crates/weavepy-vm/src/stdlib/python/_packaging.py b/crates/weavepy-vm/src/stdlib/python/_packaging.py index b16b44f..40532f7 100644 --- a/crates/weavepy-vm/src/stdlib/python/_packaging.py +++ b/crates/weavepy-vm/src/stdlib/python/_packaging.py @@ -925,12 +925,45 @@ def parse_wheel_filename(name: str): return dist, version, build, tags +def _is_weavepy() -> bool: + """Whether we're running under WeavePy (vs. vendored on stock CPython).""" + try: + return sys.implementation.name == 'weavepy' + except Exception: + return False + + def compatible_tags(): """Yield :class:`WheelTag` triples the running interpreter can satisfy. The order matches CPython's pip: most specific first, fallback last. + + Because WeavePy mirrors the CPython 3.13 binary ABI (RFC 0043), it + consumes the *stock* CPython wheel matrix verbatim — the same + ``cp313`` / ``abi3`` interpreter+ABI tags over the full + manylinux / macOS / musllinux platform set a real numpy or pandas + wheel ships. On top of that it accepts an optional **provenance** + tag (``weavepy``) for wheels a publisher built and verified against + WeavePy specifically; see below. """ major, minor = sys.version_info[:2] + plats = _platform_tags() + + # RFC 0047 (wave 5): WeavePy *provenance* tags. A wheel built and + # verified specifically against WeavePy's mirrored ABI may advertise + # the `weavepy` interpreter tag (e.g. `pkg-1.0-weavepy-cp313-.whl`). + # Such a wheel is invisible to stock CPython (which never emits a + # `weavepy` tag) yet is the *most preferred* match here — ahead of the + # generic stock `cp313` wheel it shadows — so a project can ship a + # WeavePy-blessed build alongside its PyPI artifacts. Emitted only + # when actually running under WeavePy, keeping this module + # byte-for-byte CPython-faithful if vendored elsewhere. + if _is_weavepy(): + for abi in ('cp%d%d' % (major, minor), 'abi3', 'none'): + for plat in plats: + yield WheelTag('weavepy', abi, plat) + yield WheelTag('weavepy', 'none', 'any') + pys = [ 'cp%d%d' % (major, minor), 'cp%d' % major, @@ -939,7 +972,6 @@ def compatible_tags(): 'py3', 'py2.py3', ] abis = ['cp%d%d' % (major, minor), 'abi3', 'none'] - plats = _platform_tags() for py in pys: for abi in abis: for plat in plats: @@ -949,13 +981,15 @@ def compatible_tags(): yield WheelTag(py, 'none', 'any') -def _platform_tags(): +def _platform_tags(plat=None, machine=None): + """Platform compatibility tags for ``plat``/``machine`` (defaulting to + the running host). The parameters exist for testability — the matrix + is otherwise host-derived.""" out = ['any'] - plat = sys.platform - if hasattr(os, 'uname'): - machine = os.uname().machine - else: - machine = 'x86_64' + if plat is None: + plat = sys.platform + if machine is None: + machine = os.uname().machine if hasattr(os, 'uname') else 'x86_64' if not machine: machine = 'x86_64' if plat == 'darwin': @@ -971,6 +1005,14 @@ def _platform_tags(): out.append('manylinux2014_%s' % machine) for minor in range(17, 40): out.append('manylinux_2_%d_%s' % (minor, machine)) + # PEP 656 musllinux (Alpine / musl libc). numpy and pandas both + # publish `musllinux_1_1` and `musllinux_1_2` wheels next to their + # manylinux ones; omitting these makes the resolver skip every + # binary wheel on a musl host. `musllinux_${maj}_${min}` is keyed + # on the musl ABI version (currently 1.2), so we span 1_0..1_5 for + # forward headroom, mirroring the manylinux range above. + for minor in range(0, 6): + out.append('musllinux_1_%d_%s' % (minor, machine)) elif plat == 'win32': out.append('win_amd64') out.append('win32') @@ -994,7 +1036,11 @@ def wheel_score(filename: str) -> int: """Return a priority score; higher = more preferred. Mirrors pip's tie-breaking: prefer cp-tagged wheels over abi3 over - none, prefer arch-specific platform tags over `any`. + none, prefer arch-specific platform tags over `any`. A WeavePy + *provenance* wheel (interpreter tag ``weavepy``, RFC 0047) outranks + the generic stock build it shadows — it is only ever a candidate when + running under WeavePy (``compatible_tags`` gates it), so the boost + never perturbs selection elsewhere. """ try: _, _, _, tags = parse_wheel_filename(filename) @@ -1003,7 +1049,9 @@ def wheel_score(filename: str) -> int: best = 0 for t in tags: s = 0 - if t.python.startswith('cp'): + if t.python == 'weavepy': + s += 16 + elif t.python.startswith('cp'): s += 8 if t.abi != 'none': s += 4 diff --git a/crates/weavepy-vm/src/stdlib/python/_pydatetime.py b/crates/weavepy-vm/src/stdlib/python/_pydatetime.py index 38e1f76..4a47ac8 100644 --- a/crates/weavepy-vm/src/stdlib/python/_pydatetime.py +++ b/crates/weavepy-vm/src/stdlib/python/_pydatetime.py @@ -2208,7 +2208,16 @@ def __add__(self, other): minutes=self._minute, seconds=self._second, microseconds=self._microsecond) - delta += other + # WeavePy: mirror the C `_datetime` module, which combines the operand + # through its raw GET_TD_DAYS/SECONDS/MICROSECONDS fields rather than a + # Python-level `+`. Adding `other` directly defers to a `timedelta` + # *subclass*'s reflected `__radd__` (pandas `Timedelta`, whose + # ns-resolution constructor overflows on the ~734503-day intermediate + # `delta`), spuriously raising `OutOfBoundsTimedelta` where CPython's C + # datetime succeeds (e.g. `datetime(2012, 1, 1) - Timedelta("1 day")`). + # Normalising to a base `timedelta` is a round-trip no-op for a plain + # `timedelta` and reproduces the C field reads exactly. + delta += timedelta(other.days, other.seconds, other.microseconds) hour, rem = divmod(delta.seconds, 3600) minute, second = divmod(rem, 60) if 0 < delta.days <= _MAXORDINAL: diff --git a/crates/weavepy-vm/src/stdlib/python/_pytest.py b/crates/weavepy-vm/src/stdlib/python/_pytest.py index 196364f..bc7c652 100644 --- a/crates/weavepy-vm/src/stdlib/python/_pytest.py +++ b/crates/weavepy-vm/src/stdlib/python/_pytest.py @@ -22,6 +22,7 @@ """ import importlib +import importlib.util import inspect import os import re @@ -32,6 +33,7 @@ __all__ = [ 'main', 'fixture', 'raises', 'warns', 'skip', 'fail', 'xfail', + 'importorskip', 'approx', 'mark', 'param', 'Session', 'Item', 'Collector', 'ExitCode', 'Module', 'Function', 'Class', 'UsageError', 'CollectionError', @@ -88,6 +90,42 @@ def xfail(reason: str = ''): raise _XFailed(reason or 'xfail') +def importorskip(modname, minversion=None, reason=None): + """Import ``modname`` or skip the test if it is unavailable. + + Mirrors ``pytest.importorskip``: pandas guards optional-dependency tests + (scipy, pyarrow, numexpr, …) with it, so a missing dependency must skip + rather than error. Returns the imported (sub)module on success. + """ + try: + __import__(modname) + except ImportError as exc: + raise _Skipped( + reason or "could not import {!r}: {}".format(modname, exc)) + mod = sys.modules[modname] + if minversion is not None: + have = getattr(mod, '__version__', None) + if have is not None and _version_tuple(have) < _version_tuple(minversion): + raise _Skipped( + reason or "module {!r} has __version__ {!r}, required {!r}".format( + modname, have, minversion)) + return mod + + +def _version_tuple(v): + """Best-effort dotted-version parse for ``importorskip(minversion=...)``.""" + out = [] + for part in str(v).split('.'): + num = '' + for ch in part: + if ch.isdigit(): + num += ch + else: + break + out.append(int(num) if num else 0) + return tuple(out) + + # ============================================================ marker module class _MarkerDecorator: @@ -141,6 +179,16 @@ def __getattr__(self, name): # fixtures with `request.addfinalizer` teardown. _FIXTURE_REGISTRY = {} +# Distinguishes "fixture not found / not cached" from a fixture that +# legitimately produced ``None`` (e.g. pandas' ``tz_naive_fixture`` yields +# ``None`` for the naive timezone). Using ``None`` as the sentinel would drop +# that value and raise a spurious "missing argument" error. +_NOTSET = object() + +# Set by `_run` for the duration of a session so `request.config` (and any +# other config-consulting shim surface) can reach the active `_Config`. +_ACTIVE_CONFIG = None + class _FixtureDef: __slots__ = ('fn', 'scope', 'params', 'ids', 'autouse', 'name', 'generator') @@ -183,13 +231,67 @@ def deco(fn): fname = name or fn.__name__ defn = _FixtureDef(fn, scope, params, ids, autouse, fname) fn._pytest_fixture = defn - _FIXTURE_REGISTRY[fname] = defn + # A fixture defined as a *method* (first parameter `self`) belongs + # to its class, not the global namespace — registering it globally + # would (a) leak an unbound fn that can't be called without `self` + # and (b) let one class's fixture shadow an unrelated module + # fixture of the same name. Class collection rediscovers these via + # the `_pytest_fixture` attribute and binds them to the instance. + try: + first = next(iter(inspect.signature(fn).parameters), None) + except (TypeError, ValueError): + first = None + if first != 'self': + _FIXTURE_REGISTRY[fname] = defn return fn if callable_ is not None and callable(callable_): return deco(callable_) return deco +def _register_fixture_aliases(mod): + """Register a module's fixtures under *every* attribute name bound to them. + + pandas binds extra module-level names to an existing fixture to get a + second, independently-parametrised copy for cartesian-product tests:: + + nulls_fixture2 = nulls_fixture # pandas/conftest.py + tz_aware_fixture2 = tz_aware_fixture + + The `fixture` decorator only keys ``fn.__name__`` in `_FIXTURE_REGISTRY`, so + requesting ``nulls_fixture2`` failed with a bogus "missing positional + argument". Real pytest registers a fixture under each attribute name that + references it; mirror that by scanning the module post-import and adding any + alias whose name isn't already claimed by another fixture. Imported fixtures + (``from …conftest import somefix``) are picked up the same way, matching + pytest's "import to make available" behaviour. + """ + try: + names = dir(mod) + except Exception: + return + for attr in names: + if attr.startswith('__'): + continue + try: + obj = getattr(mod, attr) + except Exception: + continue + defn = getattr(obj, '_pytest_fixture', None) + if defn is None: + continue + # A `self`-method fixture belongs to its class (bound during class + # collection); never expose it as a module-global. + try: + first = next(iter(inspect.signature(obj).parameters), None) + except (TypeError, ValueError): + first = None + if first == 'self': + continue + if attr not in _FIXTURE_REGISTRY: + _FIXTURE_REGISTRY[attr] = defn + + # Per-scope caches, refreshed by `_FixtureManager.enter_scope`. class _FixtureManager: """Tracks fixture instances and teardowns across scopes.""" @@ -220,7 +322,7 @@ def reset_scope(self, scope): self._caches[scope].clear() def get_cached(self, name, scope, param): - return self._caches[scope].get((name, param)) + return self._caches[scope].get((name, param), _NOTSET) def set_cached(self, name, scope, param, value): self._caches[scope][(name, param)] = value @@ -249,6 +351,15 @@ def _builtin_fixture_monkeypatch(request): # noqa: ARG001 return _MonkeyPatchHandle() +def _builtin_fixture_doctest_namespace(request): # noqa: ARG001 + # Real pytest injects this (session-scoped) from its doctest plugin; + # pandas' autouse ``add_doctest_imports`` populates it with ``np``/``pd``. + # We don't collect doctests, so a plain dict the fixture can scribble on + # is sufficient — without it the autouse fixture fails with a missing + # ``doctest_namespace`` argument on *every* test. + return {} + + class _MonkeyPatchHandle: """Minimal monkeypatch fixture for swapping attrs / env vars.""" @@ -366,11 +477,23 @@ def __init__(self, out, err): self.err = err +def _builtin_fixture_pytestconfig(request): + """The active ``Config`` — pytest's built-in ``pytestconfig`` fixture. + + pandas' ``strict_data_files(pytestconfig)`` (and hence the widely-used + ``datapath`` fixture) depends on it; without it every ``datapath``-based + test errors with a missing-argument ``TypeError``. + """ + return _ACTIVE_CONFIG + + _BUILTIN_FIXTURES = { 'tmp_path': _builtin_fixture_tmp_path, 'tmpdir': _builtin_fixture_tmpdir, 'capsys': _builtin_fixture_capsys, 'monkeypatch': _builtin_fixture_monkeypatch, + 'doctest_namespace': _builtin_fixture_doctest_namespace, + 'pytestconfig': _builtin_fixture_pytestconfig, } @@ -395,7 +518,39 @@ def addfinalizer(self, fn): self._manager.add_finalizer(self._scope, fn) def getfixturevalue(self, name): - return _resolve_fixture(name, self._manager, self.item, self.node) + val = _resolve_fixture(name, self._manager, self.item, self.node) + if val is _NOTSET: + raise LookupError('no fixture named {!r}'.format(name)) + return val + + # `request.applymarker(pytest.mark.xfail(...))` (and the `request.node. + # add_marker` spelling) attach a marker to the running test at call time. + # pandas uses this pervasively for data-dependent xfail/skip. The marker + # lands on the item's `marks`, which `_run_one_item` re-scans *after* the + # test body runs, so a runtime-applied xfail/skip is honoured. + def applymarker(self, marker): + if self.item is not None: + self.item.marks.append(marker) + + add_marker = applymarker + + @property + def config(self): + return _ACTIVE_CONFIG + + @property + def function(self): + return getattr(self.item, 'callable', None) + + @property + def keywords(self): + # A name→marker mapping is a good-enough stand-in for pytest's + # keyword set; tests probe it with `"" in request.keywords`. + kw = {getattr(m, 'name', None): m for m in getattr(self.item, 'marks', [])} + name = getattr(self.item, 'name', None) + if name is not None: + kw[name] = True + return kw def _resolve_fixture(name, manager=None, item=None, node=None, param=None, @@ -408,7 +563,14 @@ def _resolve_fixture(name, manager=None, item=None, node=None, param=None, """ if manager is None: manager = _FIXTURE_MANAGER - defn = _FIXTURE_REGISTRY.get(name) + # Class-scoped method fixtures (bound to the test instance) shadow + # module/global fixtures of the same name; fall back to the global + # registry for module-level fixtures. + defn = None + if item is not None: + defn = getattr(item, '_fixture_map', {}).get(name) + if defn is None: + defn = _FIXTURE_REGISTRY.get(name) if defn is not None: # Parametrised fixture: pick the active parameter for this # item if `parametrize` filled it in. @@ -417,7 +579,7 @@ def _resolve_fixture(name, manager=None, item=None, node=None, param=None, active_param = getattr(item, '_fixture_params', {}).get(name) cache_key = active_param cached = manager.get_cached(name, defn.scope, cache_key) - if cached is not None: + if cached is not _NOTSET: return cached req = _Request(node=node, item=item, manager=manager, scope=defn.scope, fixturename=name, param=active_param) @@ -429,7 +591,7 @@ def _resolve_fixture(name, manager=None, item=None, node=None, param=None, kwargs[pname] = req else: sub = _resolve_fixture(pname, manager, item, node) - if sub is not None: + if sub is not _NOTSET: kwargs[pname] = sub if defn.generator: it = defn.fn(**kwargs) @@ -453,7 +615,7 @@ def _teardown(it=it): if name == 'monkeypatch': manager.add_finalizer('function', val.undo) return val - return None + return _NOTSET _FIXTURE_MANAGER = _FixtureManager() @@ -580,13 +742,23 @@ class Item(Collector): """A single test item (callable).""" def __init__(self, name, parent, callable_, marks=None, params=None, - param_id=None): + param_id=None, fixture_params=None, fixture_map=None): super().__init__(name, parent) self.callable = callable_ self.marks = marks or [] - # Parametrize sets `_fixture_params` so the resolver picks - # the right value for each fixture argument. - self._fixture_params = params or {} + # `@pytest.mark.parametrize` injects argument values passed + # *directly* to the test (these win over fixtures of the same + # name). + self._direct_params = params or {} + # Parametrised *fixtures* in the test's dependency closure bind + # one `request.param` value per fixture here; `_resolve_fixture` + # reads them so a `@pytest.fixture(params=[...])` multiplies the + # test and threads the active parameter through dependents. + self._fixture_params = fixture_params or {} + # Fixtures visible to this test: class-scoped method fixtures + # (bound to the test instance) layered over the module/global + # registry. Empty for the plain module-function case. + self._fixture_map = fixture_map or {} self._param_id = param_id @property @@ -598,23 +770,46 @@ def nodeid(self): return '{}::{}'.format(self.parent.nodeid, base) return base + def add_marker(self, marker, append=True): + """`request.node.add_marker(...)` — attach a marker at call time.""" + if append: + self.marks.append(marker) + else: + self.marks.insert(0, marker) + + def get_closest_marker(self, name, default=None): + for m in reversed(self.marks): + if getattr(m, 'name', None) == name: + return m + return default + def runtest(self): sig = inspect.signature(self.callable) kwargs = {} # Eagerly resolve any autouse fixtures so their teardowns # get queued (matches pytest's ordering: autouse fires for - # every test in scope even if not requested by name). - for fname, defn in _FIXTURE_REGISTRY.items(): + # every test in scope even if not requested by name). Both the + # global registry and this test's class-scoped fixtures count. + for fname, defn in list(self._fixture_map.items()) + list(_FIXTURE_REGISTRY.items()): if defn.autouse: _resolve_fixture(fname, _FIXTURE_MANAGER, self, self.parent) for pname in sig.parameters: # Parametrize injects directly-passed values that aren't # fixtures — those win over the resolver. - if pname in self._fixture_params: - kwargs[pname] = self._fixture_params[pname] + if pname in self._direct_params: + kwargs[pname] = self._direct_params[pname] + continue + # The built-in `request` fixture: pandas takes it directly as a + # test argument (for `request.applymarker`, `request.node`, + # `request.getfixturevalue`, …), so it must be supplied here just + # as `_resolve_fixture` supplies it to dependent fixtures. + if pname == 'request': + kwargs[pname] = _Request(node=self, item=self, + manager=_FIXTURE_MANAGER, + scope='function') continue val = _resolve_fixture(pname, _FIXTURE_MANAGER, self, self.parent) - if val is not None: + if val is not _NOTSET: kwargs[pname] = val try: return self.callable(**kwargs) @@ -638,6 +833,7 @@ def nodeid(self): def collect(self): items = [] instance = self.cls() + class_fixtures = _bound_class_fixtures(self.cls, instance) for attr in dir(self.cls): if not attr.startswith('test_'): continue @@ -645,7 +841,8 @@ def collect(self): if not callable(method): continue marks = getattr(method, '_pytest_marks', []) - items.extend(_expand_parametrize(attr, self, method, marks)) + items.extend(_expand_parametrize(attr, self, method, marks, + fixture_map=class_fixtures)) return items @@ -665,15 +862,23 @@ def collect(self): raise CollectionError('cannot load module: {}'.format(self.path)) mod = importlib.util.module_from_spec(spec) sys.modules[self._mod_name()] = mod + _tr = os.environ.get('WEAVEPY_SHIM_TRACE') + if _tr: + sys.stderr.write('>>> IMPORT-START ' + self.path + '\n'); sys.stderr.flush() try: spec.loader.exec_module(mod) except Exception as exc: raise CollectionError('error importing {}: {}'.format(self.path, exc)) from None + if _tr: + sys.stderr.write('>>> IMPORT-DONE ' + self.path + '\n'); sys.stderr.flush() self.module = mod + _register_fixture_aliases(mod) out = [] for name in dir(mod): obj = getattr(mod, name) if name.startswith('test_') and callable(obj): + if _tr: + sys.stderr.write('>>> COLLECT-FN ' + name + '\n'); sys.stderr.flush() marks = getattr(obj, '_pytest_marks', []) out.extend(_expand_parametrize(name, self, obj, marks)) elif name.startswith('Test') and inspect.isclass(obj): @@ -687,23 +892,146 @@ def _mod_name(self): return base -def _expand_parametrize(name, parent, fn, marks): - """Expand `@pytest.mark.parametrize` markers into per-row items. +def _bound_class_fixtures(cls, instance): + """Discover fixtures defined as methods on ``cls`` (or its bases) and + bind them to ``instance``. + + pandas leans heavily on class-scoped fixtures (e.g. ``TestNonNano``'s + ``unit``/``val``/``td``), defined as ``@pytest.fixture`` methods that + take ``self``. Binding to the instance supplies ``self`` and drops it + from the fixture's visible signature, so the ordinary dependency + resolver can inject the fixture's *own* fixture arguments. + """ + fixtures = {} + for name in dir(cls): + try: + attr = getattr(cls, name) + except Exception: + continue + defn = getattr(attr, '_pytest_fixture', None) + if defn is None: + continue + bound = getattr(instance, name) + fixtures[defn.name] = _FixtureDef( + bound, defn.scope, defn.params, defn.ids, defn.autouse, defn.name) + return fixtures + + +def _fixture_deps(defn): + """Fixture-argument names a fixture def requests (minus ``request``).""" + try: + return [p for p in inspect.signature(defn.fn).parameters if p != 'request'] + except (TypeError, ValueError): + return [] + + +def _closure_param_fixtures(requested, fixture_map): + """Ordered list of *parametrised* fixture defs reachable from the + ``requested`` fixture names (following dependencies). A dependency is + emitted before the fixture that requests it, matching pytest's id + ordering. Both the class-scoped ``fixture_map`` and the module/global + registry are consulted.""" + order = [] + seen = set() + + def lookup(fname): + return fixture_map.get(fname) or _FIXTURE_REGISTRY.get(fname) + + def visit(fname): + if fname in seen: + return + seen.add(fname) + defn = lookup(fname) + if defn is None: + return + for dep in _fixture_deps(defn): + visit(dep) + if defn.params is not None: + order.append(defn) + + for r in requested: + visit(r) + return order + + +def _fixture_param_matrix(fn, fixture_map, skip_names): + """Cartesian product of the ``params`` of every parametrised fixture in + ``fn``'s fixture dependency closure. Returns a list of + ``(fixture_params, id_frags)`` — one entry per test instance the + parametrised fixtures multiply the test into.""" + try: + requested = [p for p in inspect.signature(fn).parameters + if p != 'request' and p not in skip_names] + except (TypeError, ValueError): + requested = [] + param_fixtures = _closure_param_fixtures(requested, fixture_map) + matrix = [({}, [], [])] + for defn in param_fixtures: + rows = [] + # ``ids`` may be None, a sequence indexed by param position, or a + # *callable* invoked per value (pytest falls back to the auto id when + # the callable returns None). Treating a callable as a sequence blows + # up with ``object of type 'function' has no len()`` and aborts the + # whole file's collection. + ids = defn.ids + for i, pv in enumerate(defn.params): + # Unwrap `pytest.param(value, id=..., marks=...)` entries in a + # `@pytest.fixture(params=[...])` list — the direct + # `@pytest.mark.parametrize` path does this but the fixture path + # historically did not, so `request.param` was the `_ParamSet` + # wrapper (pandas' `all_parsers` does `request.param()`; + # `any_string_dtype` does `storage, na = request.param`) and any + # per-param `marks=` (e.g. `skip_if_no("pyarrow")`) were dropped. + pv_id = None + pv_marks = [] + if _is_param_set(pv): + pv_id = pv.id + pv_marks = pv.marks + pv = pv.values + frag = None + if pv_id is not None: + frag = str(pv_id) + elif callable(ids): + got = ids(pv) + if got is not None: + frag = str(got) + elif ids is not None and i < len(ids) and ids[i] is not None: + frag = str(ids[i]) + if frag is None: + frag = _id_for(pv) + rows.append((pv, frag, pv_marks)) + new_matrix = [] + for fparams, frags, fmarks in matrix: + for pv, frag, pv_marks in rows: + merged = dict(fparams) + merged[defn.name] = pv + new_matrix.append( + (merged, frags + [frag], fmarks + pv_marks)) + matrix = new_matrix + return matrix + + +def _expand_parametrize(name, parent, fn, marks, fixture_map=None): + """Expand a test callable into concrete :class:`Item`s. + + Two independent sources multiply a test: + + * ``@pytest.mark.parametrize`` markers (stacked → Cartesian product), + whose values are passed *directly* to the test. + * parametrised fixtures (``@pytest.fixture(params=[...])``) anywhere in + the test's fixture dependency closure, whose active ``request.param`` + is threaded through the resolver. - Supports the canonical pytest spellings: + Supports the canonical parametrize spellings:: @pytest.mark.parametrize('a,b', [(1, 2), (3, 4)]) @pytest.mark.parametrize('a', [1, 2, 3], ids=['one', 'two', 'three']) @pytest.mark.parametrize('value', [pytest.param(1, id='one'), 2]) - - Multiple parametrize decorators stack into a Cartesian product - (pytest matrix semantics). """ + fixture_map = fixture_map or {} param_marks = [m for m in marks if m.name == 'parametrize'] other_marks = [m for m in marks if m.name != 'parametrize'] - if not param_marks: - return [Item(name, parent, fn, marks=other_marks)] - matrix = [({}, [])] # (param-binding dict, id-fragments) + matrix = [({}, [], [])] # (param-binding dict, id-fragments, per-row marks) for marker in reversed(param_marks): args = marker.args if len(args) < 2: @@ -719,9 +1047,11 @@ def _expand_parametrize(name, parent, fn, marks): for row_idx, row in enumerate(argvalues): # Unwrap `pytest.param(value, id=..., marks=...)` if used. row_id = None - if isinstance(row, _ParamSet): + row_marks = [] + if _is_param_set(row): row_value = row.values row_id = row.id + row_marks = row.marks else: row_value = row if len(names) > 1: @@ -734,20 +1064,53 @@ def _expand_parametrize(name, parent, fn, marks): else: values = [row_value] if row_id is None and explicit_ids is not None: - row_id = explicit_ids[row_idx] + # `ids=` is either a sequence indexed by row, or a *callable* + # invoked per argvalue (pytest calls it once per value and + # falls back to the auto id when it returns None). + if callable(explicit_ids): + parts = [] + for v in values: + got = explicit_ids(v) + parts.append(str(got) if got is not None else _id_for(v)) + row_id = '-'.join(parts) + else: + row_id = explicit_ids[row_idx] if row_id is None: row_id = '-'.join(_id_for(v) for v in values) - for prior_params, prior_ids in matrix: + for prior_params, prior_ids, prior_marks in matrix: merged = dict(prior_params) for nm, val in zip(names, values): merged[nm] = val - new_matrix.append((merged, prior_ids + [row_id])) + new_matrix.append( + (merged, prior_ids + [row_id], prior_marks + row_marks)) matrix = new_matrix items = [] - for params, id_frags in matrix: - pid = '-'.join(id_frags) if id_frags else None - items.append(Item(name, parent, fn, marks=other_marks, - params=params, param_id=pid)) + for params, id_frags, row_marks in matrix: + # Cross the parametrize row with the parametrised-fixture matrix. + # Names bound directly by parametrize are excluded from the fixture + # closure (parametrize wins). + fmatrix = _fixture_param_matrix(fn, fixture_map, set(params.keys())) + if os.environ.get('WEAVEPY_SHIM_TRACE') and len(fmatrix) > 8: + _cl = _closure_param_fixtures( + [p for p in inspect.signature(fn).parameters + if p != 'request' and p not in set(params.keys())], + fixture_map or {}) + sys.stderr.write('>>> EXPAND {} fmatrix={} closure={}\n'.format( + name, len(fmatrix), + [(d.name, len(d.params)) for d in _cl])) + sys.stderr.flush() + for fparams, fid_frags, fmarks in fmatrix: + all_frags = id_frags + fid_frags + pid = '-'.join(all_frags) if all_frags else None + # Per-param marks (`pytest.param(v, marks=...)`) apply on top of + # the function-level marks so a single parametrization can be + # skipped or xfailed independently of its siblings — from both the + # direct parametrize row (`row_marks`) and any parametrised-fixture + # `pytest.param` entries in the closure (`fmarks`). + items.append(Item(name, parent, fn, + marks=other_marks + row_marks + fmarks, + params=params, param_id=pid, + fixture_params=fparams, fixture_map=fixture_map)) return items @@ -758,7 +1121,15 @@ class _ParamSet: def __init__(self, values, id=None, marks=()): # noqa: A002 self.values = values self.id = id - self.marks = list(marks) if marks else [] + # `marks=` accepts a *single* mark or a collection of them, exactly + # like real pytest — `pytest.param(x, marks=td.skip_if_no("scipy"))` + # passes one `_MarkerDecorator`, which is not iterable. + if not marks: + self.marks = [] + elif isinstance(marks, _MarkerDecorator): + self.marks = [marks] + else: + self.marks = list(marks) def param(*values, id=None, marks=()): # noqa: A002 @@ -767,9 +1138,31 @@ def param(*values, id=None, marks=()): # noqa: A002 id=id, marks=marks) +def _is_param_set(obj): + """True if ``obj`` is a ``pytest.param(...)`` payload (a ``_ParamSet``). + + The module under collection may ``import pytest`` and get a *different* + module object than the one running collection — weavepy's frozen + ``pytest`` vs this re-imported/``__main__`` copy — so its ``pytest.param`` + builds a ``_ParamSet`` from a twin class. A plain ``isinstance`` against + our ``_ParamSet`` misses that instance, leaving the value wrapped + (``request.param`` becomes the wrapper and node ids read ``_ParamSet``). + Match by class name as well so unwrapping survives the module boundary. + """ + return isinstance(obj, _ParamSet) or type(obj).__name__ == '_ParamSet' + + def _id_for(value): - if isinstance(value, (int, float, bool, str, bytes)): - return repr(value) + # Mirror pytest's auto-id rules closely enough that node ids line up + # with real pytest (bool before int — bool is an int subclass). + if isinstance(value, bool): + return str(value) + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return value + if isinstance(value, bytes): + return value.decode('utf-8', 'replace') if value is None: return 'None' return type(value).__name__ @@ -837,13 +1230,37 @@ def _evaluate_skipif(args, kwargs): return False, reason +def _xfail_from_marks(item): + """Resolve the item's effective xfail state from its marks. + + Returns ``(expected, reason, raises, run, strict)``. Honours an optional + condition (first non-str positional arg or ``condition=``). Re-read + *after* the test body so a ``request.applymarker(pytest.mark.xfail(...))`` + applied at call time is counted, matching pytest. + """ + for m in item.marks: + if getattr(m, 'name', None) != 'xfail': + continue + kw = m.kwargs + cond = kw.get('condition', _NOTSET) + if cond is _NOTSET and m.args and not isinstance(m.args[0], str): + cond = m.args[0] + if cond is _NOTSET: + cond = True + if not cond: + continue + reason = kw.get('reason') or '' + if not reason and m.args and isinstance(m.args[0], str): + reason = m.args[0] + return (True, reason, kw.get('raises'), kw.get('run', True), + kw.get('strict', False)) + return (False, '', None, True, False) + + def _run_one_item(item, config): """Run a single :class:`Item`; emit a result tuple.""" start = time.time() - # Apply marks. - skip_reason = None - xfail_expected = False - xfail_reason = '' + # Apply marks known before the body runs (skip / skipif). for m in item.marks: if m.name == 'skip': args = m.args @@ -854,10 +1271,10 @@ def _run_one_item(item, config): should, reason = _evaluate_skipif(m.args, m.kwargs) if should: return ('skipped', item, reason or 'skipif', time.time() - start) - if m.name == 'xfail': - xfail_expected = True - xfail_reason = (m.kwargs.get('reason') - or (m.args[0] if m.args else '')) + # `xfail(run=False)` means "don't execute the body at all". + xe, xr, _xraises, xrun, _xstrict = _xfail_from_marks(item) + if xe and not xrun: + return ('xfailed', item, xr, time.time() - start) try: item.runtest() except _Skipped as exc: @@ -866,11 +1283,20 @@ def _run_one_item(item, config): return ('xfailed', item, str(exc), time.time() - start) except (AssertionError, Exception) as exc: tb = traceback.format_exc() - if xfail_expected: - return ('xfailed', item, xfail_reason or repr(exc), time.time() - start) + # Re-read marks: `request.applymarker(xfail)` may have added one + # inside the body before the failure. + xe, xr, xraises, _xrun, _xstrict = _xfail_from_marks(item) + if xe and (xraises is None or isinstance(exc, xraises)): + return ('xfailed', item, xr or repr(exc), time.time() - start) return ('failed', item, tb, time.time() - start) - if xfail_expected: - return ('xpassed', item, xfail_reason, time.time() - start) + # Passed — but a runtime-applied (or decorator) xfail turns this into an + # xpass (a strict xfail that passes is a failure, as in pytest). + xe, xr, _xraises, _xrun, xstrict = _xfail_from_marks(item) + if xe: + if xstrict: + return ('failed', item, + '[XPASS(strict)] ' + (xr or ''), time.time() - start) + return ('xpassed', item, xr, time.time() - start) return ('passed', item, '', time.time() - start) @@ -887,6 +1313,19 @@ def __init__(self, paths, verbose=0, exitfirst=False, keyword=None, self.quiet = quiet self.rootdir = os.getcwd() + def getoption(self, name, default=None, skip=False): + """Best-effort ``Config.getoption``. + + The shim registers no command-line plugins, so every project option + (``--no-strict-data-files``, ``--doctest-modules``, …) is absent; + return the caller's ``default`` (falsy) instead of raising. That is + the lenient reading pandas' fixtures expect — ``datapath`` then + *skips* a missing data file rather than erroring. + """ + return default + + getvalue = getoption + # ============================================================ main @@ -938,6 +1377,8 @@ def main(args=None): def _run(config): + global _ACTIVE_CONFIG + _ACTIVE_CONFIG = config session = Session(config) files = [] for p in config.paths: @@ -975,7 +1416,11 @@ def _run(config): results = [] n_passed = n_failed = n_skipped = n_xfailed = n_xpassed = 0 + _trace = os.environ.get('WEAVEPY_SHIM_TRACE') for item in collected: + if _trace: + sys.stderr.write('>>> RUN ' + str(item.nodeid) + '\n') + sys.stderr.flush() rv = _run_one_item(item, config) results.append(rv) outcome = rv[0] @@ -1064,9 +1509,37 @@ def _load_conftests(test_path): mod = importlib.util.module_from_spec(spec) sys.modules[modname] = mod spec.loader.exec_module(mod) + _register_fixture_aliases(mod) except Exception: - pass + # A conftest that fails to import silently drops *all* of its + # fixtures, which resurfaces later as a baffling "missing positional + # argument" on every test that requests one (a missing stdlib module + # like `shlex` once took out pandas' entire `io` conftest). Real + # pytest hard-errors here; we keep loading the rest, but drop the + # half-initialized module and surface the cause under the shim trace + # flag so the silent fixture loss is at least discoverable. + sys.modules.pop(modname, None) + if os.environ.get('WEAVEPY_SHIM_TRACE'): + import traceback + sys.stderr.write( + 'weavepy-pytest: conftest failed to import: {}\n'.format(path)) + traceback.print_exc() if __name__ == '__main__': + # When launched as a script (`weavepy shim/pytest.py ...` or + # `python shim/pytest.py ...`), any `import pytest` performed by a + # conftest or test binds to the *module* named ``pytest`` — the frozen + # shim under WeavePy, or this same file re-imported off ``sys.path`` + # under CPython — which is a DIFFERENT module object than ``__main__``. + # Fixtures registered by those conftests land in *that* module's + # ``_FIXTURE_REGISTRY``; a runner executing here in ``__main__`` would + # consult an empty registry and silently drop every conftest fixture + # (e.g. pandas' parametrised ``tz_naive_fixture``/``tz_aware_fixture``). + # Delegate to the canonical module so collection, fixture resolution and + # the registry all share one namespace — the same reason real pytest's + # ``__main__`` trampolines through ``pytest.console_main``. + import pytest as _canonical + if _canonical is not sys.modules.get(__name__): + sys.exit(_canonical.main()) sys.exit(main()) diff --git a/crates/weavepy-vm/src/stdlib/python/_weave_import_fallback.py b/crates/weavepy-vm/src/stdlib/python/_weave_import_fallback.py index 35aac9c..da9f070 100644 --- a/crates/weavepy-vm/src/stdlib/python/_weave_import_fallback.py +++ b/crates/weavepy-vm/src/stdlib/python/_weave_import_fallback.py @@ -15,6 +15,15 @@ import sys +# Sentinel marking the "live module" form of :func:`import_via_finders`'s +# return value. When a finder builds the module itself (PEP 451 +# ``create_module``/``exec_module``, no code object) the helper drives that +# protocol here and hands the finished module back under this tag; the Rust +# loader then caches it as ``sys.modules[name]`` verbatim. Kept identical to +# ``LIVE_MODULE_SENTINEL`` on the Rust side. A NUL prefix keeps it from +# colliding with any real module's code object. +_LIVE_MODULE = "\x00weave-live-module" + def find_spec_for(name): """Locate *name* through ``sys.meta_path``; return its spec or ``None``. @@ -43,8 +52,25 @@ def find_spec_for(name): spec = find_spec(name, parent_path) except ImportError: spec = None - if spec is not None: - return spec + if spec is None: + continue + # WeavePy's *native* loader is authoritative for builtin and frozen + # modules and has already tried — and failed — to resolve `name` as + # one before this last-resort fallback runs. But the first time any + # code touches `importlib` (e.g. `import six`), importlib installs its + # `BuiltinImporter` / `FrozenImporter` on the (otherwise empty) + # `sys.meta_path`; those still *claim* such names purely by listing + # — `_datetime` is in `sys.builtin_module_names` — while + # `create_module` returns ``None`` and no working module is built in + # this VM. Skip a builtin/frozen-origin spec so the genuine + # `ModuleNotFoundError` stands (letting `datetime`'s + # ``try: from _datetime import * / except ImportError:`` fall back to + # `_pydatetime`), while still honouring custom finders (six's + # `six.moves`) and path-based archives (`zipimport`). + origin = getattr(spec, "origin", None) + if origin in ("built-in", "frozen"): + continue + return spec return None @@ -53,17 +79,23 @@ def import_via_finders(name): needs to build a *native* module itself. Returns ``None`` when no finder claims *name* (the caller raises its own - ``ModuleNotFoundError``), or when the match is a namespace package / has - no code object (the native loader's own namespace handling applies). - Otherwise returns the tuple:: + ``ModuleNotFoundError``), or when the match is a namespace package the + native loader handles itself. For a finder that exposes a *code object* + (``zipimport``, sourceless ``.pyc``) it returns the tuple:: (code, is_package, filename, submodule_search_locations, loader, spec) - Building the module on the Rust side (rather than via - ``importlib._bootstrap._load`` + ``types.ModuleType``) is what keeps it a - first-class native module object — so dotted-import parent binding and - ``from pkg import sub`` resolve it correctly. Errors raised while reading - the code object are genuine import failures and propagate. + so the Rust loader can build a first-class *native* module — keeping + dotted-import parent binding and ``from pkg import sub`` correct. For a + finder that builds the module *itself* via the PEP 451 + ``create_module``/``exec_module`` protocol (no code object — e.g. six's + ``_SixMetaPathImporter`` for the virtual ``six.moves``) it drives that + protocol here and returns the live module under the ``_LIVE_MODULE`` tag:: + + (_LIVE_MODULE, module) + + Errors raised while loading a *found* module are genuine import failures + and propagate. """ spec = find_spec_for(name) if spec is None: @@ -72,12 +104,59 @@ def import_via_finders(name): if loader is None: return None get_code = getattr(loader, "get_code", None) - if get_code is None: - return None - code = get_code(name) - if code is None: - return None - locations = spec.submodule_search_locations - is_package = locations is not None - return (code, is_package, spec.origin, - list(locations) if is_package else None, loader, spec) + code = get_code(name) if get_code is not None else None + if code is not None: + locations = spec.submodule_search_locations + is_package = locations is not None + return (code, is_package, spec.origin, + list(locations) if is_package else None, loader, spec) + # No code object: a finder that constructs the module itself. Drive the + # PEP 451 create_module/exec_module protocol and hand the result back. + return (_LIVE_MODULE, _build_dynamic(spec, name, loader)) + + +def _build_dynamic(spec, name, loader): + """Construct *name* via the PEP 451 protocol of its *loader* and return the + live module (already registered in ``sys.modules``). + + Mirrors the relevant slice of ``importlib._bootstrap._load``: honour an + already-present ``sys.modules`` entry (reload / circular import), let the + loader build the object (``create_module``), set the import-system + attributes, register *before* executing so a circular import sees the + partial module, then run ``exec_module``. On failure the half-built entry + is removed so a retry starts clean. + """ + existing = sys.modules.get(name) + if existing is not None: + return existing + create_module = getattr(loader, "create_module", None) + module = create_module(spec) if create_module is not None else None + if module is None: + import types + module = types.ModuleType(name) + # PEP 451 _init_module_attrs (the subset finders depend on). Some loaders + # (six's) hand back a pre-built module whose attributes are already set; + # assigning again is harmless, and `__path__` is only added when the spec + # says it is a package and the module hasn't declared its own. + try: + module.__name__ = name + except Exception: + pass + try: + module.__loader__ = loader + module.__spec__ = spec + if (spec.submodule_search_locations is not None + and not hasattr(module, "__path__")): + module.__path__ = spec.submodule_search_locations + except Exception: + pass + sys.modules[name] = module + exec_module = getattr(loader, "exec_module", None) + try: + if exec_module is not None: + exec_module(module) + except BaseException: + sys.modules.pop(name, None) + raise + # exec_module may have replaced the entry (rare); return whatever is bound. + return sys.modules.get(name, module) diff --git a/crates/weavepy-vm/src/stdlib/python/ast.py b/crates/weavepy-vm/src/stdlib/python/ast.py index 80cae6e..efcc629 100644 --- a/crates/weavepy-vm/src/stdlib/python/ast.py +++ b/crates/weavepy-vm/src/stdlib/python/ast.py @@ -12,6 +12,9 @@ """ import _ast +import sys +from enum import IntEnum, auto +from contextlib import contextmanager, nullcontext # --------------------------------------------------------------------------- @@ -906,3 +909,1130 @@ def _convert(node): return _convert_signed_num(node) return _convert(node_or_string) + + +# --------------------------------------------------------------------------- +# unparse() — AST -> source. Verbatim port of CPython 3.13's `_Unparser` +# (the `@_simple_enum(IntEnum)` optimization on `_Precedence` is expanded to +# a plain `IntEnum` subclass, which WeavePy's `enum` supports). +# --------------------------------------------------------------------------- + + +# Large float and imaginary literals get turned into infinities in the AST. +# We unparse those infinities to INFSTR. +_INFSTR = "1e" + repr(sys.float_info.max_10_exp + 1) + +class _Precedence(IntEnum): + """Precedence table that originated from python grammar.""" + + NAMED_EXPR = auto() # := + TUPLE = auto() # , + YIELD = auto() # 'yield', 'yield from' + TEST = auto() # 'if'-'else', 'lambda' + OR = auto() # 'or' + AND = auto() # 'and' + NOT = auto() # 'not' + CMP = auto() # '<', '>', '==', '>=', '<=', '!=', + # 'in', 'not in', 'is', 'is not' + EXPR = auto() + BOR = EXPR # '|' + BXOR = auto() # '^' + BAND = auto() # '&' + SHIFT = auto() # '<<', '>>' + ARITH = auto() # '+', '-' + TERM = auto() # '*', '@', '/', '%', '//' + FACTOR = auto() # unary '+', '-', '~' + POWER = auto() # '**' + AWAIT = auto() # 'await' + ATOM = auto() + + def next(self): + try: + return self.__class__(self + 1) + except ValueError: + return self + + +_SINGLE_QUOTES = ("'", '"') +_MULTI_QUOTES = ('"""', "'''") +_ALL_QUOTES = (*_SINGLE_QUOTES, *_MULTI_QUOTES) + +class _Unparser(NodeVisitor): + """Methods in this class recursively traverse an AST and + output source code for the abstract syntax; original formatting + is disregarded.""" + + def __init__(self): + self._source = [] + self._precedences = {} + self._type_ignores = {} + self._indent = 0 + self._in_try_star = False + + def interleave(self, inter, f, seq): + """Call f on each item in seq, calling inter() in between.""" + seq = iter(seq) + try: + f(next(seq)) + except StopIteration: + pass + else: + for x in seq: + inter() + f(x) + + def items_view(self, traverser, items): + """Traverse and separate the given *items* with a comma and append it to + the buffer. If *items* is a single item sequence, a trailing comma + will be added.""" + if len(items) == 1: + traverser(items[0]) + self.write(",") + else: + self.interleave(lambda: self.write(", "), traverser, items) + + def maybe_newline(self): + """Adds a newline if it isn't the start of generated source""" + if self._source: + self.write("\n") + + def fill(self, text=""): + """Indent a piece of text and append it, according to the current + indentation level""" + self.maybe_newline() + self.write(" " * self._indent + text) + + def write(self, *text): + """Add new source parts""" + self._source.extend(text) + + @contextmanager + def buffered(self, buffer = None): + if buffer is None: + buffer = [] + + original_source = self._source + self._source = buffer + yield buffer + self._source = original_source + + @contextmanager + def block(self, *, extra = None): + """A context manager for preparing the source for blocks. It adds + the character':', increases the indentation on enter and decreases + the indentation on exit. If *extra* is given, it will be directly + appended after the colon character. + """ + self.write(":") + if extra: + self.write(extra) + self._indent += 1 + yield + self._indent -= 1 + + @contextmanager + def delimit(self, start, end): + """A context manager for preparing the source for expressions. It adds + *start* to the buffer and enters, after exit it adds *end*.""" + + self.write(start) + yield + self.write(end) + + def delimit_if(self, start, end, condition): + if condition: + return self.delimit(start, end) + else: + return nullcontext() + + def require_parens(self, precedence, node): + """Shortcut to adding precedence related parens""" + return self.delimit_if("(", ")", self.get_precedence(node) > precedence) + + def get_precedence(self, node): + return self._precedences.get(node, _Precedence.TEST) + + def set_precedence(self, precedence, *nodes): + for node in nodes: + self._precedences[node] = precedence + + def get_raw_docstring(self, node): + """If a docstring node is found in the body of the *node* parameter, + return that docstring node, None otherwise. + + Logic mirrored from ``_PyAST_GetDocString``.""" + if not isinstance( + node, (AsyncFunctionDef, FunctionDef, ClassDef, Module) + ) or len(node.body) < 1: + return None + node = node.body[0] + if not isinstance(node, Expr): + return None + node = node.value + if isinstance(node, Constant) and isinstance(node.value, str): + return node + + def get_type_comment(self, node): + comment = self._type_ignores.get(node.lineno) or node.type_comment + if comment is not None: + return f" # type: {comment}" + + def traverse(self, node): + if isinstance(node, list): + for item in node: + self.traverse(item) + else: + super().visit(node) + + # Note: as visit() resets the output text, do NOT rely on + # NodeVisitor.generic_visit to handle any nodes (as it calls back in to + # the subclass visit() method, which resets self._source to an empty list) + def visit(self, node): + """Outputs a source code string that, if converted back to an ast + (using ast.parse) will generate an AST equivalent to *node*""" + self._source = [] + self.traverse(node) + return "".join(self._source) + + def _write_docstring_and_traverse_body(self, node): + if (docstring := self.get_raw_docstring(node)): + self._write_docstring(docstring) + self.traverse(node.body[1:]) + else: + self.traverse(node.body) + + def visit_Module(self, node): + self._type_ignores = { + ignore.lineno: f"ignore{ignore.tag}" + for ignore in node.type_ignores + } + self._write_docstring_and_traverse_body(node) + self._type_ignores.clear() + + def visit_FunctionType(self, node): + with self.delimit("(", ")"): + self.interleave( + lambda: self.write(", "), self.traverse, node.argtypes + ) + + self.write(" -> ") + self.traverse(node.returns) + + def visit_Expr(self, node): + self.fill() + self.set_precedence(_Precedence.YIELD, node.value) + self.traverse(node.value) + + def visit_NamedExpr(self, node): + with self.require_parens(_Precedence.NAMED_EXPR, node): + self.set_precedence(_Precedence.ATOM, node.target, node.value) + self.traverse(node.target) + self.write(" := ") + self.traverse(node.value) + + def visit_Import(self, node): + self.fill("import ") + self.interleave(lambda: self.write(", "), self.traverse, node.names) + + def visit_ImportFrom(self, node): + self.fill("from ") + self.write("." * (node.level or 0)) + if node.module: + self.write(node.module) + self.write(" import ") + self.interleave(lambda: self.write(", "), self.traverse, node.names) + + def visit_Assign(self, node): + self.fill() + for target in node.targets: + self.set_precedence(_Precedence.TUPLE, target) + self.traverse(target) + self.write(" = ") + self.traverse(node.value) + if type_comment := self.get_type_comment(node): + self.write(type_comment) + + def visit_AugAssign(self, node): + self.fill() + self.traverse(node.target) + self.write(" " + self.binop[node.op.__class__.__name__] + "= ") + self.traverse(node.value) + + def visit_AnnAssign(self, node): + self.fill() + with self.delimit_if("(", ")", not node.simple and isinstance(node.target, Name)): + self.traverse(node.target) + self.write(": ") + self.traverse(node.annotation) + if node.value: + self.write(" = ") + self.traverse(node.value) + + def visit_Return(self, node): + self.fill("return") + if node.value: + self.write(" ") + self.traverse(node.value) + + def visit_Pass(self, node): + self.fill("pass") + + def visit_Break(self, node): + self.fill("break") + + def visit_Continue(self, node): + self.fill("continue") + + def visit_Delete(self, node): + self.fill("del ") + self.interleave(lambda: self.write(", "), self.traverse, node.targets) + + def visit_Assert(self, node): + self.fill("assert ") + self.traverse(node.test) + if node.msg: + self.write(", ") + self.traverse(node.msg) + + def visit_Global(self, node): + self.fill("global ") + self.interleave(lambda: self.write(", "), self.write, node.names) + + def visit_Nonlocal(self, node): + self.fill("nonlocal ") + self.interleave(lambda: self.write(", "), self.write, node.names) + + def visit_Await(self, node): + with self.require_parens(_Precedence.AWAIT, node): + self.write("await") + if node.value: + self.write(" ") + self.set_precedence(_Precedence.ATOM, node.value) + self.traverse(node.value) + + def visit_Yield(self, node): + with self.require_parens(_Precedence.YIELD, node): + self.write("yield") + if node.value: + self.write(" ") + self.set_precedence(_Precedence.ATOM, node.value) + self.traverse(node.value) + + def visit_YieldFrom(self, node): + with self.require_parens(_Precedence.YIELD, node): + self.write("yield from ") + if not node.value: + raise ValueError("Node can't be used without a value attribute.") + self.set_precedence(_Precedence.ATOM, node.value) + self.traverse(node.value) + + def visit_Raise(self, node): + self.fill("raise") + if not node.exc: + if node.cause: + raise ValueError(f"Node can't use cause without an exception.") + return + self.write(" ") + self.traverse(node.exc) + if node.cause: + self.write(" from ") + self.traverse(node.cause) + + def do_visit_try(self, node): + self.fill("try") + with self.block(): + self.traverse(node.body) + for ex in node.handlers: + self.traverse(ex) + if node.orelse: + self.fill("else") + with self.block(): + self.traverse(node.orelse) + if node.finalbody: + self.fill("finally") + with self.block(): + self.traverse(node.finalbody) + + def visit_Try(self, node): + prev_in_try_star = self._in_try_star + try: + self._in_try_star = False + self.do_visit_try(node) + finally: + self._in_try_star = prev_in_try_star + + def visit_TryStar(self, node): + prev_in_try_star = self._in_try_star + try: + self._in_try_star = True + self.do_visit_try(node) + finally: + self._in_try_star = prev_in_try_star + + def visit_ExceptHandler(self, node): + self.fill("except*" if self._in_try_star else "except") + if node.type: + self.write(" ") + self.traverse(node.type) + if node.name: + self.write(" as ") + self.write(node.name) + with self.block(): + self.traverse(node.body) + + def visit_ClassDef(self, node): + self.maybe_newline() + for deco in node.decorator_list: + self.fill("@") + self.traverse(deco) + self.fill("class " + node.name) + if hasattr(node, "type_params"): + self._type_params_helper(node.type_params) + with self.delimit_if("(", ")", condition = node.bases or node.keywords): + comma = False + for e in node.bases: + if comma: + self.write(", ") + else: + comma = True + self.traverse(e) + for e in node.keywords: + if comma: + self.write(", ") + else: + comma = True + self.traverse(e) + + with self.block(): + self._write_docstring_and_traverse_body(node) + + def visit_FunctionDef(self, node): + self._function_helper(node, "def") + + def visit_AsyncFunctionDef(self, node): + self._function_helper(node, "async def") + + def _function_helper(self, node, fill_suffix): + self.maybe_newline() + for deco in node.decorator_list: + self.fill("@") + self.traverse(deco) + def_str = fill_suffix + " " + node.name + self.fill(def_str) + if hasattr(node, "type_params"): + self._type_params_helper(node.type_params) + with self.delimit("(", ")"): + self.traverse(node.args) + if node.returns: + self.write(" -> ") + self.traverse(node.returns) + with self.block(extra=self.get_type_comment(node)): + self._write_docstring_and_traverse_body(node) + + def _type_params_helper(self, type_params): + if type_params is not None and len(type_params) > 0: + with self.delimit("[", "]"): + self.interleave(lambda: self.write(", "), self.traverse, type_params) + + def visit_TypeVar(self, node): + self.write(node.name) + if node.bound: + self.write(": ") + self.traverse(node.bound) + if node.default_value: + self.write(" = ") + self.traverse(node.default_value) + + def visit_TypeVarTuple(self, node): + self.write("*" + node.name) + if node.default_value: + self.write(" = ") + self.traverse(node.default_value) + + def visit_ParamSpec(self, node): + self.write("**" + node.name) + if node.default_value: + self.write(" = ") + self.traverse(node.default_value) + + def visit_TypeAlias(self, node): + self.fill("type ") + self.traverse(node.name) + self._type_params_helper(node.type_params) + self.write(" = ") + self.traverse(node.value) + + def visit_For(self, node): + self._for_helper("for ", node) + + def visit_AsyncFor(self, node): + self._for_helper("async for ", node) + + def _for_helper(self, fill, node): + self.fill(fill) + self.set_precedence(_Precedence.TUPLE, node.target) + self.traverse(node.target) + self.write(" in ") + self.traverse(node.iter) + with self.block(extra=self.get_type_comment(node)): + self.traverse(node.body) + if node.orelse: + self.fill("else") + with self.block(): + self.traverse(node.orelse) + + def visit_If(self, node): + self.fill("if ") + self.traverse(node.test) + with self.block(): + self.traverse(node.body) + # collapse nested ifs into equivalent elifs. + while node.orelse and len(node.orelse) == 1 and isinstance(node.orelse[0], If): + node = node.orelse[0] + self.fill("elif ") + self.traverse(node.test) + with self.block(): + self.traverse(node.body) + # final else + if node.orelse: + self.fill("else") + with self.block(): + self.traverse(node.orelse) + + def visit_While(self, node): + self.fill("while ") + self.traverse(node.test) + with self.block(): + self.traverse(node.body) + if node.orelse: + self.fill("else") + with self.block(): + self.traverse(node.orelse) + + def visit_With(self, node): + self.fill("with ") + self.interleave(lambda: self.write(", "), self.traverse, node.items) + with self.block(extra=self.get_type_comment(node)): + self.traverse(node.body) + + def visit_AsyncWith(self, node): + self.fill("async with ") + self.interleave(lambda: self.write(", "), self.traverse, node.items) + with self.block(extra=self.get_type_comment(node)): + self.traverse(node.body) + + def _str_literal_helper( + self, string, *, quote_types=_ALL_QUOTES, escape_special_whitespace=False + ): + """Helper for writing string literals, minimizing escapes. + Returns the tuple (string literal to write, possible quote types). + """ + def escape_char(c): + # \n and \t are non-printable, but we only escape them if + # escape_special_whitespace is True + if not escape_special_whitespace and c in "\n\t": + return c + # Always escape backslashes and other non-printable characters + if c == "\\" or not c.isprintable(): + return c.encode("unicode_escape").decode("ascii") + return c + + escaped_string = "".join(map(escape_char, string)) + possible_quotes = quote_types + if "\n" in escaped_string: + possible_quotes = [q for q in possible_quotes if q in _MULTI_QUOTES] + possible_quotes = [q for q in possible_quotes if q not in escaped_string] + if not possible_quotes: + # If there aren't any possible_quotes, fallback to using repr + # on the original string. Try to use a quote from quote_types, + # e.g., so that we use triple quotes for docstrings. + string = repr(string) + quote = next((q for q in quote_types if string[0] in q), string[0]) + return string[1:-1], [quote] + if escaped_string: + # Sort so that we prefer '''"''' over """\"""" + possible_quotes.sort(key=lambda q: q[0] == escaped_string[-1]) + # If we're using triple quotes and we'd need to escape a final + # quote, escape it + if possible_quotes[0][0] == escaped_string[-1]: + assert len(possible_quotes[0]) == 3 + escaped_string = escaped_string[:-1] + "\\" + escaped_string[-1] + return escaped_string, possible_quotes + + def _write_str_avoiding_backslashes(self, string, *, quote_types=_ALL_QUOTES): + """Write string literal value with a best effort attempt to avoid backslashes.""" + string, quote_types = self._str_literal_helper(string, quote_types=quote_types) + quote_type = quote_types[0] + self.write(f"{quote_type}{string}{quote_type}") + + def visit_JoinedStr(self, node): + self.write("f") + + fstring_parts = [] + for value in node.values: + with self.buffered() as buffer: + self._write_fstring_inner(value) + fstring_parts.append( + ("".join(buffer), isinstance(value, Constant)) + ) + + new_fstring_parts = [] + quote_types = list(_ALL_QUOTES) + fallback_to_repr = False + for value, is_constant in fstring_parts: + if is_constant: + value, new_quote_types = self._str_literal_helper( + value, + quote_types=quote_types, + escape_special_whitespace=True, + ) + if set(new_quote_types).isdisjoint(quote_types): + fallback_to_repr = True + break + quote_types = new_quote_types + else: + if "\n" in value: + quote_types = [q for q in quote_types if q in _MULTI_QUOTES] + assert quote_types + + new_quote_types = [q for q in quote_types if q not in value] + if new_quote_types: + quote_types = new_quote_types + new_fstring_parts.append(value) + + if fallback_to_repr: + # If we weren't able to find a quote type that works for all parts + # of the JoinedStr, fallback to using repr and triple single quotes. + quote_types = ["'''"] + new_fstring_parts.clear() + for value, is_constant in fstring_parts: + if is_constant: + value = repr('"' + value) # force repr to use single quotes + expected_prefix = "'\"" + assert value.startswith(expected_prefix), repr(value) + value = value[len(expected_prefix):-1] + new_fstring_parts.append(value) + + value = "".join(new_fstring_parts) + quote_type = quote_types[0] + self.write(f"{quote_type}{value}{quote_type}") + + def _write_fstring_inner(self, node, is_format_spec=False): + if isinstance(node, JoinedStr): + # for both the f-string itself, and format_spec + for value in node.values: + self._write_fstring_inner(value, is_format_spec=is_format_spec) + elif isinstance(node, Constant) and isinstance(node.value, str): + value = node.value.replace("{", "{{").replace("}", "}}") + + if is_format_spec: + value = value.replace("\\", "\\\\") + value = value.replace("'", "\\'") + value = value.replace('"', '\\"') + value = value.replace("\n", "\\n") + self.write(value) + elif isinstance(node, FormattedValue): + self.visit_FormattedValue(node) + else: + raise ValueError(f"Unexpected node inside JoinedStr, {node!r}") + + def visit_FormattedValue(self, node): + def unparse_inner(inner): + unparser = type(self)() + unparser.set_precedence(_Precedence.TEST.next(), inner) + return unparser.visit(inner) + + with self.delimit("{", "}"): + expr = unparse_inner(node.value) + if expr.startswith("{"): + # Separate pair of opening brackets as "{ {" + self.write(" ") + self.write(expr) + if node.conversion != -1: + self.write(f"!{chr(node.conversion)}") + if node.format_spec: + self.write(":") + self._write_fstring_inner(node.format_spec, is_format_spec=True) + + def visit_Name(self, node): + self.write(node.id) + + def _write_docstring(self, node): + self.fill() + if node.kind == "u": + self.write("u") + self._write_str_avoiding_backslashes(node.value, quote_types=_MULTI_QUOTES) + + def _write_constant(self, value): + if isinstance(value, (float, complex)): + # Substitute overflowing decimal literal for AST infinities, + # and inf - inf for NaNs. + self.write( + repr(value) + .replace("inf", _INFSTR) + .replace("nan", f"({_INFSTR}-{_INFSTR})") + ) + else: + self.write(repr(value)) + + def visit_Constant(self, node): + value = node.value + if isinstance(value, tuple): + with self.delimit("(", ")"): + self.items_view(self._write_constant, value) + elif value is ...: + self.write("...") + else: + if node.kind == "u": + self.write("u") + self._write_constant(node.value) + + def visit_List(self, node): + with self.delimit("[", "]"): + self.interleave(lambda: self.write(", "), self.traverse, node.elts) + + def visit_ListComp(self, node): + with self.delimit("[", "]"): + self.traverse(node.elt) + for gen in node.generators: + self.traverse(gen) + + def visit_GeneratorExp(self, node): + with self.delimit("(", ")"): + self.traverse(node.elt) + for gen in node.generators: + self.traverse(gen) + + def visit_SetComp(self, node): + with self.delimit("{", "}"): + self.traverse(node.elt) + for gen in node.generators: + self.traverse(gen) + + def visit_DictComp(self, node): + with self.delimit("{", "}"): + self.traverse(node.key) + self.write(": ") + self.traverse(node.value) + for gen in node.generators: + self.traverse(gen) + + def visit_comprehension(self, node): + if node.is_async: + self.write(" async for ") + else: + self.write(" for ") + self.set_precedence(_Precedence.TUPLE, node.target) + self.traverse(node.target) + self.write(" in ") + self.set_precedence(_Precedence.TEST.next(), node.iter, *node.ifs) + self.traverse(node.iter) + for if_clause in node.ifs: + self.write(" if ") + self.traverse(if_clause) + + def visit_IfExp(self, node): + with self.require_parens(_Precedence.TEST, node): + self.set_precedence(_Precedence.TEST.next(), node.body, node.test) + self.traverse(node.body) + self.write(" if ") + self.traverse(node.test) + self.write(" else ") + self.set_precedence(_Precedence.TEST, node.orelse) + self.traverse(node.orelse) + + def visit_Set(self, node): + if node.elts: + with self.delimit("{", "}"): + self.interleave(lambda: self.write(", "), self.traverse, node.elts) + else: + # `{}` would be interpreted as a dictionary literal, and + # `set` might be shadowed. Thus: + self.write('{*()}') + + def visit_Dict(self, node): + def write_key_value_pair(k, v): + self.traverse(k) + self.write(": ") + self.traverse(v) + + def write_item(item): + k, v = item + if k is None: + # for dictionary unpacking operator in dicts {**{'y': 2}} + # see PEP 448 for details + self.write("**") + self.set_precedence(_Precedence.EXPR, v) + self.traverse(v) + else: + write_key_value_pair(k, v) + + with self.delimit("{", "}"): + self.interleave( + lambda: self.write(", "), write_item, zip(node.keys, node.values) + ) + + def visit_Tuple(self, node): + with self.delimit_if( + "(", + ")", + len(node.elts) == 0 or self.get_precedence(node) > _Precedence.TUPLE + ): + self.items_view(self.traverse, node.elts) + + unop = {"Invert": "~", "Not": "not", "UAdd": "+", "USub": "-"} + unop_precedence = { + "not": _Precedence.NOT, + "~": _Precedence.FACTOR, + "+": _Precedence.FACTOR, + "-": _Precedence.FACTOR, + } + + def visit_UnaryOp(self, node): + operator = self.unop[node.op.__class__.__name__] + operator_precedence = self.unop_precedence[operator] + with self.require_parens(operator_precedence, node): + self.write(operator) + # factor prefixes (+, -, ~) shouldn't be separated + # from the value they belong, (e.g: +1 instead of + 1) + if operator_precedence is not _Precedence.FACTOR: + self.write(" ") + self.set_precedence(operator_precedence, node.operand) + self.traverse(node.operand) + + binop = { + "Add": "+", + "Sub": "-", + "Mult": "*", + "MatMult": "@", + "Div": "/", + "Mod": "%", + "LShift": "<<", + "RShift": ">>", + "BitOr": "|", + "BitXor": "^", + "BitAnd": "&", + "FloorDiv": "//", + "Pow": "**", + } + + binop_precedence = { + "+": _Precedence.ARITH, + "-": _Precedence.ARITH, + "*": _Precedence.TERM, + "@": _Precedence.TERM, + "/": _Precedence.TERM, + "%": _Precedence.TERM, + "<<": _Precedence.SHIFT, + ">>": _Precedence.SHIFT, + "|": _Precedence.BOR, + "^": _Precedence.BXOR, + "&": _Precedence.BAND, + "//": _Precedence.TERM, + "**": _Precedence.POWER, + } + + binop_rassoc = frozenset(("**",)) + def visit_BinOp(self, node): + operator = self.binop[node.op.__class__.__name__] + operator_precedence = self.binop_precedence[operator] + with self.require_parens(operator_precedence, node): + if operator in self.binop_rassoc: + left_precedence = operator_precedence.next() + right_precedence = operator_precedence + else: + left_precedence = operator_precedence + right_precedence = operator_precedence.next() + + self.set_precedence(left_precedence, node.left) + self.traverse(node.left) + self.write(f" {operator} ") + self.set_precedence(right_precedence, node.right) + self.traverse(node.right) + + cmpops = { + "Eq": "==", + "NotEq": "!=", + "Lt": "<", + "LtE": "<=", + "Gt": ">", + "GtE": ">=", + "Is": "is", + "IsNot": "is not", + "In": "in", + "NotIn": "not in", + } + + def visit_Compare(self, node): + with self.require_parens(_Precedence.CMP, node): + self.set_precedence(_Precedence.CMP.next(), node.left, *node.comparators) + self.traverse(node.left) + for o, e in zip(node.ops, node.comparators): + self.write(" " + self.cmpops[o.__class__.__name__] + " ") + self.traverse(e) + + boolops = {"And": "and", "Or": "or"} + boolop_precedence = {"and": _Precedence.AND, "or": _Precedence.OR} + + def visit_BoolOp(self, node): + operator = self.boolops[node.op.__class__.__name__] + operator_precedence = self.boolop_precedence[operator] + + def increasing_level_traverse(node): + nonlocal operator_precedence + operator_precedence = operator_precedence.next() + self.set_precedence(operator_precedence, node) + self.traverse(node) + + with self.require_parens(operator_precedence, node): + s = f" {operator} " + self.interleave(lambda: self.write(s), increasing_level_traverse, node.values) + + def visit_Attribute(self, node): + self.set_precedence(_Precedence.ATOM, node.value) + self.traverse(node.value) + # Special case: 3.__abs__() is a syntax error, so if node.value + # is an integer literal then we need to either parenthesize + # it or add an extra space to get 3 .__abs__(). + if isinstance(node.value, Constant) and isinstance(node.value.value, int): + self.write(" ") + self.write(".") + self.write(node.attr) + + def visit_Call(self, node): + self.set_precedence(_Precedence.ATOM, node.func) + self.traverse(node.func) + with self.delimit("(", ")"): + comma = False + for e in node.args: + if comma: + self.write(", ") + else: + comma = True + self.traverse(e) + for e in node.keywords: + if comma: + self.write(", ") + else: + comma = True + self.traverse(e) + + def visit_Subscript(self, node): + def is_non_empty_tuple(slice_value): + return ( + isinstance(slice_value, Tuple) + and slice_value.elts + ) + + self.set_precedence(_Precedence.ATOM, node.value) + self.traverse(node.value) + with self.delimit("[", "]"): + if is_non_empty_tuple(node.slice): + # parentheses can be omitted if the tuple isn't empty + self.items_view(self.traverse, node.slice.elts) + else: + self.traverse(node.slice) + + def visit_Starred(self, node): + self.write("*") + self.set_precedence(_Precedence.EXPR, node.value) + self.traverse(node.value) + + def visit_Ellipsis(self, node): + self.write("...") + + def visit_Slice(self, node): + if node.lower: + self.traverse(node.lower) + self.write(":") + if node.upper: + self.traverse(node.upper) + if node.step: + self.write(":") + self.traverse(node.step) + + def visit_Match(self, node): + self.fill("match ") + self.traverse(node.subject) + with self.block(): + for case in node.cases: + self.traverse(case) + + def visit_arg(self, node): + self.write(node.arg) + if node.annotation: + self.write(": ") + self.traverse(node.annotation) + + def visit_arguments(self, node): + first = True + # normal arguments + all_args = node.posonlyargs + node.args + defaults = [None] * (len(all_args) - len(node.defaults)) + node.defaults + for index, elements in enumerate(zip(all_args, defaults), 1): + a, d = elements + if first: + first = False + else: + self.write(", ") + self.traverse(a) + if d: + self.write("=") + self.traverse(d) + if index == len(node.posonlyargs): + self.write(", /") + + # varargs, or bare '*' if no varargs but keyword-only arguments present + if node.vararg or node.kwonlyargs: + if first: + first = False + else: + self.write(", ") + self.write("*") + if node.vararg: + self.write(node.vararg.arg) + if node.vararg.annotation: + self.write(": ") + self.traverse(node.vararg.annotation) + + # keyword-only arguments + if node.kwonlyargs: + for a, d in zip(node.kwonlyargs, node.kw_defaults): + self.write(", ") + self.traverse(a) + if d: + self.write("=") + self.traverse(d) + + # kwargs + if node.kwarg: + if first: + first = False + else: + self.write(", ") + self.write("**" + node.kwarg.arg) + if node.kwarg.annotation: + self.write(": ") + self.traverse(node.kwarg.annotation) + + def visit_keyword(self, node): + if node.arg is None: + self.write("**") + else: + self.write(node.arg) + self.write("=") + self.traverse(node.value) + + def visit_Lambda(self, node): + with self.require_parens(_Precedence.TEST, node): + self.write("lambda") + with self.buffered() as buffer: + self.traverse(node.args) + if buffer: + self.write(" ", *buffer) + self.write(": ") + self.set_precedence(_Precedence.TEST, node.body) + self.traverse(node.body) + + def visit_alias(self, node): + self.write(node.name) + if node.asname: + self.write(" as " + node.asname) + + def visit_withitem(self, node): + self.traverse(node.context_expr) + if node.optional_vars: + self.write(" as ") + self.traverse(node.optional_vars) + + def visit_match_case(self, node): + self.fill("case ") + self.traverse(node.pattern) + if node.guard: + self.write(" if ") + self.traverse(node.guard) + with self.block(): + self.traverse(node.body) + + def visit_MatchValue(self, node): + self.traverse(node.value) + + def visit_MatchSingleton(self, node): + self._write_constant(node.value) + + def visit_MatchSequence(self, node): + with self.delimit("[", "]"): + self.interleave( + lambda: self.write(", "), self.traverse, node.patterns + ) + + def visit_MatchStar(self, node): + name = node.name + if name is None: + name = "_" + self.write(f"*{name}") + + def visit_MatchMapping(self, node): + def write_key_pattern_pair(pair): + k, p = pair + self.traverse(k) + self.write(": ") + self.traverse(p) + + with self.delimit("{", "}"): + keys = node.keys + self.interleave( + lambda: self.write(", "), + write_key_pattern_pair, + zip(keys, node.patterns, strict=True), + ) + rest = node.rest + if rest is not None: + if keys: + self.write(", ") + self.write(f"**{rest}") + + def visit_MatchClass(self, node): + self.set_precedence(_Precedence.ATOM, node.cls) + self.traverse(node.cls) + with self.delimit("(", ")"): + patterns = node.patterns + self.interleave( + lambda: self.write(", "), self.traverse, patterns + ) + attrs = node.kwd_attrs + if attrs: + def write_attr_pattern(pair): + attr, pattern = pair + self.write(f"{attr}=") + self.traverse(pattern) + + if patterns: + self.write(", ") + self.interleave( + lambda: self.write(", "), + write_attr_pattern, + zip(attrs, node.kwd_patterns, strict=True), + ) + + def visit_MatchAs(self, node): + name = node.name + pattern = node.pattern + if name is None: + self.write("_") + elif pattern is None: + self.write(node.name) + else: + with self.require_parens(_Precedence.TEST, node): + self.set_precedence(_Precedence.BOR, node.pattern) + self.traverse(node.pattern) + self.write(f" as {node.name}") + + def visit_MatchOr(self, node): + with self.require_parens(_Precedence.BOR, node): + self.set_precedence(_Precedence.BOR.next(), *node.patterns) + self.interleave(lambda: self.write(" | "), self.traverse, node.patterns) + +def unparse(ast_obj): + unparser = _Unparser() + return unparser.visit(ast_obj) diff --git a/crates/weavepy-vm/src/stdlib/python/contextvars.py b/crates/weavepy-vm/src/stdlib/python/contextvars.py index 9173e90..dca91fc 100644 --- a/crates/weavepy-vm/src/stdlib/python/contextvars.py +++ b/crates/weavepy-vm/src/stdlib/python/contextvars.py @@ -13,6 +13,10 @@ __all__ = ["ContextVar", "Context", "Token", "copy_context"] +# `ContextVar[int]` yields a `types.GenericAlias` (CPython exposes this on +# the C `ContextVar`). `types` only imports `sys`, so this is safe here. +from types import GenericAlias as _GenericAlias + _MISSING = object() @@ -46,6 +50,8 @@ class ContextVar: __slots__ = ("_name", "_default", "_id") + __class_getitem__ = classmethod(_GenericAlias) + _counter = 0 def __init__(self, name, *, default=_MISSING): diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/__init__.py b/crates/weavepy-vm/src/stdlib/python/ctypes/__init__.py new file mode 100644 index 0000000..7fc1181 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/__init__.py @@ -0,0 +1,602 @@ +"""create and manipulate C data types in Python""" + +import os as _os +import sys as _sys +import sysconfig as _sysconfig +import types as _types + +__version__ = "1.1.0" + +from _ctypes import Union, Structure, Array +from _ctypes import _Pointer +from _ctypes import CFuncPtr as _CFuncPtr +from _ctypes import __version__ as _ctypes_version +from _ctypes import RTLD_LOCAL, RTLD_GLOBAL +from _ctypes import ArgumentError +from _ctypes import SIZEOF_TIME_T + +from struct import calcsize as _calcsize + +if __version__ != _ctypes_version: + raise Exception("Version number mismatch", __version__, _ctypes_version) + +if _os.name == "nt": + from _ctypes import FormatError + +DEFAULT_MODE = RTLD_LOCAL +if _os.name == "posix" and _sys.platform == "darwin": + # On OS X 10.3, we use RTLD_GLOBAL as default mode + # because RTLD_LOCAL does not work at least on some + # libraries. OS X 10.3 is Darwin 7, so we check for + # that. + + if int(_os.uname().release.split('.')[0]) < 8: + DEFAULT_MODE = RTLD_GLOBAL + +from _ctypes import FUNCFLAG_CDECL as _FUNCFLAG_CDECL, \ + FUNCFLAG_PYTHONAPI as _FUNCFLAG_PYTHONAPI, \ + FUNCFLAG_USE_ERRNO as _FUNCFLAG_USE_ERRNO, \ + FUNCFLAG_USE_LASTERROR as _FUNCFLAG_USE_LASTERROR + +# WINOLEAPI -> HRESULT +# WINOLEAPI_(type) +# +# STDMETHODCALLTYPE +# +# STDMETHOD(name) +# STDMETHOD_(type, name) +# +# STDAPICALLTYPE + +def create_string_buffer(init, size=None): + """create_string_buffer(aBytes) -> character array + create_string_buffer(anInteger) -> character array + create_string_buffer(aBytes, anInteger) -> character array + """ + if isinstance(init, bytes): + if size is None: + size = len(init)+1 + _sys.audit("ctypes.create_string_buffer", init, size) + buftype = c_char * size + buf = buftype() + buf.value = init + return buf + elif isinstance(init, int): + _sys.audit("ctypes.create_string_buffer", None, init) + buftype = c_char * init + buf = buftype() + return buf + raise TypeError(init) + +# Alias to create_string_buffer() for backward compatibility +c_buffer = create_string_buffer + +_c_functype_cache = {} +def CFUNCTYPE(restype, *argtypes, **kw): + """CFUNCTYPE(restype, *argtypes, + use_errno=False, use_last_error=False) -> function prototype. + + restype: the result type + argtypes: a sequence specifying the argument types + + The function prototype can be called in different ways to create a + callable object: + + prototype(integer address) -> foreign function + prototype(callable) -> create and return a C callable function from callable + prototype(integer index, method name[, paramflags]) -> foreign function calling a COM method + prototype((ordinal number, dll object)[, paramflags]) -> foreign function exported by ordinal + prototype((function name, dll object)[, paramflags]) -> foreign function exported by name + """ + flags = _FUNCFLAG_CDECL + if kw.pop("use_errno", False): + flags |= _FUNCFLAG_USE_ERRNO + if kw.pop("use_last_error", False): + flags |= _FUNCFLAG_USE_LASTERROR + if kw: + raise ValueError("unexpected keyword argument(s) %s" % kw.keys()) + + try: + return _c_functype_cache[(restype, argtypes, flags)] + except KeyError: + pass + + class CFunctionType(_CFuncPtr): + _argtypes_ = argtypes + _restype_ = restype + _flags_ = flags + _c_functype_cache[(restype, argtypes, flags)] = CFunctionType + return CFunctionType + +if _os.name == "nt": + from _ctypes import LoadLibrary as _LoadLibrary + from _ctypes import FUNCFLAG_STDCALL as _FUNCFLAG_STDCALL + + _win_functype_cache = {} + def WINFUNCTYPE(restype, *argtypes, **kw): + # docstring set later (very similar to CFUNCTYPE.__doc__) + flags = _FUNCFLAG_STDCALL + if kw.pop("use_errno", False): + flags |= _FUNCFLAG_USE_ERRNO + if kw.pop("use_last_error", False): + flags |= _FUNCFLAG_USE_LASTERROR + if kw: + raise ValueError("unexpected keyword argument(s) %s" % kw.keys()) + + try: + return _win_functype_cache[(restype, argtypes, flags)] + except KeyError: + pass + + class WinFunctionType(_CFuncPtr): + _argtypes_ = argtypes + _restype_ = restype + _flags_ = flags + _win_functype_cache[(restype, argtypes, flags)] = WinFunctionType + return WinFunctionType + if WINFUNCTYPE.__doc__: + WINFUNCTYPE.__doc__ = CFUNCTYPE.__doc__.replace("CFUNCTYPE", "WINFUNCTYPE") + +elif _os.name == "posix": + from _ctypes import dlopen as _dlopen + +from _ctypes import sizeof, byref, addressof, alignment, resize +from _ctypes import get_errno, set_errno +from _ctypes import _SimpleCData + +def _check_size(typ, typecode=None): + # Check if sizeof(ctypes_type) against struct.calcsize. This + # should protect somewhat against a misconfigured libffi. + from struct import calcsize + if typecode is None: + # Most _type_ codes are the same as used in struct + typecode = typ._type_ + actual, required = sizeof(typ), calcsize(typecode) + if actual != required: + raise SystemError("sizeof(%s) wrong: %d instead of %d" % \ + (typ, actual, required)) + +class py_object(_SimpleCData): + _type_ = "O" + def __repr__(self): + try: + return super().__repr__() + except ValueError: + return "%s()" % type(self).__name__ +_check_size(py_object, "P") + +class c_short(_SimpleCData): + _type_ = "h" +_check_size(c_short) + +class c_ushort(_SimpleCData): + _type_ = "H" +_check_size(c_ushort) + +class c_long(_SimpleCData): + _type_ = "l" +_check_size(c_long) + +class c_ulong(_SimpleCData): + _type_ = "L" +_check_size(c_ulong) + +if _calcsize("i") == _calcsize("l"): + # if int and long have the same size, make c_int an alias for c_long + c_int = c_long + c_uint = c_ulong +else: + class c_int(_SimpleCData): + _type_ = "i" + _check_size(c_int) + + class c_uint(_SimpleCData): + _type_ = "I" + _check_size(c_uint) + +class c_float(_SimpleCData): + _type_ = "f" +_check_size(c_float) + +class c_double(_SimpleCData): + _type_ = "d" +_check_size(c_double) + +class c_longdouble(_SimpleCData): + _type_ = "g" +if sizeof(c_longdouble) == sizeof(c_double): + c_longdouble = c_double + +if _calcsize("l") == _calcsize("q"): + # if long and long long have the same size, make c_longlong an alias for c_long + c_longlong = c_long + c_ulonglong = c_ulong +else: + class c_longlong(_SimpleCData): + _type_ = "q" + _check_size(c_longlong) + + class c_ulonglong(_SimpleCData): + _type_ = "Q" + ## def from_param(cls, val): + ## return ('d', float(val), val) + ## from_param = classmethod(from_param) + _check_size(c_ulonglong) + +class c_ubyte(_SimpleCData): + _type_ = "B" +c_ubyte.__ctype_le__ = c_ubyte.__ctype_be__ = c_ubyte +# backward compatibility: +##c_uchar = c_ubyte +_check_size(c_ubyte) + +class c_byte(_SimpleCData): + _type_ = "b" +c_byte.__ctype_le__ = c_byte.__ctype_be__ = c_byte +_check_size(c_byte) + +class c_char(_SimpleCData): + _type_ = "c" +c_char.__ctype_le__ = c_char.__ctype_be__ = c_char +_check_size(c_char) + +class c_char_p(_SimpleCData): + _type_ = "z" + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, c_void_p.from_buffer(self).value) +_check_size(c_char_p, "P") + +class c_void_p(_SimpleCData): + _type_ = "P" +c_voidp = c_void_p # backwards compatibility (to a bug) +_check_size(c_void_p) + +class c_bool(_SimpleCData): + _type_ = "?" + +from _ctypes import POINTER, pointer, _pointer_type_cache + +class c_wchar_p(_SimpleCData): + _type_ = "Z" + def __repr__(self): + return "%s(%s)" % (self.__class__.__name__, c_void_p.from_buffer(self).value) + +class c_wchar(_SimpleCData): + _type_ = "u" + +def _reset_cache(): + _pointer_type_cache.clear() + _c_functype_cache.clear() + if _os.name == "nt": + _win_functype_cache.clear() + # _SimpleCData.c_wchar_p_from_param + POINTER(c_wchar).from_param = c_wchar_p.from_param + # _SimpleCData.c_char_p_from_param + POINTER(c_char).from_param = c_char_p.from_param + _pointer_type_cache[None] = c_void_p + +def create_unicode_buffer(init, size=None): + """create_unicode_buffer(aString) -> character array + create_unicode_buffer(anInteger) -> character array + create_unicode_buffer(aString, anInteger) -> character array + """ + if isinstance(init, str): + if size is None: + if sizeof(c_wchar) == 2: + # UTF-16 requires a surrogate pair (2 wchar_t) for non-BMP + # characters (outside [U+0000; U+FFFF] range). +1 for trailing + # NUL character. + size = sum(2 if ord(c) > 0xFFFF else 1 for c in init) + 1 + else: + # 32-bit wchar_t (1 wchar_t per Unicode character). +1 for + # trailing NUL character. + size = len(init) + 1 + _sys.audit("ctypes.create_unicode_buffer", init, size) + buftype = c_wchar * size + buf = buftype() + buf.value = init + return buf + elif isinstance(init, int): + _sys.audit("ctypes.create_unicode_buffer", None, init) + buftype = c_wchar * init + buf = buftype() + return buf + raise TypeError(init) + + +def SetPointerType(pointer, cls): + import warnings + warnings._deprecated("ctypes.SetPointerType", remove=(3, 15)) + if _pointer_type_cache.get(cls, None) is not None: + raise RuntimeError("This type already exists in the cache") + if id(pointer) not in _pointer_type_cache: + raise RuntimeError("What's this???") + pointer.set_type(cls) + _pointer_type_cache[cls] = pointer + del _pointer_type_cache[id(pointer)] + +def ARRAY(typ, len): + return typ * len + +################################################################ + + +class CDLL(object): + """An instance of this class represents a loaded dll/shared + library, exporting functions using the standard C calling + convention (named 'cdecl' on Windows). + + The exported functions can be accessed as attributes, or by + indexing with the function name. Examples: + + .qsort -> callable object + ['qsort'] -> callable object + + Calling the functions releases the Python GIL during the call and + reacquires it afterwards. + """ + _func_flags_ = _FUNCFLAG_CDECL + _func_restype_ = c_int + # default values for repr + _name = '' + _handle = 0 + _FuncPtr = None + + def __init__(self, name, mode=DEFAULT_MODE, handle=None, + use_errno=False, + use_last_error=False, + winmode=None): + class _FuncPtr(_CFuncPtr): + _flags_ = self._func_flags_ + _restype_ = self._func_restype_ + if use_errno: + _flags_ |= _FUNCFLAG_USE_ERRNO + if use_last_error: + _flags_ |= _FUNCFLAG_USE_LASTERROR + + self._FuncPtr = _FuncPtr + if name: + name = _os.fspath(name) + + self._handle = self._load_library(name, mode, handle, winmode) + + if _os.name == "nt": + def _load_library(self, name, mode, handle, winmode): + if winmode is None: + import nt as _nt + winmode = _nt._LOAD_LIBRARY_SEARCH_DEFAULT_DIRS + # WINAPI LoadLibrary searches for a DLL if the given name + # is not fully qualified with an explicit drive. For POSIX + # compatibility, and because the DLL search path no longer + # contains the working directory, begin by fully resolving + # any name that contains a path separator. + if name is not None and ('/' in name or '\\' in name): + name = _nt._getfullpathname(name) + winmode |= _nt._LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR + self._name = name + if handle is not None: + return handle + return _LoadLibrary(self._name, winmode) + + else: + def _load_library(self, name, mode, handle, winmode): + # If the filename that has been provided is an iOS/tvOS/watchOS + # .fwork file, dereference the location to the true origin of the + # binary. + if name and name.endswith(".fwork"): + with open(name) as f: + name = _os.path.join( + _os.path.dirname(_sys.executable), + f.read().strip() + ) + if _sys.platform.startswith("aix"): + """When the name contains ".a(" and ends with ")", + e.g., "libFOO.a(libFOO.so)" - this is taken to be an + archive(member) syntax for dlopen(), and the mode is adjusted. + Otherwise, name is presented to dlopen() as a file argument. + """ + if name and name.endswith(")") and ".a(" in name: + mode |= _os.RTLD_MEMBER | _os.RTLD_NOW + self._name = name + if handle is not None: + return handle + return _dlopen(name, mode) + + def __repr__(self): + return "<%s '%s', handle %x at %#x>" % \ + (self.__class__.__name__, self._name, + (self._handle & (_sys.maxsize*2 + 1)), + id(self) & (_sys.maxsize*2 + 1)) + + def __getattr__(self, name): + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + func = self.__getitem__(name) + setattr(self, name, func) + return func + + def __getitem__(self, name_or_ordinal): + func = self._FuncPtr((name_or_ordinal, self)) + if not isinstance(name_or_ordinal, int): + func.__name__ = name_or_ordinal + return func + +class PyDLL(CDLL): + """This class represents the Python library itself. It allows + accessing Python API functions. The GIL is not released, and + Python exceptions are handled correctly. + """ + _func_flags_ = _FUNCFLAG_CDECL | _FUNCFLAG_PYTHONAPI + +if _os.name == "nt": + + class WinDLL(CDLL): + """This class represents a dll exporting functions using the + Windows stdcall calling convention. + """ + _func_flags_ = _FUNCFLAG_STDCALL + + # XXX Hm, what about HRESULT as normal parameter? + # Mustn't it derive from c_long then? + from _ctypes import _check_HRESULT, _SimpleCData + class HRESULT(_SimpleCData): + _type_ = "l" + # _check_retval_ is called with the function's result when it + # is used as restype. It checks for the FAILED bit, and + # raises an OSError if it is set. + # + # The _check_retval_ method is implemented in C, so that the + # method definition itself is not included in the traceback + # when it raises an error - that is what we want (and Python + # doesn't have a way to raise an exception in the caller's + # frame). + _check_retval_ = _check_HRESULT + + class OleDLL(CDLL): + """This class represents a dll exporting functions using the + Windows stdcall calling convention, and returning HRESULT. + HRESULT error values are automatically raised as OSError + exceptions. + """ + _func_flags_ = _FUNCFLAG_STDCALL + _func_restype_ = HRESULT + +class LibraryLoader(object): + def __init__(self, dlltype): + self._dlltype = dlltype + + def __getattr__(self, name): + if name[0] == '_': + raise AttributeError(name) + try: + dll = self._dlltype(name) + except OSError: + raise AttributeError(name) + setattr(self, name, dll) + return dll + + def __getitem__(self, name): + return getattr(self, name) + + def LoadLibrary(self, name): + return self._dlltype(name) + + __class_getitem__ = classmethod(_types.GenericAlias) + +cdll = LibraryLoader(CDLL) +pydll = LibraryLoader(PyDLL) + +if _os.name == "nt": + pythonapi = PyDLL("python dll", None, _sys.dllhandle) +elif _sys.platform in ["android", "cygwin"]: + # These are Unix-like platforms which use a dynamically-linked libpython. + pythonapi = PyDLL(_sysconfig.get_config_var("LDLIBRARY")) +else: + pythonapi = PyDLL(None) + + +if _os.name == "nt": + windll = LibraryLoader(WinDLL) + oledll = LibraryLoader(OleDLL) + + GetLastError = windll.kernel32.GetLastError + from _ctypes import get_last_error, set_last_error + + def WinError(code=None, descr=None): + if code is None: + code = GetLastError() + if descr is None: + descr = FormatError(code).strip() + return OSError(None, descr, None, code) + +if sizeof(c_uint) == sizeof(c_void_p): + c_size_t = c_uint + c_ssize_t = c_int +elif sizeof(c_ulong) == sizeof(c_void_p): + c_size_t = c_ulong + c_ssize_t = c_long +elif sizeof(c_ulonglong) == sizeof(c_void_p): + c_size_t = c_ulonglong + c_ssize_t = c_longlong + +# functions + +from _ctypes import _memmove_addr, _memset_addr, _string_at_addr, _cast_addr + +## void *memmove(void *, const void *, size_t); +memmove = CFUNCTYPE(c_void_p, c_void_p, c_void_p, c_size_t)(_memmove_addr) + +## void *memset(void *, int, size_t) +memset = CFUNCTYPE(c_void_p, c_void_p, c_int, c_size_t)(_memset_addr) + +def PYFUNCTYPE(restype, *argtypes): + class CFunctionType(_CFuncPtr): + _argtypes_ = argtypes + _restype_ = restype + _flags_ = _FUNCFLAG_CDECL | _FUNCFLAG_PYTHONAPI + return CFunctionType + +_cast = PYFUNCTYPE(py_object, c_void_p, py_object, py_object)(_cast_addr) +def cast(obj, typ): + return _cast(obj, obj, typ) + +_string_at = PYFUNCTYPE(py_object, c_void_p, c_int)(_string_at_addr) +def string_at(ptr, size=-1): + """string_at(ptr[, size]) -> string + + Return the byte string at void *ptr.""" + return _string_at(ptr, size) + +try: + from _ctypes import _wstring_at_addr +except ImportError: + pass +else: + _wstring_at = PYFUNCTYPE(py_object, c_void_p, c_int)(_wstring_at_addr) + def wstring_at(ptr, size=-1): + """wstring_at(ptr[, size]) -> string + + Return the wide-character string at void *ptr.""" + return _wstring_at(ptr, size) + + +if _os.name == "nt": # COM stuff + def DllGetClassObject(rclsid, riid, ppv): + try: + ccom = __import__("comtypes.server.inprocserver", globals(), locals(), ['*']) + except ImportError: + return -2147221231 # CLASS_E_CLASSNOTAVAILABLE + else: + return ccom.DllGetClassObject(rclsid, riid, ppv) + + def DllCanUnloadNow(): + try: + ccom = __import__("comtypes.server.inprocserver", globals(), locals(), ['*']) + except ImportError: + return 0 # S_OK + return ccom.DllCanUnloadNow() + +from ctypes._endian import BigEndianStructure, LittleEndianStructure +from ctypes._endian import BigEndianUnion, LittleEndianUnion + +# Fill in specifically-sized types +c_int8 = c_byte +c_uint8 = c_ubyte +for kind in [c_short, c_int, c_long, c_longlong]: + if sizeof(kind) == 2: c_int16 = kind + elif sizeof(kind) == 4: c_int32 = kind + elif sizeof(kind) == 8: c_int64 = kind +for kind in [c_ushort, c_uint, c_ulong, c_ulonglong]: + if sizeof(kind) == 2: c_uint16 = kind + elif sizeof(kind) == 4: c_uint32 = kind + elif sizeof(kind) == 8: c_uint64 = kind +del(kind) + +if SIZEOF_TIME_T == 8: + c_time_t = c_int64 +elif SIZEOF_TIME_T == 4: + c_time_t = c_int32 +else: + raise SystemError(f"Unexpected sizeof(time_t): {SIZEOF_TIME_T=}") + +_reset_cache() diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/_aix.py b/crates/weavepy-vm/src/stdlib/python/ctypes/_aix.py new file mode 100644 index 0000000..ee790f7 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/_aix.py @@ -0,0 +1,327 @@ +""" +Lib/ctypes.util.find_library() support for AIX +Similar approach as done for Darwin support by using separate files +but unlike Darwin - no extension such as ctypes.macholib.* + +dlopen() is an interface to AIX initAndLoad() - primary documentation at: +https://www.ibm.com/support/knowledgecenter/en/ssw_aix_61/com.ibm.aix.basetrf1/dlopen.htm +https://www.ibm.com/support/knowledgecenter/en/ssw_aix_61/com.ibm.aix.basetrf1/load.htm + +AIX supports two styles for dlopen(): svr4 (System V Release 4) which is common on posix +platforms, but also a BSD style - aka SVR3. + +From AIX 5.3 Difference Addendum (December 2004) +2.9 SVR4 linking affinity +Nowadays, there are two major object file formats used by the operating systems: +XCOFF: The COFF enhanced by IBM and others. The original COFF (Common +Object File Format) was the base of SVR3 and BSD 4.2 systems. +ELF: Executable and Linking Format that was developed by AT&T and is a +base for SVR4 UNIX. + +While the shared library content is identical on AIX - one is located as a filepath name +(svr4 style) and the other is located as a member of an archive (and the archive +is located as a filepath name). + +The key difference arises when supporting multiple abi formats (i.e., 32 and 64 bit). +For svr4 either only one ABI is supported, or there are two directories, or there +are different file names. The most common solution for multiple ABI is multiple +directories. + +For the XCOFF (aka AIX) style - one directory (one archive file) is sufficient +as multiple shared libraries can be in the archive - even sharing the same name. +In documentation the archive is also referred to as the "base" and the shared +library object is referred to as the "member". + +For dlopen() on AIX (read initAndLoad()) the calls are similar. +Default activity occurs when no path information is provided. When path +information is provided dlopen() does not search any other directories. + +For SVR4 - the shared library name is the name of the file expected: libFOO.so +For AIX - the shared library is expressed as base(member). The search is for the +base (e.g., libFOO.a) and once the base is found the shared library - identified by +member (e.g., libFOO.so, or shr.o) is located and loaded. + +The mode bit RTLD_MEMBER tells initAndLoad() that it needs to use the AIX (SVR3) +naming style. +""" +__author__ = "Michael Felt " + +import re +from os import environ, path +from sys import executable +from ctypes import c_void_p, sizeof +from subprocess import Popen, PIPE, DEVNULL + +# Executable bit size - 32 or 64 +# Used to filter the search in an archive by size, e.g., -X64 +AIX_ABI = sizeof(c_void_p) * 8 + + +from sys import maxsize +def _last_version(libnames, sep): + def _num_version(libname): + # "libxyz.so.MAJOR.MINOR" => [MAJOR, MINOR] + parts = libname.split(sep) + nums = [] + try: + while parts: + nums.insert(0, int(parts.pop())) + except ValueError: + pass + return nums or [maxsize] + return max(reversed(libnames), key=_num_version) + +def get_ld_header(p): + # "nested-function, but placed at module level + ld_header = None + for line in p.stdout: + if line.startswith(('/', './', '../')): + ld_header = line + elif "INDEX" in line: + return ld_header.rstrip('\n') + return None + +def get_ld_header_info(p): + # "nested-function, but placed at module level + # as an ld_header was found, return known paths, archives and members + # these lines start with a digit + info = [] + for line in p.stdout: + if re.match("[0-9]", line): + info.append(line) + else: + # blank line (separator), consume line and end for loop + break + return info + +def get_ld_headers(file): + """ + Parse the header of the loader section of executable and archives + This function calls /usr/bin/dump -H as a subprocess + and returns a list of (ld_header, ld_header_info) tuples. + """ + # get_ld_headers parsing: + # 1. Find a line that starts with /, ./, or ../ - set as ld_header + # 2. If "INDEX" in occurs in a following line - return ld_header + # 3. get info (lines starting with [0-9]) + ldr_headers = [] + p = Popen(["/usr/bin/dump", f"-X{AIX_ABI}", "-H", file], + universal_newlines=True, stdout=PIPE, stderr=DEVNULL) + # be sure to read to the end-of-file - getting all entries + while ld_header := get_ld_header(p): + ldr_headers.append((ld_header, get_ld_header_info(p))) + p.stdout.close() + p.wait() + return ldr_headers + +def get_shared(ld_headers): + """ + extract the shareable objects from ld_headers + character "[" is used to strip off the path information. + Note: the "[" and "]" characters that are part of dump -H output + are not removed here. + """ + shared = [] + for (line, _) in ld_headers: + # potential member lines contain "[" + # otherwise, no processing needed + if "[" in line: + # Strip off trailing colon (:) + shared.append(line[line.index("["):-1]) + return shared + +def get_one_match(expr, lines): + """ + Must be only one match, otherwise result is None. + When there is a match, strip leading "[" and trailing "]" + """ + # member names in the ld_headers output are between square brackets + expr = rf'\[({expr})\]' + matches = list(filter(None, (re.search(expr, line) for line in lines))) + if len(matches) == 1: + return matches[0].group(1) + else: + return None + +# additional processing to deal with AIX legacy names for 64-bit members +def get_legacy(members): + """ + This routine provides historical aka legacy naming schemes started + in AIX4 shared library support for library members names. + e.g., in /usr/lib/libc.a the member name shr.o for 32-bit binary and + shr_64.o for 64-bit binary. + """ + if AIX_ABI == 64: + # AIX 64-bit member is one of shr64.o, shr_64.o, or shr4_64.o + expr = r'shr4?_?64\.o' + member = get_one_match(expr, members) + if member: + return member + else: + # 32-bit legacy names - both shr.o and shr4.o exist. + # shr.o is the preferred name so we look for shr.o first + # i.e., shr4.o is returned only when shr.o does not exist + for name in ['shr.o', 'shr4.o']: + member = get_one_match(re.escape(name), members) + if member: + return member + return None + +def get_version(name, members): + """ + Sort list of members and return highest numbered version - if it exists. + This function is called when an unversioned libFOO.a(libFOO.so) has + not been found. + + Versioning for the member name is expected to follow + GNU LIBTOOL conventions: the highest version (x, then X.y, then X.Y.z) + * find [libFoo.so.X] + * find [libFoo.so.X.Y] + * find [libFoo.so.X.Y.Z] + + Before the GNU convention became the standard scheme regardless of + binary size AIX packagers used GNU convention "as-is" for 32-bit + archive members but used an "distinguishing" name for 64-bit members. + This scheme inserted either 64 or _64 between libFOO and .so + - generally libFOO_64.so, but occasionally libFOO64.so + """ + # the expression ending for versions must start as + # '.so.[0-9]', i.e., *.so.[at least one digit] + # while multiple, more specific expressions could be specified + # to search for .so.X, .so.X.Y and .so.X.Y.Z + # after the first required 'dot' digit + # any combination of additional 'dot' digits pairs are accepted + # anything more than libFOO.so.digits.digits.digits + # should be seen as a member name outside normal expectations + exprs = [rf'lib{name}\.so\.[0-9]+[0-9.]*', + rf'lib{name}_?64\.so\.[0-9]+[0-9.]*'] + for expr in exprs: + versions = [] + for line in members: + m = re.search(expr, line) + if m: + versions.append(m.group(0)) + if versions: + return _last_version(versions, '.') + return None + +def get_member(name, members): + """ + Return an archive member matching the request in name. + Name is the library name without any prefix like lib, suffix like .so, + or version number. + Given a list of members find and return the most appropriate result + Priority is given to generic libXXX.so, then a versioned libXXX.so.a.b.c + and finally, legacy AIX naming scheme. + """ + # look first for a generic match - prepend lib and append .so + expr = rf'lib{name}\.so' + member = get_one_match(expr, members) + if member: + return member + elif AIX_ABI == 64: + expr = rf'lib{name}64\.so' + member = get_one_match(expr, members) + if member: + return member + # since an exact match with .so as suffix was not found + # look for a versioned name + # If a versioned name is not found, look for AIX legacy member name + member = get_version(name, members) + if member: + return member + else: + return get_legacy(members) + +def get_libpaths(): + """ + On AIX, the buildtime searchpath is stored in the executable. + as "loader header information". + The command /usr/bin/dump -H extracts this info. + Prefix searched libraries with LD_LIBRARY_PATH (preferred), + or LIBPATH if defined. These paths are appended to the paths + to libraries the python executable is linked with. + This mimics AIX dlopen() behavior. + """ + libpaths = environ.get("LD_LIBRARY_PATH") + if libpaths is None: + libpaths = environ.get("LIBPATH") + if libpaths is None: + libpaths = [] + else: + libpaths = libpaths.split(":") + objects = get_ld_headers(executable) + for (_, lines) in objects: + for line in lines: + # the second (optional) argument is PATH if it includes a / + path = line.split()[1] + if "/" in path: + libpaths.extend(path.split(":")) + return libpaths + +def find_shared(paths, name): + """ + paths is a list of directories to search for an archive. + name is the abbreviated name given to find_library(). + Process: search "paths" for archive, and if an archive is found + return the result of get_member(). + If an archive is not found then return None + """ + for dir in paths: + # /lib is a symbolic link to /usr/lib, skip it + if dir == "/lib": + continue + # "lib" is prefixed to emulate compiler name resolution, + # e.g., -lc to libc + base = f'lib{name}.a' + archive = path.join(dir, base) + if path.exists(archive): + members = get_shared(get_ld_headers(archive)) + member = get_member(re.escape(name), members) + if member is not None: + return (base, member) + else: + return (None, None) + return (None, None) + +def find_library(name): + """AIX implementation of ctypes.util.find_library() + Find an archive member that will dlopen(). If not available, + also search for a file (or link) with a .so suffix. + + AIX supports two types of schemes that can be used with dlopen(). + The so-called SystemV Release4 (svr4) format is commonly suffixed + with .so while the (default) AIX scheme has the library (archive) + ending with the suffix .a + As an archive has multiple members (e.g., 32-bit and 64-bit) in one file + the argument passed to dlopen must include both the library and + the member names in a single string. + + find_library() looks first for an archive (.a) with a suitable member. + If no archive+member pair is found, look for a .so file. + """ + + libpaths = get_libpaths() + (base, member) = find_shared(libpaths, name) + if base is not None: + return f"{base}({member})" + + # To get here, a member in an archive has not been found + # In other words, either: + # a) a .a file was not found + # b) a .a file did not have a suitable member + # So, look for a .so file + # Check libpaths for .so file + # Note, the installation must prepare a link from a .so + # to a versioned file + # This is common practice by GNU libtool on other platforms + soname = f"lib{name}.so" + for dir in libpaths: + # /lib is a symbolic link to /usr/lib, skip it + if dir == "/lib": + continue + shlib = path.join(dir, soname) + if path.exists(shlib): + return soname + # if we are here, we have not found anything plausible + return None diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/_endian.py b/crates/weavepy-vm/src/stdlib/python/ctypes/_endian.py new file mode 100644 index 0000000..6382dd2 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/_endian.py @@ -0,0 +1,78 @@ +import sys +from ctypes import Array, Structure, Union + +_array_type = type(Array) + +def _other_endian(typ): + """Return the type with the 'other' byte order. Simple types like + c_int and so on already have __ctype_be__ and __ctype_le__ + attributes which contain the types, for more complicated types + arrays and structures are supported. + """ + # check _OTHER_ENDIAN attribute (present if typ is primitive type) + if hasattr(typ, _OTHER_ENDIAN): + return getattr(typ, _OTHER_ENDIAN) + # if typ is array + if isinstance(typ, _array_type): + return _other_endian(typ._type_) * typ._length_ + # if typ is structure or union + if issubclass(typ, (Structure, Union)): + return typ + raise TypeError("This type does not support other endian: %s" % typ) + +class _swapped_meta: + def __setattr__(self, attrname, value): + if attrname == "_fields_": + fields = [] + for desc in value: + name = desc[0] + typ = desc[1] + rest = desc[2:] + fields.append((name, _other_endian(typ)) + rest) + value = fields + super().__setattr__(attrname, value) +class _swapped_struct_meta(_swapped_meta, type(Structure)): pass +class _swapped_union_meta(_swapped_meta, type(Union)): pass + +################################################################ + +# Note: The Structure metaclass checks for the *presence* (not the +# value!) of a _swappedbytes_ attribute to determine the bit order in +# structures containing bit fields. + +if sys.byteorder == "little": + _OTHER_ENDIAN = "__ctype_be__" + + LittleEndianStructure = Structure + + class BigEndianStructure(Structure, metaclass=_swapped_struct_meta): + """Structure with big endian byte order""" + __slots__ = () + _swappedbytes_ = None + + LittleEndianUnion = Union + + class BigEndianUnion(Union, metaclass=_swapped_union_meta): + """Union with big endian byte order""" + __slots__ = () + _swappedbytes_ = None + +elif sys.byteorder == "big": + _OTHER_ENDIAN = "__ctype_le__" + + BigEndianStructure = Structure + + class LittleEndianStructure(Structure, metaclass=_swapped_struct_meta): + """Structure with little endian byte order""" + __slots__ = () + _swappedbytes_ = None + + BigEndianUnion = Union + + class LittleEndianUnion(Union, metaclass=_swapped_union_meta): + """Union with little endian byte order""" + __slots__ = () + _swappedbytes_ = None + +else: + raise RuntimeError("Invalid byteorder") diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/__init__.py b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/__init__.py new file mode 100644 index 0000000..5621def --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/__init__.py @@ -0,0 +1,9 @@ +""" +Enough Mach-O to make your head spin. + +See the relevant header files in /usr/include/mach-o + +And also Apple's documentation. +""" + +__version__ = '1.0' diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dyld.py b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dyld.py new file mode 100644 index 0000000..c9ae6cb --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dyld.py @@ -0,0 +1,165 @@ +""" +dyld emulation +""" + +import os +from ctypes.macholib.framework import framework_info +from ctypes.macholib.dylib import dylib_info +from itertools import * +try: + from _ctypes import _dyld_shared_cache_contains_path +except ImportError: + def _dyld_shared_cache_contains_path(*args): + raise NotImplementedError + +__all__ = [ + 'dyld_find', 'framework_find', + 'framework_info', 'dylib_info', +] + +# These are the defaults as per man dyld(1) +# +DEFAULT_FRAMEWORK_FALLBACK = [ '/opt/homebrew/Frameworks', + os.path.expanduser("~/Library/Frameworks"), + "/Library/Frameworks", + "/Network/Library/Frameworks", + "/System/Library/Frameworks", +] + +DEFAULT_LIBRARY_FALLBACK = [ '/opt/homebrew/lib', '/opt/homebrew/opt/openssl@3/lib', + os.path.expanduser("~/lib"), + "/usr/local/lib", + "/lib", + "/usr/lib", +] + +def dyld_env(env, var): + if env is None: + env = os.environ + rval = env.get(var) + if rval is None: + return [] + return rval.split(':') + +def dyld_image_suffix(env=None): + if env is None: + env = os.environ + return env.get('DYLD_IMAGE_SUFFIX') + +def dyld_framework_path(env=None): + return dyld_env(env, 'DYLD_FRAMEWORK_PATH') + +def dyld_library_path(env=None): + return dyld_env(env, 'DYLD_LIBRARY_PATH') + +def dyld_fallback_framework_path(env=None): + return dyld_env(env, 'DYLD_FALLBACK_FRAMEWORK_PATH') + +def dyld_fallback_library_path(env=None): + return dyld_env(env, 'DYLD_FALLBACK_LIBRARY_PATH') + +def dyld_image_suffix_search(iterator, env=None): + """For a potential path iterator, add DYLD_IMAGE_SUFFIX semantics""" + suffix = dyld_image_suffix(env) + if suffix is None: + return iterator + def _inject(iterator=iterator, suffix=suffix): + for path in iterator: + if path.endswith('.dylib'): + yield path[:-len('.dylib')] + suffix + '.dylib' + else: + yield path + suffix + yield path + return _inject() + +def dyld_override_search(name, env=None): + # If DYLD_FRAMEWORK_PATH is set and this dylib_name is a + # framework name, use the first file that exists in the framework + # path if any. If there is none go on to search the DYLD_LIBRARY_PATH + # if any. + + framework = framework_info(name) + + if framework is not None: + for path in dyld_framework_path(env): + yield os.path.join(path, framework['name']) + + # If DYLD_LIBRARY_PATH is set then use the first file that exists + # in the path. If none use the original name. + for path in dyld_library_path(env): + yield os.path.join(path, os.path.basename(name)) + +def dyld_executable_path_search(name, executable_path=None): + # If we haven't done any searching and found a library and the + # dylib_name starts with "@executable_path/" then construct the + # library name. + if name.startswith('@executable_path/') and executable_path is not None: + yield os.path.join(executable_path, name[len('@executable_path/'):]) + +def dyld_default_search(name, env=None): + yield name + + framework = framework_info(name) + + if framework is not None: + fallback_framework_path = dyld_fallback_framework_path(env) + for path in fallback_framework_path: + yield os.path.join(path, framework['name']) + + fallback_library_path = dyld_fallback_library_path(env) + for path in fallback_library_path: + yield os.path.join(path, os.path.basename(name)) + + if framework is not None and not fallback_framework_path: + for path in DEFAULT_FRAMEWORK_FALLBACK: + yield os.path.join(path, framework['name']) + + if not fallback_library_path: + for path in DEFAULT_LIBRARY_FALLBACK: + yield os.path.join(path, os.path.basename(name)) + +def dyld_find(name, executable_path=None, env=None): + """ + Find a library or framework using dyld semantics + """ + for path in dyld_image_suffix_search(chain( + dyld_override_search(name, env), + dyld_executable_path_search(name, executable_path), + dyld_default_search(name, env), + ), env): + + if os.path.isfile(path): + return path + try: + if _dyld_shared_cache_contains_path(path): + return path + except NotImplementedError: + pass + + raise ValueError("dylib %s could not be found" % (name,)) + +def framework_find(fn, executable_path=None, env=None): + """ + Find a framework using dyld semantics in a very loose manner. + + Will take input such as: + Python + Python.framework + Python.framework/Versions/Current + """ + error = None + try: + return dyld_find(fn, executable_path=executable_path, env=env) + except ValueError as e: + error = e + fmwk_index = fn.rfind('.framework') + if fmwk_index == -1: + fmwk_index = len(fn) + fn += '.framework' + fn = os.path.join(fn, os.path.basename(fn[:fmwk_index])) + try: + return dyld_find(fn, executable_path=executable_path, env=env) + except ValueError: + raise error + finally: + error = None diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dylib.py b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dylib.py new file mode 100644 index 0000000..0ad4cba --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/dylib.py @@ -0,0 +1,42 @@ +""" +Generic dylib path manipulation +""" + +import re + +__all__ = ['dylib_info'] + +DYLIB_RE = re.compile(r"""(?x) +(?P^.*)(?:^|/) +(?P + (?P\w+?) + (?:\.(?P[^._]+))? + (?:_(?P[^._]+))? + \.dylib$ +) +""") + +def dylib_info(filename): + """ + A dylib name can take one of the following four forms: + Location/Name.SomeVersion_Suffix.dylib + Location/Name.SomeVersion.dylib + Location/Name_Suffix.dylib + Location/Name.dylib + + returns None if not found or a mapping equivalent to: + dict( + location='Location', + name='Name.SomeVersion_Suffix.dylib', + shortname='Name', + version='SomeVersion', + suffix='Suffix', + ) + + Note that SomeVersion and Suffix are optional and may be None + if not present. + """ + is_dylib = DYLIB_RE.match(filename) + if not is_dylib: + return None + return is_dylib.groupdict() diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/framework.py b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/framework.py new file mode 100644 index 0000000..495679f --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/macholib/framework.py @@ -0,0 +1,42 @@ +""" +Generic framework path manipulation +""" + +import re + +__all__ = ['framework_info'] + +STRICT_FRAMEWORK_RE = re.compile(r"""(?x) +(?P^.*)(?:^|/) +(?P + (?P\w+).framework/ + (?:Versions/(?P[^/]+)/)? + (?P=shortname) + (?:_(?P[^_]+))? +)$ +""") + +def framework_info(filename): + """ + A framework name can take one of the following four forms: + Location/Name.framework/Versions/SomeVersion/Name_Suffix + Location/Name.framework/Versions/SomeVersion/Name + Location/Name.framework/Name_Suffix + Location/Name.framework/Name + + returns None if not found, or a mapping equivalent to: + dict( + location='Location', + name='Name.framework/Versions/SomeVersion/Name_Suffix', + shortname='Name', + version='SomeVersion', + suffix='Suffix', + ) + + Note that SomeVersion and Suffix are optional and may be None + if not present + """ + is_framework = STRICT_FRAMEWORK_RE.match(filename) + if not is_framework: + return None + return is_framework.groupdict() diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/util.py b/crates/weavepy-vm/src/stdlib/python/ctypes/util.py new file mode 100644 index 0000000..117bf06 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/util.py @@ -0,0 +1,388 @@ +import os +import shutil +import subprocess +import sys + +# find_library(name) returns the pathname of a library, or None. +if os.name == "nt": + + def _get_build_version(): + """Return the version of MSVC that was used to build Python. + + For Python 2.3 and up, the version number is included in + sys.version. For earlier versions, assume the compiler is MSVC 6. + """ + # This function was copied from Lib/distutils/msvccompiler.py + prefix = "MSC v." + i = sys.version.find(prefix) + if i == -1: + return 6 + i = i + len(prefix) + s, rest = sys.version[i:].split(" ", 1) + majorVersion = int(s[:-2]) - 6 + if majorVersion >= 13: + majorVersion += 1 + minorVersion = int(s[2:3]) / 10.0 + # I don't think paths are affected by minor version in version 6 + if majorVersion == 6: + minorVersion = 0 + if majorVersion >= 6: + return majorVersion + minorVersion + # else we don't know what version of the compiler this is + return None + + def find_msvcrt(): + """Return the name of the VC runtime dll""" + version = _get_build_version() + if version is None: + # better be safe than sorry + return None + if version <= 6: + clibname = 'msvcrt' + elif version <= 13: + clibname = 'msvcr%d' % (version * 10) + else: + # CRT is no longer directly loadable. See issue23606 for the + # discussion about alternative approaches. + return None + + # If python was built with in debug mode + import importlib.machinery + if '_d.pyd' in importlib.machinery.EXTENSION_SUFFIXES: + clibname += 'd' + return clibname+'.dll' + + def find_library(name): + if name in ('c', 'm'): + return find_msvcrt() + # See MSDN for the REAL search order. + for directory in os.environ['PATH'].split(os.pathsep): + fname = os.path.join(directory, name) + if os.path.isfile(fname): + return fname + if fname.lower().endswith(".dll"): + continue + fname = fname + ".dll" + if os.path.isfile(fname): + return fname + return None + +elif os.name == "posix" and sys.platform in {"darwin", "ios", "tvos", "watchos"}: + from ctypes.macholib.dyld import dyld_find as _dyld_find + def find_library(name): + possible = ['lib%s.dylib' % name, + '%s.dylib' % name, + '%s.framework/%s' % (name, name)] + for name in possible: + try: + return _dyld_find(name) + except ValueError: + continue + return None + +elif sys.platform.startswith("aix"): + # AIX has two styles of storing shared libraries + # GNU auto_tools refer to these as svr4 and aix + # svr4 (System V Release 4) is a regular file, often with .so as suffix + # AIX style uses an archive (suffix .a) with members (e.g., shr.o, libssl.so) + # see issue#26439 and _aix.py for more details + + from ctypes._aix import find_library + +elif sys.platform == "android": + def find_library(name): + directory = "/system/lib" + if "64" in os.uname().machine: + directory += "64" + + fname = f"{directory}/lib{name}.so" + return fname if os.path.isfile(fname) else None + +elif os.name == "posix": + # Andreas Degert's find functions, using gcc, /sbin/ldconfig, objdump + import re, tempfile + + def _is_elf(filename): + "Return True if the given file is an ELF file" + elf_header = b'\x7fELF' + try: + with open(filename, 'br') as thefile: + return thefile.read(4) == elf_header + except FileNotFoundError: + return False + + def _findLib_gcc(name): + # Run GCC's linker with the -t (aka --trace) option and examine the + # library name it prints out. The GCC command will fail because we + # haven't supplied a proper program with main(), but that does not + # matter. + expr = os.fsencode(r'[^\(\)\s]*lib%s\.[^\(\)\s]*' % re.escape(name)) + + c_compiler = shutil.which('gcc') + if not c_compiler: + c_compiler = shutil.which('cc') + if not c_compiler: + # No C compiler available, give up + return None + + temp = tempfile.NamedTemporaryFile() + try: + args = [c_compiler, '-Wl,-t', '-o', temp.name, '-l' + name] + + env = dict(os.environ) + env['LC_ALL'] = 'C' + env['LANG'] = 'C' + try: + proc = subprocess.Popen(args, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env) + except OSError: # E.g. bad executable + return None + with proc: + trace = proc.stdout.read() + finally: + try: + temp.close() + except FileNotFoundError: + # Raised if the file was already removed, which is the normal + # behaviour of GCC if linking fails + pass + res = re.findall(expr, trace) + if not res: + return None + + for file in res: + # Check if the given file is an elf file: gcc can report + # some files that are linker scripts and not actual + # shared objects. See bpo-41976 for more details + if not _is_elf(file): + continue + return os.fsdecode(file) + + + if sys.platform == "sunos5": + # use /usr/ccs/bin/dump on solaris + def _get_soname(f): + if not f: + return None + + try: + proc = subprocess.Popen(("/usr/ccs/bin/dump", "-Lpv", f), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + except OSError: # E.g. command not found + return None + with proc: + data = proc.stdout.read() + res = re.search(br'\[.*\]\sSONAME\s+([^\s]+)', data) + if not res: + return None + return os.fsdecode(res.group(1)) + else: + def _get_soname(f): + # assuming GNU binutils / ELF + if not f: + return None + objdump = shutil.which('objdump') + if not objdump: + # objdump is not available, give up + return None + + try: + proc = subprocess.Popen((objdump, '-p', '-j', '.dynamic', f), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + except OSError: # E.g. bad executable + return None + with proc: + dump = proc.stdout.read() + res = re.search(br'\sSONAME\s+([^\s]+)', dump) + if not res: + return None + return os.fsdecode(res.group(1)) + + if sys.platform.startswith(("freebsd", "openbsd", "dragonfly")): + + def _num_version(libname): + # "libxyz.so.MAJOR.MINOR" => [ MAJOR, MINOR ] + parts = libname.split(b".") + nums = [] + try: + while parts: + nums.insert(0, int(parts.pop())) + except ValueError: + pass + return nums or [sys.maxsize] + + def find_library(name): + ename = re.escape(name) + expr = r':-l%s\.\S+ => \S*/(lib%s\.\S+)' % (ename, ename) + expr = os.fsencode(expr) + + try: + proc = subprocess.Popen(('/sbin/ldconfig', '-r'), + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + except OSError: # E.g. command not found + data = b'' + else: + with proc: + data = proc.stdout.read() + + res = re.findall(expr, data) + if not res: + return _get_soname(_findLib_gcc(name)) + res.sort(key=_num_version) + return os.fsdecode(res[-1]) + + elif sys.platform == "sunos5": + + def _findLib_crle(name, is64): + if not os.path.exists('/usr/bin/crle'): + return None + + env = dict(os.environ) + env['LC_ALL'] = 'C' + + if is64: + args = ('/usr/bin/crle', '-64') + else: + args = ('/usr/bin/crle',) + + paths = None + try: + proc = subprocess.Popen(args, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env) + except OSError: # E.g. bad executable + return None + with proc: + for line in proc.stdout: + line = line.strip() + if line.startswith(b'Default Library Path (ELF):'): + paths = os.fsdecode(line).split()[4] + + if not paths: + return None + + for dir in paths.split(":"): + libfile = os.path.join(dir, "lib%s.so" % name) + if os.path.exists(libfile): + return libfile + + return None + + def find_library(name, is64 = False): + return _get_soname(_findLib_crle(name, is64) or _findLib_gcc(name)) + + else: + + def _findSoname_ldconfig(name): + import struct + if struct.calcsize('l') == 4: + machine = os.uname().machine + '-32' + else: + machine = os.uname().machine + '-64' + mach_map = { + 'x86_64-64': 'libc6,x86-64', + 'ppc64-64': 'libc6,64bit', + 'sparc64-64': 'libc6,64bit', + 's390x-64': 'libc6,64bit', + 'ia64-64': 'libc6,IA-64', + } + abi_type = mach_map.get(machine, 'libc6') + + # XXX assuming GLIBC's ldconfig (with option -p) + regex = r'\s+(lib%s\.[^\s]+)\s+\(%s' + regex = os.fsencode(regex % (re.escape(name), abi_type)) + try: + with subprocess.Popen(['/sbin/ldconfig', '-p'], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdout=subprocess.PIPE, + env={'LC_ALL': 'C', 'LANG': 'C'}) as p: + res = re.search(regex, p.stdout.read()) + if res: + return os.fsdecode(res.group(1)) + except OSError: + pass + + def _findLib_ld(name): + # See issue #9998 for why this is needed + expr = r'[^\(\)\s]*lib%s\.[^\(\)\s]*' % re.escape(name) + cmd = ['ld', '-t'] + libpath = os.environ.get('LD_LIBRARY_PATH') + if libpath: + for d in libpath.split(':'): + cmd.extend(['-L', d]) + cmd.extend(['-o', os.devnull, '-l%s' % name]) + result = None + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + out, _ = p.communicate() + res = re.findall(expr, os.fsdecode(out)) + for file in res: + # Check if the given file is an elf file: gcc can report + # some files that are linker scripts and not actual + # shared objects. See bpo-41976 for more details + if not _is_elf(file): + continue + return os.fsdecode(file) + except Exception: + pass # result will be None + return result + + def find_library(name): + # See issue #9998 + return _findSoname_ldconfig(name) or \ + _get_soname(_findLib_gcc(name)) or _get_soname(_findLib_ld(name)) + +################################################################ +# test code + +def test(): + from ctypes import cdll + if os.name == "nt": + print(cdll.msvcrt) + print(cdll.load("msvcrt")) + print(find_library("msvcrt")) + + if os.name == "posix": + # find and load_version + print(find_library("m")) + print(find_library("c")) + print(find_library("bz2")) + + # load + if sys.platform == "darwin": + print(cdll.LoadLibrary("libm.dylib")) + print(cdll.LoadLibrary("libcrypto.dylib")) + print(cdll.LoadLibrary("libSystem.dylib")) + print(cdll.LoadLibrary("System.framework/System")) + # issue-26439 - fix broken test call for AIX + elif sys.platform.startswith("aix"): + from ctypes import CDLL + if sys.maxsize < 2**32: + print(f"Using CDLL(name, os.RTLD_MEMBER): {CDLL('libc.a(shr.o)', os.RTLD_MEMBER)}") + print(f"Using cdll.LoadLibrary(): {cdll.LoadLibrary('libc.a(shr.o)')}") + # librpm.so is only available as 32-bit shared library + print(find_library("rpm")) + print(cdll.LoadLibrary("librpm.so")) + else: + print(f"Using CDLL(name, os.RTLD_MEMBER): {CDLL('libc.a(shr_64.o)', os.RTLD_MEMBER)}") + print(f"Using cdll.LoadLibrary(): {cdll.LoadLibrary('libc.a(shr_64.o)')}") + print(f"crypt\t:: {find_library('crypt')}") + print(f"crypt\t:: {cdll.LoadLibrary(find_library('crypt'))}") + print(f"crypto\t:: {find_library('crypto')}") + print(f"crypto\t:: {cdll.LoadLibrary(find_library('crypto'))}") + else: + print(cdll.LoadLibrary("libm.so")) + print(cdll.LoadLibrary("libcrypt.so")) + print(find_library("crypt")) + +if __name__ == "__main__": + test() diff --git a/crates/weavepy-vm/src/stdlib/python/ctypes/wintypes.py b/crates/weavepy-vm/src/stdlib/python/ctypes/wintypes.py new file mode 100644 index 0000000..9c4e721 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/ctypes/wintypes.py @@ -0,0 +1,202 @@ +# The most useful windows datatypes +import ctypes + +BYTE = ctypes.c_ubyte +WORD = ctypes.c_ushort +DWORD = ctypes.c_ulong + +#UCHAR = ctypes.c_uchar +CHAR = ctypes.c_char +WCHAR = ctypes.c_wchar +UINT = ctypes.c_uint +INT = ctypes.c_int + +DOUBLE = ctypes.c_double +FLOAT = ctypes.c_float + +BOOLEAN = BYTE +BOOL = ctypes.c_long + +class VARIANT_BOOL(ctypes._SimpleCData): + _type_ = "v" + def __repr__(self): + return "%s(%r)" % (self.__class__.__name__, self.value) + +ULONG = ctypes.c_ulong +LONG = ctypes.c_long + +USHORT = ctypes.c_ushort +SHORT = ctypes.c_short + +# in the windows header files, these are structures. +_LARGE_INTEGER = LARGE_INTEGER = ctypes.c_longlong +_ULARGE_INTEGER = ULARGE_INTEGER = ctypes.c_ulonglong + +LPCOLESTR = LPOLESTR = OLESTR = ctypes.c_wchar_p +LPCWSTR = LPWSTR = ctypes.c_wchar_p +LPCSTR = LPSTR = ctypes.c_char_p +LPCVOID = LPVOID = ctypes.c_void_p + +# WPARAM is defined as UINT_PTR (unsigned type) +# LPARAM is defined as LONG_PTR (signed type) +if ctypes.sizeof(ctypes.c_long) == ctypes.sizeof(ctypes.c_void_p): + WPARAM = ctypes.c_ulong + LPARAM = ctypes.c_long +elif ctypes.sizeof(ctypes.c_longlong) == ctypes.sizeof(ctypes.c_void_p): + WPARAM = ctypes.c_ulonglong + LPARAM = ctypes.c_longlong + +ATOM = WORD +LANGID = WORD + +COLORREF = DWORD +LGRPID = DWORD +LCTYPE = DWORD + +LCID = DWORD + +################################################################ +# HANDLE types +HANDLE = ctypes.c_void_p # in the header files: void * + +HACCEL = HANDLE +HBITMAP = HANDLE +HBRUSH = HANDLE +HCOLORSPACE = HANDLE +HDC = HANDLE +HDESK = HANDLE +HDWP = HANDLE +HENHMETAFILE = HANDLE +HFONT = HANDLE +HGDIOBJ = HANDLE +HGLOBAL = HANDLE +HHOOK = HANDLE +HICON = HANDLE +HINSTANCE = HANDLE +HKEY = HANDLE +HKL = HANDLE +HLOCAL = HANDLE +HMENU = HANDLE +HMETAFILE = HANDLE +HMODULE = HANDLE +HMONITOR = HANDLE +HPALETTE = HANDLE +HPEN = HANDLE +HRGN = HANDLE +HRSRC = HANDLE +HSTR = HANDLE +HTASK = HANDLE +HWINSTA = HANDLE +HWND = HANDLE +SC_HANDLE = HANDLE +SERVICE_STATUS_HANDLE = HANDLE + +################################################################ +# Some important structure definitions + +class RECT(ctypes.Structure): + _fields_ = [("left", LONG), + ("top", LONG), + ("right", LONG), + ("bottom", LONG)] +tagRECT = _RECTL = RECTL = RECT + +class _SMALL_RECT(ctypes.Structure): + _fields_ = [('Left', SHORT), + ('Top', SHORT), + ('Right', SHORT), + ('Bottom', SHORT)] +SMALL_RECT = _SMALL_RECT + +class _COORD(ctypes.Structure): + _fields_ = [('X', SHORT), + ('Y', SHORT)] + +class POINT(ctypes.Structure): + _fields_ = [("x", LONG), + ("y", LONG)] +tagPOINT = _POINTL = POINTL = POINT + +class SIZE(ctypes.Structure): + _fields_ = [("cx", LONG), + ("cy", LONG)] +tagSIZE = SIZEL = SIZE + +def RGB(red, green, blue): + return red + (green << 8) + (blue << 16) + +class FILETIME(ctypes.Structure): + _fields_ = [("dwLowDateTime", DWORD), + ("dwHighDateTime", DWORD)] +_FILETIME = FILETIME + +class MSG(ctypes.Structure): + _fields_ = [("hWnd", HWND), + ("message", UINT), + ("wParam", WPARAM), + ("lParam", LPARAM), + ("time", DWORD), + ("pt", POINT)] +tagMSG = MSG +MAX_PATH = 260 + +class WIN32_FIND_DATAA(ctypes.Structure): + _fields_ = [("dwFileAttributes", DWORD), + ("ftCreationTime", FILETIME), + ("ftLastAccessTime", FILETIME), + ("ftLastWriteTime", FILETIME), + ("nFileSizeHigh", DWORD), + ("nFileSizeLow", DWORD), + ("dwReserved0", DWORD), + ("dwReserved1", DWORD), + ("cFileName", CHAR * MAX_PATH), + ("cAlternateFileName", CHAR * 14)] + +class WIN32_FIND_DATAW(ctypes.Structure): + _fields_ = [("dwFileAttributes", DWORD), + ("ftCreationTime", FILETIME), + ("ftLastAccessTime", FILETIME), + ("ftLastWriteTime", FILETIME), + ("nFileSizeHigh", DWORD), + ("nFileSizeLow", DWORD), + ("dwReserved0", DWORD), + ("dwReserved1", DWORD), + ("cFileName", WCHAR * MAX_PATH), + ("cAlternateFileName", WCHAR * 14)] + +################################################################ +# Pointer types + +LPBOOL = PBOOL = ctypes.POINTER(BOOL) +PBOOLEAN = ctypes.POINTER(BOOLEAN) +LPBYTE = PBYTE = ctypes.POINTER(BYTE) +PCHAR = ctypes.POINTER(CHAR) +LPCOLORREF = ctypes.POINTER(COLORREF) +LPDWORD = PDWORD = ctypes.POINTER(DWORD) +LPFILETIME = PFILETIME = ctypes.POINTER(FILETIME) +PFLOAT = ctypes.POINTER(FLOAT) +LPHANDLE = PHANDLE = ctypes.POINTER(HANDLE) +PHKEY = ctypes.POINTER(HKEY) +LPHKL = ctypes.POINTER(HKL) +LPINT = PINT = ctypes.POINTER(INT) +PLARGE_INTEGER = ctypes.POINTER(LARGE_INTEGER) +PLCID = ctypes.POINTER(LCID) +LPLONG = PLONG = ctypes.POINTER(LONG) +LPMSG = PMSG = ctypes.POINTER(MSG) +LPPOINT = PPOINT = ctypes.POINTER(POINT) +PPOINTL = ctypes.POINTER(POINTL) +LPRECT = PRECT = ctypes.POINTER(RECT) +LPRECTL = PRECTL = ctypes.POINTER(RECTL) +LPSC_HANDLE = ctypes.POINTER(SC_HANDLE) +PSHORT = ctypes.POINTER(SHORT) +LPSIZE = PSIZE = ctypes.POINTER(SIZE) +LPSIZEL = PSIZEL = ctypes.POINTER(SIZEL) +PSMALL_RECT = ctypes.POINTER(SMALL_RECT) +LPUINT = PUINT = ctypes.POINTER(UINT) +PULARGE_INTEGER = ctypes.POINTER(ULARGE_INTEGER) +PULONG = ctypes.POINTER(ULONG) +PUSHORT = ctypes.POINTER(USHORT) +PWCHAR = ctypes.POINTER(WCHAR) +LPWIN32_FIND_DATAA = PWIN32_FIND_DATAA = ctypes.POINTER(WIN32_FIND_DATAA) +LPWIN32_FIND_DATAW = PWIN32_FIND_DATAW = ctypes.POINTER(WIN32_FIND_DATAW) +LPWORD = PWORD = ctypes.POINTER(WORD) diff --git a/crates/weavepy-vm/src/stdlib/python/inspect.py b/crates/weavepy-vm/src/stdlib/python/inspect.py index 8cb2468..fff1ca6 100644 --- a/crates/weavepy-vm/src/stdlib/python/inspect.py +++ b/crates/weavepy-vm/src/stdlib/python/inspect.py @@ -12,6 +12,7 @@ import linecache import types import functools +import enum __all__ = [ @@ -609,6 +610,102 @@ def getsource(obj): return "".join(lines) +class EndOfBlock(Exception): + pass + + +class BlockFinder: + """Provide a tokeneater() method to detect the end of a code block. + + Verbatim port of CPython 3.13's ``inspect.BlockFinder``; used by + :func:`getblock` to trim a list of source lines down to the first + complete ``def``/``class``/``lambda`` block. Libraries such as + hypothesis rely on ``inspect.getblock`` to recover lambda source.""" + + def __init__(self): + self.indent = 0 + self.islambda = False + self.started = False + self.passline = False + self.indecorator = False + self.decoratorhasargs = False + self.last = 1 + self.body_col0 = None + + def tokeneater(self, type, token, srowcol, erowcol, line): + import tokenize as _tokenize + if not self.started and not self.indecorator: + # skip any decorators + if token == "@": + self.indecorator = True + # look for the first "def", "class" or "lambda" + elif token in ("def", "class", "lambda"): + if token == "lambda": + self.islambda = True + self.started = True + self.passline = True # skip to the end of the line + elif token == "(": + if self.indecorator: + self.decoratorhasargs = True + elif token == ")": + if self.indecorator: + self.indecorator = False + self.decoratorhasargs = False + elif type == _tokenize.NEWLINE: + self.passline = False # stop skipping when a NEWLINE is seen + self.last = srowcol[0] + if self.islambda: # lambdas always end at the first NEWLINE + raise EndOfBlock + # hitting a NEWLINE when in a decorator without args + # ends the decorator + if self.indecorator and not self.decoratorhasargs: + self.indecorator = False + elif self.passline: + pass + elif type == _tokenize.INDENT: + if self.body_col0 is None and self.started: + self.body_col0 = erowcol[1] + self.indent = self.indent + 1 + self.passline = True + elif type == _tokenize.DEDENT: + self.indent = self.indent - 1 + # the end of matching indent/dedent pairs end a block + # (note that this only works for "def"/"class" blocks, + # not e.g. for "if: else:" or "try: finally:" blocks) + if self.indent <= 0: + raise EndOfBlock + elif type == _tokenize.COMMENT: + if self.body_col0 is not None and srowcol[1] >= self.body_col0: + # When saw a comment, must be inside the block + self.last = srowcol[0] + elif self.indent == 0 and type not in (_tokenize.COMMENT, _tokenize.NL): + # any other token on the same indentation level end the previous + # block as well, except the pseudo-tokens COMMENT and NL. + raise EndOfBlock + + +def getblock(lines): + """Extract the block of code at the top of the given list of lines.""" + import tokenize as _tokenize + blockfinder = BlockFinder() + _token = None + try: + tokens = _tokenize.generate_tokens(iter(lines).__next__) + for _token in tokens: + blockfinder.tokeneater(*_token) + except (EndOfBlock, IndentationError): + pass + except SyntaxError as e: + if "unmatched" not in (getattr(e, "msg", "") or "") or _token is None: + raise e from None + _, *_token_info = _token + try: + blockfinder.tokeneater(_tokenize.NEWLINE, *_token_info) + except (EndOfBlock, IndentationError): + pass + return lines[:blockfinder.last] + + def getabsfile(obj, _filename=None): """Return an absolute path to the source or compiled file for an object. @@ -1069,6 +1166,15 @@ class _empty: pass +# Distinct "argument not supplied" sentinel for `replace()`. It must differ +# from `_empty`, because `_empty` is itself a legitimate value a caller may +# want to install (e.g. hypothesis strips annotations via +# `param.replace(annotation=Parameter.empty)`); conflating the two silently +# keeps the old annotation. Mirrors CPython's private `inspect._void`. +class _void: + pass + + def formatannotation(annotation, base_module=None): if getattr(annotation, '__module__', None) == 'typing': import re @@ -1107,13 +1213,38 @@ def _is_wrapper(f): return func -class Parameter: +class _ParameterKind(enum.IntEnum): + # Mirrors CPython `inspect._ParameterKind`: an `IntEnum` so that + # `param.kind == Parameter.VAR_POSITIONAL` keeps its integer semantics + # while introspection (`param.kind.name`, `.description`) also works — + # hypothesis reads `p.kind.name.startswith("POSITIONAL_")`. POSITIONAL_ONLY = 0 POSITIONAL_OR_KEYWORD = 1 VAR_POSITIONAL = 2 KEYWORD_ONLY = 3 VAR_KEYWORD = 4 + @property + def description(self): + return _PARAM_NAME_MAPPING[self] + + +_PARAM_NAME_MAPPING = { + _ParameterKind.POSITIONAL_ONLY: "positional-only", + _ParameterKind.POSITIONAL_OR_KEYWORD: "positional or keyword", + _ParameterKind.VAR_POSITIONAL: "variadic positional", + _ParameterKind.KEYWORD_ONLY: "keyword-only", + _ParameterKind.VAR_KEYWORD: "variadic keyword", +} + + +class Parameter: + POSITIONAL_ONLY = _ParameterKind.POSITIONAL_ONLY + POSITIONAL_OR_KEYWORD = _ParameterKind.POSITIONAL_OR_KEYWORD + VAR_POSITIONAL = _ParameterKind.VAR_POSITIONAL + KEYWORD_ONLY = _ParameterKind.KEYWORD_ONLY + VAR_KEYWORD = _ParameterKind.VAR_KEYWORD + empty = _empty __slots__ = ("_name", "_kind", "_default", "_annotation") @@ -1140,13 +1271,16 @@ def default(self): def annotation(self): return self._annotation - def replace(self, *, name=None, kind=None, default=_empty, annotation=_empty): - return Parameter( - name if name is not None else self._name, - kind if kind is not None else self._kind, - default=default if default is not _empty else self._default, - annotation=annotation if annotation is not _empty else self._annotation, - ) + def replace(self, *, name=_void, kind=_void, default=_void, annotation=_void): + if name is _void: + name = self._name + if kind is _void: + kind = self._kind + if default is _void: + default = self._default + if annotation is _void: + annotation = self._annotation + return type(self)(name, kind, default=default, annotation=annotation) def __repr__(self): formatted = self._name @@ -1262,10 +1396,10 @@ def parameters(self): def return_annotation(self): return self._return_annotation - def replace(self, *, parameters=_empty, return_annotation=_empty): - params = list(self._parameters.values()) if parameters is _empty else list(parameters) - ret = self._return_annotation if return_annotation is _empty else return_annotation - return Signature(params, return_annotation=ret) + def replace(self, *, parameters=_void, return_annotation=_void): + params = list(self._parameters.values()) if parameters is _void else list(parameters) + ret = self._return_annotation if return_annotation is _void else return_annotation + return type(self)(params, return_annotation=ret) def _hash_basis(self): # CPython: keyword-only parameters compare order-insensitively. @@ -1386,8 +1520,10 @@ def __str__(self): return self.format() @classmethod - def from_callable(cls, func): - return signature(func) + def from_callable(cls, func, *, follow_wrapped=True, + globals=None, locals=None, eval_str=False): + return signature(func, follow_wrapped=follow_wrapped, + globals=globals, locals=locals, eval_str=eval_str) def _signature_drop_first(sig): @@ -1517,20 +1653,21 @@ def _signature_from_text(text): return Signature(pending) -def signature(callable_, *, follow_wrapped=True): +def signature(callable_, *, follow_wrapped=True, globals=None, locals=None, eval_str=False): if not callable(callable_): raise TypeError(f"{callable_!r} is not a callable object") + _sig_kw = dict(globals=globals, locals=locals, eval_str=eval_str) obj = callable_ if ismethod(obj): # Bound method: signature of the underlying function minus the # bound argument. - return _signature_drop_first(signature(obj.__func__)) + return _signature_drop_first(signature(obj.__func__, **_sig_kw)) # Was this function wrapped by a decorator? An explicit # `__signature__` anywhere on the chain stops the walk. if follow_wrapped: obj = unwrap(obj, stop=lambda f: hasattr(f, "__signature__")) if ismethod(obj): - return _signature_drop_first(signature(obj.__func__)) + return _signature_drop_first(signature(obj.__func__, **_sig_kw)) explicit = getattr(obj, "__signature__", None) if explicit is not None: # CPython: `__signature__` may be a string, or a callable @@ -1546,7 +1683,7 @@ def signature(callable_, *, follow_wrapped=True): "unexpected object {!r} in __signature__ attribute".format(explicit)) return sig if isinstance(obj, functools.partial): - return _signature_get_partial(signature(obj.func), obj) + return _signature_get_partial(signature(obj.func, **_sig_kw), obj) if isclass(obj): # A metaclass with a custom `__call__` takes over construction # entirely (CPython `_signature_from_callable`): derive the @@ -1563,7 +1700,7 @@ def signature(callable_, *, follow_wrapped=True): break if meta_call is not None: if isfunction(meta_call): - sig = signature(meta_call) + sig = signature(meta_call, **_sig_kw) params = list(sig.parameters.values())[1:] return Signature(params, return_annotation=sig.return_annotation) raise ValueError(f"no signature found for {obj!r}") @@ -1571,12 +1708,12 @@ def signature(callable_, *, follow_wrapped=True): # fall back to __init__. A class signature carries no return annotation. new = getattr(obj, "__new__", None) if new is not None and new is not object.__new__: - sig = signature(new) + sig = signature(new, **_sig_kw) params = [p for name, p in sig.parameters.items() if name != "cls"] return Signature(params) init = getattr(obj, "__init__", None) if init is not None and init is not object.__init__: - sig = signature(init) + sig = signature(init, **_sig_kw) params = [p for name, p in sig.parameters.items() if name != "self"] return Signature(params) return Signature([]) @@ -1591,7 +1728,7 @@ def signature(callable_, *, follow_wrapped=True): # signature from the type's __call__, dropping the bound `self`. call = getattr(type(obj), "__call__", None) if call is not None and (isfunction(call) or ismethod(call)): - sig = signature(call) + sig = signature(call, **_sig_kw) params = [p for name, p in sig.parameters.items() if name != "self"] return Signature(params, return_annotation=sig.return_annotation) # Best effort: return an "unknown" signature. @@ -1605,23 +1742,35 @@ def signature(callable_, *, follow_wrapped=True): n_args = len(spec.args) f = _func_of(callable_) posonly = getattr(f.__code__, "co_posonlyargcount", 0) if f is not None else 0 + # With `eval_str=True` (PEP 563 / stringized annotations), resolve the raw + # `str` annotations to their runtime objects via `get_annotations`; mirrors + # CPython threading `globals`/`locals`/`eval_str` into the annotation dict. + if eval_str: + try: + anns = get_annotations( + callable_, globals=globals, locals=locals, eval_str=True + ) + except Exception: + anns = spec.annotations + else: + anns = spec.annotations for i, name in enumerate(spec.args): if i >= n_args - n_defaults: default = defaults[i - (n_args - n_defaults)] else: default = _empty - annotation = spec.annotations.get(name, _empty) + annotation = anns.get(name, _empty) kind = Parameter.POSITIONAL_ONLY if i < posonly else Parameter.POSITIONAL_OR_KEYWORD params.append(Parameter(name, kind, default=default, annotation=annotation)) if spec.varargs: params.append(Parameter(spec.varargs, Parameter.VAR_POSITIONAL, - annotation=spec.annotations.get(spec.varargs, _empty))) + annotation=anns.get(spec.varargs, _empty))) for name in spec.kwonlyargs: params.append(Parameter(name, Parameter.KEYWORD_ONLY, default=spec.kwonlydefaults.get(name, _empty), - annotation=spec.annotations.get(name, _empty))) + annotation=anns.get(name, _empty))) if spec.varkw: params.append(Parameter(spec.varkw, Parameter.VAR_KEYWORD, - annotation=spec.annotations.get(spec.varkw, _empty))) - return Signature(params, return_annotation=spec.annotations.get("return", _empty)) + annotation=anns.get(spec.varkw, _empty))) + return Signature(params, return_annotation=anns.get("return", _empty)) diff --git a/crates/weavepy-vm/src/stdlib/python/pickle.py b/crates/weavepy-vm/src/stdlib/python/pickle.py index d59a57e..5c40915 100644 --- a/crates/weavepy-vm/src/stdlib/python/pickle.py +++ b/crates/weavepy-vm/src/stdlib/python/pickle.py @@ -1,1008 +1,1900 @@ -"""Public ``pickle`` module (RFC 0019). - -A pure-Python implementation of the CPython pickle protocol that -faithfully matches the on-the-wire byte sequences for the -universally-supported subset (None / bool / int / float / bytes / -str / tuple / list / dict / set / frozenset / nested combinations). - -Object/class pickling is *not* exposed here — that requires -``__reduce__`` plumbing through the type system which lives in a -later RFC. Calling ``pickle.dumps`` on an arbitrary object will -raise ``PicklingError`` instead of silently producing a bytestream -that cannot be loaded. -""" +"""Create portable serialized representations of Python objects. -import copyreg -import io -import struct +See module copyreg for a mechanism for registering custom picklers. +See module pickletools source for extensive comments. -HIGHEST_PROTOCOL = 5 -DEFAULT_PROTOCOL = 5 - -PROTO = b"\x80" -FRAME = b"\x95" -EMPTY_DICT = b"}" -EMPTY_LIST = b"]" -EMPTY_TUPLE = b")" -EMPTY_SET = b"\x8f" -NONE = b"N" -NEWTRUE = b"\x88" -NEWFALSE = b"\x89" -BININT = b"J" -BININT1 = b"K" -BININT2 = b"M" -LONG1 = b"\x8a" -LONG4 = b"\x8b" -BINFLOAT = b"G" -BINUNICODE = b"X" -SHORT_BINUNICODE = b"\x8c" -BINUNICODE8 = b"\x8d" -BINBYTES = b"B" -SHORT_BINBYTES = b"C" -BINBYTES8 = b"\x8e" -SETITEMS = b"u" -APPENDS = b"e" -TUPLE1 = b"\x85" -TUPLE2 = b"\x86" -TUPLE3 = b"\x87" -TUPLE = b"t" -ADDITEMS = b"\x90" -FROZENSET = b"\x91" -MARK = b"(" -STOP = b"." -POP = b"0" -POP_MARK = b"1" -# Memo opcodes — preserve object identity/sharing and enable cyclic -# structures. PUT/GET use a textual index (protocol 0), BINPUT/BINGET a -# 1-byte index, LONG_BINPUT/LONG_BINGET a 4-byte index, and MEMOIZE -# (protocol 4+) appends the stack top to the memo with no explicit index. -PUT = b"p" -BINPUT = b"q" -LONG_BINPUT = b"r" -GET = b"g" -BINGET = b"h" -LONG_BINGET = b"j" -MEMOIZE = b"\x94" -# Global reference + reduce opcodes used to serialize functions and -# classes by their qualified name. CPython uses these for everything -# from `pickle.dumps(int)` to `pickle.dumps(my_module.my_func)`. -GLOBAL = b"c" -STACK_GLOBAL = b"\x93" -REDUCE = b"R" -BUILD = b"b" -NEWOBJ = b"\x81" -NEWOBJ_EX = b"\x92" -# Protocol-0 text opcodes — never *emitted* here (we write protocol 2+ -# framing), but historical pickles (e.g. CPython's cross-version -# compatibility blobs in Lib/test) still carry them on the read side. -INT = b"I" -LONG = b"L" -FLOAT = b"F" -STRING = b"S" -# Python-2-era 8-bit `str` opcodes; carried by cross-version compat blobs -# (e.g. `datetimetester.test_compat_unpickle`). Decoded via the unpickler's -# `encoding` like CPython's `load_binstring`/`load_short_binstring`. -BINSTRING = b"T" -SHORT_BINSTRING = b"U" -UNICODE = b"V" -LIST = b"l" -DICT = b"d" -APPEND = b"a" -SETITEM = b"s" -DUP = b"2" - -# --- exceptions ----------------------------------------------------------- +Classes: + Pickler + Unpickler -class PickleError(Exception): - pass +Functions: + dump(object, file) + dumps(object) -> string + load(file) -> object + loads(bytes) -> object -class PicklingError(PickleError): - pass +Misc variables: + __version__ + format_version + compatible_formats -class UnpicklingError(PickleError): - pass +""" +from types import FunctionType +from copyreg import dispatch_table +from copyreg import _extension_registry, _inverted_registry, _extension_cache +from itertools import islice +from functools import partial +import sys +from sys import maxsize +from struct import pack, unpack +import re +import io +import codecs +import _compat_pickle + +__all__ = ["PickleError", "PicklingError", "UnpicklingError", "Pickler", + "Unpickler", "dump", "dumps", "load", "loads"] + +try: + from _pickle import PickleBuffer + __all__.append("PickleBuffer") + _HAVE_PICKLE_BUFFER = True +except ImportError: + # CPython exposes PickleBuffer only from the C `_pickle`; WeavePy's + # accelerator does not provide it, so define a faithful pure-Python + # equivalent backed by `memoryview` (protocol-5 out-of-band buffers). + class PickleBuffer: + """Wrapper for a buffer exposing the PEP 574 picklebuffer protocol.""" + + __slots__ = ("_view",) + + def __init__(self, buffer): + self._view = memoryview(buffer) + + def raw(self): + view = self._view + if view is None: + raise ValueError( + "operation forbidden on released PickleBuffer object") + if not view.contiguous: + raise BufferError( + "PickleBuffer can not be converted to a contiguous buffer") + return view.cast("B") + + def release(self): + if self._view is not None: + self._view.release() + self._view = None + + def __buffer__(self, flags): + view = self._view + if view is None: + raise ValueError( + "operation forbidden on released PickleBuffer object") + return view + + __all__.append("PickleBuffer") + _HAVE_PICKLE_BUFFER = True + + +# Shortcut for use in isinstance testing +bytes_types = (bytes, bytearray) + +# These are purely informational; no code uses these. +format_version = "4.0" # File format version we write +compatible_formats = ["1.0", # Original protocol 0 + "1.1", # Protocol 0 with INST added + "1.2", # Original protocol 1 + "1.3", # Protocol 1 with BINFLOAT added + "2.0", # Protocol 2 + "3.0", # Protocol 3 + "4.0", # Protocol 4 + "5.0", # Protocol 5 + ] # Old format versions we can read + +# This is the highest protocol number we know how to read. +HIGHEST_PROTOCOL = 5 -# --- public entry points -------------------------------------------------- +# The protocol we write by default. May be less than HIGHEST_PROTOCOL. +# Only bump this if the oldest still supported version of Python already +# includes it. +DEFAULT_PROTOCOL = 4 +class PickleError(Exception): + """A common base class for the other pickling exceptions.""" + pass -def dumps(obj, protocol=None, *, fix_imports=True, buffer_callback=None): - if protocol is None: - protocol = DEFAULT_PROTOCOL - # CPython `Pickler.__init__`: a negative protocol selects - # HIGHEST_PROTOCOL. - if protocol < 0: - protocol = HIGHEST_PROTOCOL - if not 0 <= protocol <= HIGHEST_PROTOCOL: - raise ValueError("unsupported pickle protocol: %d" % protocol) - pickler = _Pickler(io.BytesIO(), protocol) - pickler.dump(obj) - return pickler._buf.getvalue() +class PicklingError(PickleError): + """This exception is raised when an unpicklable object is passed to the + dump() method. + """ + pass -def dump(obj, file, protocol=None, *, fix_imports=True, buffer_callback=None): - file.write(dumps(obj, protocol, fix_imports=fix_imports, - buffer_callback=buffer_callback)) +class UnpicklingError(PickleError): + """This exception is raised when there is a problem unpickling an object, + such as a security violation. + Note that other exceptions may also be raised during unpickling, including + (but not necessarily limited to) AttributeError, EOFError, ImportError, + and IndexError. -def loads(data, *, fix_imports=True, encoding="ASCII", errors="strict"): - return _Unpickler(io.BytesIO(data), encoding=encoding, errors=errors).load() + """ + pass +# An instance of _Stop is raised by Unpickler.load_stop() in response to +# the STOP opcode, passing the object that is the result of unpickling. +class _Stop(Exception): + def __init__(self, value): + self.value = value + +# Pickle opcodes. See pickletools.py for extensive docs. The listing +# here is in kind-of alphabetical order of 1-character pickle code. +# pickletools groups them by purpose. + +MARK = b'(' # push special markobject on stack +STOP = b'.' # every pickle ends with STOP +POP = b'0' # discard topmost stack item +POP_MARK = b'1' # discard stack top through topmost markobject +DUP = b'2' # duplicate top stack item +FLOAT = b'F' # push float object; decimal string argument +INT = b'I' # push integer or bool; decimal string argument +BININT = b'J' # push four-byte signed int +BININT1 = b'K' # push 1-byte unsigned int +LONG = b'L' # push long; decimal string argument +BININT2 = b'M' # push 2-byte unsigned int +NONE = b'N' # push None +PERSID = b'P' # push persistent object; id is taken from string arg +BINPERSID = b'Q' # " " " ; " " " " stack +REDUCE = b'R' # apply callable to argtuple, both on stack +STRING = b'S' # push string; NL-terminated string argument +BINSTRING = b'T' # push string; counted binary string argument +SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes +UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument +BINUNICODE = b'X' # " " " ; counted UTF-8 string argument +APPEND = b'a' # append stack top to list below it +BUILD = b'b' # call __setstate__ or __dict__.update() +GLOBAL = b'c' # push self.find_class(modname, name); 2 string args +DICT = b'd' # build a dict from stack items +EMPTY_DICT = b'}' # push empty dict +APPENDS = b'e' # extend list on stack by topmost stack slice +GET = b'g' # push item from memo on stack; index is string arg +BINGET = b'h' # " " " " " " ; " " 1-byte arg +INST = b'i' # build & push class instance +LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg +LIST = b'l' # build list from topmost stack items +EMPTY_LIST = b']' # push empty list +OBJ = b'o' # build & push class instance +PUT = b'p' # store stack top in memo; index is string arg +BINPUT = b'q' # " " " " " ; " " 1-byte arg +LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg +SETITEM = b's' # add key+value pair to dict +TUPLE = b't' # build tuple from topmost stack items +EMPTY_TUPLE = b')' # push empty tuple +SETITEMS = b'u' # modify dict by adding topmost key+value pairs +BINFLOAT = b'G' # push float; arg is 8-byte float encoding + +TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py +FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py + +# Protocol 2 + +PROTO = b'\x80' # identify pickle protocol +NEWOBJ = b'\x81' # build object by applying cls.__new__ to argtuple +EXT1 = b'\x82' # push object from extension registry; 1-byte index +EXT2 = b'\x83' # ditto, but 2-byte index +EXT4 = b'\x84' # ditto, but 4-byte index +TUPLE1 = b'\x85' # build 1-tuple from stack top +TUPLE2 = b'\x86' # build 2-tuple from two topmost stack items +TUPLE3 = b'\x87' # build 3-tuple from three topmost stack items +NEWTRUE = b'\x88' # push True +NEWFALSE = b'\x89' # push False +LONG1 = b'\x8a' # push long from < 256 bytes +LONG4 = b'\x8b' # push really big long + +_tuplesize2code = [EMPTY_TUPLE, TUPLE1, TUPLE2, TUPLE3] + +# Protocol 3 (Python 3.x) + +BINBYTES = b'B' # push bytes; counted binary string argument +SHORT_BINBYTES = b'C' # " " ; " " " " < 256 bytes + +# Protocol 4 + +SHORT_BINUNICODE = b'\x8c' # push short string; UTF-8 length < 256 bytes +BINUNICODE8 = b'\x8d' # push very long string +BINBYTES8 = b'\x8e' # push very long bytes string +EMPTY_SET = b'\x8f' # push empty set on the stack +ADDITEMS = b'\x90' # modify set by adding topmost stack items +FROZENSET = b'\x91' # build frozenset from topmost stack items +NEWOBJ_EX = b'\x92' # like NEWOBJ but work with keyword only arguments +STACK_GLOBAL = b'\x93' # same as GLOBAL but using names on the stacks +MEMOIZE = b'\x94' # store top of the stack in memo +FRAME = b'\x95' # indicate the beginning of a new frame + +# Protocol 5 + +BYTEARRAY8 = b'\x96' # push bytearray +NEXT_BUFFER = b'\x97' # push next out-of-band buffer +READONLY_BUFFER = b'\x98' # make top of stack readonly + +__all__.extend([x for x in dir() if re.match("[A-Z][A-Z0-9_]+$", x)]) + + +class _Framer: + + _FRAME_SIZE_MIN = 4 + _FRAME_SIZE_TARGET = 64 * 1024 + + def __init__(self, file_write): + self.file_write = file_write + self.current_frame = None + + def start_framing(self): + self.current_frame = io.BytesIO() + + def end_framing(self): + if self.current_frame and self.current_frame.tell() > 0: + self.commit_frame(force=True) + self.current_frame = None + + def commit_frame(self, force=False): + if self.current_frame: + f = self.current_frame + if f.tell() >= self._FRAME_SIZE_TARGET or force: + data = f.getbuffer() + write = self.file_write + if len(data) >= self._FRAME_SIZE_MIN: + # Issue a single call to the write method of the underlying + # file object for the frame opcode with the size of the + # frame. The concatenation is expected to be less expensive + # than issuing an additional call to write. + write(FRAME + pack("': + raise AttributeError("Can't get local attribute {!r} on {!r}" + .format(name, top)) + try: + parent = obj + obj = getattr(obj, subpath) + except AttributeError: + raise AttributeError("Can't get attribute {!r} on {!r}" + .format(name, top)) from None + return obj, parent + +def whichmodule(obj, name): + """Find the module an object belong to.""" + module_name = getattr(obj, '__module__', None) + if module_name is not None: + return module_name + # Protect the iteration by using a list copy of sys.modules against dynamic + # modules that trigger imports of other modules upon calls to getattr. + for module_name, module in sys.modules.copy().items(): + if (module_name == '__main__' + or module_name == '__mp_main__' # bpo-42406 + or module is None): + continue + try: + if _getattribute(module, name)[0] is obj: + return module_name + except AttributeError: + pass + return '__main__' + +def encode_long(x): + r"""Encode a long to a two's complement little-endian binary string. + Note that 0 is a special case, returning an empty string, to save a + byte in the LONG1 pickling context. + + >>> encode_long(0) + b'' + >>> encode_long(255) + b'\xff\x00' + >>> encode_long(32767) + b'\xff\x7f' + >>> encode_long(-256) + b'\x00\xff' + >>> encode_long(-32768) + b'\x00\x80' + >>> encode_long(-128) + b'\x80' + >>> encode_long(127) + b'\x7f' + >>> + """ + if x == 0: + return b'' + nbytes = (x.bit_length() >> 3) + 1 + result = x.to_bytes(nbytes, byteorder='little', signed=True) + if x < 0 and nbytes > 1: + if result[-1] == 0xff and (result[-2] & 0x80) != 0: + result = result[:-1] + return result + +def decode_long(data): + r"""Decode a long from a two's complement little-endian binary string. + + >>> decode_long(b'') + 0 + >>> decode_long(b"\xff\x00") + 255 + >>> decode_long(b"\xff\x7f") + 32767 + >>> decode_long(b"\x00\xff") + -256 + >>> decode_long(b"\x00\x80") + -32768 + >>> decode_long(b"\x80") + -128 + >>> decode_long(b"\x7f") + 127 + """ + return int.from_bytes(data, byteorder='little', signed=True) -def _resolves_to_self(module, qualname, obj): - """True when ``module.qualname`` imports back to *obj* itself. - This is CPython's ``save_global`` self-consistency check: an object is - only safe to pickle by reference (functions, classes, module globals) - when the dotted name found in its declaring module *is* that object. - Callable instances inherit their class's ``__qualname__`` and would - otherwise be mistaken for the class. - """ - try: - target = __import__(module, fromlist=["_"]) - for part in qualname.split("."): - target = getattr(target, part) - return target is obj - except Exception: - return False +_NoValue = object() +# Pickling machinery class _Pickler: - def __init__(self, buf, protocol): - self._buf = buf + + def __init__(self, file, protocol=None, *, fix_imports=True, + buffer_callback=None): + """This takes a binary file for writing a pickle data stream. + + The optional *protocol* argument tells the pickler to use the + given protocol; supported protocols are 0, 1, 2, 3, 4 and 5. + The default protocol is 4. It was introduced in Python 3.4, and + is incompatible with previous versions. + + Specifying a negative protocol version selects the highest + protocol version supported. The higher the protocol used, the + more recent the version of Python needed to read the pickle + produced. + + The *file* argument must have a write() method that accepts a + single bytes argument. It can thus be a file object opened for + binary writing, an io.BytesIO instance, or any other custom + object that meets this interface. + + If *fix_imports* is True and *protocol* is less than 3, pickle + will try to map the new Python 3 names to the old module names + used in Python 2, so that the pickle data stream is readable + with Python 2. + + If *buffer_callback* is None (the default), buffer views are + serialized into *file* as part of the pickle stream. + + If *buffer_callback* is not None, then it can be called any number + of times with a buffer view. If the callback returns a false value + (such as None), the given buffer is out-of-band; otherwise the + buffer is serialized in-band, i.e. inside the pickle stream. + + It is an error if *buffer_callback* is not None and *protocol* + is None or smaller than 5. + """ if protocol is None: protocol = DEFAULT_PROTOCOL - elif protocol < 0: + if protocol < 0: protocol = HIGHEST_PROTOCOL - self.protocol = protocol - self.bin = protocol >= 1 - self.fast = False - # id(obj) -> (memo_index, obj). Keeping a reference to `obj` - # prevents its id from being reused mid-pickle. + elif not 0 <= protocol <= HIGHEST_PROTOCOL: + raise ValueError("pickle protocol must be <= %d" % HIGHEST_PROTOCOL) + if buffer_callback is not None and protocol < 5: + raise ValueError("buffer_callback needs protocol >= 5") + self._buffer_callback = buffer_callback + try: + self._file_write = file.write + except AttributeError: + raise TypeError("file must have a 'write' attribute") + self.framer = _Framer(self._file_write) + self.write = self.framer.write + self._write_large_bytes = self.framer.write_large_bytes self.memo = {} + self.proto = int(protocol) + self.bin = protocol >= 1 + self.fast = 0 + self.fix_imports = fix_imports and protocol < 3 + + def clear_memo(self): + """Clears the pickler's "memo". + + The memo is the data structure that remembers which objects the + pickler has already seen, so that shared or recursive objects + are pickled by reference and not by value. This method is + useful when re-using picklers. + """ + self.memo.clear() def dump(self, obj): - self._buf.write(PROTO + bytes([self.protocol])) - self._save(obj) - self._buf.write(STOP) - - def _memoize(self, obj): - """Record `obj` (already written / on the stack) in the memo and - emit the PUT opcode so a later occurrence can reference it.""" - if self.fast or id(obj) in self.memo: + """Write a pickled representation of obj to the open file.""" + # Check whether Pickler was initialized correctly. This is + # only needed to mimic the behavior of _pickle.Pickler.dump(). + if not hasattr(self, "_file_write"): + raise PicklingError("Pickler.__init__() was not called by " + "%s.__init__()" % (self.__class__.__name__,)) + if self.proto >= 2: + self.write(PROTO + pack("= 4: + self.framer.start_framing() + self.save(obj) + self.write(STOP) + self.framer.end_framing() + + def memoize(self, obj): + """Store an object in the memo.""" + + # The Pickler memo is a dictionary mapping object ids to 2-tuples + # that contain the Unpickler memo key and the object being memoized. + # The memo key is written to the pickle and will become + # the key in the Unpickler's memo. The object is stored in the + # Pickler memo so that transient objects are kept alive during + # pickling. + + # The use of the Unpickler memo length as the memo key is just a + # convention. The only requirement is that the memo values be unique. + # But there appears no advantage to any other scheme, and this + # scheme allows the Unpickler memo to be implemented as a plain (but + # growable) array, indexed by memo key. + if self.fast: return + assert id(obj) not in self.memo idx = len(self.memo) - if self.protocol >= 4: - self._buf.write(MEMOIZE) + self.write(self.put(idx)) + self.memo[id(obj)] = idx, obj + + # Return a PUT (BINPUT, LONG_BINPUT) opcode string, with argument i. + def put(self, idx): + if self.proto >= 4: + return MEMOIZE elif self.bin: if idx < 256: - self._buf.write(BINPUT + bytes([idx])) + return BINPUT + pack("); getattr(...)`. - # We tolerate a missing `__module__` by falling back to - # `__main__` (CPython does the same when a function is defined - # interactively). - try: - is_callable_like = callable(obj) - except Exception: - is_callable_like = False - try: - # Name-based (not `isinstance`) for the same threading reason - # as above; walk the metaclass MRO so classes with a custom - # metaclass (`EnumType`, `ABCMeta`, …) count as types too. - is_type = any(t.__name__ == "type" for t in type(obj).__mro__) - except Exception: - is_type = False - if is_callable_like or is_type: - module = getattr(obj, "__module__", None) or "__main__" - qualname = ( - getattr(obj, "__qualname__", None) - or getattr(obj, "__name__", None) - ) - # Only pickle by name when that name actually resolves back to - # *this* object (CPython's `save_global` self-check). A callable - # *instance* — e.g. `operator.attrgetter('x')` — inherits its - # class's `__qualname__`, so without this guard it would be - # saved as the bare class and unpickle to the class object - # rather than round-tripping through `__reduce__`. - if qualname and _resolves_to_self(module, qualname, obj): - self._save_global(module, qualname) + # Check for persistent id (defined by a subclass) + if save_persistent_id: + pid = self.persistent_id(obj) + if pid is not None: + self.save_pers(pid) return - # Classes and plain/builtin functions are *only* picklable by - # reference. CPython's `save_global` raises PicklingError for - # anything that doesn't resolve (e.g. a class defined inside a - # function: `` in its qualname); falling through to - # `__reduce_ex__` here would mis-pickle the class as an - # instance of its metaclass. - if is_type or tname in ("function", "builtin_function_or_method"): - raise PicklingError( - "Can't pickle %r: it's not found as %s.%s" - % (obj, module, qualname) - ) - # Arbitrary instances — try __reduce_ex__ / __reduce__ (the - # canonical CPython pickle protocol). Falls back to the - # PicklingError below if neither is provided. - # Exceptions raised by a user `__reduce_ex__` / `__reduce__` - # propagate, as in CPython — `enum._make_class_unpicklable` - # relies on its injected TypeError reaching the caller. - reduce_ex = getattr(obj, "__reduce_ex__", None) - if reduce_ex is not None: - rv = reduce_ex(self.protocol) - if rv is not None and rv is not NotImplemented: - self._save_reduce(rv, obj) - return - reduce = getattr(obj, "__reduce__", None) - if reduce is not None: - rv = reduce() - if rv is not None and rv is not NotImplemented: - self._save_reduce(rv, obj) - return - raise PicklingError( - "Can't pickle %r: pickle currently only supports primitive types" - % obj - ) - - def _save_global(self, module, qualname): - encoded_mod = module.encode("utf-8") - encoded_name = qualname.encode("utf-8") - # Protocol 4+ uses STACK_GLOBAL with two unicode strings on the - # stack; older protocols use the textual GLOBAL opcode. We emit - # the older form because it round-trips through any unpickler. - self._buf.write(GLOBAL) - self._buf.write(encoded_mod) - self._buf.write(b"\n") - self._buf.write(encoded_name) - self._buf.write(b"\n") - - def _save_reduce(self, rv, obj=None): - if isinstance(rv, str): - self._save_global(rv.rsplit(".", 1)[0] if "." in rv else "builtins", rv) - return - if not isinstance(rv, tuple) or len(rv) < 2: - raise PicklingError("reduce result must be a string or tuple") - func = rv[0] - args = rv[1] - state = rv[2] if len(rv) > 2 else None - listitems = rv[3] if len(rv) > 3 else None - dictitems = rv[4] if len(rv) > 4 else None - self._save(func) - self._save(tuple(args)) - self._buf.write(REDUCE) - # Memoize the just-constructed object *before* applying state / - # items, so a self-referential object can back-reference itself. - if obj is not None: - self._memoize(obj) - # CPython `Pickler._batch_appends` / `_batch_setitems`: drain the - # iterators in MARK…APPENDS / MARK…SETITEMS batches so the loader - # folds the items back into the object on the stack. - if listitems is not None: - items = list(listitems) - for start in range(0, len(items), 1000): - batch = items[start : start + 1000] - self._buf.write(MARK) - for item in batch: - self._save(item) - self._buf.write(APPENDS) - if dictitems is not None: - items = list(dictitems) - for start in range(0, len(items), 1000): - batch = items[start : start + 1000] - self._buf.write(MARK) - for k, v in batch: - self._save(k) - self._save(v) - self._buf.write(SETITEMS) - if state is not None: - self._save(state) - self._buf.write(BUILD) - - def _save_int(self, n): - if 0 <= n < 256: - self._buf.write(BININT1 + bytes([n])) - return - if 0 <= n < 65536: - self._buf.write(BININT2 + struct.pack("d", x)) - def _save_bytes(self, b): - n = len(b) - if n < 256: - self._buf.write(SHORT_BINBYTES + bytes([n]) + b) - elif n < 2**32: - self._buf.write(BINBYTES + struct.pack(" idx: - items.append(self.stack.pop()) - items.reverse() - return items - - -_STOP = object() - - -def _none(u): - u.stack.append(None) - - -def _newtrue(u): - u.stack.append(True) - - -def _newfalse(u): - u.stack.append(False) - - -def _binint(u): - u.stack.append(struct.unpack("d", u._read_short(8))[0]) - - -def _binbytes(u): - n = struct.unpack("= 2 and func_name == "__newobj_ex__": + cls, args, kwargs = args + if not hasattr(cls, "__new__"): + raise PicklingError("args[0] from {} args has no __new__" + .format(func_name)) + if obj is not None and cls is not obj.__class__: + raise PicklingError("args[0] from {} args has the wrong class" + .format(func_name)) + if self.proto >= 4: + save(cls) + save(args) + save(kwargs) + write(NEWOBJ_EX) + else: + func = partial(cls.__new__, cls, *args, **kwargs) + save(func) + save(()) + write(REDUCE) + elif self.proto >= 2 and func_name == "__newobj__": + # A __reduce__ implementation can direct protocol 2 or newer to + # use the more efficient NEWOBJ opcode, while still + # allowing protocol 0 and 1 to work normally. For this to + # work, the function returned by __reduce__ should be + # called __newobj__, and its first argument should be a + # class. The implementation for __newobj__ + # should be as follows, although pickle has no way to + # verify this: + # + # def __newobj__(cls, *args): + # return cls.__new__(cls, *args) + # + # Protocols 0 and 1 will pickle a reference to __newobj__, + # while protocol 2 (and above) will pickle a reference to + # cls, the remaining args tuple, and the NEWOBJ code, + # which calls cls.__new__(cls, *args) at unpickling time + # (see load_newobj below). If __reduce__ returns a + # three-tuple, the state from the third tuple item will be + # pickled regardless of the protocol, calling __setstate__ + # at unpickling time (see load_build below). + # + # Note that no standard __newobj__ implementation exists; + # you have to provide your own. This is to enforce + # compatibility with Python 2.2 (pickles written using + # protocol 0 or 1 in Python 2.3 should be unpicklable by + # Python 2.2). + cls = args[0] + if not hasattr(cls, "__new__"): + raise PicklingError( + "args[0] from __newobj__ args has no __new__") + if obj is not None and cls is not obj.__class__: + raise PicklingError( + "args[0] from __newobj__ args has the wrong class") + args = args[1:] + save(cls) + save(args) + write(NEWOBJ) + else: + save(func) + save(args) + write(REDUCE) -def _additems(u): - items = u._pop_to_mark() - s = u.stack[-1] - for i in items: - s.add(i) + if obj is not None: + # If the object is already in the memo, this means it is + # recursive. In this case, throw away everything we put on the + # stack, and fetch the object back from the memo. + if id(obj) in self.memo: + write(POP + self.get(self.memo[id(obj)][0])) + else: + self.memoize(obj) + # More new special cases (that work with older protocols as + # well): when __reduce__ returns a tuple with 4 or 5 items, + # the 4th and 5th item should be iterators that provide list + # items and dict items (as (key, value) tuples), or None. -def _frozenset(u): - items = u._pop_to_mark() - u.stack.append(frozenset(items)) + if listitems is not None: + self._batch_appends(listitems) + if dictitems is not None: + self._batch_setitems(dictitems) -def _mark(u): - u.markers.append(len(u.stack)) + if state is not None: + if state_setter is None: + save(state) + write(BUILD) + else: + # If a state_setter is specified, call it instead of load_build + # to update obj's with its previous state. + # First, push state_setter and its tuple of expected arguments + # (obj, state) onto the stack. + save(state_setter) + save(obj) # simple BINGET opcode as obj is already memoized. + save(state) + write(TUPLE2) + # Trigger a state_setter(obj, state) function call. + write(REDUCE) + # The purpose of state_setter is to carry-out an + # inplace modification of obj. We do not care about what the + # method might return, so its output is eventually removed from + # the stack. + write(POP) + + # Methods below this point are dispatched through the dispatch table + + dispatch = {} + + def save_none(self, obj): + self.write(NONE) + dispatch[type(None)] = save_none + + def save_bool(self, obj): + if self.proto >= 2: + self.write(NEWTRUE if obj else NEWFALSE) + else: + self.write(TRUE if obj else FALSE) + dispatch[bool] = save_bool + def save_long(self, obj): + if self.bin: + # If the int is small enough to fit in a signed 4-byte 2's-comp + # format, we can store it more efficiently than the general + # case. + # First one- and two-byte unsigned ints: + if obj >= 0: + if obj <= 0xff: + self.write(BININT1 + pack("= 2: + encoded = encode_long(obj) + n = len(encoded) + if n < 256: + self.write(LONG1 + pack("d', obj)) + else: + self.write(FLOAT + repr(obj).encode("ascii") + b'\n') + dispatch[float] = save_float + + def _save_bytes_no_memo(self, obj): + # helper for writing bytes objects for protocol >= 3 + # without memoizing them + assert self.proto >= 3 + n = len(obj) + if n <= 0xff: + self.write(SHORT_BINBYTES + pack(" 0xffffffff and self.proto >= 4: + self._write_large_bytes(BINBYTES8 + pack("= self.framer._FRAME_SIZE_TARGET: + self._write_large_bytes(BINBYTES + pack("= 5 + # without memoizing them + assert self.proto >= 5 + n = len(obj) + if n >= self.framer._FRAME_SIZE_TARGET: + self._write_large_bytes(BYTEARRAY8 + pack("= 5") + with obj.raw() as m: + if not m.contiguous: + raise PicklingError("PickleBuffer can not be pickled when " + "pointing to a non-contiguous buffer") + in_band = True + if self._buffer_callback is not None: + in_band = bool(self._buffer_callback(obj)) + if in_band: + # Write data in-band + # XXX The C implementation avoids a copy here + buf = m.tobytes() + in_memo = id(buf) in self.memo + if m.readonly: + if in_memo: + self._save_bytes_no_memo(buf) + else: + self.save_bytes(buf) + else: + if in_memo: + self._save_bytearray_no_memo(buf) + else: + self.save_bytearray(buf) + else: + # Write data out-of-band + self.write(NEXT_BUFFER) + if m.readonly: + self.write(READONLY_BUFFER) + + dispatch[PickleBuffer] = save_picklebuffer + + def save_str(self, obj): + if self.bin: + encoded = obj.encode('utf-8', 'surrogatepass') + n = len(encoded) + if n <= 0xff and self.proto >= 4: + self.write(SHORT_BINUNICODE + pack(" 0xffffffff and self.proto >= 4: + self._write_large_bytes(BINUNICODE8 + pack("= self.framer._FRAME_SIZE_TARGET: + self._write_large_bytes(BINUNICODE + pack("= 2: + for element in obj: + save(element) + # Subtle. Same as in the big comment below. + if id(obj) in memo: + get = self.get(memo[id(obj)][0]) + self.write(POP * n + get) + else: + self.write(_tuplesize2code[n]) + self.memoize(obj) + return -def _stop(_u): - return _STOP + # proto 0 or proto 1 and tuple isn't empty, or proto > 1 and tuple + # has more than 3 elements. + write = self.write + write(MARK) + for element in obj: + save(element) + + if id(obj) in memo: + # Subtle. d was not in memo when we entered save_tuple(), so + # the process of saving the tuple's elements must have saved + # the tuple itself: the tuple is recursive. The proper action + # now is to throw away everything we put on the stack, and + # simply GET the tuple (it's already constructed). This check + # could have been done in the "for element" loop instead, but + # recursive tuples are a rare thing. + get = self.get(memo[id(obj)][0]) + if self.bin: + write(POP_MARK + get) + else: # proto 0 -- POP_MARK not available + write(POP * (n+1) + get) + return + # No recursion. + write(TUPLE) + self.memoize(obj) -def _pop(u): - u.stack.pop() + dispatch[tuple] = save_tuple + def save_list(self, obj): + if self.bin: + self.write(EMPTY_LIST) + else: # proto 0 -- can't use EMPTY_LIST + self.write(MARK + LIST) -def _pop_mark(u): - idx = u.markers.pop() - del u.stack[idx:] + self.memoize(obj) + self._batch_appends(obj) + dispatch[list] = save_list -def _put(u): - idx = int(_read_line(u)) - u.memo[idx] = u.stack[-1] + _BATCHSIZE = 1000 + def _batch_appends(self, items): + # Helper to batch up APPENDS sequences + save = self.save + write = self.write -def _binput(u): - idx = u.file.read(1)[0] - u.memo[idx] = u.stack[-1] + if not self.bin: + for x in items: + save(x) + write(APPEND) + return + it = iter(items) + while True: + tmp = list(islice(it, self._BATCHSIZE)) + n = len(tmp) + if n > 1: + write(MARK) + for x in tmp: + save(x) + write(APPENDS) + elif n: + save(tmp[0]) + write(APPEND) + # else tmp is empty, and we're done + if n < self._BATCHSIZE: + return -def _long_binput(u): - idx = struct.unpack("= 1 only + save = self.save + write = self.write -def _get(u): - idx = int(_read_line(u)) - u.stack.append(u.memo[idx]) + if not self.bin: + for k, v in items: + save(k) + save(v) + write(SETITEM) + return + it = iter(items) + while True: + tmp = list(islice(it, self._BATCHSIZE)) + n = len(tmp) + if n > 1: + write(MARK) + for k, v in tmp: + save(k) + save(v) + write(SETITEMS) + elif n: + k, v = tmp[0] + save(k) + save(v) + write(SETITEM) + # else tmp is empty, and we're done + if n < self._BATCHSIZE: + return -def _binget(u): - idx = u.file.read(1)[0] - u.stack.append(u.memo[idx]) + def save_set(self, obj): + save = self.save + write = self.write + if self.proto < 4: + self.save_reduce(set, (list(obj),), obj=obj) + return -def _long_binget(u): - idx = struct.unpack(" 0: + write(MARK) + for item in batch: + save(item) + write(ADDITEMS) + if n < self._BATCHSIZE: + return + dispatch[set] = save_set -def _read_line(u): - out = b"" - while True: - ch = u.file.read(1) - if not ch: - raise UnpicklingError("unexpected EOF reading line") - if ch == b"\n": - break - out = out + ch - return out + def save_frozenset(self, obj): + save = self.save + write = self.write + if self.proto < 4: + self.save_reduce(frozenset, (list(obj),), obj=obj) + return -def _global(u): - module = _read_line(u).decode("utf-8") - name = _read_line(u).decode("utf-8") - u.stack.append(_find_class(module, name)) + write(MARK) + for item in obj: + save(item) + if id(obj) in self.memo: + # If the object is already in the memo, this means it is + # recursive. In this case, throw away everything we put on the + # stack, and fetch the object back from the memo. + write(POP_MARK + self.get(self.memo[id(obj)][0])) + return -def _stack_global(u): - name = u.stack.pop() - module = u.stack.pop() - u.stack.append(_find_class(module, name)) + write(FROZENSET) + self.memoize(obj) + dispatch[frozenset] = save_frozenset + def save_global(self, obj, name=None): + write = self.write + memo = self.memo -def _find_class(module_name, qualname): - # `builtins` is a synthetic module name CPython uses for `len`, - # `dict`, `Exception`, etc. WeavePy exposes those as ambient - # globals rather than via an importable module, so route the - # lookup through the running frame's builtins (which the VM - # populates exactly with `default_builtins()`). - if module_name in ("builtins", "__builtin__"): - import builtins as _b # may or may not exist as a module - obj = _b - else: - import sys as _sys - obj = _sys.modules.get(module_name) - if obj is None: - import importlib - obj = importlib.import_module(module_name) - for part in qualname.split("."): - obj = getattr(obj, part) - return obj - - -def _reduce(u): - args = u.stack.pop() - func = u.stack.pop() - u.stack.append(func(*args)) - - -def _build(u): - state = u.stack.pop() - obj = u.stack[-1] - setstate = getattr(obj, "__setstate__", None) - if setstate is not None: - setstate(state) - return - # CPython `load_build`: a 2-tuple state is (__dict__ state, slot - # state); apply the dict half directly and the slots via setattr. - slotstate = None - if isinstance(state, tuple) and len(state) == 2: - state, slotstate = state - if state: - # Write the dict half straight into the instance dict, bypassing - # `__setattr__` (CPython `load_build` does `inst.__dict__[k] = v`) - # — this is what lets frozen dataclasses unpickle. - d = obj.__dict__ - for k, v in state.items(): - d[k] = v - if slotstate: - for k, v in slotstate.items(): - setattr(obj, k, v) - - -def _newobj(u): - args = u.stack.pop() - cls = u.stack.pop() - u.stack.append(cls.__new__(cls, *args)) - - -def _int_op(u): - # Protocol-0 INT covers booleans too: `I00`/`I01` are False/True. - data = _read_line(u) - if data == b"00": - u.stack.append(False) - elif data == b"01": - u.stack.append(True) - else: - u.stack.append(int(data)) + if name is None: + name = getattr(obj, '__qualname__', None) + if name is None: + name = obj.__name__ + module_name = whichmodule(obj, name) + try: + __import__(module_name, level=0) + module = sys.modules[module_name] + obj2, parent = _getattribute(module, name) + except (ImportError, KeyError, AttributeError): + raise PicklingError( + "Can't pickle %r: it's not found as %s.%s" % + (obj, module_name, name)) from None + else: + if obj2 is not obj: + raise PicklingError( + "Can't pickle %r: it's not the same object as %s.%s" % + (obj, module_name, name)) + + if self.proto >= 2: + code = _extension_registry.get((module_name, name), _NoValue) + if code is not _NoValue: + if code <= 0xff: + data = pack("= 3. + if self.proto >= 4: + self.save(module_name) + self.save(name) + write(STACK_GLOBAL) + elif '.' in name: + # In protocol < 4, objects with multi-part __qualname__ + # are represented as + # getattr(getattr(..., attrname1), attrname2). + dotted_path = name.split('.') + name = dotted_path.pop(0) + save = self.save + for attrname in dotted_path: + save(getattr) + if self.proto < 2: + write(MARK) + self._save_toplevel_by_name(module_name, name) + for attrname in dotted_path: + save(attrname) + if self.proto < 2: + write(TUPLE) + else: + write(TUPLE2) + write(REDUCE) + else: + self._save_toplevel_by_name(module_name, name) -def _long_op(u): - data = _read_line(u) - if data.endswith(b"L"): - data = data[:-1] - u.stack.append(int(data)) + self.memoize(obj) + def _save_toplevel_by_name(self, module_name, name): + if self.proto >= 3: + # Non-ASCII identifiers are supported only with protocols >= 3. + self.write(GLOBAL + bytes(module_name, "utf-8") + b'\n' + + bytes(name, "utf-8") + b'\n') + else: + if self.fix_imports: + r_name_mapping = _compat_pickle.REVERSE_NAME_MAPPING + r_import_mapping = _compat_pickle.REVERSE_IMPORT_MAPPING + if (module_name, name) in r_name_mapping: + module_name, name = r_name_mapping[(module_name, name)] + elif module_name in r_import_mapping: + module_name = r_import_mapping[module_name] + try: + self.write(GLOBAL + bytes(module_name, "ascii") + b'\n' + + bytes(name, "ascii") + b'\n') + except UnicodeEncodeError: + raise PicklingError( + "can't pickle global identifier '%s.%s' using " + "pickle protocol %i" % (module_name, name, self.proto)) from None -def _float_op(u): - u.stack.append(float(_read_line(u))) + def save_type(self, obj): + if obj is type(None): + return self.save_reduce(type, (None,), obj=obj) + elif obj is type(NotImplemented): + return self.save_reduce(type, (NotImplemented,), obj=obj) + elif obj is type(...): + return self.save_reduce(type, (...,), obj=obj) + return self.save_global(obj) + dispatch[FunctionType] = save_global + dispatch[type] = save_type -def _string_op(u): - data = _read_line(u).decode("latin-1") - # Repr-quoted: strip the matching quotes and unescape. - if len(data) >= 2 and data[0] == data[-1] and data[0] in "\"'": - data = data[1:-1] - else: - raise UnpicklingError("the STRING opcode argument must be quoted") - u.stack.append(data.encode("latin-1").decode("unicode_escape")) +# Unpickling machinery -def _unicode_op(u): - u.stack.append(_read_line(u).decode("raw-unicode-escape")) +class _Unpickler: + def __init__(self, file, *, fix_imports=True, + encoding="ASCII", errors="strict", buffers=None): + """This takes a binary file for reading a pickle data stream. + + The protocol version of the pickle is detected automatically, so + no proto argument is needed. + + The argument *file* must have two methods, a read() method that + takes an integer argument, and a readline() method that requires + no arguments. Both methods should return bytes. Thus *file* + can be a binary file object opened for reading, an io.BytesIO + object, or any other custom object that meets this interface. + + The file-like object must have two methods, a read() method + that takes an integer argument, and a readline() method that + requires no arguments. Both methods should return bytes. + Thus file-like object can be a binary file object opened for + reading, a BytesIO object, or any other custom object that + meets this interface. + + If *buffers* is not None, it should be an iterable of buffer-enabled + objects that is consumed each time the pickle stream references + an out-of-band buffer view. Such buffers have been given in order + to the *buffer_callback* of a Pickler object. + + If *buffers* is None (the default), then the buffers are taken + from the pickle stream, assuming they are serialized there. + It is an error for *buffers* to be None if the pickle stream + was produced with a non-None *buffer_callback*. + + Other optional arguments are *fix_imports*, *encoding* and + *errors*, which are used to control compatibility support for + pickle stream generated by Python 2. If *fix_imports* is True, + pickle will try to map the old Python 2 names to the new names + used in Python 3. The *encoding* and *errors* tell pickle how + to decode 8-bit string instances pickled by Python 2; these + default to 'ASCII' and 'strict', respectively. *encoding* can be + 'bytes' to read these 8-bit string instances as bytes objects. + """ + self._buffers = iter(buffers) if buffers is not None else None + self._file_readline = file.readline + self._file_read = file.read + self.memo = {} + self.encoding = encoding + self.errors = errors + self.proto = 0 + self.fix_imports = fix_imports -def _list_op(u): - u.stack.append(u._pop_to_mark()) + def load(self): + """Read a pickled object representation from the open file. + + Return the reconstituted object hierarchy specified in the file. + """ + # Check whether Unpickler was initialized correctly. This is + # only needed to mimic the behavior of _pickle.Unpickler.dump(). + if not hasattr(self, "_file_read"): + raise UnpicklingError("Unpickler.__init__() was not called by " + "%s.__init__()" % (self.__class__.__name__,)) + self._unframer = _Unframer(self._file_read, self._file_readline) + self.read = self._unframer.read + self.readinto = self._unframer.readinto + self.readline = self._unframer.readline + self.metastack = [] + self.stack = [] + self.append = self.stack.append + self.proto = 0 + read = self.read + dispatch = self.dispatch + try: + while True: + key = read(1) + if not key: + raise EOFError + assert isinstance(key, bytes_types) + dispatch[key[0]](self) + except _Stop as stopinst: + return stopinst.value + + # Return a list of items pushed in the stack after last MARK instruction. + def pop_mark(self): + items = self.stack + self.stack = self.metastack.pop() + self.append = self.stack.append + return items + def persistent_load(self, pid): + raise UnpicklingError("unsupported persistent id encountered") -def _dict_op(u): - items = u._pop_to_mark() - d = {} - for i in range(0, len(items), 2): - d[items[i]] = items[i + 1] - u.stack.append(d) + dispatch = {} + def load_proto(self): + proto = self.read(1)[0] + if not 0 <= proto <= HIGHEST_PROTOCOL: + raise ValueError("unsupported pickle protocol: %d" % proto) + self.proto = proto + dispatch[PROTO[0]] = load_proto -def _append(u): - value = u.stack.pop() - u.stack[-1].append(value) + def load_frame(self): + frame_size, = unpack(' sys.maxsize: + raise ValueError("frame size > sys.maxsize: %d" % frame_size) + self._unframer.load_frame(frame_size) + dispatch[FRAME[0]] = load_frame + def load_persid(self): + try: + pid = self.readline()[:-1].decode("ascii") + except UnicodeDecodeError: + raise UnpicklingError( + "persistent IDs in protocol 0 must be ASCII strings") + self.append(self.persistent_load(pid)) + dispatch[PERSID[0]] = load_persid + + def load_binpersid(self): + pid = self.stack.pop() + self.append(self.persistent_load(pid)) + dispatch[BINPERSID[0]] = load_binpersid + + def load_none(self): + self.append(None) + dispatch[NONE[0]] = load_none + + def load_false(self): + self.append(False) + dispatch[NEWFALSE[0]] = load_false + + def load_true(self): + self.append(True) + dispatch[NEWTRUE[0]] = load_true + + def load_int(self): + data = self.readline() + if data == FALSE[1:]: + val = False + elif data == TRUE[1:]: + val = True + else: + val = int(data, 0) + self.append(val) + dispatch[INT[0]] = load_int + + def load_binint(self): + self.append(unpack('d', self.read(8))[0]) + dispatch[BINFLOAT[0]] = load_binfloat -def _setitem(u): - value = u.stack.pop() - key = u.stack.pop() - u.stack[-1][key] = value + def _decode_string(self, value): + # Used to allow strings from Python 2 to be decoded either as + # bytes or Unicode strings. This should be used only with the + # STRING, BINSTRING and SHORT_BINSTRING opcodes. + if self.encoding == "bytes": + return value + else: + return value.decode(self.encoding, self.errors) + def load_string(self): + data = self.readline()[:-1] + # Strip outermost quotes + if len(data) >= 2 and data[0] == data[-1] and data[0] in b'"\'': + data = data[1:-1] + else: + raise UnpicklingError("the STRING opcode argument must be quoted") + self.append(self._decode_string(codecs.escape_decode(data)[0])) + dispatch[STRING[0]] = load_string + + def load_binstring(self): + # Deprecated BINSTRING uses signed 32-bit length + len, = unpack(' maxsize: + raise UnpicklingError("BINBYTES exceeds system's maximum size " + "of %d bytes" % maxsize) + self.append(self.read(len)) + dispatch[BINBYTES[0]] = load_binbytes + + def load_unicode(self): + self.append(str(self.readline()[:-1], 'raw-unicode-escape')) + dispatch[UNICODE[0]] = load_unicode + + def load_binunicode(self): + len, = unpack(' maxsize: + raise UnpicklingError("BINUNICODE exceeds system's maximum size " + "of %d bytes" % maxsize) + self.append(str(self.read(len), 'utf-8', 'surrogatepass')) + dispatch[BINUNICODE[0]] = load_binunicode + + def load_binunicode8(self): + len, = unpack(' maxsize: + raise UnpicklingError("BINUNICODE8 exceeds system's maximum size " + "of %d bytes" % maxsize) + self.append(str(self.read(len), 'utf-8', 'surrogatepass')) + dispatch[BINUNICODE8[0]] = load_binunicode8 + + def load_binbytes8(self): + len, = unpack(' maxsize: + raise UnpicklingError("BINBYTES8 exceeds system's maximum size " + "of %d bytes" % maxsize) + self.append(self.read(len)) + dispatch[BINBYTES8[0]] = load_binbytes8 + + def load_bytearray8(self): + len, = unpack(' maxsize: + raise UnpicklingError("BYTEARRAY8 exceeds system's maximum size " + "of %d bytes" % maxsize) + b = bytearray(len) + self.readinto(b) + self.append(b) + dispatch[BYTEARRAY8[0]] = load_bytearray8 + + def load_next_buffer(self): + if self._buffers is None: + raise UnpicklingError("pickle stream refers to out-of-band data " + "but no *buffers* argument was given") + try: + buf = next(self._buffers) + except StopIteration: + raise UnpicklingError("not enough out-of-band buffers") + self.append(buf) + dispatch[NEXT_BUFFER[0]] = load_next_buffer + + def load_readonly_buffer(self): + buf = self.stack[-1] + with memoryview(buf) as m: + if not m.readonly: + self.stack[-1] = m.toreadonly() + dispatch[READONLY_BUFFER[0]] = load_readonly_buffer + + def load_short_binstring(self): + len = self.read(1)[0] + data = self.read(len) + self.append(self._decode_string(data)) + dispatch[SHORT_BINSTRING[0]] = load_short_binstring + + def load_short_binbytes(self): + len = self.read(1)[0] + self.append(self.read(len)) + dispatch[SHORT_BINBYTES[0]] = load_short_binbytes + + def load_short_binunicode(self): + len = self.read(1)[0] + self.append(str(self.read(len), 'utf-8', 'surrogatepass')) + dispatch[SHORT_BINUNICODE[0]] = load_short_binunicode + + def load_tuple(self): + items = self.pop_mark() + self.append(tuple(items)) + dispatch[TUPLE[0]] = load_tuple + + def load_empty_tuple(self): + self.append(()) + dispatch[EMPTY_TUPLE[0]] = load_empty_tuple + + def load_tuple1(self): + self.stack[-1] = (self.stack[-1],) + dispatch[TUPLE1[0]] = load_tuple1 + + def load_tuple2(self): + self.stack[-2:] = [(self.stack[-2], self.stack[-1])] + dispatch[TUPLE2[0]] = load_tuple2 + + def load_tuple3(self): + self.stack[-3:] = [(self.stack[-3], self.stack[-2], self.stack[-1])] + dispatch[TUPLE3[0]] = load_tuple3 + + def load_empty_list(self): + self.append([]) + dispatch[EMPTY_LIST[0]] = load_empty_list + + def load_empty_dictionary(self): + self.append({}) + dispatch[EMPTY_DICT[0]] = load_empty_dictionary + + def load_empty_set(self): + self.append(set()) + dispatch[EMPTY_SET[0]] = load_empty_set + + def load_frozenset(self): + items = self.pop_mark() + self.append(frozenset(items)) + dispatch[FROZENSET[0]] = load_frozenset + + def load_list(self): + items = self.pop_mark() + self.append(items) + dispatch[LIST[0]] = load_list + + def load_dict(self): + items = self.pop_mark() + d = {items[i]: items[i+1] + for i in range(0, len(items), 2)} + self.append(d) + dispatch[DICT[0]] = load_dict + + # INST and OBJ differ only in how they get a class object. It's not + # only sensible to do the rest in a common routine, the two routines + # previously diverged and grew different bugs. + # klass is the class to instantiate, and k points to the topmost mark + # object, following which are the arguments for klass.__init__. + def _instantiate(self, klass, args): + if (args or not isinstance(klass, type) or + hasattr(klass, "__getinitargs__")): + try: + value = klass(*args) + except TypeError as err: + raise TypeError("in constructor for %s: %s" % + (klass.__name__, str(err)), err.__traceback__) + else: + value = klass.__new__(klass) + self.append(value) + + def load_inst(self): + module = self.readline()[:-1].decode("ascii") + name = self.readline()[:-1].decode("ascii") + klass = self.find_class(module, name) + self._instantiate(klass, self.pop_mark()) + dispatch[INST[0]] = load_inst + + def load_obj(self): + # Stack is ... markobject classobject arg1 arg2 ... + args = self.pop_mark() + cls = args.pop(0) + self._instantiate(cls, args) + dispatch[OBJ[0]] = load_obj + + def load_newobj(self): + args = self.stack.pop() + cls = self.stack.pop() + obj = cls.__new__(cls, *args) + self.append(obj) + dispatch[NEWOBJ[0]] = load_newobj + + def load_newobj_ex(self): + kwargs = self.stack.pop() + args = self.stack.pop() + cls = self.stack.pop() + obj = cls.__new__(cls, *args, **kwargs) + self.append(obj) + dispatch[NEWOBJ_EX[0]] = load_newobj_ex + + def load_global(self): + module = self.readline()[:-1].decode("utf-8") + name = self.readline()[:-1].decode("utf-8") + klass = self.find_class(module, name) + self.append(klass) + dispatch[GLOBAL[0]] = load_global + + def load_stack_global(self): + name = self.stack.pop() + module = self.stack.pop() + if type(name) is not str or type(module) is not str: + raise UnpicklingError("STACK_GLOBAL requires str") + self.append(self.find_class(module, name)) + dispatch[STACK_GLOBAL[0]] = load_stack_global + + def load_ext1(self): + code = self.read(1)[0] + self.get_extension(code) + dispatch[EXT1[0]] = load_ext1 + + def load_ext2(self): + code, = unpack('= 4: + return _getattribute(sys.modules[module], name)[0] + else: + return getattr(sys.modules[module], name) + + def load_reduce(self): + stack = self.stack + args = stack.pop() + func = stack[-1] + stack[-1] = func(*args) + dispatch[REDUCE[0]] = load_reduce + + def load_pop(self): + if self.stack: + del self.stack[-1] + else: + self.pop_mark() + dispatch[POP[0]] = load_pop -def _dup(u): - u.stack.append(u.stack[-1]) + def load_pop_mark(self): + self.pop_mark() + dispatch[POP_MARK[0]] = load_pop_mark + def load_dup(self): + self.append(self.stack[-1]) + dispatch[DUP[0]] = load_dup -def _newobj_ex(u): - kwargs = u.stack.pop() - args = u.stack.pop() - cls = u.stack.pop() - if kwargs: - u.stack.append(cls.__new__(cls, *args, **kwargs)) + def load_get(self): + i = int(self.readline()[:-1]) + try: + self.append(self.memo[i]) + except KeyError: + msg = f'Memo value not found at index {i}' + raise UnpicklingError(msg) from None + dispatch[GET[0]] = load_get + + def load_binget(self): + i = self.read(1)[0] + try: + self.append(self.memo[i]) + except KeyError as exc: + msg = f'Memo value not found at index {i}' + raise UnpicklingError(msg) from None + dispatch[BINGET[0]] = load_binget + + def load_long_binget(self): + i, = unpack(' maxsize: + raise ValueError("negative LONG_BINPUT argument") + self.memo[i] = self.stack[-1] + dispatch[LONG_BINPUT[0]] = load_long_binput + + def load_memoize(self): + memo = self.memo + memo[len(memo)] = self.stack[-1] + dispatch[MEMOIZE[0]] = load_memoize + + def load_append(self): + stack = self.stack + value = stack.pop() + list = stack[-1] + list.append(value) + dispatch[APPEND[0]] = load_append + + def load_appends(self): + items = self.pop_mark() + list_obj = self.stack[-1] + try: + extend = list_obj.extend + except AttributeError: + pass + else: + extend(items) + return + # Even if the PEP 307 requires extend() and append() methods, + # fall back on append() if the object has no extend() method + # for backward compatibility. + append = list_obj.append + for item in items: + append(item) + dispatch[APPENDS[0]] = load_appends + + def load_setitem(self): + stack = self.stack + value = stack.pop() + key = stack.pop() + dict = stack[-1] + dict[key] = value + dispatch[SETITEM[0]] = load_setitem + + def load_setitems(self): + items = self.pop_mark() + dict = self.stack[-1] + for i in range(0, len(items), 2): + dict[items[i]] = items[i + 1] + dispatch[SETITEMS[0]] = load_setitems + + def load_additems(self): + items = self.pop_mark() + set_obj = self.stack[-1] + if isinstance(set_obj, set): + set_obj.update(items) + else: + add = set_obj.add + for item in items: + add(item) + dispatch[ADDITEMS[0]] = load_additems + + def load_build(self): + stack = self.stack + state = stack.pop() + inst = stack[-1] + setstate = getattr(inst, "__setstate__", _NoValue) + if setstate is not _NoValue: + setstate(state) + return + slotstate = None + if isinstance(state, tuple) and len(state) == 2: + state, slotstate = state + if state: + inst_dict = inst.__dict__ + intern = sys.intern + for k, v in state.items(): + if type(k) is str: + inst_dict[intern(k)] = v + else: + inst_dict[k] = v + if slotstate: + for k, v in slotstate.items(): + setattr(inst, k, v) + dispatch[BUILD[0]] = load_build + + def load_mark(self): + self.metastack.append(self.stack) + self.stack = [] + self.append = self.stack.append + dispatch[MARK[0]] = load_mark + + def load_stop(self): + value = self.stack.pop() + raise _Stop(value) + dispatch[STOP[0]] = load_stop + + +# Shorthands + +def _dump(obj, file, protocol=None, *, fix_imports=True, buffer_callback=None): + _Pickler(file, protocol, fix_imports=fix_imports, + buffer_callback=buffer_callback).dump(obj) + +def _dumps(obj, protocol=None, *, fix_imports=True, buffer_callback=None): + f = io.BytesIO() + _Pickler(f, protocol, fix_imports=fix_imports, + buffer_callback=buffer_callback).dump(obj) + res = f.getvalue() + assert isinstance(res, bytes_types) + return res + +def _load(file, *, fix_imports=True, encoding="ASCII", errors="strict", + buffers=None): + return _Unpickler(file, fix_imports=fix_imports, buffers=buffers, + encoding=encoding, errors=errors).load() + +def _loads(s, /, *, fix_imports=True, encoding="ASCII", errors="strict", + buffers=None): + if isinstance(s, str): + raise TypeError("Can't load pickle from unicode string") + file = io.BytesIO(s) + return _Unpickler(file, fix_imports=fix_imports, buffers=buffers, + encoding=encoding, errors=errors).load() + +# Use the faster _pickle if possible +try: + from _pickle import ( + PickleError, + PicklingError, + UnpicklingError, + Pickler, + Unpickler, + dump, + dumps, + load, + loads + ) +except ImportError: + Pickler, Unpickler = _Pickler, _Unpickler + dump, dumps, load, loads = _dump, _dumps, _load, _loads + +# Doctest +def _test(): + import doctest + return doctest.testmod() + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser( + description='display contents of the pickle files') + parser.add_argument( + 'pickle_file', + nargs='*', help='the pickle file') + parser.add_argument( + '-t', '--test', action='store_true', + help='run self-test suite') + parser.add_argument( + '-v', action='store_true', + help='run verbosely; only affects self-test run') + args = parser.parse_args() + if args.test: + _test() else: - u.stack.append(cls.__new__(cls, *args)) - - -_OPCODES = { - PROTO: _proto, - FRAME: _frame, - NONE: _none, - NEWTRUE: _newtrue, - NEWFALSE: _newfalse, - BININT: _binint, - BININT1: _binint1, - BININT2: _binint2, - LONG1: _long1, - LONG4: _long4, - BINFLOAT: _binfloat, - BINBYTES: _binbytes, - SHORT_BINBYTES: _short_binbytes, - BINBYTES8: _binbytes8, - BINUNICODE: _binunicode, - SHORT_BINUNICODE: _short_binunicode, - BINUNICODE8: _binunicode8, - EMPTY_TUPLE: _empty_tuple, - TUPLE1: _tuple1, - TUPLE2: _tuple2, - TUPLE3: _tuple3, - TUPLE: _tuple_op, - EMPTY_LIST: _empty_list, - APPENDS: _appends, - EMPTY_DICT: _empty_dict, - SETITEMS: _setitems, - EMPTY_SET: _empty_set, - ADDITEMS: _additems, - FROZENSET: _frozenset, - MARK: _mark, - STOP: _stop, - POP: _pop, - POP_MARK: _pop_mark, - PUT: _put, - BINPUT: _binput, - LONG_BINPUT: _long_binput, - MEMOIZE: _memoize, - GET: _get, - BINGET: _binget, - LONG_BINGET: _long_binget, - GLOBAL: _global, - STACK_GLOBAL: _stack_global, - REDUCE: _reduce, - BUILD: _build, - NEWOBJ: _newobj, - NEWOBJ_EX: _newobj_ex, - INT: _int_op, - LONG: _long_op, - FLOAT: _float_op, - STRING: _string_op, - BINSTRING: _binstring, - SHORT_BINSTRING: _short_binstring, - UNICODE: _unicode_op, - LIST: _list_op, - DICT: _dict_op, - APPEND: _append, - SETITEM: _setitem, - DUP: _dup, -} - - -__all__ = ["dump", "dumps", "load", "loads", - "Pickler", "Unpickler", - "PickleError", "PicklingError", "UnpicklingError", - "DEFAULT_PROTOCOL", "HIGHEST_PROTOCOL"] - - -# Public class aliases for code that does ``pickle.Pickler(f).dump(obj)``. -class Pickler(_Pickler): - def __init__(self, file, protocol=None, *, fix_imports=True, - buffer_callback=None): - super().__init__(file, protocol if protocol is not None else DEFAULT_PROTOCOL) - - -class Unpickler(_Unpickler): - pass - - -# CPython keeps the pure-Python implementations reachable under -# underscore names after the C-accelerator import (`pickle._dumps` is -# probed by test_descr's reduce checks). There is no separate C -# implementation here, so they alias the public functions. -_dump, _dumps, _load, _loads = dump, dumps, load, loads + if not args.pickle_file: + parser.print_help() + else: + import pprint + for fn in args.pickle_file: + if fn == '-': + obj = load(sys.stdin.buffer) + else: + with open(fn, 'rb') as f: + obj = load(f) + pprint.pprint(obj) diff --git a/crates/weavepy-vm/src/stdlib/python/re_engine.py b/crates/weavepy-vm/src/stdlib/python/re_engine.py index f32f102..dc2c38e 100644 --- a/crates/weavepy-vm/src/stdlib/python/re_engine.py +++ b/crates/weavepy-vm/src/stdlib/python/re_engine.py @@ -13,6 +13,7 @@ import sys import _sre +from types import GenericAlias as _GenericAlias from . import _parser from ._constants import error as PatternError @@ -85,6 +86,10 @@ def _clamp_span(string, pos, endpos): class Pattern: __module__ = 're' + # PEP 585: `re.Pattern[str]` / `re.Pattern[bytes]` yield a + # `types.GenericAlias` (CPython exposes this on the C `Pattern` type). + __class_getitem__ = classmethod(_GenericAlias) + def __init__(self, handle, pattern, flags, groups, groupindex, indexgroup): self._handle = handle self.pattern = pattern @@ -294,6 +299,9 @@ def _flags_repr(flags): class Match: __module__ = 're' + # PEP 585: `re.Match[str]` / `re.Match[bytes]` yield a `types.GenericAlias`. + __class_getitem__ = classmethod(_GenericAlias) + def __init__(self, pattern, string, pos, endpos, r): self.re = pattern self.string = string diff --git a/crates/weavepy-vm/src/stdlib/python/shlex.py b/crates/weavepy-vm/src/stdlib/python/shlex.py new file mode 100644 index 0000000..f482161 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/shlex.py @@ -0,0 +1,345 @@ +"""A lexical analyzer class for simple shell-like syntaxes.""" + +# Module and documentation by Eric S. Raymond, 21 Dec 1998 +# Input stacking and error message cleanup added by ESR, March 2000 +# push_source() and pop_source() made explicit by ESR, January 2001. +# Posix compliance, split(), string arguments, and +# iterator interface by Gustavo Niemeyer, April 2003. +# changes to tokenize more like Posix shells by Vinay Sajip, July 2016. + +import os +import re +import sys +from collections import deque + +from io import StringIO + +__all__ = ["shlex", "split", "quote", "join"] + +class shlex: + "A lexical analyzer class for simple shell-like syntaxes." + def __init__(self, instream=None, infile=None, posix=False, + punctuation_chars=False): + if isinstance(instream, str): + instream = StringIO(instream) + if instream is not None: + self.instream = instream + self.infile = infile + else: + self.instream = sys.stdin + self.infile = None + self.posix = posix + if posix: + self.eof = None + else: + self.eof = '' + self.commenters = '#' + self.wordchars = ('abcdfeghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_') + if self.posix: + self.wordchars += ('ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ' + 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ') + self.whitespace = ' \t\r\n' + self.whitespace_split = False + self.quotes = '\'"' + self.escape = '\\' + self.escapedquotes = '"' + self.state = ' ' + self.pushback = deque() + self.lineno = 1 + self.debug = 0 + self.token = '' + self.filestack = deque() + self.source = None + if not punctuation_chars: + punctuation_chars = '' + elif punctuation_chars is True: + punctuation_chars = '();<>|&' + self._punctuation_chars = punctuation_chars + if punctuation_chars: + # _pushback_chars is a push back queue used by lookahead logic + self._pushback_chars = deque() + # these chars added because allowed in file names, args, wildcards + self.wordchars += '~-./*?=' + #remove any punctuation chars from wordchars + t = self.wordchars.maketrans(dict.fromkeys(punctuation_chars)) + self.wordchars = self.wordchars.translate(t) + + @property + def punctuation_chars(self): + return self._punctuation_chars + + def push_token(self, tok): + "Push a token onto the stack popped by the get_token method" + if self.debug >= 1: + print("shlex: pushing token " + repr(tok)) + self.pushback.appendleft(tok) + + def push_source(self, newstream, newfile=None): + "Push an input source onto the lexer's input source stack." + if isinstance(newstream, str): + newstream = StringIO(newstream) + self.filestack.appendleft((self.infile, self.instream, self.lineno)) + self.infile = newfile + self.instream = newstream + self.lineno = 1 + if self.debug: + if newfile is not None: + print('shlex: pushing to file %s' % (self.infile,)) + else: + print('shlex: pushing to stream %s' % (self.instream,)) + + def pop_source(self): + "Pop the input source stack." + self.instream.close() + (self.infile, self.instream, self.lineno) = self.filestack.popleft() + if self.debug: + print('shlex: popping to %s, line %d' \ + % (self.instream, self.lineno)) + self.state = ' ' + + def get_token(self): + "Get a token from the input stream (or from stack if it's nonempty)" + if self.pushback: + tok = self.pushback.popleft() + if self.debug >= 1: + print("shlex: popping token " + repr(tok)) + return tok + # No pushback. Get a token. + raw = self.read_token() + # Handle inclusions + if self.source is not None: + while raw == self.source: + spec = self.sourcehook(self.read_token()) + if spec: + (newfile, newstream) = spec + self.push_source(newstream, newfile) + raw = self.get_token() + # Maybe we got EOF instead? + while raw == self.eof: + if not self.filestack: + return self.eof + else: + self.pop_source() + raw = self.get_token() + # Neither inclusion nor EOF + if self.debug >= 1: + if raw != self.eof: + print("shlex: token=" + repr(raw)) + else: + print("shlex: token=EOF") + return raw + + def read_token(self): + quoted = False + escapedstate = ' ' + while True: + if self.punctuation_chars and self._pushback_chars: + nextchar = self._pushback_chars.pop() + else: + nextchar = self.instream.read(1) + if nextchar == '\n': + self.lineno += 1 + if self.debug >= 3: + print("shlex: in state %r I see character: %r" % (self.state, + nextchar)) + if self.state is None: + self.token = '' # past end of file + break + elif self.state == ' ': + if not nextchar: + self.state = None # end of file + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in whitespace state") + if self.token or (self.posix and quoted): + break # emit current token + else: + continue + elif nextchar in self.commenters: + self.instream.readline() + self.lineno += 1 + elif self.posix and nextchar in self.escape: + escapedstate = 'a' + self.state = nextchar + elif nextchar in self.wordchars: + self.token = nextchar + self.state = 'a' + elif nextchar in self.punctuation_chars: + self.token = nextchar + self.state = 'c' + elif nextchar in self.quotes: + if not self.posix: + self.token = nextchar + self.state = nextchar + elif self.whitespace_split: + self.token = nextchar + self.state = 'a' + else: + self.token = nextchar + if self.token or (self.posix and quoted): + break # emit current token + else: + continue + elif self.state in self.quotes: + quoted = True + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in quotes state") + # XXX what error should be raised here? + raise ValueError("No closing quotation") + if nextchar == self.state: + if not self.posix: + self.token += nextchar + self.state = ' ' + break + else: + self.state = 'a' + elif (self.posix and nextchar in self.escape and self.state + in self.escapedquotes): + escapedstate = self.state + self.state = nextchar + else: + self.token += nextchar + elif self.state in self.escape: + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in escape state") + # XXX what error should be raised here? + raise ValueError("No escaped character") + # In posix shells, only the quote itself or the escape + # character may be escaped within quotes. + if (escapedstate in self.quotes and + nextchar != self.state and nextchar != escapedstate): + self.token += self.state + self.token += nextchar + self.state = escapedstate + elif self.state in ('a', 'c'): + if not nextchar: + self.state = None # end of file + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in word state") + self.state = ' ' + if self.token or (self.posix and quoted): + break # emit current token + else: + continue + elif nextchar in self.commenters: + self.instream.readline() + self.lineno += 1 + if self.posix: + self.state = ' ' + if self.token or (self.posix and quoted): + break # emit current token + else: + continue + elif self.state == 'c': + if nextchar in self.punctuation_chars: + self.token += nextchar + else: + if nextchar not in self.whitespace: + self._pushback_chars.append(nextchar) + self.state = ' ' + break + elif self.posix and nextchar in self.quotes: + self.state = nextchar + elif self.posix and nextchar in self.escape: + escapedstate = 'a' + self.state = nextchar + elif (nextchar in self.wordchars or nextchar in self.quotes + or (self.whitespace_split and + nextchar not in self.punctuation_chars)): + self.token += nextchar + else: + if self.punctuation_chars: + self._pushback_chars.append(nextchar) + else: + self.pushback.appendleft(nextchar) + if self.debug >= 2: + print("shlex: I see punctuation in word state") + self.state = ' ' + if self.token or (self.posix and quoted): + break # emit current token + else: + continue + result = self.token + self.token = '' + if self.posix and not quoted and result == '': + result = None + if self.debug > 1: + if result: + print("shlex: raw token=" + repr(result)) + else: + print("shlex: raw token=EOF") + return result + + def sourcehook(self, newfile): + "Hook called on a filename to be sourced." + if newfile[0] == '"': + newfile = newfile[1:-1] + # This implements cpp-like semantics for relative-path inclusion. + if isinstance(self.infile, str) and not os.path.isabs(newfile): + newfile = os.path.join(os.path.dirname(self.infile), newfile) + return (newfile, open(newfile, "r")) + + def error_leader(self, infile=None, lineno=None): + "Emit a C-compiler-like, Emacs-friendly error-message leader." + if infile is None: + infile = self.infile + if lineno is None: + lineno = self.lineno + return "\"%s\", line %d: " % (infile, lineno) + + def __iter__(self): + return self + + def __next__(self): + token = self.get_token() + if token == self.eof: + raise StopIteration + return token + +def split(s, comments=False, posix=True): + """Split the string *s* using shell-like syntax.""" + if s is None: + raise ValueError("s argument must not be None") + lex = shlex(s, posix=posix) + lex.whitespace_split = True + if not comments: + lex.commenters = '' + return list(lex) + + +def join(split_command): + """Return a shell-escaped string from *split_command*.""" + return ' '.join(quote(arg) for arg in split_command) + + +_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII).search + +def quote(s): + """Return a shell-escaped version of the string *s*.""" + if not s: + return "''" + if _find_unsafe(s) is None: + return s + + # use single quotes, and put single quotes into double quotes + # the string $'b is then quoted as '$'"'"'b' + return "'" + s.replace("'", "'\"'\"'") + "'" + + +def _print_tokens(lexer): + while tt := lexer.get_token(): + print("Token: " + repr(tt)) + +if __name__ == '__main__': + if len(sys.argv) == 1: + _print_tokens(shlex()) + else: + fn = sys.argv[1] + with open(fn) as f: + _print_tokens(shlex(f, fn)) diff --git a/crates/weavepy-vm/src/stdlib/python/typing.py b/crates/weavepy-vm/src/stdlib/python/typing.py index 040c532..c483019 100644 --- a/crates/weavepy-vm/src/stdlib/python/typing.py +++ b/crates/weavepy-vm/src/stdlib/python/typing.py @@ -29,7 +29,19 @@ # ---- sentinel singletons ---------------------------------------------------- -class _SpecialForm: +class _Final: + """Mixin marking the typing special forms (CPython ``typing._Final``). + + Libraries introspecting the ``typing`` module key off this base to + recognise special forms, e.g. hypothesis's + ``typing_root_type = (typing._Final, typing._GenericAlias)``. The + CPython mixin also prohibits subclassing; that guard is omitted here + since our special forms are plain singletons.""" + + __slots__ = () + + +class _SpecialForm(_Final): """Marker for special typing constructs (``Any``, ``Optional``, ``Union``, etc.). Subscriptable to produce :class:`_GenericAlias`.""" @@ -45,6 +57,10 @@ def __repr__(self): def __getitem__(self, params): if not isinstance(params, tuple): params = (params,) + if self._name == "Concatenate": + # PEP 612: `Concatenate[int, P]` — a dedicated alias type so + # `collections.abc.Callable[Concatenate[...], R]` accepts it. + return _ConcatenateGenericAlias(self, params) if self._name == "Union": # CPython normalizes at construction: `None` becomes # `type(None)`, nested unions flatten, duplicates collapse @@ -70,6 +86,14 @@ def __getitem__(self, params): def __call__(self, *args, **kwargs): raise TypeError(f"Cannot instantiate {self._name!r}") + # PEP 604: `Any | None`, `Final | int`, … — a special form participates + # in `X | Y` union syntax exactly like a real type does. + def __or__(self, other): + return Union[self, other] + + def __ror__(self, other): + return Union[other, self] + Any = _SpecialForm("Any") NoReturn = _SpecialForm("NoReturn") @@ -84,6 +108,135 @@ def __call__(self, *args, **kwargs): # (``tuple[int]``) yields ``Unpack[self]`` exactly once, mirroring # CPython's ``ga_iternext`` (which lazily reaches ``typing.Unpack``). Unpack = _SpecialForm("Unpack") +# PEP 655 (``Required`` / ``NotRequired`` for ``TypedDict`` items), +# PEP 675 (``LiteralString``), PEP 647/742 (``TypeGuard`` / ``TypeIs``), +# ``TypeAlias`` (PEP 613) and ``Concatenate`` (PEP 612). numpy's +# ``_typing`` imports several of these at module scope. +Required = _SpecialForm("Required") +NotRequired = _SpecialForm("NotRequired") +ReadOnly = _SpecialForm("ReadOnly") +LiteralString = _SpecialForm("LiteralString") +TypeAlias = _SpecialForm("TypeAlias") +TypeGuard = _SpecialForm("TypeGuard") +TypeIs = _SpecialForm("TypeIs") +Concatenate = _SpecialForm("Concatenate") + + +class ForwardRef: + """Internal wrapper to hold a forward reference (a string annotation). + + A self-contained but faithful stand-in for CPython's + :class:`typing.ForwardRef`: it holds the source string, compiles it + eagerly (so a malformed reference raises at construction, as CPython + does), and evaluates it lazily against the supplied namespaces, + caching the result. Widely imported by type-aware libraries + (``hypothesis``, ``pydantic``, ``attrs``), so it must exist even when + only its identity / attributes are used. + """ + + __slots__ = ( + '__forward_arg__', '__forward_code__', '__forward_evaluated__', + '__forward_value__', '__forward_is_argument__', '__forward_is_class__', + '__forward_module__', + ) + + def __init__(self, arg, is_argument=True, module=None, *, is_class=False): + if not isinstance(arg, str): + raise TypeError(f"Forward reference must be a string -- got {arg!r}") + try: + code = compile(arg, '', 'eval') + except SyntaxError: + raise SyntaxError( + f"Forward reference must be an expression -- got {arg!r}") + self.__forward_arg__ = arg + self.__forward_code__ = code + self.__forward_evaluated__ = False + self.__forward_value__ = None + self.__forward_is_argument__ = is_argument + self.__forward_is_class__ = is_class + self.__forward_module__ = module + + def _evaluate(self, globalns, localns, *args, **kwargs): + # Accept the extra positional/keyword args later CPython versions + # pass (`type_params`, `recursive_guard`) without depending on them. + if not self.__forward_evaluated__: + if globalns is None and localns is None: + globalns = localns = {} + elif globalns is None: + globalns = localns + elif localns is None: + localns = globalns + if self.__forward_module__ is not None: + import sys + globalns = getattr( + sys.modules.get(self.__forward_module__, None), + '__dict__', globalns) + self.__forward_value__ = eval(self.__forward_code__, globalns, localns) + self.__forward_evaluated__ = True + return self.__forward_value__ + + def __eq__(self, other): + if not isinstance(other, ForwardRef): + return NotImplemented + if self.__forward_evaluated__ and other.__forward_evaluated__: + return (self.__forward_arg__ == other.__forward_arg__ and + self.__forward_value__ == other.__forward_value__) + return self.__forward_arg__ == other.__forward_arg__ + + def __hash__(self): + return hash(self.__forward_arg__) + + def __or__(self, other): + return Union[self, other] + + def __ror__(self, other): + return Union[other, self] + + def __repr__(self): + if self.__forward_module__ is None: + module_repr = '' + else: + module_repr = f', module={self.__forward_module__!r}' + return f'ForwardRef({self.__forward_arg__!r}{module_repr})' + + +_eval_type_sentinel = object() + + +def _eval_type(t, globalns, localns, type_params=_eval_type_sentinel, *, + recursive_guard=frozenset()): + """Evaluate all forward references in the type ``t``. + + Mirrors CPython's private ``typing._eval_type``: ``ForwardRef`` (and + bare ``str``) annotations are resolved against ``globalns``/``localns``; + subscripted generics have their ``__args__`` evaluated recursively. The + ``type_params`` parameter exists (PEP 695) for feature-detecting + libraries — hypothesis checks ``"type_params" in + inspect.signature(typing._eval_type).parameters``. + """ + if isinstance(t, str): + t = ForwardRef(t) + if isinstance(t, ForwardRef): + return t._evaluate(globalns, localns, type_params, + recursive_guard=recursive_guard) + args = getattr(t, "__args__", None) + if args: + ev_args = tuple( + _eval_type(a, globalns, localns, type_params, + recursive_guard=recursive_guard) + for a in args + ) + if ev_args == tuple(args): + return t + if isinstance(t, _GenericAlias): + return _GenericAlias(t.__origin__, ev_args) + origin = getattr(t, "__origin__", None) + if origin is not None: + try: + return origin[ev_args] + except Exception: + return t + return t def Optional(*params): @@ -146,6 +299,14 @@ def __repr__(self): prefix = "~" return f"{prefix}{self.__name__}" + # PEP 604: a type variable participates in `T | X` union syntax, e.g. + # `forced: T | None` (CPython `typevarobject.c::typevar_or`). + def __or__(self, other): + return Union[self, other] + + def __ror__(self, other): + return Union[other, self] + class ParamSpec(TypeVar): pass @@ -162,6 +323,65 @@ def __iter__(self): yield Unpack[self] +# ---- PEP 695 type aliases --------------------------------------------------- + + +class TypeAliasType: + """Runtime object for a PEP 695 ``type X = ...`` statement. + + Mirrors CPython 3.12+'s ``typing.TypeAliasType``: the alias *body* + is **lazily** evaluated. The compiler wraps the body in a thunk + (``lambda : body``) and the VM's + ``__weavepy_type_alias__`` intrinsic constructs this object, passing + the freshly-minted type parameters. The thunk is only invoked — with + those parameters bound — the first time :attr:`__value__` is read. + + Deferring evaluation is what lets aliases that build unions or + subscript *other* aliases be defined at import time without forcing + the referenced machinery to exist yet, e.g. numpy's:: + + type _DualArrayLike[DTypeT, BuiltinT] = ( + _SupportsArray[DTypeT] | _NestedSequence[...] | BuiltinT + ) + type ArrayLike = Buffer | _DualArrayLike[np.dtype, complex] + """ + + def __init__(self, name, type_params, evaluate): + # No ``__slots__`` — attributes live in ``__dict__`` so the lazy + # cache fields don't need to be predeclared. + self.__name__ = name + self.__type_params__ = tuple(type_params) + self._evaluate = evaluate + self._evaluated = False + self._value = None + + @property + def __value__(self): + if not self._evaluated: + self._value = self._evaluate(*self.__type_params__) + self._evaluated = True + return self._value + + def __or__(self, other): + return Union[self, other] + + def __ror__(self, other): + return Union[other, self] + + def __getitem__(self, params): + # ``Alias[args]`` — parameterise. Matches CPython, which returns + # a ``typing._GenericAlias`` whose ``__origin__`` is the alias. + if not isinstance(params, tuple): + params = (params,) + return _GenericAlias(self, params) + + def __repr__(self): + return self.__name__ + + def __call__(self, *args, **kwargs): + raise TypeError(f"Cannot instantiate type alias {self.__name__!r}") + + # ---- generic alias ---------------------------------------------------------- @@ -238,6 +458,17 @@ def __subclasscheck__(self, cls): ) +class _ConcatenateGenericAlias(_GenericAlias): + """Result of ``Concatenate[...]`` (PEP 612). + + A distinct class so downstream code that pattern-matches on the type — + e.g. ``collections.abc._is_param_expr`` — recognises it by name + (``type(obj).__name__ == '_ConcatenateGenericAlias'`` and + ``__module__ == 'typing'``), exactly like CPython's typing.""" + + __slots__ = () + + def _as_class(x): """Coerce a bare typing alias to the runtime class it stands in for, so ``issubclass``/``isinstance`` can compare against it.""" @@ -314,6 +545,55 @@ def __subclasscheck__(self, cls): Type = _OriginAlias("Type", type) +# ---- collections.abc / collections / contextlib re-exports ------------------ +# CPython's ``typing`` re-exports the standard ABCs (and a few concrete +# containers) as subscriptable generic aliases — ``typing.Mapping``, +# ``typing.Iterator``, ``typing.ContextManager``, ``typing.Deque``, … . With +# ``from __future__ import annotations`` most annotations never evaluate, but +# the *names* must still be importable: pandas does +# ``from typing import ContextManager`` and ``from typing import Mapping``. +# Back each by its real runtime class so ``isinstance(x, typing.Mapping)`` +# and ``typing.Mapping[str, int]`` behave like the underlying ABC. +import collections as _collections +import collections.abc as _abc +import contextlib as _contextlib + +Hashable = _OriginAlias("Hashable", _abc.Hashable) +Sized = _OriginAlias("Sized", _abc.Sized) +Container = _OriginAlias("Container", _abc.Container) +Collection = _OriginAlias("Collection", _abc.Collection) +Iterable = _OriginAlias("Iterable", _abc.Iterable) +Iterator = _OriginAlias("Iterator", _abc.Iterator) +Reversible = _OriginAlias("Reversible", _abc.Reversible) +Generator = _OriginAlias("Generator", _abc.Generator) +Awaitable = _OriginAlias("Awaitable", _abc.Awaitable) +Coroutine = _OriginAlias("Coroutine", _abc.Coroutine) +AsyncIterable = _OriginAlias("AsyncIterable", _abc.AsyncIterable) +AsyncIterator = _OriginAlias("AsyncIterator", _abc.AsyncIterator) +AsyncGenerator = _OriginAlias("AsyncGenerator", _abc.AsyncGenerator) +AbstractSet = _OriginAlias("AbstractSet", _abc.Set) +MutableSet = _OriginAlias("MutableSet", _abc.MutableSet) +Mapping = _OriginAlias("Mapping", _abc.Mapping) +MutableMapping = _OriginAlias("MutableMapping", _abc.MutableMapping) +Sequence = _OriginAlias("Sequence", _abc.Sequence) +MutableSequence = _OriginAlias("MutableSequence", _abc.MutableSequence) +MappingView = _OriginAlias("MappingView", _abc.MappingView) +KeysView = _OriginAlias("KeysView", _abc.KeysView) +ItemsView = _OriginAlias("ItemsView", _abc.ItemsView) +ValuesView = _OriginAlias("ValuesView", _abc.ValuesView) +Deque = _OriginAlias("Deque", _collections.deque) +DefaultDict = _OriginAlias("DefaultDict", _collections.defaultdict) +OrderedDict = _OriginAlias("OrderedDict", _collections.OrderedDict) +Counter = _OriginAlias("Counter", _collections.Counter) +ChainMap = _OriginAlias("ChainMap", _collections.ChainMap) +ContextManager = _OriginAlias("ContextManager", _contextlib.AbstractContextManager) +AsyncContextManager = _OriginAlias( + "AsyncContextManager", _contextlib.AbstractAsyncContextManager +) +if hasattr(_abc, "ByteString"): # removed in 3.14; present in 3.13 + ByteString = _OriginAlias("ByteString", _abc.ByteString) + + class Callable: """``Callable[..., R]`` and ``Callable[[A, B], R]``.""" @@ -616,6 +896,61 @@ def clear_overloads(): _overload_registry.clear() +_ASSERT_NEVER_REPR_MAX_LENGTH = 100 + + +def assert_never(arg, /): + """Statically assert that a line of code is unreachable. + + At runtime this raises ``AssertionError`` (PEP 484 / 3.11+). + """ + value = repr(arg) + if len(value) > _ASSERT_NEVER_REPR_MAX_LENGTH: + value = value[:_ASSERT_NEVER_REPR_MAX_LENGTH] + '...' + raise AssertionError(f"Expected code to be unreachable, but got: {value}") + + +def assert_type(val, typ, /): + """Ask a static type checker to confirm *val* has type *typ*. + + At runtime this is a no-op returning *val* unchanged (3.11+). + """ + return val + + +def reveal_type(obj, /): + """Reveal the inferred static type of *obj* (3.11+). + + At runtime prints the runtime type to stderr and returns *obj*. + """ + import sys + print(f"Runtime type is {type(obj).__name__!r}", file=sys.stderr) + return obj + + +def dataclass_transform( + *, + eq_default=True, + order_default=False, + kw_only_default=False, + frozen_default=False, + field_specifiers=(), + **kwargs, +): + """Decorator marking an object as providing dataclass-like behaviour (PEP 681).""" + def decorator(cls_or_fn): + cls_or_fn.__dataclass_transform__ = { + "eq_default": eq_default, + "order_default": order_default, + "kw_only_default": kw_only_default, + "frozen_default": frozen_default, + "field_specifiers": field_specifiers, + "kwargs": kwargs, + } + return cls_or_fn + return decorator + + def final(f): """Decorator to indicate final methods and final classes. @@ -632,6 +967,60 @@ def final(f): return f +def no_type_check(arg): + """Decorator to indicate that annotations are not type hints. + + The argument must be a class or function; if it is a class, it + applies recursively to all methods and classes defined in that class + (but not to methods defined in its superclasses or subclasses). + This mutates the function(s) or class(es) in place. + + Faithful port of CPython's ``typing.no_type_check`` — pandas' + ``resample`` machinery imports it (``from typing import no_type_check``). + """ + if isinstance(arg, type): + for key in dir(arg): + obj = getattr(arg, key, None) + if obj is None: + continue + if ( + not hasattr(obj, "__qualname__") + or obj.__qualname__ != f"{arg.__qualname__}.{getattr(obj, '__name__', '')}" + or getattr(obj, "__module__", None) != getattr(arg, "__module__", None) + ): + # Only modify objects defined in this type directly. + continue + if isinstance(obj, (classmethod, staticmethod)): + obj = obj.__func__ + if callable(obj): + try: + obj.__no_type_check__ = True + except (AttributeError, TypeError): + pass + try: + arg.__no_type_check__ = True + except TypeError: # built-in classes + pass + return arg + + +def no_type_check_decorator(decorator): + """Decorator to give another decorator the ``@no_type_check`` effect. + + This wraps a decorator with something that wraps the decorated + function in ``@no_type_check``. + """ + import functools + + @functools.wraps(decorator) + def wrapped_decorator(*args, **kwds): + func = decorator(*args, **kwds) + func = no_type_check(func) + return func + + return wrapped_decorator + + def get_type_hints(obj, globalns=None, localns=None, include_extras=False): """Return a dict of annotations for the given class or function. @@ -842,6 +1231,79 @@ def _namedtuple_mro_entries(bases): NamedTuple.__mro_entries__ = _namedtuple_mro_entries +# ---- TypedDict (PEP 589 / 655) --------------------------------------------- + + +class _TypedDictMeta(type): + """Metaclass backing ``class X(TypedDict): ...``. + + At runtime a ``TypedDict`` is just a ``dict`` subclass; the metaclass + records ``__annotations__`` plus the ``__required_keys__`` / + ``__optional_keys__`` split (honouring ``total=`` and + ``Required[...]`` / ``NotRequired[...]`` item markers). We never + enforce the schema — there is no static checker — but the surface is + enough for libraries (numpy's ``_DTypeDict``) that subclass it for + annotation purposes only. + """ + + def __new__(mcls, name, bases, ns, total=True, **kwargs): + # Bootstrap of the ``TypedDict`` sentinel base itself, and plain + # ``type.__new__`` paths, carry no TypedDict base — pass through. + if not any(isinstance(b, _TypedDictMeta) for b in bases): + return super().__new__(mcls, name, bases, ns) + + own = dict(ns.get("__annotations__", {})) + annotations = {} + required = set() + optional = set() + # Merge inherited TypedDict schemas first (CPython order). + for b in bases: + if isinstance(b, _TypedDictMeta): + annotations.update(getattr(b, "__annotations__", {}) or {}) + required |= getattr(b, "__required_keys__", frozenset()) + optional |= getattr(b, "__optional_keys__", frozenset()) + for key, ann in own.items(): + annotations[key] = ann + req = total + marker = getattr(getattr(ann, "__origin__", None), "_name", None) + if marker == "Required": + req = True + elif marker == "NotRequired": + req = False + required.discard(key) + optional.discard(key) + (required if req else optional).add(key) + + tp_dict = super().__new__(mcls, name, (dict,), ns) + tp_dict.__annotations__ = annotations + tp_dict.__required_keys__ = frozenset(required) + tp_dict.__optional_keys__ = frozenset(optional) + tp_dict.__total__ = total + return tp_dict + + def __call__(cls, *args, **kwargs): + # ``TypedDict`` values are constructed like plain dicts. + return dict(*args, **kwargs) + + +def TypedDict(typename, fields=None, /, *, total=True, **kwargs): + """Functional form: ``Movie = TypedDict('Movie', {'name': str})``.""" + if fields is None: + fields = kwargs + ns = {"__annotations__": dict(fields)} + return _TypedDictMeta(typename, (_TypedDict,), ns, total=total) + + +_TypedDict = type.__new__(_TypedDictMeta, "TypedDict", (), {}) + + +def _typeddict_mro_entries(bases): + return (_TypedDict,) + + +TypedDict.__mro_entries__ = _typeddict_mro_entries + + # ---- nominal collections wrappers (PEP 585 aliases) ------------------------ # CPython 3.9+ deprecated ``typing.List`` etc. in favour of bare @@ -885,10 +1347,17 @@ def _namedtuple_mro_entries(bases): "overload", "get_overloads", "clear_overloads", + "assert_never", + "assert_type", + "reveal_type", + "dataclass_transform", "get_type_hints", "get_origin", "get_args", + "no_type_check", + "no_type_check_decorator", "NewType", + "ForwardRef", "TYPE_CHECKING", "Deque", "DefaultDict", diff --git a/crates/weavepy-vm/src/stdlib/python/uuid.py b/crates/weavepy-vm/src/stdlib/python/uuid.py new file mode 100644 index 0000000..55f46eb --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/uuid.py @@ -0,0 +1,784 @@ +r"""UUID objects (universally unique identifiers) according to RFC 4122. + +This module provides immutable UUID objects (class UUID) and the functions +uuid1(), uuid3(), uuid4(), uuid5() for generating version 1, 3, 4, and 5 +UUIDs as specified in RFC 4122. + +If all you want is a unique ID, you should probably call uuid1() or uuid4(). +Note that uuid1() may compromise privacy since it creates a UUID containing +the computer's network address. uuid4() creates a random UUID. + +Typical usage: + + >>> import uuid + + # make a UUID based on the host ID and current time + >>> uuid.uuid1() # doctest: +SKIP + UUID('a8098c1a-f86e-11da-bd1a-00112444be1e') + + # make a UUID using an MD5 hash of a namespace UUID and a name + >>> uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org') + UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e') + + # make a random UUID + >>> uuid.uuid4() # doctest: +SKIP + UUID('16fd2706-8baf-433b-82eb-8c7fada847da') + + # make a UUID using a SHA-1 hash of a namespace UUID and a name + >>> uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d') + + # make a UUID from a string of hex digits (braces and hyphens ignored) + >>> x = uuid.UUID('{00010203-0405-0607-0809-0a0b0c0d0e0f}') + + # convert a UUID to a string of hex digits in standard form + >>> str(x) + '00010203-0405-0607-0809-0a0b0c0d0e0f' + + # get the raw 16 bytes of the UUID + >>> x.bytes + b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + + # make a UUID from a 16-byte string + >>> uuid.UUID(bytes=x.bytes) + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') +""" + +import os +import sys + +from enum import Enum, _simple_enum + + +__author__ = 'Ka-Ping Yee ' + +# The recognized platforms - known behaviors +if sys.platform in {'win32', 'darwin', 'emscripten', 'wasi'}: + _AIX = _LINUX = False +elif sys.platform == 'linux': + _LINUX = True + _AIX = False +else: + import platform + _platform_system = platform.system() + _AIX = _platform_system == 'AIX' + _LINUX = _platform_system in ('Linux', 'Android') + +_MAC_DELIM = b':' +_MAC_OMITS_LEADING_ZEROES = False +if _AIX: + _MAC_DELIM = b'.' + _MAC_OMITS_LEADING_ZEROES = True + +RESERVED_NCS, RFC_4122, RESERVED_MICROSOFT, RESERVED_FUTURE = [ + 'reserved for NCS compatibility', 'specified in RFC 4122', + 'reserved for Microsoft compatibility', 'reserved for future definition'] + +int_ = int # The built-in int type +bytes_ = bytes # The built-in bytes type + + +@_simple_enum(Enum) +class SafeUUID: + safe = 0 + unsafe = -1 + unknown = None + + +class UUID: + """Instances of the UUID class represent UUIDs as specified in RFC 4122. + UUID objects are immutable, hashable, and usable as dictionary keys. + Converting a UUID to a string with str() yields something in the form + '12345678-1234-1234-1234-123456789abc'. The UUID constructor accepts + five possible forms: a similar string of hexadecimal digits, or a tuple + of six integer fields (with 32-bit, 16-bit, 16-bit, 8-bit, 8-bit, and + 48-bit values respectively) as an argument named 'fields', or a string + of 16 bytes (with all the integer fields in big-endian order) as an + argument named 'bytes', or a string of 16 bytes (with the first three + fields in little-endian order) as an argument named 'bytes_le', or a + single 128-bit integer as an argument named 'int'. + + UUIDs have these read-only attributes: + + bytes the UUID as a 16-byte string (containing the six + integer fields in big-endian byte order) + + bytes_le the UUID as a 16-byte string (with time_low, time_mid, + and time_hi_version in little-endian byte order) + + fields a tuple of the six integer fields of the UUID, + which are also available as six individual attributes + and two derived attributes: + + time_low the first 32 bits of the UUID + time_mid the next 16 bits of the UUID + time_hi_version the next 16 bits of the UUID + clock_seq_hi_variant the next 8 bits of the UUID + clock_seq_low the next 8 bits of the UUID + node the last 48 bits of the UUID + + time the 60-bit timestamp + clock_seq the 14-bit sequence number + + hex the UUID as a 32-character hexadecimal string + + int the UUID as a 128-bit integer + + urn the UUID as a URN as specified in RFC 4122 + + variant the UUID variant (one of the constants RESERVED_NCS, + RFC_4122, RESERVED_MICROSOFT, or RESERVED_FUTURE) + + version the UUID version number (1 through 5, meaningful only + when the variant is RFC_4122) + + is_safe An enum indicating whether the UUID has been generated in + a way that is safe for multiprocessing applications, via + uuid_generate_time_safe(3). + """ + + __slots__ = ('int', 'is_safe', '__weakref__') + + def __init__(self, hex=None, bytes=None, bytes_le=None, fields=None, + int=None, version=None, + *, is_safe=SafeUUID.unknown): + r"""Create a UUID from either a string of 32 hexadecimal digits, + a string of 16 bytes as the 'bytes' argument, a string of 16 bytes + in little-endian order as the 'bytes_le' argument, a tuple of six + integers (32-bit time_low, 16-bit time_mid, 16-bit time_hi_version, + 8-bit clock_seq_hi_variant, 8-bit clock_seq_low, 48-bit node) as + the 'fields' argument, or a single 128-bit integer as the 'int' + argument. When a string of hex digits is given, curly braces, + hyphens, and a URN prefix are all optional. For example, these + expressions all yield the same UUID: + + UUID('{12345678-1234-5678-1234-567812345678}') + UUID('12345678123456781234567812345678') + UUID('urn:uuid:12345678-1234-5678-1234-567812345678') + UUID(bytes='\x12\x34\x56\x78'*4) + UUID(bytes_le='\x78\x56\x34\x12\x34\x12\x78\x56' + + '\x12\x34\x56\x78\x12\x34\x56\x78') + UUID(fields=(0x12345678, 0x1234, 0x5678, 0x12, 0x34, 0x567812345678)) + UUID(int=0x12345678123456781234567812345678) + + Exactly one of 'hex', 'bytes', 'bytes_le', 'fields', or 'int' must + be given. The 'version' argument is optional; if given, the resulting + UUID will have its variant and version set according to RFC 4122, + overriding the given 'hex', 'bytes', 'bytes_le', 'fields', or 'int'. + + is_safe is an enum exposed as an attribute on the instance. It + indicates whether the UUID has been generated in a way that is safe + for multiprocessing applications, via uuid_generate_time_safe(3). + """ + + if [hex, bytes, bytes_le, fields, int].count(None) != 4: + raise TypeError('one of the hex, bytes, bytes_le, fields, ' + 'or int arguments must be given') + if hex is not None: + hex = hex.replace('urn:', '').replace('uuid:', '') + hex = hex.strip('{}').replace('-', '') + if len(hex) != 32: + raise ValueError('badly formed hexadecimal UUID string') + int = int_(hex, 16) + if bytes_le is not None: + if len(bytes_le) != 16: + raise ValueError('bytes_le is not a 16-char string') + bytes = (bytes_le[4-1::-1] + bytes_le[6-1:4-1:-1] + + bytes_le[8-1:6-1:-1] + bytes_le[8:]) + if bytes is not None: + if len(bytes) != 16: + raise ValueError('bytes is not a 16-char string') + assert isinstance(bytes, bytes_), repr(bytes) + int = int_.from_bytes(bytes) # big endian + if fields is not None: + if len(fields) != 6: + raise ValueError('fields is not a 6-tuple') + (time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node) = fields + if not 0 <= time_low < 1<<32: + raise ValueError('field 1 out of range (need a 32-bit value)') + if not 0 <= time_mid < 1<<16: + raise ValueError('field 2 out of range (need a 16-bit value)') + if not 0 <= time_hi_version < 1<<16: + raise ValueError('field 3 out of range (need a 16-bit value)') + if not 0 <= clock_seq_hi_variant < 1<<8: + raise ValueError('field 4 out of range (need an 8-bit value)') + if not 0 <= clock_seq_low < 1<<8: + raise ValueError('field 5 out of range (need an 8-bit value)') + if not 0 <= node < 1<<48: + raise ValueError('field 6 out of range (need a 48-bit value)') + clock_seq = (clock_seq_hi_variant << 8) | clock_seq_low + int = ((time_low << 96) | (time_mid << 80) | + (time_hi_version << 64) | (clock_seq << 48) | node) + if int is not None: + if not 0 <= int < 1<<128: + raise ValueError('int is out of range (need a 128-bit value)') + if version is not None: + if not 1 <= version <= 5: + raise ValueError('illegal version number') + # Set the variant to RFC 4122. + int &= ~(0xc000 << 48) + int |= 0x8000 << 48 + # Set the version number. + int &= ~(0xf000 << 64) + int |= version << 76 + object.__setattr__(self, 'int', int) + object.__setattr__(self, 'is_safe', is_safe) + + def __getstate__(self): + d = {'int': self.int} + if self.is_safe != SafeUUID.unknown: + # is_safe is a SafeUUID instance. Return just its value, so that + # it can be un-pickled in older Python versions without SafeUUID. + d['is_safe'] = self.is_safe.value + return d + + def __setstate__(self, state): + object.__setattr__(self, 'int', state['int']) + # is_safe was added in 3.7; it is also omitted when it is "unknown" + object.__setattr__(self, 'is_safe', + SafeUUID(state['is_safe']) + if 'is_safe' in state else SafeUUID.unknown) + + def __eq__(self, other): + if isinstance(other, UUID): + return self.int == other.int + return NotImplemented + + # Q. What's the value of being able to sort UUIDs? + # A. Use them as keys in a B-Tree or similar mapping. + + def __lt__(self, other): + if isinstance(other, UUID): + return self.int < other.int + return NotImplemented + + def __gt__(self, other): + if isinstance(other, UUID): + return self.int > other.int + return NotImplemented + + def __le__(self, other): + if isinstance(other, UUID): + return self.int <= other.int + return NotImplemented + + def __ge__(self, other): + if isinstance(other, UUID): + return self.int >= other.int + return NotImplemented + + def __hash__(self): + return hash(self.int) + + def __int__(self): + return self.int + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, str(self)) + + def __setattr__(self, name, value): + raise TypeError('UUID objects are immutable') + + def __str__(self): + hex = '%032x' % self.int + return '%s-%s-%s-%s-%s' % ( + hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + + @property + def bytes(self): + return self.int.to_bytes(16) # big endian + + @property + def bytes_le(self): + bytes = self.bytes + return (bytes[4-1::-1] + bytes[6-1:4-1:-1] + bytes[8-1:6-1:-1] + + bytes[8:]) + + @property + def fields(self): + return (self.time_low, self.time_mid, self.time_hi_version, + self.clock_seq_hi_variant, self.clock_seq_low, self.node) + + @property + def time_low(self): + return self.int >> 96 + + @property + def time_mid(self): + return (self.int >> 80) & 0xffff + + @property + def time_hi_version(self): + return (self.int >> 64) & 0xffff + + @property + def clock_seq_hi_variant(self): + return (self.int >> 56) & 0xff + + @property + def clock_seq_low(self): + return (self.int >> 48) & 0xff + + @property + def time(self): + return (((self.time_hi_version & 0x0fff) << 48) | + (self.time_mid << 32) | self.time_low) + + @property + def clock_seq(self): + return (((self.clock_seq_hi_variant & 0x3f) << 8) | + self.clock_seq_low) + + @property + def node(self): + return self.int & 0xffffffffffff + + @property + def hex(self): + return '%032x' % self.int + + @property + def urn(self): + return 'urn:uuid:' + str(self) + + @property + def variant(self): + if not self.int & (0x8000 << 48): + return RESERVED_NCS + elif not self.int & (0x4000 << 48): + return RFC_4122 + elif not self.int & (0x2000 << 48): + return RESERVED_MICROSOFT + else: + return RESERVED_FUTURE + + @property + def version(self): + # The version bits are only meaningful for RFC 4122 UUIDs. + if self.variant == RFC_4122: + return int((self.int >> 76) & 0xf) + + +def _get_command_stdout(command, *args): + import io, os, shutil, subprocess + + try: + path_dirs = os.environ.get('PATH', os.defpath).split(os.pathsep) + path_dirs.extend(['/sbin', '/usr/sbin']) + executable = shutil.which(command, path=os.pathsep.join(path_dirs)) + if executable is None: + return None + # LC_ALL=C to ensure English output, stderr=DEVNULL to prevent output + # on stderr (Note: we don't have an example where the words we search + # for are actually localized, but in theory some system could do so.) + env = dict(os.environ) + env['LC_ALL'] = 'C' + # Empty strings will be quoted by popen so we should just ommit it + if args != ('',): + command = (executable, *args) + else: + command = (executable,) + proc = subprocess.Popen(command, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env) + if not proc: + return None + stdout, stderr = proc.communicate() + return io.BytesIO(stdout) + except (OSError, subprocess.SubprocessError): + return None + + +# For MAC (a.k.a. IEEE 802, or EUI-48) addresses, the second least significant +# bit of the first octet signifies whether the MAC address is universally (0) +# or locally (1) administered. Network cards from hardware manufacturers will +# always be universally administered to guarantee global uniqueness of the MAC +# address, but any particular machine may have other interfaces which are +# locally administered. An example of the latter is the bridge interface to +# the Touch Bar on MacBook Pros. +# +# This bit works out to be the 42nd bit counting from 1 being the least +# significant, or 1<<41. We'll prefer universally administered MAC addresses +# over locally administered ones since the former are globally unique, but +# we'll return the first of the latter found if that's all the machine has. +# +# See https://en.wikipedia.org/wiki/MAC_address#Universal_vs._local_(U/L_bit) + +def _is_universal(mac): + return not (mac & (1 << 41)) + + +def _find_mac_near_keyword(command, args, keywords, get_word_index): + """Searches a command's output for a MAC address near a keyword. + + Each line of words in the output is case-insensitively searched for + any of the given keywords. Upon a match, get_word_index is invoked + to pick a word from the line, given the index of the match. For + example, lambda i: 0 would get the first word on the line, while + lambda i: i - 1 would get the word preceding the keyword. + """ + stdout = _get_command_stdout(command, args) + if stdout is None: + return None + + first_local_mac = None + for line in stdout: + words = line.lower().rstrip().split() + for i in range(len(words)): + if words[i] in keywords: + try: + word = words[get_word_index(i)] + mac = int(word.replace(_MAC_DELIM, b''), 16) + except (ValueError, IndexError): + # Virtual interfaces, such as those provided by + # VPNs, do not have a colon-delimited MAC address + # as expected, but a 16-byte HWAddr separated by + # dashes. These should be ignored in favor of a + # real MAC address + pass + else: + if _is_universal(mac): + return mac + first_local_mac = first_local_mac or mac + return first_local_mac or None + + +def _parse_mac(word): + # Accept 'HH:HH:HH:HH:HH:HH' MAC address (ex: '52:54:00:9d:0e:67'), + # but reject IPv6 address (ex: 'fe80::5054:ff:fe9' or '123:2:3:4:5:6:7:8'). + # + # Virtual interfaces, such as those provided by VPNs, do not have a + # colon-delimited MAC address as expected, but a 16-byte HWAddr separated + # by dashes. These should be ignored in favor of a real MAC address + parts = word.split(_MAC_DELIM) + if len(parts) != 6: + return + if _MAC_OMITS_LEADING_ZEROES: + # (Only) on AIX the macaddr value given is not prefixed by 0, e.g. + # en0 1500 link#2 fa.bc.de.f7.62.4 110854824 0 160133733 0 0 + # not + # en0 1500 link#2 fa.bc.de.f7.62.04 110854824 0 160133733 0 0 + if not all(1 <= len(part) <= 2 for part in parts): + return + hexstr = b''.join(part.rjust(2, b'0') for part in parts) + else: + if not all(len(part) == 2 for part in parts): + return + hexstr = b''.join(parts) + try: + return int(hexstr, 16) + except ValueError: + return + + +def _find_mac_under_heading(command, args, heading): + """Looks for a MAC address under a heading in a command's output. + + The first line of words in the output is searched for the given + heading. Words at the same word index as the heading in subsequent + lines are then examined to see if they look like MAC addresses. + """ + stdout = _get_command_stdout(command, args) + if stdout is None: + return None + + keywords = stdout.readline().rstrip().split() + try: + column_index = keywords.index(heading) + except ValueError: + return None + + first_local_mac = None + for line in stdout: + words = line.rstrip().split() + try: + word = words[column_index] + except IndexError: + continue + + mac = _parse_mac(word) + if mac is None: + continue + if _is_universal(mac): + return mac + if first_local_mac is None: + first_local_mac = mac + + return first_local_mac + + +# The following functions call external programs to 'get' a macaddr value to +# be used as basis for an uuid +def _ifconfig_getnode(): + """Get the hardware address on Unix by running ifconfig.""" + # This works on Linux ('' or '-a'), Tru64 ('-av'), but not all Unixes. + keywords = (b'hwaddr', b'ether', b'address:', b'lladdr') + for args in ('', '-a', '-av'): + mac = _find_mac_near_keyword('ifconfig', args, keywords, lambda i: i+1) + if mac: + return mac + return None + +def _ip_getnode(): + """Get the hardware address on Unix by running ip.""" + # This works on Linux with iproute2. + mac = _find_mac_near_keyword('ip', 'link', [b'link/ether'], lambda i: i+1) + if mac: + return mac + return None + +def _arp_getnode(): + """Get the hardware address on Unix by running arp.""" + import os, socket + if not hasattr(socket, "gethostbyname"): + return None + try: + ip_addr = socket.gethostbyname(socket.gethostname()) + except OSError: + return None + + # Try getting the MAC addr from arp based on our IP address (Solaris). + mac = _find_mac_near_keyword('arp', '-an', [os.fsencode(ip_addr)], lambda i: -1) + if mac: + return mac + + # This works on OpenBSD + mac = _find_mac_near_keyword('arp', '-an', [os.fsencode(ip_addr)], lambda i: i+1) + if mac: + return mac + + # This works on Linux, FreeBSD and NetBSD + mac = _find_mac_near_keyword('arp', '-an', [os.fsencode('(%s)' % ip_addr)], + lambda i: i+2) + # Return None instead of 0. + if mac: + return mac + return None + +def _lanscan_getnode(): + """Get the hardware address on Unix by running lanscan.""" + # This might work on HP-UX. + return _find_mac_near_keyword('lanscan', '-ai', [b'lan0'], lambda i: 0) + +def _netstat_getnode(): + """Get the hardware address on Unix by running netstat.""" + # This works on AIX and might work on Tru64 UNIX. + return _find_mac_under_heading('netstat', '-ian', b'Address') + + +# Import optional C extension at toplevel, to help disabling it when testing +try: + import _uuid + _generate_time_safe = getattr(_uuid, "generate_time_safe", None) + _has_stable_extractable_node = getattr(_uuid, "has_stable_extractable_node", False) + _UuidCreate = getattr(_uuid, "UuidCreate", None) +except ImportError: + _uuid = None + _generate_time_safe = None + _has_stable_extractable_node = False + _UuidCreate = None + + +def _unix_getnode(): + """Get the hardware address on Unix using the _uuid extension module.""" + if _generate_time_safe and _has_stable_extractable_node: + uuid_time, _ = _generate_time_safe() + return UUID(bytes=uuid_time).node + +def _windll_getnode(): + """Get the hardware address on Windows using the _uuid extension module.""" + if _UuidCreate and _has_stable_extractable_node: + uuid_bytes = _UuidCreate() + return UUID(bytes_le=uuid_bytes).node + +def _random_getnode(): + """Get a random node ID.""" + # RFC 9562, §6.10-3 says that + # + # Implementations MAY elect to obtain a 48-bit cryptographic-quality + # random number as per Section 6.9 to use as the Node ID. [...] [and] + # implementations MUST set the least significant bit of the first octet + # of the Node ID to 1. This bit is the unicast or multicast bit, which + # will never be set in IEEE 802 addresses obtained from network cards. + # + # The "multicast bit" of a MAC address is defined to be "the least + # significant bit of the first octet". This works out to be the 41st bit + # counting from 1 being the least significant bit, or 1<<40. + # + # See https://en.wikipedia.org/w/index.php?title=MAC_address&oldid=1128764812#Universal_vs._local_(U/L_bit) + return int.from_bytes(os.urandom(6)) | (1 << 40) + + +# _OS_GETTERS, when known, are targeted for a specific OS or platform. +# The order is by 'common practice' on the specified platform. +# Note: 'posix' and 'windows' _OS_GETTERS are prefixed by a dll/dlload() method +# which, when successful, means none of these "external" methods are called. +# _GETTERS is (also) used by test_uuid.py to SkipUnless(), e.g., +# @unittest.skipUnless(_uuid._ifconfig_getnode in _uuid._GETTERS, ...) +if _LINUX: + _OS_GETTERS = [_ip_getnode, _ifconfig_getnode] +elif sys.platform == 'darwin': + _OS_GETTERS = [_ifconfig_getnode, _arp_getnode, _netstat_getnode] +elif sys.platform == 'win32': + # bpo-40201: _windll_getnode will always succeed, so these are not needed + _OS_GETTERS = [] +elif _AIX: + _OS_GETTERS = [_netstat_getnode] +else: + _OS_GETTERS = [_ifconfig_getnode, _ip_getnode, _arp_getnode, + _netstat_getnode, _lanscan_getnode] +if os.name == 'posix': + _GETTERS = [_unix_getnode] + _OS_GETTERS +elif os.name == 'nt': + _GETTERS = [_windll_getnode] + _OS_GETTERS +else: + _GETTERS = _OS_GETTERS + +_node = None + +def getnode(): + """Get the hardware address as a 48-bit positive integer. + + The first time this runs, it may launch a separate program, which could + be quite slow. If all attempts to obtain the hardware address fail, we + choose a random 48-bit number with its eighth bit set to 1 as recommended + in RFC 4122. + """ + global _node + if _node is not None: + return _node + + for getter in _GETTERS + [_random_getnode]: + try: + _node = getter() + except: + continue + if (_node is not None) and (0 <= _node < (1 << 48)): + return _node + assert False, '_random_getnode() returned invalid value: {}'.format(_node) + + +_last_timestamp = None + +def uuid1(node=None, clock_seq=None): + """Generate a UUID from a host ID, sequence number, and the current time. + If 'node' is not given, getnode() is used to obtain the hardware + address. If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + # When the system provides a version-1 UUID generator, use it (but don't + # use UuidCreate here because its UUIDs don't conform to RFC 4122). + if _generate_time_safe is not None and node is clock_seq is None: + uuid_time, safely_generated = _generate_time_safe() + try: + is_safe = SafeUUID(safely_generated) + except ValueError: + is_safe = SafeUUID.unknown + return UUID(bytes=uuid_time, is_safe=is_safe) + + global _last_timestamp + import time + nanoseconds = time.time_ns() + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = nanoseconds // 100 + 0x01b21dd213814000 + if _last_timestamp is not None and timestamp <= _last_timestamp: + timestamp = _last_timestamp + 1 + _last_timestamp = timestamp + if clock_seq is None: + import random + clock_seq = random.getrandbits(14) # instead of stable storage + time_low = timestamp & 0xffffffff + time_mid = (timestamp >> 32) & 0xffff + time_hi_version = (timestamp >> 48) & 0x0fff + clock_seq_low = clock_seq & 0xff + clock_seq_hi_variant = (clock_seq >> 8) & 0x3f + if node is None: + node = getnode() + return UUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + +def uuid3(namespace, name): + """Generate a UUID from the MD5 hash of a namespace UUID and a name.""" + if isinstance(name, str): + name = bytes(name, "utf-8") + from hashlib import md5 + digest = md5( + namespace.bytes + name, + usedforsecurity=False + ).digest() + return UUID(bytes=digest[:16], version=3) + +def uuid4(): + """Generate a random UUID.""" + return UUID(bytes=os.urandom(16), version=4) + +def uuid5(namespace, name): + """Generate a UUID from the SHA-1 hash of a namespace UUID and a name.""" + if isinstance(name, str): + name = bytes(name, "utf-8") + from hashlib import sha1 + hash = sha1(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=5) + + +def main(): + """Run the uuid command line interface.""" + uuid_funcs = { + "uuid1": uuid1, + "uuid3": uuid3, + "uuid4": uuid4, + "uuid5": uuid5 + } + uuid_namespace_funcs = ("uuid3", "uuid5") + namespaces = { + "@dns": NAMESPACE_DNS, + "@url": NAMESPACE_URL, + "@oid": NAMESPACE_OID, + "@x500": NAMESPACE_X500 + } + + import argparse + parser = argparse.ArgumentParser( + description="Generates a uuid using the selected uuid function.") + parser.add_argument("-u", "--uuid", choices=uuid_funcs.keys(), default="uuid4", + help="The function to use to generate the uuid. " + "By default uuid4 function is used.") + parser.add_argument("-n", "--namespace", + help="The namespace is a UUID, or '@ns' where 'ns' is a " + "well-known predefined UUID addressed by namespace name. " + "Such as @dns, @url, @oid, and @x500. " + "Only required for uuid3/uuid5 functions.") + parser.add_argument("-N", "--name", + help="The name used as part of generating the uuid. " + "Only required for uuid3/uuid5 functions.") + + args = parser.parse_args() + uuid_func = uuid_funcs[args.uuid] + namespace = args.namespace + name = args.name + + if args.uuid in uuid_namespace_funcs: + if not namespace or not name: + parser.error( + "Incorrect number of arguments. " + f"{args.uuid} requires a namespace and a name. " + "Run 'python -m uuid -h' for more information." + ) + namespace = namespaces[namespace] if namespace in namespaces else UUID(namespace) + print(uuid_func(namespace, name)) + else: + print(uuid_func()) + + +# The following standard UUIDs are for use with uuid3() or uuid5(). + +NAMESPACE_DNS = UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_URL = UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_OID = UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_X500 = UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8') + +if __name__ == "__main__": + main() diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/NodeFilter.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/NodeFilter.py new file mode 100644 index 0000000..640e0bf --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/NodeFilter.py @@ -0,0 +1,27 @@ +# This is the Python mapping for interface NodeFilter from +# DOM2-Traversal-Range. It contains only constants. + +class NodeFilter: + """ + This is the DOM2 NodeFilter interface. It contains only constants. + """ + FILTER_ACCEPT = 1 + FILTER_REJECT = 2 + FILTER_SKIP = 3 + + SHOW_ALL = 0xFFFFFFFF + SHOW_ELEMENT = 0x00000001 + SHOW_ATTRIBUTE = 0x00000002 + SHOW_TEXT = 0x00000004 + SHOW_CDATA_SECTION = 0x00000008 + SHOW_ENTITY_REFERENCE = 0x00000010 + SHOW_ENTITY = 0x00000020 + SHOW_PROCESSING_INSTRUCTION = 0x00000040 + SHOW_COMMENT = 0x00000080 + SHOW_DOCUMENT = 0x00000100 + SHOW_DOCUMENT_TYPE = 0x00000200 + SHOW_DOCUMENT_FRAGMENT = 0x00000400 + SHOW_NOTATION = 0x00000800 + + def acceptNode(self, node): + raise NotImplementedError diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/__init__.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/__init__.py new file mode 100644 index 0000000..97cf9a6 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/__init__.py @@ -0,0 +1,140 @@ +"""W3C Document Object Model implementation for Python. + +The Python mapping of the Document Object Model is documented in the +Python Library Reference in the section on the xml.dom package. + +This package contains the following modules: + +minidom -- A simple implementation of the Level 1 DOM with namespace + support added (based on the Level 2 specification) and other + minor Level 2 functionality. + +pulldom -- DOM builder supporting on-demand tree-building for selected + subtrees of the document. + +""" + + +class Node: + """Class giving the NodeType constants.""" + __slots__ = () + + # DOM implementations may use this as a base class for their own + # Node implementations. If they don't, the constants defined here + # should still be used as the canonical definitions as they match + # the values given in the W3C recommendation. Client code can + # safely refer to these values in all tests of Node.nodeType + # values. + + ELEMENT_NODE = 1 + ATTRIBUTE_NODE = 2 + TEXT_NODE = 3 + CDATA_SECTION_NODE = 4 + ENTITY_REFERENCE_NODE = 5 + ENTITY_NODE = 6 + PROCESSING_INSTRUCTION_NODE = 7 + COMMENT_NODE = 8 + DOCUMENT_NODE = 9 + DOCUMENT_TYPE_NODE = 10 + DOCUMENT_FRAGMENT_NODE = 11 + NOTATION_NODE = 12 + + +#ExceptionCode +INDEX_SIZE_ERR = 1 +DOMSTRING_SIZE_ERR = 2 +HIERARCHY_REQUEST_ERR = 3 +WRONG_DOCUMENT_ERR = 4 +INVALID_CHARACTER_ERR = 5 +NO_DATA_ALLOWED_ERR = 6 +NO_MODIFICATION_ALLOWED_ERR = 7 +NOT_FOUND_ERR = 8 +NOT_SUPPORTED_ERR = 9 +INUSE_ATTRIBUTE_ERR = 10 +INVALID_STATE_ERR = 11 +SYNTAX_ERR = 12 +INVALID_MODIFICATION_ERR = 13 +NAMESPACE_ERR = 14 +INVALID_ACCESS_ERR = 15 +VALIDATION_ERR = 16 + + +class DOMException(Exception): + """Abstract base class for DOM exceptions. + Exceptions with specific codes are specializations of this class.""" + + def __init__(self, *args, **kw): + if self.__class__ is DOMException: + raise RuntimeError( + "DOMException should not be instantiated directly") + Exception.__init__(self, *args, **kw) + + def _get_code(self): + return self.code + + +class IndexSizeErr(DOMException): + code = INDEX_SIZE_ERR + +class DomstringSizeErr(DOMException): + code = DOMSTRING_SIZE_ERR + +class HierarchyRequestErr(DOMException): + code = HIERARCHY_REQUEST_ERR + +class WrongDocumentErr(DOMException): + code = WRONG_DOCUMENT_ERR + +class InvalidCharacterErr(DOMException): + code = INVALID_CHARACTER_ERR + +class NoDataAllowedErr(DOMException): + code = NO_DATA_ALLOWED_ERR + +class NoModificationAllowedErr(DOMException): + code = NO_MODIFICATION_ALLOWED_ERR + +class NotFoundErr(DOMException): + code = NOT_FOUND_ERR + +class NotSupportedErr(DOMException): + code = NOT_SUPPORTED_ERR + +class InuseAttributeErr(DOMException): + code = INUSE_ATTRIBUTE_ERR + +class InvalidStateErr(DOMException): + code = INVALID_STATE_ERR + +class SyntaxErr(DOMException): + code = SYNTAX_ERR + +class InvalidModificationErr(DOMException): + code = INVALID_MODIFICATION_ERR + +class NamespaceErr(DOMException): + code = NAMESPACE_ERR + +class InvalidAccessErr(DOMException): + code = INVALID_ACCESS_ERR + +class ValidationErr(DOMException): + code = VALIDATION_ERR + +class UserDataHandler: + """Class giving the operation constants for UserDataHandler.handle().""" + + # Based on DOM Level 3 (WD 9 April 2002) + + NODE_CLONED = 1 + NODE_IMPORTED = 2 + NODE_DELETED = 3 + NODE_RENAMED = 4 + +XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" +XMLNS_NAMESPACE = "http://www.w3.org/2000/xmlns/" +XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml" +EMPTY_NAMESPACE = None +EMPTY_PREFIX = None + +from .domreg import getDOMImplementation, registerDOMImplementation diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/domreg.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/domreg.py new file mode 100644 index 0000000..69c17ee --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/domreg.py @@ -0,0 +1,99 @@ +"""Registration facilities for DOM. This module should not be used +directly. Instead, the functions getDOMImplementation and +registerDOMImplementation should be imported from xml.dom.""" + +# This is a list of well-known implementations. Well-known names +# should be published by posting to xml-sig@python.org, and are +# subsequently recorded in this file. + +import sys + +well_known_implementations = { + 'minidom':'xml.dom.minidom', + '4DOM': 'xml.dom.DOMImplementation', + } + +# DOM implementations not officially registered should register +# themselves with their + +registered = {} + +def registerDOMImplementation(name, factory): + """registerDOMImplementation(name, factory) + + Register the factory function with the name. The factory function + should return an object which implements the DOMImplementation + interface. The factory function can either return the same object, + or a new one (e.g. if that implementation supports some + customization).""" + + registered[name] = factory + +def _good_enough(dom, features): + "_good_enough(dom, features) -> Return 1 if the dom offers the features" + for f,v in features: + if not dom.hasFeature(f,v): + return 0 + return 1 + +def getDOMImplementation(name=None, features=()): + """getDOMImplementation(name = None, features = ()) -> DOM implementation. + + Return a suitable DOM implementation. The name is either + well-known, the module name of a DOM implementation, or None. If + it is not None, imports the corresponding module and returns + DOMImplementation object if the import succeeds. + + If name is not given, consider the available implementations to + find one with the required feature set. If no implementation can + be found, raise an ImportError. The features list must be a sequence + of (feature, version) pairs which are passed to hasFeature.""" + + import os + creator = None + mod = well_known_implementations.get(name) + if mod: + mod = __import__(mod, {}, {}, ['getDOMImplementation']) + return mod.getDOMImplementation() + elif name: + return registered[name]() + elif not sys.flags.ignore_environment and "PYTHON_DOM" in os.environ: + return getDOMImplementation(name = os.environ["PYTHON_DOM"]) + + # User did not specify a name, try implementations in arbitrary + # order, returning the one that has the required features + if isinstance(features, str): + features = _parse_feature_string(features) + for creator in registered.values(): + dom = creator() + if _good_enough(dom, features): + return dom + + for creator in well_known_implementations.keys(): + try: + dom = getDOMImplementation(name = creator) + except Exception: # typically ImportError, or AttributeError + continue + if _good_enough(dom, features): + return dom + + raise ImportError("no suitable DOM implementation found") + +def _parse_feature_string(s): + features = [] + parts = s.split() + i = 0 + length = len(parts) + while i < length: + feature = parts[i] + if feature[0] in "0123456789": + raise ValueError("bad feature name: %r" % (feature,)) + i = i + 1 + version = None + if i < length: + v = parts[i] + if v[0] in "0123456789": + i = i + 1 + version = v + features.append((feature, version)) + return tuple(features) diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/expatbuilder.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/expatbuilder.py new file mode 100644 index 0000000..7dd667b --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/expatbuilder.py @@ -0,0 +1,962 @@ +"""Facility to use the Expat parser to load a minidom instance +from a string or file. + +This avoids all the overhead of SAX and pulldom to gain performance. +""" + +# Warning! +# +# This module is tightly bound to the implementation details of the +# minidom DOM and can't be used with other DOM implementations. This +# is due, in part, to a lack of appropriate methods in the DOM (there is +# no way to create Entity and Notation nodes via the DOM Level 2 +# interface), and for performance. The latter is the cause of some fairly +# cryptic code. +# +# Performance hacks: +# +# - .character_data_handler() has an extra case in which continuing +# data is appended to an existing Text node; this can be a +# speedup since pyexpat can break up character data into multiple +# callbacks even though we set the buffer_text attribute on the +# parser. This also gives us the advantage that we don't need a +# separate normalization pass. +# +# - Determining that a node exists is done using an identity comparison +# with None rather than a truth test; this avoids searching for and +# calling any methods on the node object if it exists. (A rather +# nice speedup is achieved this way as well!) + +from xml.dom import xmlbuilder, minidom, Node +from xml.dom import EMPTY_NAMESPACE, EMPTY_PREFIX, XMLNS_NAMESPACE +from xml.parsers import expat +from xml.dom.minidom import _append_child, _set_attribute_node +from xml.dom.NodeFilter import NodeFilter + +TEXT_NODE = Node.TEXT_NODE +CDATA_SECTION_NODE = Node.CDATA_SECTION_NODE +DOCUMENT_NODE = Node.DOCUMENT_NODE + +FILTER_ACCEPT = xmlbuilder.DOMBuilderFilter.FILTER_ACCEPT +FILTER_REJECT = xmlbuilder.DOMBuilderFilter.FILTER_REJECT +FILTER_SKIP = xmlbuilder.DOMBuilderFilter.FILTER_SKIP +FILTER_INTERRUPT = xmlbuilder.DOMBuilderFilter.FILTER_INTERRUPT + +theDOMImplementation = minidom.getDOMImplementation() + +# Expat typename -> TypeInfo +_typeinfo_map = { + "CDATA": minidom.TypeInfo(None, "cdata"), + "ENUM": minidom.TypeInfo(None, "enumeration"), + "ENTITY": minidom.TypeInfo(None, "entity"), + "ENTITIES": minidom.TypeInfo(None, "entities"), + "ID": minidom.TypeInfo(None, "id"), + "IDREF": minidom.TypeInfo(None, "idref"), + "IDREFS": minidom.TypeInfo(None, "idrefs"), + "NMTOKEN": minidom.TypeInfo(None, "nmtoken"), + "NMTOKENS": minidom.TypeInfo(None, "nmtokens"), + } + +class ElementInfo(object): + __slots__ = '_attr_info', '_model', 'tagName' + + def __init__(self, tagName, model=None): + self.tagName = tagName + self._attr_info = [] + self._model = model + + def __getstate__(self): + return self._attr_info, self._model, self.tagName + + def __setstate__(self, state): + self._attr_info, self._model, self.tagName = state + + def getAttributeType(self, aname): + for info in self._attr_info: + if info[1] == aname: + t = info[-2] + if t[0] == "(": + return _typeinfo_map["ENUM"] + else: + return _typeinfo_map[info[-2]] + return minidom._no_type + + def getAttributeTypeNS(self, namespaceURI, localName): + return minidom._no_type + + def isElementContent(self): + if self._model: + type = self._model[0] + return type not in (expat.model.XML_CTYPE_ANY, + expat.model.XML_CTYPE_MIXED) + else: + return False + + def isEmpty(self): + if self._model: + return self._model[0] == expat.model.XML_CTYPE_EMPTY + else: + return False + + def isId(self, aname): + for info in self._attr_info: + if info[1] == aname: + return info[-2] == "ID" + return False + + def isIdNS(self, euri, ename, auri, aname): + # not sure this is meaningful + return self.isId((auri, aname)) + +def _intern(builder, s): + return builder._intern_setdefault(s, s) + +def _parse_ns_name(builder, name): + assert ' ' in name + parts = name.split(' ') + intern = builder._intern_setdefault + if len(parts) == 3: + uri, localname, prefix = parts + prefix = intern(prefix, prefix) + qname = "%s:%s" % (prefix, localname) + qname = intern(qname, qname) + localname = intern(localname, localname) + elif len(parts) == 2: + uri, localname = parts + prefix = EMPTY_PREFIX + qname = localname = intern(localname, localname) + else: + raise ValueError("Unsupported syntax: spaces in URIs not supported: %r" % name) + return intern(uri, uri), localname, prefix, qname + + +class ExpatBuilder: + """Document builder that uses Expat to build a ParsedXML.DOM document + instance.""" + + def __init__(self, options=None): + if options is None: + options = xmlbuilder.Options() + self._options = options + if self._options.filter is not None: + self._filter = FilterVisibilityController(self._options.filter) + else: + self._filter = None + # This *really* doesn't do anything in this case, so + # override it with something fast & minimal. + self._finish_start_element = id + self._parser = None + self.reset() + + def createParser(self): + """Create a new parser object.""" + return expat.ParserCreate() + + def getParser(self): + """Return the parser object, creating a new one if needed.""" + if not self._parser: + self._parser = self.createParser() + self._intern_setdefault = self._parser.intern.setdefault + self._parser.buffer_text = True + self._parser.ordered_attributes = True + self._parser.specified_attributes = True + self.install(self._parser) + return self._parser + + def reset(self): + """Free all data structures used during DOM construction.""" + self.document = theDOMImplementation.createDocument( + EMPTY_NAMESPACE, None, None) + self.curNode = self.document + self._elem_info = self.document._elem_info + self._cdata = False + + def install(self, parser): + """Install the callbacks needed to build the DOM into the parser.""" + # This creates circular references! + parser.StartDoctypeDeclHandler = self.start_doctype_decl_handler + parser.StartElementHandler = self.first_element_handler + parser.EndElementHandler = self.end_element_handler + parser.ProcessingInstructionHandler = self.pi_handler + if self._options.entities: + parser.EntityDeclHandler = self.entity_decl_handler + parser.NotationDeclHandler = self.notation_decl_handler + if self._options.comments: + parser.CommentHandler = self.comment_handler + if self._options.cdata_sections: + parser.StartCdataSectionHandler = self.start_cdata_section_handler + parser.EndCdataSectionHandler = self.end_cdata_section_handler + parser.CharacterDataHandler = self.character_data_handler_cdata + else: + parser.CharacterDataHandler = self.character_data_handler + parser.ExternalEntityRefHandler = self.external_entity_ref_handler + parser.XmlDeclHandler = self.xml_decl_handler + parser.ElementDeclHandler = self.element_decl_handler + parser.AttlistDeclHandler = self.attlist_decl_handler + + def parseFile(self, file): + """Parse a document from a file object, returning the document + node.""" + parser = self.getParser() + first_buffer = True + try: + while buffer := file.read(16*1024): + parser.Parse(buffer, False) + if first_buffer and self.document.documentElement: + self._setup_subset(buffer) + first_buffer = False + parser.Parse(b"", True) + except ParseEscape: + pass + doc = self.document + self.reset() + self._parser = None + return doc + + def parseString(self, string): + """Parse a document from a string, returning the document node.""" + parser = self.getParser() + try: + parser.Parse(string, True) + self._setup_subset(string) + except ParseEscape: + pass + doc = self.document + self.reset() + self._parser = None + return doc + + def _setup_subset(self, buffer): + """Load the internal subset if there might be one.""" + if self.document.doctype: + extractor = InternalSubsetExtractor() + extractor.parseString(buffer) + subset = extractor.getSubset() + self.document.doctype.internalSubset = subset + + def start_doctype_decl_handler(self, doctypeName, systemId, publicId, + has_internal_subset): + doctype = self.document.implementation.createDocumentType( + doctypeName, publicId, systemId) + doctype.ownerDocument = self.document + _append_child(self.document, doctype) + self.document.doctype = doctype + if self._filter and self._filter.acceptNode(doctype) == FILTER_REJECT: + self.document.doctype = None + del self.document.childNodes[-1] + doctype = None + self._parser.EntityDeclHandler = None + self._parser.NotationDeclHandler = None + if has_internal_subset: + if doctype is not None: + doctype.entities._seq = [] + doctype.notations._seq = [] + self._parser.CommentHandler = None + self._parser.ProcessingInstructionHandler = None + self._parser.EndDoctypeDeclHandler = self.end_doctype_decl_handler + + def end_doctype_decl_handler(self): + if self._options.comments: + self._parser.CommentHandler = self.comment_handler + self._parser.ProcessingInstructionHandler = self.pi_handler + if not (self._elem_info or self._filter): + self._finish_end_element = id + + def pi_handler(self, target, data): + node = self.document.createProcessingInstruction(target, data) + _append_child(self.curNode, node) + if self._filter and self._filter.acceptNode(node) == FILTER_REJECT: + self.curNode.removeChild(node) + + def character_data_handler_cdata(self, data): + childNodes = self.curNode.childNodes + if self._cdata: + if ( self._cdata_continue + and childNodes[-1].nodeType == CDATA_SECTION_NODE): + childNodes[-1].appendData(data) + return + node = self.document.createCDATASection(data) + self._cdata_continue = True + elif childNodes and childNodes[-1].nodeType == TEXT_NODE: + node = childNodes[-1] + value = node.data + data + node.data = value + return + else: + node = minidom.Text() + node.data = data + node.ownerDocument = self.document + _append_child(self.curNode, node) + + def character_data_handler(self, data): + childNodes = self.curNode.childNodes + if childNodes and childNodes[-1].nodeType == TEXT_NODE: + node = childNodes[-1] + node.data = node.data + data + return + node = minidom.Text() + node.data = node.data + data + node.ownerDocument = self.document + _append_child(self.curNode, node) + + def entity_decl_handler(self, entityName, is_parameter_entity, value, + base, systemId, publicId, notationName): + if is_parameter_entity: + # we don't care about parameter entities for the DOM + return + if not self._options.entities: + return + node = self.document._create_entity(entityName, publicId, + systemId, notationName) + if value is not None: + # internal entity + # node *should* be readonly, but we'll cheat + child = self.document.createTextNode(value) + node.childNodes.append(child) + self.document.doctype.entities._seq.append(node) + if self._filter and self._filter.acceptNode(node) == FILTER_REJECT: + del self.document.doctype.entities._seq[-1] + + def notation_decl_handler(self, notationName, base, systemId, publicId): + node = self.document._create_notation(notationName, publicId, systemId) + self.document.doctype.notations._seq.append(node) + if self._filter and self._filter.acceptNode(node) == FILTER_ACCEPT: + del self.document.doctype.notations._seq[-1] + + def comment_handler(self, data): + node = self.document.createComment(data) + _append_child(self.curNode, node) + if self._filter and self._filter.acceptNode(node) == FILTER_REJECT: + self.curNode.removeChild(node) + + def start_cdata_section_handler(self): + self._cdata = True + self._cdata_continue = False + + def end_cdata_section_handler(self): + self._cdata = False + self._cdata_continue = False + + def external_entity_ref_handler(self, context, base, systemId, publicId): + return 1 + + def first_element_handler(self, name, attributes): + if self._filter is None and not self._elem_info: + self._finish_end_element = id + self.getParser().StartElementHandler = self.start_element_handler + self.start_element_handler(name, attributes) + + def start_element_handler(self, name, attributes): + node = self.document.createElement(name) + _append_child(self.curNode, node) + self.curNode = node + + if attributes: + for i in range(0, len(attributes), 2): + a = minidom.Attr(attributes[i], EMPTY_NAMESPACE, + None, EMPTY_PREFIX) + value = attributes[i+1] + a.value = value + a.ownerDocument = self.document + _set_attribute_node(node, a) + + if node is not self.document.documentElement: + self._finish_start_element(node) + + def _finish_start_element(self, node): + if self._filter: + # To be general, we'd have to call isSameNode(), but this + # is sufficient for minidom: + if node is self.document.documentElement: + return + filt = self._filter.startContainer(node) + if filt == FILTER_REJECT: + # ignore this node & all descendents + Rejecter(self) + elif filt == FILTER_SKIP: + # ignore this node, but make it's children become + # children of the parent node + Skipper(self) + else: + return + self.curNode = node.parentNode + node.parentNode.removeChild(node) + node.unlink() + + # If this ever changes, Namespaces.end_element_handler() needs to + # be changed to match. + # + def end_element_handler(self, name): + curNode = self.curNode + self.curNode = curNode.parentNode + self._finish_end_element(curNode) + + def _finish_end_element(self, curNode): + info = self._elem_info.get(curNode.tagName) + if info: + self._handle_white_text_nodes(curNode, info) + if self._filter: + if curNode is self.document.documentElement: + return + if self._filter.acceptNode(curNode) == FILTER_REJECT: + self.curNode.removeChild(curNode) + curNode.unlink() + + def _handle_white_text_nodes(self, node, info): + if (self._options.whitespace_in_element_content + or not info.isElementContent()): + return + + # We have element type information and should remove ignorable + # whitespace; identify for text nodes which contain only + # whitespace. + L = [] + for child in node.childNodes: + if child.nodeType == TEXT_NODE and not child.data.strip(): + L.append(child) + + # Remove ignorable whitespace from the tree. + for child in L: + node.removeChild(child) + + def element_decl_handler(self, name, model): + info = self._elem_info.get(name) + if info is None: + self._elem_info[name] = ElementInfo(name, model) + else: + assert info._model is None + info._model = model + + def attlist_decl_handler(self, elem, name, type, default, required): + info = self._elem_info.get(elem) + if info is None: + info = ElementInfo(elem) + self._elem_info[elem] = info + info._attr_info.append( + [None, name, None, None, default, 0, type, required]) + + def xml_decl_handler(self, version, encoding, standalone): + self.document.version = version + self.document.encoding = encoding + # This is still a little ugly, thanks to the pyexpat API. ;-( + if standalone >= 0: + if standalone: + self.document.standalone = True + else: + self.document.standalone = False + + +# Don't include FILTER_INTERRUPT, since that's checked separately +# where allowed. +_ALLOWED_FILTER_RETURNS = (FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP) + +class FilterVisibilityController(object): + """Wrapper around a DOMBuilderFilter which implements the checks + to make the whatToShow filter attribute work.""" + + __slots__ = 'filter', + + def __init__(self, filter): + self.filter = filter + + def startContainer(self, node): + mask = self._nodetype_mask[node.nodeType] + if self.filter.whatToShow & mask: + val = self.filter.startContainer(node) + if val == FILTER_INTERRUPT: + raise ParseEscape + if val not in _ALLOWED_FILTER_RETURNS: + raise ValueError( + "startContainer() returned illegal value: " + repr(val)) + return val + else: + return FILTER_ACCEPT + + def acceptNode(self, node): + mask = self._nodetype_mask[node.nodeType] + if self.filter.whatToShow & mask: + val = self.filter.acceptNode(node) + if val == FILTER_INTERRUPT: + raise ParseEscape + if val == FILTER_SKIP: + # move all child nodes to the parent, and remove this node + parent = node.parentNode + for child in node.childNodes[:]: + parent.appendChild(child) + # node is handled by the caller + return FILTER_REJECT + if val not in _ALLOWED_FILTER_RETURNS: + raise ValueError( + "acceptNode() returned illegal value: " + repr(val)) + return val + else: + return FILTER_ACCEPT + + _nodetype_mask = { + Node.ELEMENT_NODE: NodeFilter.SHOW_ELEMENT, + Node.ATTRIBUTE_NODE: NodeFilter.SHOW_ATTRIBUTE, + Node.TEXT_NODE: NodeFilter.SHOW_TEXT, + Node.CDATA_SECTION_NODE: NodeFilter.SHOW_CDATA_SECTION, + Node.ENTITY_REFERENCE_NODE: NodeFilter.SHOW_ENTITY_REFERENCE, + Node.ENTITY_NODE: NodeFilter.SHOW_ENTITY, + Node.PROCESSING_INSTRUCTION_NODE: NodeFilter.SHOW_PROCESSING_INSTRUCTION, + Node.COMMENT_NODE: NodeFilter.SHOW_COMMENT, + Node.DOCUMENT_NODE: NodeFilter.SHOW_DOCUMENT, + Node.DOCUMENT_TYPE_NODE: NodeFilter.SHOW_DOCUMENT_TYPE, + Node.DOCUMENT_FRAGMENT_NODE: NodeFilter.SHOW_DOCUMENT_FRAGMENT, + Node.NOTATION_NODE: NodeFilter.SHOW_NOTATION, + } + + +class FilterCrutch(object): + __slots__ = '_builder', '_level', '_old_start', '_old_end' + + def __init__(self, builder): + self._level = 0 + self._builder = builder + parser = builder._parser + self._old_start = parser.StartElementHandler + self._old_end = parser.EndElementHandler + parser.StartElementHandler = self.start_element_handler + parser.EndElementHandler = self.end_element_handler + +class Rejecter(FilterCrutch): + __slots__ = () + + def __init__(self, builder): + FilterCrutch.__init__(self, builder) + parser = builder._parser + for name in ("ProcessingInstructionHandler", + "CommentHandler", + "CharacterDataHandler", + "StartCdataSectionHandler", + "EndCdataSectionHandler", + "ExternalEntityRefHandler", + ): + setattr(parser, name, None) + + def start_element_handler(self, *args): + self._level = self._level + 1 + + def end_element_handler(self, *args): + if self._level == 0: + # restore the old handlers + parser = self._builder._parser + self._builder.install(parser) + parser.StartElementHandler = self._old_start + parser.EndElementHandler = self._old_end + else: + self._level = self._level - 1 + +class Skipper(FilterCrutch): + __slots__ = () + + def start_element_handler(self, *args): + node = self._builder.curNode + self._old_start(*args) + if self._builder.curNode is not node: + self._level = self._level + 1 + + def end_element_handler(self, *args): + if self._level == 0: + # We're popping back out of the node we're skipping, so we + # shouldn't need to do anything but reset the handlers. + self._builder._parser.StartElementHandler = self._old_start + self._builder._parser.EndElementHandler = self._old_end + self._builder = None + else: + self._level = self._level - 1 + self._old_end(*args) + + +# framework document used by the fragment builder. +# Takes a string for the doctype, subset string, and namespace attrs string. + +_FRAGMENT_BUILDER_INTERNAL_SYSTEM_ID = \ + "http://xml.python.org/entities/fragment-builder/internal" + +_FRAGMENT_BUILDER_TEMPLATE = ( + '''\ + +%%s +]> +&fragment-builder-internal;''' + % _FRAGMENT_BUILDER_INTERNAL_SYSTEM_ID) + + +class FragmentBuilder(ExpatBuilder): + """Builder which constructs document fragments given XML source + text and a context node. + + The context node is expected to provide information about the + namespace declarations which are in scope at the start of the + fragment. + """ + + def __init__(self, context, options=None): + if context.nodeType == DOCUMENT_NODE: + self.originalDocument = context + self.context = context + else: + self.originalDocument = context.ownerDocument + self.context = context + ExpatBuilder.__init__(self, options) + + def reset(self): + ExpatBuilder.reset(self) + self.fragment = None + + def parseFile(self, file): + """Parse a document fragment from a file object, returning the + fragment node.""" + return self.parseString(file.read()) + + def parseString(self, string): + """Parse a document fragment from a string, returning the + fragment node.""" + self._source = string + parser = self.getParser() + doctype = self.originalDocument.doctype + ident = "" + if doctype: + subset = doctype.internalSubset or self._getDeclarations() + if doctype.publicId: + ident = ('PUBLIC "%s" "%s"' + % (doctype.publicId, doctype.systemId)) + elif doctype.systemId: + ident = 'SYSTEM "%s"' % doctype.systemId + else: + subset = "" + nsattrs = self._getNSattrs() # get ns decls from node's ancestors + document = _FRAGMENT_BUILDER_TEMPLATE % (ident, subset, nsattrs) + try: + parser.Parse(document, True) + except: + self.reset() + raise + fragment = self.fragment + self.reset() +## self._parser = None + return fragment + + def _getDeclarations(self): + """Re-create the internal subset from the DocumentType node. + + This is only needed if we don't already have the + internalSubset as a string. + """ + doctype = self.context.ownerDocument.doctype + s = "" + if doctype: + for i in range(doctype.notations.length): + notation = doctype.notations.item(i) + if s: + s = s + "\n " + s = "%s' \ + % (s, notation.publicId, notation.systemId) + else: + s = '%s SYSTEM "%s">' % (s, notation.systemId) + for i in range(doctype.entities.length): + entity = doctype.entities.item(i) + if s: + s = s + "\n " + s = "%s" + return s + + def _getNSattrs(self): + return "" + + def external_entity_ref_handler(self, context, base, systemId, publicId): + if systemId == _FRAGMENT_BUILDER_INTERNAL_SYSTEM_ID: + # this entref is the one that we made to put the subtree + # in; all of our given input is parsed in here. + old_document = self.document + old_cur_node = self.curNode + parser = self._parser.ExternalEntityParserCreate(context) + # put the real document back, parse into the fragment to return + self.document = self.originalDocument + self.fragment = self.document.createDocumentFragment() + self.curNode = self.fragment + try: + parser.Parse(self._source, True) + finally: + self.curNode = old_cur_node + self.document = old_document + self._source = None + return -1 + else: + return ExpatBuilder.external_entity_ref_handler( + self, context, base, systemId, publicId) + + +class Namespaces: + """Mix-in class for builders; adds support for namespaces.""" + + def _initNamespaces(self): + # list of (prefix, uri) ns declarations. Namespace attrs are + # constructed from this and added to the element's attrs. + self._ns_ordered_prefixes = [] + + def createParser(self): + """Create a new namespace-handling parser.""" + parser = expat.ParserCreate(namespace_separator=" ") + parser.namespace_prefixes = True + return parser + + def install(self, parser): + """Insert the namespace-handlers onto the parser.""" + ExpatBuilder.install(self, parser) + if self._options.namespace_declarations: + parser.StartNamespaceDeclHandler = ( + self.start_namespace_decl_handler) + + def start_namespace_decl_handler(self, prefix, uri): + """Push this namespace declaration on our storage.""" + self._ns_ordered_prefixes.append((prefix, uri)) + + def start_element_handler(self, name, attributes): + if ' ' in name: + uri, localname, prefix, qname = _parse_ns_name(self, name) + else: + uri = EMPTY_NAMESPACE + qname = name + localname = None + prefix = EMPTY_PREFIX + node = minidom.Element(qname, uri, prefix, localname) + node.ownerDocument = self.document + _append_child(self.curNode, node) + self.curNode = node + + if self._ns_ordered_prefixes: + for prefix, uri in self._ns_ordered_prefixes: + if prefix: + a = minidom.Attr(_intern(self, 'xmlns:' + prefix), + XMLNS_NAMESPACE, prefix, "xmlns") + else: + a = minidom.Attr("xmlns", XMLNS_NAMESPACE, + "xmlns", EMPTY_PREFIX) + a.value = uri + a.ownerDocument = self.document + _set_attribute_node(node, a) + del self._ns_ordered_prefixes[:] + + if attributes: + node._ensure_attributes() + _attrs = node._attrs + _attrsNS = node._attrsNS + for i in range(0, len(attributes), 2): + aname = attributes[i] + value = attributes[i+1] + if ' ' in aname: + uri, localname, prefix, qname = _parse_ns_name(self, aname) + a = minidom.Attr(qname, uri, localname, prefix) + _attrs[qname] = a + _attrsNS[(uri, localname)] = a + else: + a = minidom.Attr(aname, EMPTY_NAMESPACE, + aname, EMPTY_PREFIX) + _attrs[aname] = a + _attrsNS[(EMPTY_NAMESPACE, aname)] = a + a.ownerDocument = self.document + a.value = value + a.ownerElement = node + + if __debug__: + # This only adds some asserts to the original + # end_element_handler(), so we only define this when -O is not + # used. If changing one, be sure to check the other to see if + # it needs to be changed as well. + # + def end_element_handler(self, name): + curNode = self.curNode + if ' ' in name: + uri, localname, prefix, qname = _parse_ns_name(self, name) + assert (curNode.namespaceURI == uri + and curNode.localName == localname + and curNode.prefix == prefix), \ + "element stack messed up! (namespace)" + else: + assert curNode.nodeName == name, \ + "element stack messed up - bad nodeName" + assert curNode.namespaceURI == EMPTY_NAMESPACE, \ + "element stack messed up - bad namespaceURI" + self.curNode = curNode.parentNode + self._finish_end_element(curNode) + + +class ExpatBuilderNS(Namespaces, ExpatBuilder): + """Document builder that supports namespaces.""" + + def reset(self): + ExpatBuilder.reset(self) + self._initNamespaces() + + +class FragmentBuilderNS(Namespaces, FragmentBuilder): + """Fragment builder that supports namespaces.""" + + def reset(self): + FragmentBuilder.reset(self) + self._initNamespaces() + + def _getNSattrs(self): + """Return string of namespace attributes from this element and + ancestors.""" + # XXX This needs to be re-written to walk the ancestors of the + # context to build up the namespace information from + # declarations, elements, and attributes found in context. + # Otherwise we have to store a bunch more data on the DOM + # (though that *might* be more reliable -- not clear). + attrs = "" + context = self.context + L = [] + while context: + if hasattr(context, '_ns_prefix_uri'): + for prefix, uri in context._ns_prefix_uri.items(): + # add every new NS decl from context to L and attrs string + if prefix in L: + continue + L.append(prefix) + if prefix: + declname = "xmlns:" + prefix + else: + declname = "xmlns" + if attrs: + attrs = "%s\n %s='%s'" % (attrs, declname, uri) + else: + attrs = " %s='%s'" % (declname, uri) + context = context.parentNode + return attrs + + +class ParseEscape(Exception): + """Exception raised to short-circuit parsing in InternalSubsetExtractor.""" + pass + +class InternalSubsetExtractor(ExpatBuilder): + """XML processor which can rip out the internal document type subset.""" + + subset = None + + def getSubset(self): + """Return the internal subset as a string.""" + return self.subset + + def parseFile(self, file): + try: + ExpatBuilder.parseFile(self, file) + except ParseEscape: + pass + + def parseString(self, string): + try: + ExpatBuilder.parseString(self, string) + except ParseEscape: + pass + + def install(self, parser): + parser.StartDoctypeDeclHandler = self.start_doctype_decl_handler + parser.StartElementHandler = self.start_element_handler + + def start_doctype_decl_handler(self, name, publicId, systemId, + has_internal_subset): + if has_internal_subset: + parser = self.getParser() + self.subset = [] + parser.DefaultHandler = self.subset.append + parser.EndDoctypeDeclHandler = self.end_doctype_decl_handler + else: + raise ParseEscape() + + def end_doctype_decl_handler(self): + s = ''.join(self.subset).replace('\r\n', '\n').replace('\r', '\n') + self.subset = s + raise ParseEscape() + + def start_element_handler(self, name, attrs): + raise ParseEscape() + + +def parse(file, namespaces=True): + """Parse a document, returning the resulting Document node. + + 'file' may be either a file name or an open file object. + """ + if namespaces: + builder = ExpatBuilderNS() + else: + builder = ExpatBuilder() + + if isinstance(file, str): + with open(file, 'rb') as fp: + result = builder.parseFile(fp) + else: + result = builder.parseFile(file) + return result + + +def parseString(string, namespaces=True): + """Parse a document from a string, returning the resulting + Document node. + """ + if namespaces: + builder = ExpatBuilderNS() + else: + builder = ExpatBuilder() + return builder.parseString(string) + + +def parseFragment(file, context, namespaces=True): + """Parse a fragment of a document, given the context from which it + was originally extracted. context should be the parent of the + node(s) which are in the fragment. + + 'file' may be either a file name or an open file object. + """ + if namespaces: + builder = FragmentBuilderNS(context) + else: + builder = FragmentBuilder(context) + + if isinstance(file, str): + with open(file, 'rb') as fp: + result = builder.parseFile(fp) + else: + result = builder.parseFile(file) + return result + + +def parseFragmentString(string, context, namespaces=True): + """Parse a fragment of a document from a string, given the context + from which it was originally extracted. context should be the + parent of the node(s) which are in the fragment. + """ + if namespaces: + builder = FragmentBuilderNS(context) + else: + builder = FragmentBuilder(context) + return builder.parseString(string) + + +def makeBuilder(options): + """Create a builder based on an Options object.""" + if options.namespaces: + return ExpatBuilderNS(options) + else: + return ExpatBuilder(options) diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/minicompat.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/minicompat.py new file mode 100644 index 0000000..5d6fae9 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/minicompat.py @@ -0,0 +1,109 @@ +"""Python version compatibility support for minidom. + +This module contains internal implementation details and +should not be imported; use xml.dom.minidom instead. +""" + +# This module should only be imported using "import *". +# +# The following names are defined: +# +# NodeList -- lightest possible NodeList implementation +# +# EmptyNodeList -- lightest possible NodeList that is guaranteed to +# remain empty (immutable) +# +# StringTypes -- tuple of defined string types +# +# defproperty -- function used in conjunction with GetattrMagic; +# using these together is needed to make them work +# as efficiently as possible in both Python 2.2+ +# and older versions. For example: +# +# class MyClass(GetattrMagic): +# def _get_myattr(self): +# return something +# +# defproperty(MyClass, "myattr", +# "return some value") +# +# For Python 2.2 and newer, this will construct a +# property object on the class, which avoids +# needing to override __getattr__(). It will only +# work for read-only attributes. +# +# For older versions of Python, inheriting from +# GetattrMagic will use the traditional +# __getattr__() hackery to achieve the same effect, +# but less efficiently. +# +# defproperty() should be used for each version of +# the relevant _get_() function. + +__all__ = ["NodeList", "EmptyNodeList", "StringTypes", "defproperty"] + +import xml.dom + +StringTypes = (str,) + + +class NodeList(list): + __slots__ = () + + def item(self, index): + if 0 <= index < len(self): + return self[index] + + def _get_length(self): + return len(self) + + def _set_length(self, value): + raise xml.dom.NoModificationAllowedErr( + "attempt to modify read-only attribute 'length'") + + length = property(_get_length, _set_length, + doc="The number of nodes in the NodeList.") + + # For backward compatibility + def __setstate__(self, state): + if state is None: + state = [] + self[:] = state + + +class EmptyNodeList(tuple): + __slots__ = () + + def __add__(self, other): + NL = NodeList() + NL.extend(other) + return NL + + def __radd__(self, other): + NL = NodeList() + NL.extend(other) + return NL + + def item(self, index): + return None + + def _get_length(self): + return 0 + + def _set_length(self, value): + raise xml.dom.NoModificationAllowedErr( + "attempt to modify read-only attribute 'length'") + + length = property(_get_length, _set_length, + doc="The number of nodes in the NodeList.") + + +def defproperty(klass, name, doc): + get = getattr(klass, ("_get_" + name)) + def set(self, value, name=name): + raise xml.dom.NoModificationAllowedErr( + "attempt to modify read-only attribute " + repr(name)) + assert not hasattr(klass, "_set_" + name), \ + "expected not to find _set_" + name + prop = property(get, set, doc=doc) + setattr(klass, name, prop) diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/minidom.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/minidom.py new file mode 100644 index 0000000..16b33b9 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/minidom.py @@ -0,0 +1,2024 @@ +"""Simple implementation of the Level 1 DOM. + +Namespaces and other minor Level 2 features are also supported. + +parse("foo.xml") + +parseString("") + +Todo: +===== + * convenience methods for getting elements and text. + * more testing + * bring some of the writer and linearizer code into conformance with this + interface + * SAX 2 namespaces +""" + +import io +import xml.dom + +from xml.dom import EMPTY_NAMESPACE, EMPTY_PREFIX, XMLNS_NAMESPACE, domreg +from xml.dom.minicompat import * +from xml.dom.xmlbuilder import DOMImplementationLS, DocumentLS + +# This is used by the ID-cache invalidation checks; the list isn't +# actually complete, since the nodes being checked will never be the +# DOCUMENT_NODE or DOCUMENT_FRAGMENT_NODE. (The node being checked is +# the node being added or removed, not the node being modified.) +# +_nodeTypes_with_children = (xml.dom.Node.ELEMENT_NODE, + xml.dom.Node.ENTITY_REFERENCE_NODE) + + +class Node(xml.dom.Node): + namespaceURI = None # this is non-null only for elements and attributes + parentNode = None + ownerDocument = None + nextSibling = None + previousSibling = None + + prefix = EMPTY_PREFIX # non-null only for NS elements and attributes + + def __bool__(self): + return True + + def toxml(self, encoding=None, standalone=None): + return self.toprettyxml("", "", encoding, standalone) + + def toprettyxml(self, indent="\t", newl="\n", encoding=None, + standalone=None): + if encoding is None: + writer = io.StringIO() + else: + writer = io.TextIOWrapper(io.BytesIO(), + encoding=encoding, + errors="xmlcharrefreplace", + newline='\n') + if self.nodeType == Node.DOCUMENT_NODE: + # Can pass encoding only to document, to put it into XML header + self.writexml(writer, "", indent, newl, encoding, standalone) + else: + self.writexml(writer, "", indent, newl) + if encoding is None: + return writer.getvalue() + else: + return writer.detach().getvalue() + + def hasChildNodes(self): + return bool(self.childNodes) + + def _get_childNodes(self): + return self.childNodes + + def _get_firstChild(self): + if self.childNodes: + return self.childNodes[0] + + def _get_lastChild(self): + if self.childNodes: + return self.childNodes[-1] + + def insertBefore(self, newChild, refChild): + if newChild.nodeType == self.DOCUMENT_FRAGMENT_NODE: + for c in tuple(newChild.childNodes): + self.insertBefore(c, refChild) + ### The DOM does not clearly specify what to return in this case + return newChild + if newChild.nodeType not in self._child_node_types: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(newChild), repr(self))) + if newChild.parentNode is not None: + newChild.parentNode.removeChild(newChild) + if refChild is None: + self.appendChild(newChild) + else: + try: + index = self.childNodes.index(refChild) + except ValueError: + raise xml.dom.NotFoundErr() + if newChild.nodeType in _nodeTypes_with_children: + _clear_id_cache(self) + self.childNodes.insert(index, newChild) + newChild.nextSibling = refChild + refChild.previousSibling = newChild + if index: + node = self.childNodes[index-1] + node.nextSibling = newChild + newChild.previousSibling = node + else: + newChild.previousSibling = None + newChild.parentNode = self + return newChild + + def appendChild(self, node): + if node.nodeType == self.DOCUMENT_FRAGMENT_NODE: + for c in tuple(node.childNodes): + self.appendChild(c) + ### The DOM does not clearly specify what to return in this case + return node + if node.nodeType not in self._child_node_types: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(node), repr(self))) + elif node.nodeType in _nodeTypes_with_children: + _clear_id_cache(self) + if node.parentNode is not None: + node.parentNode.removeChild(node) + _append_child(self, node) + node.nextSibling = None + return node + + def replaceChild(self, newChild, oldChild): + if newChild.nodeType == self.DOCUMENT_FRAGMENT_NODE: + refChild = oldChild.nextSibling + self.removeChild(oldChild) + return self.insertBefore(newChild, refChild) + if newChild.nodeType not in self._child_node_types: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(newChild), repr(self))) + if newChild is oldChild: + return + if newChild.parentNode is not None: + newChild.parentNode.removeChild(newChild) + try: + index = self.childNodes.index(oldChild) + except ValueError: + raise xml.dom.NotFoundErr() + self.childNodes[index] = newChild + newChild.parentNode = self + oldChild.parentNode = None + if (newChild.nodeType in _nodeTypes_with_children + or oldChild.nodeType in _nodeTypes_with_children): + _clear_id_cache(self) + newChild.nextSibling = oldChild.nextSibling + newChild.previousSibling = oldChild.previousSibling + oldChild.nextSibling = None + oldChild.previousSibling = None + if newChild.previousSibling: + newChild.previousSibling.nextSibling = newChild + if newChild.nextSibling: + newChild.nextSibling.previousSibling = newChild + return oldChild + + def removeChild(self, oldChild): + try: + self.childNodes.remove(oldChild) + except ValueError: + raise xml.dom.NotFoundErr() + if oldChild.nextSibling is not None: + oldChild.nextSibling.previousSibling = oldChild.previousSibling + if oldChild.previousSibling is not None: + oldChild.previousSibling.nextSibling = oldChild.nextSibling + oldChild.nextSibling = oldChild.previousSibling = None + if oldChild.nodeType in _nodeTypes_with_children: + _clear_id_cache(self) + + oldChild.parentNode = None + return oldChild + + def normalize(self): + L = [] + for child in self.childNodes: + if child.nodeType == Node.TEXT_NODE: + if not child.data: + # empty text node; discard + if L: + L[-1].nextSibling = child.nextSibling + if child.nextSibling: + child.nextSibling.previousSibling = child.previousSibling + child.unlink() + elif L and L[-1].nodeType == child.nodeType: + # collapse text node + node = L[-1] + node.data = node.data + child.data + node.nextSibling = child.nextSibling + if child.nextSibling: + child.nextSibling.previousSibling = node + child.unlink() + else: + L.append(child) + else: + L.append(child) + if child.nodeType == Node.ELEMENT_NODE: + child.normalize() + self.childNodes[:] = L + + def cloneNode(self, deep): + return _clone_node(self, deep, self.ownerDocument or self) + + def isSupported(self, feature, version): + return self.ownerDocument.implementation.hasFeature(feature, version) + + def _get_localName(self): + # Overridden in Element and Attr where localName can be Non-Null + return None + + # Node interfaces from Level 3 (WD 9 April 2002) + + def isSameNode(self, other): + return self is other + + def getInterface(self, feature): + if self.isSupported(feature, None): + return self + else: + return None + + # The "user data" functions use a dictionary that is only present + # if some user data has been set, so be careful not to assume it + # exists. + + def getUserData(self, key): + try: + return self._user_data[key][0] + except (AttributeError, KeyError): + return None + + def setUserData(self, key, data, handler): + old = None + try: + d = self._user_data + except AttributeError: + d = {} + self._user_data = d + if key in d: + old = d[key][0] + if data is None: + # ignore handlers passed for None + handler = None + if old is not None: + del d[key] + else: + d[key] = (data, handler) + return old + + def _call_user_data_handler(self, operation, src, dst): + if hasattr(self, "_user_data"): + for key, (data, handler) in list(self._user_data.items()): + if handler is not None: + handler.handle(operation, key, data, src, dst) + + # minidom-specific API: + + def unlink(self): + self.parentNode = self.ownerDocument = None + if self.childNodes: + for child in self.childNodes: + child.unlink() + self.childNodes = NodeList() + self.previousSibling = None + self.nextSibling = None + + # A Node is its own context manager, to ensure that an unlink() call occurs. + # This is similar to how a file object works. + def __enter__(self): + return self + + def __exit__(self, et, ev, tb): + self.unlink() + +defproperty(Node, "firstChild", doc="First child node, or None.") +defproperty(Node, "lastChild", doc="Last child node, or None.") +defproperty(Node, "localName", doc="Namespace-local name of this node.") + + +def _append_child(self, node): + # fast path with less checks; usable by DOM builders if careful + childNodes = self.childNodes + if childNodes: + last = childNodes[-1] + node.previousSibling = last + last.nextSibling = node + childNodes.append(node) + node.parentNode = self + + +def _write_data(writer, text, attr): + "Writes datachars to writer." + if not text: + return + # See the comments in ElementTree.py for behavior and + # implementation details. + if "&" in text: + text = text.replace("&", "&") + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if attr: + if '"' in text: + text = text.replace('"', """) + if "\r" in text: + text = text.replace("\r", " ") + if "\n" in text: + text = text.replace("\n", " ") + if "\t" in text: + text = text.replace("\t", " ") + writer.write(text) + +def _get_elements_by_tagName_helper(parent, name, rc): + for node in parent.childNodes: + if node.nodeType == Node.ELEMENT_NODE and \ + (name == "*" or node.tagName == name): + rc.append(node) + _get_elements_by_tagName_helper(node, name, rc) + return rc + +def _get_elements_by_tagName_ns_helper(parent, nsURI, localName, rc): + for node in parent.childNodes: + if node.nodeType == Node.ELEMENT_NODE: + if ((localName == "*" or node.localName == localName) and + (nsURI == "*" or node.namespaceURI == nsURI)): + rc.append(node) + _get_elements_by_tagName_ns_helper(node, nsURI, localName, rc) + return rc + +class DocumentFragment(Node): + nodeType = Node.DOCUMENT_FRAGMENT_NODE + nodeName = "#document-fragment" + nodeValue = None + attributes = None + parentNode = None + _child_node_types = (Node.ELEMENT_NODE, + Node.TEXT_NODE, + Node.CDATA_SECTION_NODE, + Node.ENTITY_REFERENCE_NODE, + Node.PROCESSING_INSTRUCTION_NODE, + Node.COMMENT_NODE, + Node.NOTATION_NODE) + + def __init__(self): + self.childNodes = NodeList() + + +class Attr(Node): + __slots__=('_name', '_value', 'namespaceURI', + '_prefix', 'childNodes', '_localName', 'ownerDocument', 'ownerElement') + nodeType = Node.ATTRIBUTE_NODE + attributes = None + specified = False + _is_id = False + + _child_node_types = (Node.TEXT_NODE, Node.ENTITY_REFERENCE_NODE) + + def __init__(self, qName, namespaceURI=EMPTY_NAMESPACE, localName=None, + prefix=None): + self.ownerElement = None + self.ownerDocument = None + self._name = qName + self.namespaceURI = namespaceURI + self._prefix = prefix + if localName is not None: + self._localName = localName + self.childNodes = NodeList() + + # Add the single child node that represents the value of the attr + self.childNodes.append(Text()) + + # nodeValue and value are set elsewhere + + def _get_localName(self): + try: + return self._localName + except AttributeError: + return self.nodeName.split(":", 1)[-1] + + def _get_specified(self): + return self.specified + + def _get_name(self): + return self._name + + def _set_name(self, value): + self._name = value + if self.ownerElement is not None: + _clear_id_cache(self.ownerElement) + + nodeName = name = property(_get_name, _set_name) + + def _get_value(self): + return self._value + + def _set_value(self, value): + self._value = value + self.childNodes[0].data = value + if self.ownerElement is not None: + _clear_id_cache(self.ownerElement) + self.childNodes[0].data = value + + nodeValue = value = property(_get_value, _set_value) + + def _get_prefix(self): + return self._prefix + + def _set_prefix(self, prefix): + nsuri = self.namespaceURI + if prefix == "xmlns": + if nsuri and nsuri != XMLNS_NAMESPACE: + raise xml.dom.NamespaceErr( + "illegal use of 'xmlns' prefix for the wrong namespace") + self._prefix = prefix + if prefix is None: + newName = self.localName + else: + newName = "%s:%s" % (prefix, self.localName) + if self.ownerElement: + _clear_id_cache(self.ownerElement) + self.name = newName + + prefix = property(_get_prefix, _set_prefix) + + def unlink(self): + # This implementation does not call the base implementation + # since most of that is not needed, and the expense of the + # method call is not warranted. We duplicate the removal of + # children, but that's all we needed from the base class. + elem = self.ownerElement + if elem is not None: + del elem._attrs[self.nodeName] + del elem._attrsNS[(self.namespaceURI, self.localName)] + if self._is_id: + self._is_id = False + elem._magic_id_nodes -= 1 + self.ownerDocument._magic_id_count -= 1 + for child in self.childNodes: + child.unlink() + del self.childNodes[:] + + def _get_isId(self): + if self._is_id: + return True + doc = self.ownerDocument + elem = self.ownerElement + if doc is None or elem is None: + return False + + info = doc._get_elem_info(elem) + if info is None: + return False + if self.namespaceURI: + return info.isIdNS(self.namespaceURI, self.localName) + else: + return info.isId(self.nodeName) + + def _get_schemaType(self): + doc = self.ownerDocument + elem = self.ownerElement + if doc is None or elem is None: + return _no_type + + info = doc._get_elem_info(elem) + if info is None: + return _no_type + if self.namespaceURI: + return info.getAttributeTypeNS(self.namespaceURI, self.localName) + else: + return info.getAttributeType(self.nodeName) + +defproperty(Attr, "isId", doc="True if this attribute is an ID.") +defproperty(Attr, "localName", doc="Namespace-local name of this attribute.") +defproperty(Attr, "schemaType", doc="Schema type for this attribute.") + + +class NamedNodeMap(object): + """The attribute list is a transient interface to the underlying + dictionaries. Mutations here will change the underlying element's + dictionary. + + Ordering is imposed artificially and does not reflect the order of + attributes as found in an input document. + """ + + __slots__ = ('_attrs', '_attrsNS', '_ownerElement') + + def __init__(self, attrs, attrsNS, ownerElement): + self._attrs = attrs + self._attrsNS = attrsNS + self._ownerElement = ownerElement + + def _get_length(self): + return len(self._attrs) + + def item(self, index): + try: + return self[list(self._attrs.keys())[index]] + except IndexError: + return None + + def items(self): + L = [] + for node in self._attrs.values(): + L.append((node.nodeName, node.value)) + return L + + def itemsNS(self): + L = [] + for node in self._attrs.values(): + L.append(((node.namespaceURI, node.localName), node.value)) + return L + + def __contains__(self, key): + if isinstance(key, str): + return key in self._attrs + else: + return key in self._attrsNS + + def keys(self): + return self._attrs.keys() + + def keysNS(self): + return self._attrsNS.keys() + + def values(self): + return self._attrs.values() + + def get(self, name, value=None): + return self._attrs.get(name, value) + + __len__ = _get_length + + def _cmp(self, other): + if self._attrs is getattr(other, "_attrs", None): + return 0 + else: + return (id(self) > id(other)) - (id(self) < id(other)) + + def __eq__(self, other): + return self._cmp(other) == 0 + + def __ge__(self, other): + return self._cmp(other) >= 0 + + def __gt__(self, other): + return self._cmp(other) > 0 + + def __le__(self, other): + return self._cmp(other) <= 0 + + def __lt__(self, other): + return self._cmp(other) < 0 + + def __getitem__(self, attname_or_tuple): + if isinstance(attname_or_tuple, tuple): + return self._attrsNS[attname_or_tuple] + else: + return self._attrs[attname_or_tuple] + + # same as set + def __setitem__(self, attname, value): + if isinstance(value, str): + try: + node = self._attrs[attname] + except KeyError: + node = Attr(attname) + node.ownerDocument = self._ownerElement.ownerDocument + self.setNamedItem(node) + node.value = value + else: + if not isinstance(value, Attr): + raise TypeError("value must be a string or Attr object") + node = value + self.setNamedItem(node) + + def getNamedItem(self, name): + try: + return self._attrs[name] + except KeyError: + return None + + def getNamedItemNS(self, namespaceURI, localName): + try: + return self._attrsNS[(namespaceURI, localName)] + except KeyError: + return None + + def removeNamedItem(self, name): + n = self.getNamedItem(name) + if n is not None: + _clear_id_cache(self._ownerElement) + del self._attrs[n.nodeName] + del self._attrsNS[(n.namespaceURI, n.localName)] + if hasattr(n, 'ownerElement'): + n.ownerElement = None + return n + else: + raise xml.dom.NotFoundErr() + + def removeNamedItemNS(self, namespaceURI, localName): + n = self.getNamedItemNS(namespaceURI, localName) + if n is not None: + _clear_id_cache(self._ownerElement) + del self._attrsNS[(n.namespaceURI, n.localName)] + del self._attrs[n.nodeName] + if hasattr(n, 'ownerElement'): + n.ownerElement = None + return n + else: + raise xml.dom.NotFoundErr() + + def setNamedItem(self, node): + if not isinstance(node, Attr): + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(node), repr(self))) + old = self._attrs.get(node.name) + if old: + old.unlink() + self._attrs[node.name] = node + self._attrsNS[(node.namespaceURI, node.localName)] = node + node.ownerElement = self._ownerElement + _clear_id_cache(node.ownerElement) + return old + + def setNamedItemNS(self, node): + return self.setNamedItem(node) + + def __delitem__(self, attname_or_tuple): + node = self[attname_or_tuple] + _clear_id_cache(node.ownerElement) + node.unlink() + + def __getstate__(self): + return self._attrs, self._attrsNS, self._ownerElement + + def __setstate__(self, state): + self._attrs, self._attrsNS, self._ownerElement = state + +defproperty(NamedNodeMap, "length", + doc="Number of nodes in the NamedNodeMap.") + +AttributeList = NamedNodeMap + + +class TypeInfo(object): + __slots__ = 'namespace', 'name' + + def __init__(self, namespace, name): + self.namespace = namespace + self.name = name + + def __repr__(self): + if self.namespace: + return "<%s %r (from %r)>" % (self.__class__.__name__, self.name, + self.namespace) + else: + return "<%s %r>" % (self.__class__.__name__, self.name) + + def _get_name(self): + return self.name + + def _get_namespace(self): + return self.namespace + +_no_type = TypeInfo(None, None) + +class Element(Node): + __slots__=('ownerDocument', 'parentNode', 'tagName', 'nodeName', 'prefix', + 'namespaceURI', '_localName', 'childNodes', '_attrs', '_attrsNS', + 'nextSibling', 'previousSibling') + nodeType = Node.ELEMENT_NODE + nodeValue = None + schemaType = _no_type + + _magic_id_nodes = 0 + + _child_node_types = (Node.ELEMENT_NODE, + Node.PROCESSING_INSTRUCTION_NODE, + Node.COMMENT_NODE, + Node.TEXT_NODE, + Node.CDATA_SECTION_NODE, + Node.ENTITY_REFERENCE_NODE) + + def __init__(self, tagName, namespaceURI=EMPTY_NAMESPACE, prefix=None, + localName=None): + self.ownerDocument = None + self.parentNode = None + self.tagName = self.nodeName = tagName + self.prefix = prefix + self.namespaceURI = namespaceURI + self.childNodes = NodeList() + self.nextSibling = self.previousSibling = None + + # Attribute dictionaries are lazily created + # attributes are double-indexed: + # tagName -> Attribute + # URI,localName -> Attribute + # in the future: consider lazy generation + # of attribute objects this is too tricky + # for now because of headaches with + # namespaces. + self._attrs = None + self._attrsNS = None + + def _ensure_attributes(self): + if self._attrs is None: + self._attrs = {} + self._attrsNS = {} + + def _get_localName(self): + try: + return self._localName + except AttributeError: + return self.tagName.split(":", 1)[-1] + + def _get_tagName(self): + return self.tagName + + def unlink(self): + if self._attrs is not None: + for attr in list(self._attrs.values()): + attr.unlink() + self._attrs = None + self._attrsNS = None + Node.unlink(self) + + def getAttribute(self, attname): + """Returns the value of the specified attribute. + + Returns the value of the element's attribute named attname as + a string. An empty string is returned if the element does not + have such an attribute. Note that an empty string may also be + returned as an explicitly given attribute value, use the + hasAttribute method to distinguish these two cases. + """ + if self._attrs is None: + return "" + try: + return self._attrs[attname].value + except KeyError: + return "" + + def getAttributeNS(self, namespaceURI, localName): + if self._attrsNS is None: + return "" + try: + return self._attrsNS[(namespaceURI, localName)].value + except KeyError: + return "" + + def setAttribute(self, attname, value): + attr = self.getAttributeNode(attname) + if attr is None: + attr = Attr(attname) + attr.value = value # also sets nodeValue + attr.ownerDocument = self.ownerDocument + self.setAttributeNode(attr) + elif value != attr.value: + attr.value = value + if attr.isId: + _clear_id_cache(self) + + def setAttributeNS(self, namespaceURI, qualifiedName, value): + prefix, localname = _nssplit(qualifiedName) + attr = self.getAttributeNodeNS(namespaceURI, localname) + if attr is None: + attr = Attr(qualifiedName, namespaceURI, localname, prefix) + attr.value = value + attr.ownerDocument = self.ownerDocument + self.setAttributeNode(attr) + else: + if value != attr.value: + attr.value = value + if attr.isId: + _clear_id_cache(self) + if attr.prefix != prefix: + attr.prefix = prefix + attr.nodeName = qualifiedName + + def getAttributeNode(self, attrname): + if self._attrs is None: + return None + return self._attrs.get(attrname) + + def getAttributeNodeNS(self, namespaceURI, localName): + if self._attrsNS is None: + return None + return self._attrsNS.get((namespaceURI, localName)) + + def setAttributeNode(self, attr): + if attr.ownerElement not in (None, self): + raise xml.dom.InuseAttributeErr("attribute node already owned") + self._ensure_attributes() + old1 = self._attrs.get(attr.name, None) + if old1 is not None: + self.removeAttributeNode(old1) + old2 = self._attrsNS.get((attr.namespaceURI, attr.localName), None) + if old2 is not None and old2 is not old1: + self.removeAttributeNode(old2) + _set_attribute_node(self, attr) + + if old1 is not attr: + # It might have already been part of this node, in which case + # it doesn't represent a change, and should not be returned. + return old1 + if old2 is not attr: + return old2 + + setAttributeNodeNS = setAttributeNode + + def removeAttribute(self, name): + if self._attrsNS is None: + raise xml.dom.NotFoundErr() + try: + attr = self._attrs[name] + except KeyError: + raise xml.dom.NotFoundErr() + self.removeAttributeNode(attr) + + def removeAttributeNS(self, namespaceURI, localName): + if self._attrsNS is None: + raise xml.dom.NotFoundErr() + try: + attr = self._attrsNS[(namespaceURI, localName)] + except KeyError: + raise xml.dom.NotFoundErr() + self.removeAttributeNode(attr) + + def removeAttributeNode(self, node): + if node is None: + raise xml.dom.NotFoundErr() + try: + self._attrs[node.name] + except KeyError: + raise xml.dom.NotFoundErr() + _clear_id_cache(self) + node.unlink() + # Restore this since the node is still useful and otherwise + # unlinked + node.ownerDocument = self.ownerDocument + return node + + removeAttributeNodeNS = removeAttributeNode + + def hasAttribute(self, name): + """Checks whether the element has an attribute with the specified name. + + Returns True if the element has an attribute with the specified name. + Otherwise, returns False. + """ + if self._attrs is None: + return False + return name in self._attrs + + def hasAttributeNS(self, namespaceURI, localName): + if self._attrsNS is None: + return False + return (namespaceURI, localName) in self._attrsNS + + def getElementsByTagName(self, name): + """Returns all descendant elements with the given tag name. + + Returns the list of all descendant elements (not direct children + only) with the specified tag name. + """ + return _get_elements_by_tagName_helper(self, name, NodeList()) + + def getElementsByTagNameNS(self, namespaceURI, localName): + return _get_elements_by_tagName_ns_helper( + self, namespaceURI, localName, NodeList()) + + def __repr__(self): + return "" % (self.tagName, id(self)) + + def writexml(self, writer, indent="", addindent="", newl=""): + """Write an XML element to a file-like object + + Write the element to the writer object that must provide + a write method (e.g. a file or StringIO object). + """ + # indent = current indentation + # addindent = indentation to add to higher levels + # newl = newline string + writer.write(indent+"<" + self.tagName) + + attrs = self._get_attributes() + + for a_name in attrs.keys(): + writer.write(" %s=\"" % a_name) + _write_data(writer, attrs[a_name].value, True) + writer.write("\"") + if self.childNodes: + writer.write(">") + if (len(self.childNodes) == 1 and + self.childNodes[0].nodeType in ( + Node.TEXT_NODE, Node.CDATA_SECTION_NODE)): + self.childNodes[0].writexml(writer, '', '', '') + else: + writer.write(newl) + for node in self.childNodes: + node.writexml(writer, indent+addindent, addindent, newl) + writer.write(indent) + writer.write("%s" % (self.tagName, newl)) + else: + writer.write("/>%s"%(newl)) + + def _get_attributes(self): + self._ensure_attributes() + return NamedNodeMap(self._attrs, self._attrsNS, self) + + def hasAttributes(self): + if self._attrs: + return True + else: + return False + + # DOM Level 3 attributes, based on the 22 Oct 2002 draft + + def setIdAttribute(self, name): + idAttr = self.getAttributeNode(name) + self.setIdAttributeNode(idAttr) + + def setIdAttributeNS(self, namespaceURI, localName): + idAttr = self.getAttributeNodeNS(namespaceURI, localName) + self.setIdAttributeNode(idAttr) + + def setIdAttributeNode(self, idAttr): + if idAttr is None or not self.isSameNode(idAttr.ownerElement): + raise xml.dom.NotFoundErr() + if _get_containing_entref(self) is not None: + raise xml.dom.NoModificationAllowedErr() + if not idAttr._is_id: + idAttr._is_id = True + self._magic_id_nodes += 1 + self.ownerDocument._magic_id_count += 1 + _clear_id_cache(self) + +defproperty(Element, "attributes", + doc="NamedNodeMap of attributes on the element.") +defproperty(Element, "localName", + doc="Namespace-local name of this element.") + + +def _set_attribute_node(element, attr): + _clear_id_cache(element) + element._ensure_attributes() + element._attrs[attr.name] = attr + element._attrsNS[(attr.namespaceURI, attr.localName)] = attr + + # This creates a circular reference, but Element.unlink() + # breaks the cycle since the references to the attribute + # dictionaries are tossed. + attr.ownerElement = element + +class Childless: + """Mixin that makes childless-ness easy to implement and avoids + the complexity of the Node methods that deal with children. + """ + __slots__ = () + + attributes = None + childNodes = EmptyNodeList() + firstChild = None + lastChild = None + + def _get_firstChild(self): + return None + + def _get_lastChild(self): + return None + + def appendChild(self, node): + raise xml.dom.HierarchyRequestErr( + self.nodeName + " nodes cannot have children") + + def hasChildNodes(self): + return False + + def insertBefore(self, newChild, refChild): + raise xml.dom.HierarchyRequestErr( + self.nodeName + " nodes do not have children") + + def removeChild(self, oldChild): + raise xml.dom.NotFoundErr( + self.nodeName + " nodes do not have children") + + def normalize(self): + # For childless nodes, normalize() has nothing to do. + pass + + def replaceChild(self, newChild, oldChild): + raise xml.dom.HierarchyRequestErr( + self.nodeName + " nodes do not have children") + + +class ProcessingInstruction(Childless, Node): + nodeType = Node.PROCESSING_INSTRUCTION_NODE + __slots__ = ('target', 'data') + + def __init__(self, target, data): + self.target = target + self.data = data + + # nodeValue is an alias for data + def _get_nodeValue(self): + return self.data + def _set_nodeValue(self, value): + self.data = value + nodeValue = property(_get_nodeValue, _set_nodeValue) + + # nodeName is an alias for target + def _get_nodeName(self): + return self.target + def _set_nodeName(self, value): + self.target = value + nodeName = property(_get_nodeName, _set_nodeName) + + def writexml(self, writer, indent="", addindent="", newl=""): + writer.write("%s%s" % (indent,self.target, self.data, newl)) + + +class CharacterData(Childless, Node): + __slots__=('_data', 'ownerDocument','parentNode', 'previousSibling', 'nextSibling') + + def __init__(self): + self.ownerDocument = self.parentNode = None + self.previousSibling = self.nextSibling = None + self._data = '' + Node.__init__(self) + + def _get_length(self): + return len(self.data) + __len__ = _get_length + + def _get_data(self): + return self._data + def _set_data(self, data): + self._data = data + + data = nodeValue = property(_get_data, _set_data) + + def __repr__(self): + data = self.data + if len(data) > 10: + dotdotdot = "..." + else: + dotdotdot = "" + return '' % ( + self.__class__.__name__, data[0:10], dotdotdot) + + def substringData(self, offset, count): + if offset < 0: + raise xml.dom.IndexSizeErr("offset cannot be negative") + if offset >= len(self.data): + raise xml.dom.IndexSizeErr("offset cannot be beyond end of data") + if count < 0: + raise xml.dom.IndexSizeErr("count cannot be negative") + return self.data[offset:offset+count] + + def appendData(self, arg): + self.data = self.data + arg + + def insertData(self, offset, arg): + if offset < 0: + raise xml.dom.IndexSizeErr("offset cannot be negative") + if offset >= len(self.data): + raise xml.dom.IndexSizeErr("offset cannot be beyond end of data") + if arg: + self.data = "%s%s%s" % ( + self.data[:offset], arg, self.data[offset:]) + + def deleteData(self, offset, count): + if offset < 0: + raise xml.dom.IndexSizeErr("offset cannot be negative") + if offset >= len(self.data): + raise xml.dom.IndexSizeErr("offset cannot be beyond end of data") + if count < 0: + raise xml.dom.IndexSizeErr("count cannot be negative") + if count: + self.data = self.data[:offset] + self.data[offset+count:] + + def replaceData(self, offset, count, arg): + if offset < 0: + raise xml.dom.IndexSizeErr("offset cannot be negative") + if offset >= len(self.data): + raise xml.dom.IndexSizeErr("offset cannot be beyond end of data") + if count < 0: + raise xml.dom.IndexSizeErr("count cannot be negative") + if count: + self.data = "%s%s%s" % ( + self.data[:offset], arg, self.data[offset+count:]) + +defproperty(CharacterData, "length", doc="Length of the string data.") + + +class Text(CharacterData): + __slots__ = () + + nodeType = Node.TEXT_NODE + nodeName = "#text" + attributes = None + + def splitText(self, offset): + if offset < 0 or offset > len(self.data): + raise xml.dom.IndexSizeErr("illegal offset value") + newText = self.__class__() + newText.data = self.data[offset:] + newText.ownerDocument = self.ownerDocument + next = self.nextSibling + if self.parentNode and self in self.parentNode.childNodes: + if next is None: + self.parentNode.appendChild(newText) + else: + self.parentNode.insertBefore(newText, next) + self.data = self.data[:offset] + return newText + + def writexml(self, writer, indent="", addindent="", newl=""): + _write_data(writer, "%s%s%s" % (indent, self.data, newl), False) + + # DOM Level 3 (WD 9 April 2002) + + def _get_wholeText(self): + L = [self.data] + n = self.previousSibling + while n is not None: + if n.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): + L.insert(0, n.data) + n = n.previousSibling + else: + break + n = self.nextSibling + while n is not None: + if n.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): + L.append(n.data) + n = n.nextSibling + else: + break + return ''.join(L) + + def replaceWholeText(self, content): + # XXX This needs to be seriously changed if minidom ever + # supports EntityReference nodes. + parent = self.parentNode + n = self.previousSibling + while n is not None: + if n.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): + next = n.previousSibling + parent.removeChild(n) + n = next + else: + break + n = self.nextSibling + if not content: + parent.removeChild(self) + while n is not None: + if n.nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): + next = n.nextSibling + parent.removeChild(n) + n = next + else: + break + if content: + self.data = content + return self + else: + return None + + def _get_isWhitespaceInElementContent(self): + if self.data.strip(): + return False + elem = _get_containing_element(self) + if elem is None: + return False + info = self.ownerDocument._get_elem_info(elem) + if info is None: + return False + else: + return info.isElementContent() + +defproperty(Text, "isWhitespaceInElementContent", + doc="True iff this text node contains only whitespace" + " and is in element content.") +defproperty(Text, "wholeText", + doc="The text of all logically-adjacent text nodes.") + + +def _get_containing_element(node): + c = node.parentNode + while c is not None: + if c.nodeType == Node.ELEMENT_NODE: + return c + c = c.parentNode + return None + +def _get_containing_entref(node): + c = node.parentNode + while c is not None: + if c.nodeType == Node.ENTITY_REFERENCE_NODE: + return c + c = c.parentNode + return None + + +class Comment(CharacterData): + nodeType = Node.COMMENT_NODE + nodeName = "#comment" + + def __init__(self, data): + CharacterData.__init__(self) + self._data = data + + def writexml(self, writer, indent="", addindent="", newl=""): + if "--" in self.data: + raise ValueError("'--' is not allowed in a comment node") + writer.write("%s%s" % (indent, self.data, newl)) + + +class CDATASection(Text): + __slots__ = () + + nodeType = Node.CDATA_SECTION_NODE + nodeName = "#cdata-section" + + def writexml(self, writer, indent="", addindent="", newl=""): + if self.data.find("]]>") >= 0: + raise ValueError("']]>' not allowed in a CDATA section") + writer.write("" % self.data) + + +class ReadOnlySequentialNamedNodeMap(object): + __slots__ = '_seq', + + def __init__(self, seq=()): + # seq should be a list or tuple + self._seq = seq + + def __len__(self): + return len(self._seq) + + def _get_length(self): + return len(self._seq) + + def getNamedItem(self, name): + for n in self._seq: + if n.nodeName == name: + return n + + def getNamedItemNS(self, namespaceURI, localName): + for n in self._seq: + if n.namespaceURI == namespaceURI and n.localName == localName: + return n + + def __getitem__(self, name_or_tuple): + if isinstance(name_or_tuple, tuple): + node = self.getNamedItemNS(*name_or_tuple) + else: + node = self.getNamedItem(name_or_tuple) + if node is None: + raise KeyError(name_or_tuple) + return node + + def item(self, index): + if index < 0: + return None + try: + return self._seq[index] + except IndexError: + return None + + def removeNamedItem(self, name): + raise xml.dom.NoModificationAllowedErr( + "NamedNodeMap instance is read-only") + + def removeNamedItemNS(self, namespaceURI, localName): + raise xml.dom.NoModificationAllowedErr( + "NamedNodeMap instance is read-only") + + def setNamedItem(self, node): + raise xml.dom.NoModificationAllowedErr( + "NamedNodeMap instance is read-only") + + def setNamedItemNS(self, node): + raise xml.dom.NoModificationAllowedErr( + "NamedNodeMap instance is read-only") + + def __getstate__(self): + return [self._seq] + + def __setstate__(self, state): + self._seq = state[0] + +defproperty(ReadOnlySequentialNamedNodeMap, "length", + doc="Number of entries in the NamedNodeMap.") + + +class Identified: + """Mix-in class that supports the publicId and systemId attributes.""" + + __slots__ = 'publicId', 'systemId' + + def _identified_mixin_init(self, publicId, systemId): + self.publicId = publicId + self.systemId = systemId + + def _get_publicId(self): + return self.publicId + + def _get_systemId(self): + return self.systemId + +class DocumentType(Identified, Childless, Node): + nodeType = Node.DOCUMENT_TYPE_NODE + nodeValue = None + name = None + publicId = None + systemId = None + internalSubset = None + + def __init__(self, qualifiedName): + self.entities = ReadOnlySequentialNamedNodeMap() + self.notations = ReadOnlySequentialNamedNodeMap() + if qualifiedName: + prefix, localname = _nssplit(qualifiedName) + self.name = localname + self.nodeName = self.name + + def _get_internalSubset(self): + return self.internalSubset + + def cloneNode(self, deep): + if self.ownerDocument is None: + # it's ok + clone = DocumentType(None) + clone.name = self.name + clone.nodeName = self.name + operation = xml.dom.UserDataHandler.NODE_CLONED + if deep: + clone.entities._seq = [] + clone.notations._seq = [] + for n in self.notations._seq: + notation = Notation(n.nodeName, n.publicId, n.systemId) + clone.notations._seq.append(notation) + n._call_user_data_handler(operation, n, notation) + for e in self.entities._seq: + entity = Entity(e.nodeName, e.publicId, e.systemId, + e.notationName) + entity.actualEncoding = e.actualEncoding + entity.encoding = e.encoding + entity.version = e.version + clone.entities._seq.append(entity) + e._call_user_data_handler(operation, e, entity) + self._call_user_data_handler(operation, self, clone) + return clone + else: + return None + + def writexml(self, writer, indent="", addindent="", newl=""): + writer.write(""+newl) + +class Entity(Identified, Node): + attributes = None + nodeType = Node.ENTITY_NODE + nodeValue = None + + actualEncoding = None + encoding = None + version = None + + def __init__(self, name, publicId, systemId, notation): + self.nodeName = name + self.notationName = notation + self.childNodes = NodeList() + self._identified_mixin_init(publicId, systemId) + + def _get_actualEncoding(self): + return self.actualEncoding + + def _get_encoding(self): + return self.encoding + + def _get_version(self): + return self.version + + def appendChild(self, newChild): + raise xml.dom.HierarchyRequestErr( + "cannot append children to an entity node") + + def insertBefore(self, newChild, refChild): + raise xml.dom.HierarchyRequestErr( + "cannot insert children below an entity node") + + def removeChild(self, oldChild): + raise xml.dom.HierarchyRequestErr( + "cannot remove children from an entity node") + + def replaceChild(self, newChild, oldChild): + raise xml.dom.HierarchyRequestErr( + "cannot replace children of an entity node") + +class Notation(Identified, Childless, Node): + nodeType = Node.NOTATION_NODE + nodeValue = None + + def __init__(self, name, publicId, systemId): + self.nodeName = name + self._identified_mixin_init(publicId, systemId) + + +class DOMImplementation(DOMImplementationLS): + _features = [("core", "1.0"), + ("core", "2.0"), + ("core", None), + ("xml", "1.0"), + ("xml", "2.0"), + ("xml", None), + ("ls-load", "3.0"), + ("ls-load", None), + ] + + def hasFeature(self, feature, version): + if version == "": + version = None + return (feature.lower(), version) in self._features + + def createDocument(self, namespaceURI, qualifiedName, doctype): + if doctype and doctype.parentNode is not None: + raise xml.dom.WrongDocumentErr( + "doctype object owned by another DOM tree") + doc = self._create_document() + + add_root_element = not (namespaceURI is None + and qualifiedName is None + and doctype is None) + + if not qualifiedName and add_root_element: + # The spec is unclear what to raise here; SyntaxErr + # would be the other obvious candidate. Since Xerces raises + # InvalidCharacterErr, and since SyntaxErr is not listed + # for createDocument, that seems to be the better choice. + # XXX: need to check for illegal characters here and in + # createElement. + + # DOM Level III clears this up when talking about the return value + # of this function. If namespaceURI, qName and DocType are + # Null the document is returned without a document element + # Otherwise if doctype or namespaceURI are not None + # Then we go back to the above problem + raise xml.dom.InvalidCharacterErr("Element with no name") + + if add_root_element: + prefix, localname = _nssplit(qualifiedName) + if prefix == "xml" \ + and namespaceURI != "http://www.w3.org/XML/1998/namespace": + raise xml.dom.NamespaceErr("illegal use of 'xml' prefix") + if prefix and not namespaceURI: + raise xml.dom.NamespaceErr( + "illegal use of prefix without namespaces") + element = doc.createElementNS(namespaceURI, qualifiedName) + if doctype: + doc.appendChild(doctype) + doc.appendChild(element) + + if doctype: + doctype.parentNode = doctype.ownerDocument = doc + + doc.doctype = doctype + doc.implementation = self + return doc + + def createDocumentType(self, qualifiedName, publicId, systemId): + doctype = DocumentType(qualifiedName) + doctype.publicId = publicId + doctype.systemId = systemId + return doctype + + # DOM Level 3 (WD 9 April 2002) + + def getInterface(self, feature): + if self.hasFeature(feature, None): + return self + else: + return None + + # internal + def _create_document(self): + return Document() + +class ElementInfo(object): + """Object that represents content-model information for an element. + + This implementation is not expected to be used in practice; DOM + builders should provide implementations which do the right thing + using information available to it. + + """ + + __slots__ = 'tagName', + + def __init__(self, name): + self.tagName = name + + def getAttributeType(self, aname): + return _no_type + + def getAttributeTypeNS(self, namespaceURI, localName): + return _no_type + + def isElementContent(self): + return False + + def isEmpty(self): + """Returns true iff this element is declared to have an EMPTY + content model.""" + return False + + def isId(self, aname): + """Returns true iff the named attribute is a DTD-style ID.""" + return False + + def isIdNS(self, namespaceURI, localName): + """Returns true iff the identified attribute is a DTD-style ID.""" + return False + + def __getstate__(self): + return self.tagName + + def __setstate__(self, state): + self.tagName = state + +def _clear_id_cache(node): + if node.nodeType == Node.DOCUMENT_NODE: + node._id_cache.clear() + node._id_search_stack = None + elif node.ownerDocument: + node.ownerDocument._id_cache.clear() + node.ownerDocument._id_search_stack= None + +class Document(Node, DocumentLS): + __slots__ = ('_elem_info', 'doctype', + '_id_search_stack', 'childNodes', '_id_cache') + _child_node_types = (Node.ELEMENT_NODE, Node.PROCESSING_INSTRUCTION_NODE, + Node.COMMENT_NODE, Node.DOCUMENT_TYPE_NODE) + + implementation = DOMImplementation() + nodeType = Node.DOCUMENT_NODE + nodeName = "#document" + nodeValue = None + attributes = None + parentNode = None + previousSibling = nextSibling = None + + + # Document attributes from Level 3 (WD 9 April 2002) + + actualEncoding = None + encoding = None + standalone = None + version = None + strictErrorChecking = False + errorHandler = None + documentURI = None + + _magic_id_count = 0 + + def __init__(self): + self.doctype = None + self.childNodes = NodeList() + # mapping of (namespaceURI, localName) -> ElementInfo + # and tagName -> ElementInfo + self._elem_info = {} + self._id_cache = {} + self._id_search_stack = None + + def _get_elem_info(self, element): + if element.namespaceURI: + key = element.namespaceURI, element.localName + else: + key = element.tagName + return self._elem_info.get(key) + + def _get_actualEncoding(self): + return self.actualEncoding + + def _get_doctype(self): + return self.doctype + + def _get_documentURI(self): + return self.documentURI + + def _get_encoding(self): + return self.encoding + + def _get_errorHandler(self): + return self.errorHandler + + def _get_standalone(self): + return self.standalone + + def _get_strictErrorChecking(self): + return self.strictErrorChecking + + def _get_version(self): + return self.version + + def appendChild(self, node): + if node.nodeType not in self._child_node_types: + raise xml.dom.HierarchyRequestErr( + "%s cannot be child of %s" % (repr(node), repr(self))) + if node.parentNode is not None: + # This needs to be done before the next test since this + # may *be* the document element, in which case it should + # end up re-ordered to the end. + node.parentNode.removeChild(node) + + if node.nodeType == Node.ELEMENT_NODE \ + and self._get_documentElement(): + raise xml.dom.HierarchyRequestErr( + "two document elements disallowed") + return Node.appendChild(self, node) + + def removeChild(self, oldChild): + try: + self.childNodes.remove(oldChild) + except ValueError: + raise xml.dom.NotFoundErr() + oldChild.nextSibling = oldChild.previousSibling = None + oldChild.parentNode = None + if self.documentElement is oldChild: + self.documentElement = None + + return oldChild + + def _get_documentElement(self): + for node in self.childNodes: + if node.nodeType == Node.ELEMENT_NODE: + return node + + def unlink(self): + if self.doctype is not None: + self.doctype.unlink() + self.doctype = None + Node.unlink(self) + + def cloneNode(self, deep): + if not deep: + return None + clone = self.implementation.createDocument(None, None, None) + clone.encoding = self.encoding + clone.standalone = self.standalone + clone.version = self.version + for n in self.childNodes: + childclone = _clone_node(n, deep, clone) + assert childclone.ownerDocument.isSameNode(clone) + clone.childNodes.append(childclone) + if childclone.nodeType == Node.DOCUMENT_NODE: + assert clone.documentElement is None + elif childclone.nodeType == Node.DOCUMENT_TYPE_NODE: + assert clone.doctype is None + clone.doctype = childclone + childclone.parentNode = clone + self._call_user_data_handler(xml.dom.UserDataHandler.NODE_CLONED, + self, clone) + return clone + + def createDocumentFragment(self): + d = DocumentFragment() + d.ownerDocument = self + return d + + def createElement(self, tagName): + e = Element(tagName) + e.ownerDocument = self + return e + + def createTextNode(self, data): + if not isinstance(data, str): + raise TypeError("node contents must be a string") + t = Text() + t.data = data + t.ownerDocument = self + return t + + def createCDATASection(self, data): + if not isinstance(data, str): + raise TypeError("node contents must be a string") + c = CDATASection() + c.data = data + c.ownerDocument = self + return c + + def createComment(self, data): + c = Comment(data) + c.ownerDocument = self + return c + + def createProcessingInstruction(self, target, data): + p = ProcessingInstruction(target, data) + p.ownerDocument = self + return p + + def createAttribute(self, qName): + a = Attr(qName) + a.ownerDocument = self + a.value = "" + return a + + def createElementNS(self, namespaceURI, qualifiedName): + prefix, localName = _nssplit(qualifiedName) + e = Element(qualifiedName, namespaceURI, prefix) + e.ownerDocument = self + return e + + def createAttributeNS(self, namespaceURI, qualifiedName): + prefix, localName = _nssplit(qualifiedName) + a = Attr(qualifiedName, namespaceURI, localName, prefix) + a.ownerDocument = self + a.value = "" + return a + + # A couple of implementation-specific helpers to create node types + # not supported by the W3C DOM specs: + + def _create_entity(self, name, publicId, systemId, notationName): + e = Entity(name, publicId, systemId, notationName) + e.ownerDocument = self + return e + + def _create_notation(self, name, publicId, systemId): + n = Notation(name, publicId, systemId) + n.ownerDocument = self + return n + + def getElementById(self, id): + if id in self._id_cache: + return self._id_cache[id] + if not (self._elem_info or self._magic_id_count): + return None + + stack = self._id_search_stack + if stack is None: + # we never searched before, or the cache has been cleared + stack = [self.documentElement] + self._id_search_stack = stack + elif not stack: + # Previous search was completed and cache is still valid; + # no matching node. + return None + + result = None + while stack: + node = stack.pop() + # add child elements to stack for continued searching + stack.extend([child for child in node.childNodes + if child.nodeType in _nodeTypes_with_children]) + # check this node + info = self._get_elem_info(node) + if info: + # We have to process all ID attributes before + # returning in order to get all the attributes set to + # be IDs using Element.setIdAttribute*(). + for attr in node.attributes.values(): + if attr.namespaceURI: + if info.isIdNS(attr.namespaceURI, attr.localName): + self._id_cache[attr.value] = node + if attr.value == id: + result = node + elif not node._magic_id_nodes: + break + elif info.isId(attr.name): + self._id_cache[attr.value] = node + if attr.value == id: + result = node + elif not node._magic_id_nodes: + break + elif attr._is_id: + self._id_cache[attr.value] = node + if attr.value == id: + result = node + elif node._magic_id_nodes == 1: + break + elif node._magic_id_nodes: + for attr in node.attributes.values(): + if attr._is_id: + self._id_cache[attr.value] = node + if attr.value == id: + result = node + if result is not None: + break + return result + + def getElementsByTagName(self, name): + return _get_elements_by_tagName_helper(self, name, NodeList()) + + def getElementsByTagNameNS(self, namespaceURI, localName): + return _get_elements_by_tagName_ns_helper( + self, namespaceURI, localName, NodeList()) + + def isSupported(self, feature, version): + return self.implementation.hasFeature(feature, version) + + def importNode(self, node, deep): + if node.nodeType == Node.DOCUMENT_NODE: + raise xml.dom.NotSupportedErr("cannot import document nodes") + elif node.nodeType == Node.DOCUMENT_TYPE_NODE: + raise xml.dom.NotSupportedErr("cannot import document type nodes") + return _clone_node(node, deep, self) + + def writexml(self, writer, indent="", addindent="", newl="", encoding=None, + standalone=None): + declarations = [] + + if encoding: + declarations.append(f'encoding="{encoding}"') + if standalone is not None: + declarations.append(f'standalone="{"yes" if standalone else "no"}"') + + writer.write(f'{newl}') + + for node in self.childNodes: + node.writexml(writer, indent, addindent, newl) + + # DOM Level 3 (WD 9 April 2002) + + def renameNode(self, n, namespaceURI, name): + if n.ownerDocument is not self: + raise xml.dom.WrongDocumentErr( + "cannot rename nodes from other documents;\n" + "expected %s,\nfound %s" % (self, n.ownerDocument)) + if n.nodeType not in (Node.ELEMENT_NODE, Node.ATTRIBUTE_NODE): + raise xml.dom.NotSupportedErr( + "renameNode() only applies to element and attribute nodes") + if namespaceURI != EMPTY_NAMESPACE: + if ':' in name: + prefix, localName = name.split(':', 1) + if ( prefix == "xmlns" + and namespaceURI != xml.dom.XMLNS_NAMESPACE): + raise xml.dom.NamespaceErr( + "illegal use of 'xmlns' prefix") + else: + if ( name == "xmlns" + and namespaceURI != xml.dom.XMLNS_NAMESPACE + and n.nodeType == Node.ATTRIBUTE_NODE): + raise xml.dom.NamespaceErr( + "illegal use of the 'xmlns' attribute") + prefix = None + localName = name + else: + prefix = None + localName = None + if n.nodeType == Node.ATTRIBUTE_NODE: + element = n.ownerElement + if element is not None: + is_id = n._is_id + element.removeAttributeNode(n) + else: + element = None + n.prefix = prefix + n._localName = localName + n.namespaceURI = namespaceURI + n.nodeName = name + if n.nodeType == Node.ELEMENT_NODE: + n.tagName = name + else: + # attribute node + n.name = name + if element is not None: + element.setAttributeNode(n) + if is_id: + element.setIdAttributeNode(n) + # It's not clear from a semantic perspective whether we should + # call the user data handlers for the NODE_RENAMED event since + # we're re-using the existing node. The draft spec has been + # interpreted as meaning "no, don't call the handler unless a + # new node is created." + return n + +defproperty(Document, "documentElement", + doc="Top-level element of this document.") + + +def _clone_node(node, deep, newOwnerDocument): + """ + Clone a node and give it the new owner document. + Called by Node.cloneNode and Document.importNode + """ + if node.ownerDocument.isSameNode(newOwnerDocument): + operation = xml.dom.UserDataHandler.NODE_CLONED + else: + operation = xml.dom.UserDataHandler.NODE_IMPORTED + if node.nodeType == Node.ELEMENT_NODE: + clone = newOwnerDocument.createElementNS(node.namespaceURI, + node.nodeName) + for attr in node.attributes.values(): + clone.setAttributeNS(attr.namespaceURI, attr.nodeName, attr.value) + a = clone.getAttributeNodeNS(attr.namespaceURI, attr.localName) + a.specified = attr.specified + + if deep: + for child in node.childNodes: + c = _clone_node(child, deep, newOwnerDocument) + clone.appendChild(c) + + elif node.nodeType == Node.DOCUMENT_FRAGMENT_NODE: + clone = newOwnerDocument.createDocumentFragment() + if deep: + for child in node.childNodes: + c = _clone_node(child, deep, newOwnerDocument) + clone.appendChild(c) + + elif node.nodeType == Node.TEXT_NODE: + clone = newOwnerDocument.createTextNode(node.data) + elif node.nodeType == Node.CDATA_SECTION_NODE: + clone = newOwnerDocument.createCDATASection(node.data) + elif node.nodeType == Node.PROCESSING_INSTRUCTION_NODE: + clone = newOwnerDocument.createProcessingInstruction(node.target, + node.data) + elif node.nodeType == Node.COMMENT_NODE: + clone = newOwnerDocument.createComment(node.data) + elif node.nodeType == Node.ATTRIBUTE_NODE: + clone = newOwnerDocument.createAttributeNS(node.namespaceURI, + node.nodeName) + clone.specified = True + clone.value = node.value + elif node.nodeType == Node.DOCUMENT_TYPE_NODE: + assert node.ownerDocument is not newOwnerDocument + operation = xml.dom.UserDataHandler.NODE_IMPORTED + clone = newOwnerDocument.implementation.createDocumentType( + node.name, node.publicId, node.systemId) + clone.ownerDocument = newOwnerDocument + if deep: + clone.entities._seq = [] + clone.notations._seq = [] + for n in node.notations._seq: + notation = Notation(n.nodeName, n.publicId, n.systemId) + notation.ownerDocument = newOwnerDocument + clone.notations._seq.append(notation) + if hasattr(n, '_call_user_data_handler'): + n._call_user_data_handler(operation, n, notation) + for e in node.entities._seq: + entity = Entity(e.nodeName, e.publicId, e.systemId, + e.notationName) + entity.actualEncoding = e.actualEncoding + entity.encoding = e.encoding + entity.version = e.version + entity.ownerDocument = newOwnerDocument + clone.entities._seq.append(entity) + if hasattr(e, '_call_user_data_handler'): + e._call_user_data_handler(operation, e, entity) + else: + # Note the cloning of Document and DocumentType nodes is + # implementation specific. minidom handles those cases + # directly in the cloneNode() methods. + raise xml.dom.NotSupportedErr("Cannot clone node %s" % repr(node)) + + # Check for _call_user_data_handler() since this could conceivably + # used with other DOM implementations (one of the FourThought + # DOMs, perhaps?). + if hasattr(node, '_call_user_data_handler'): + node._call_user_data_handler(operation, node, clone) + return clone + + +def _nssplit(qualifiedName): + fields = qualifiedName.split(':', 1) + if len(fields) == 2: + return fields + else: + return (None, fields[0]) + + +def _do_pulldom_parse(func, args, kwargs): + events = func(*args, **kwargs) + toktype, rootNode = events.getEvent() + events.expandNode(rootNode) + events.clear() + return rootNode + +def parse(file, parser=None, bufsize=None): + """Parse a file into a DOM by filename or file object.""" + if parser is None and not bufsize: + from xml.dom import expatbuilder + return expatbuilder.parse(file) + else: + from xml.dom import pulldom + return _do_pulldom_parse(pulldom.parse, (file,), + {'parser': parser, 'bufsize': bufsize}) + +def parseString(string, parser=None): + """Parse a file into a DOM from a string.""" + if parser is None: + from xml.dom import expatbuilder + return expatbuilder.parseString(string) + else: + from xml.dom import pulldom + return _do_pulldom_parse(pulldom.parseString, (string,), + {'parser': parser}) + +def getDOMImplementation(features=None): + if features: + if isinstance(features, str): + features = domreg._parse_feature_string(features) + for f, v in features: + if not Document.implementation.hasFeature(f, v): + return None + return Document.implementation diff --git a/crates/weavepy-vm/src/stdlib/python/xml/dom/xmlbuilder.py b/crates/weavepy-vm/src/stdlib/python/xml/dom/xmlbuilder.py new file mode 100644 index 0000000..a885262 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/dom/xmlbuilder.py @@ -0,0 +1,389 @@ +"""Implementation of the DOM Level 3 'LS-Load' feature.""" + +import copy +import xml.dom + +from xml.dom.NodeFilter import NodeFilter + + +__all__ = ["DOMBuilder", "DOMEntityResolver", "DOMInputSource"] + + +class Options: + """Features object that has variables set for each DOMBuilder feature. + + The DOMBuilder class uses an instance of this class to pass settings to + the ExpatBuilder class. + """ + + # Note that the DOMBuilder class in LoadSave constrains which of these + # values can be set using the DOM Level 3 LoadSave feature. + + namespaces = 1 + namespace_declarations = True + validation = False + external_parameter_entities = True + external_general_entities = True + external_dtd_subset = True + validate_if_schema = False + validate = False + datatype_normalization = False + create_entity_ref_nodes = True + entities = True + whitespace_in_element_content = True + cdata_sections = True + comments = True + charset_overrides_xml_encoding = True + infoset = False + supported_mediatypes_only = False + + errorHandler = None + filter = None + + +class DOMBuilder: + entityResolver = None + errorHandler = None + filter = None + + ACTION_REPLACE = 1 + ACTION_APPEND_AS_CHILDREN = 2 + ACTION_INSERT_AFTER = 3 + ACTION_INSERT_BEFORE = 4 + + _legal_actions = (ACTION_REPLACE, ACTION_APPEND_AS_CHILDREN, + ACTION_INSERT_AFTER, ACTION_INSERT_BEFORE) + + def __init__(self): + self._options = Options() + + def _get_entityResolver(self): + return self.entityResolver + def _set_entityResolver(self, entityResolver): + self.entityResolver = entityResolver + + def _get_errorHandler(self): + return self.errorHandler + def _set_errorHandler(self, errorHandler): + self.errorHandler = errorHandler + + def _get_filter(self): + return self.filter + def _set_filter(self, filter): + self.filter = filter + + def setFeature(self, name, state): + if self.supportsFeature(name): + state = state and 1 or 0 + try: + settings = self._settings[(_name_xform(name), state)] + except KeyError: + raise xml.dom.NotSupportedErr( + "unsupported feature: %r" % (name,)) from None + else: + for name, value in settings: + setattr(self._options, name, value) + else: + raise xml.dom.NotFoundErr("unknown feature: " + repr(name)) + + def supportsFeature(self, name): + return hasattr(self._options, _name_xform(name)) + + def canSetFeature(self, name, state): + key = (_name_xform(name), state and 1 or 0) + return key in self._settings + + # This dictionary maps from (feature,value) to a list of + # (option,value) pairs that should be set on the Options object. + # If a (feature,value) setting is not in this dictionary, it is + # not supported by the DOMBuilder. + # + _settings = { + ("namespace_declarations", 0): [ + ("namespace_declarations", 0)], + ("namespace_declarations", 1): [ + ("namespace_declarations", 1)], + ("validation", 0): [ + ("validation", 0)], + ("external_general_entities", 0): [ + ("external_general_entities", 0)], + ("external_general_entities", 1): [ + ("external_general_entities", 1)], + ("external_parameter_entities", 0): [ + ("external_parameter_entities", 0)], + ("external_parameter_entities", 1): [ + ("external_parameter_entities", 1)], + ("validate_if_schema", 0): [ + ("validate_if_schema", 0)], + ("create_entity_ref_nodes", 0): [ + ("create_entity_ref_nodes", 0)], + ("create_entity_ref_nodes", 1): [ + ("create_entity_ref_nodes", 1)], + ("entities", 0): [ + ("create_entity_ref_nodes", 0), + ("entities", 0)], + ("entities", 1): [ + ("entities", 1)], + ("whitespace_in_element_content", 0): [ + ("whitespace_in_element_content", 0)], + ("whitespace_in_element_content", 1): [ + ("whitespace_in_element_content", 1)], + ("cdata_sections", 0): [ + ("cdata_sections", 0)], + ("cdata_sections", 1): [ + ("cdata_sections", 1)], + ("comments", 0): [ + ("comments", 0)], + ("comments", 1): [ + ("comments", 1)], + ("charset_overrides_xml_encoding", 0): [ + ("charset_overrides_xml_encoding", 0)], + ("charset_overrides_xml_encoding", 1): [ + ("charset_overrides_xml_encoding", 1)], + ("infoset", 0): [], + ("infoset", 1): [ + ("namespace_declarations", 0), + ("validate_if_schema", 0), + ("create_entity_ref_nodes", 0), + ("entities", 0), + ("cdata_sections", 0), + ("datatype_normalization", 1), + ("whitespace_in_element_content", 1), + ("comments", 1), + ("charset_overrides_xml_encoding", 1)], + ("supported_mediatypes_only", 0): [ + ("supported_mediatypes_only", 0)], + ("namespaces", 0): [ + ("namespaces", 0)], + ("namespaces", 1): [ + ("namespaces", 1)], + } + + def getFeature(self, name): + xname = _name_xform(name) + try: + return getattr(self._options, xname) + except AttributeError: + if name == "infoset": + options = self._options + return (options.datatype_normalization + and options.whitespace_in_element_content + and options.comments + and options.charset_overrides_xml_encoding + and not (options.namespace_declarations + or options.validate_if_schema + or options.create_entity_ref_nodes + or options.entities + or options.cdata_sections)) + raise xml.dom.NotFoundErr("feature %s not known" % repr(name)) + + def parseURI(self, uri): + if self.entityResolver: + input = self.entityResolver.resolveEntity(None, uri) + else: + input = DOMEntityResolver().resolveEntity(None, uri) + return self.parse(input) + + def parse(self, input): + options = copy.copy(self._options) + options.filter = self.filter + options.errorHandler = self.errorHandler + fp = input.byteStream + if fp is None and input.systemId: + import urllib.request + fp = urllib.request.urlopen(input.systemId) + return self._parse_bytestream(fp, options) + + def parseWithContext(self, input, cnode, action): + if action not in self._legal_actions: + raise ValueError("not a legal action") + raise NotImplementedError("Haven't written this yet...") + + def _parse_bytestream(self, stream, options): + import xml.dom.expatbuilder + builder = xml.dom.expatbuilder.makeBuilder(options) + return builder.parseFile(stream) + + +def _name_xform(name): + return name.lower().replace('-', '_') + + +class DOMEntityResolver(object): + __slots__ = '_opener', + + def resolveEntity(self, publicId, systemId): + assert systemId is not None + source = DOMInputSource() + source.publicId = publicId + source.systemId = systemId + source.byteStream = self._get_opener().open(systemId) + + # determine the encoding if the transport provided it + source.encoding = self._guess_media_encoding(source) + + # determine the base URI is we can + import posixpath, urllib.parse + parts = urllib.parse.urlparse(systemId) + scheme, netloc, path, params, query, fragment = parts + # XXX should we check the scheme here as well? + if path and not path.endswith("/"): + path = posixpath.dirname(path) + "/" + parts = scheme, netloc, path, params, query, fragment + source.baseURI = urllib.parse.urlunparse(parts) + + return source + + def _get_opener(self): + try: + return self._opener + except AttributeError: + self._opener = self._create_opener() + return self._opener + + def _create_opener(self): + import urllib.request + return urllib.request.build_opener() + + def _guess_media_encoding(self, source): + info = source.byteStream.info() + # import email.message + # assert isinstance(info, email.message.Message) + charset = info.get_param('charset') + if charset is not None: + return charset.lower() + return None + + +class DOMInputSource(object): + __slots__ = ('byteStream', 'characterStream', 'stringData', + 'encoding', 'publicId', 'systemId', 'baseURI') + + def __init__(self): + self.byteStream = None + self.characterStream = None + self.stringData = None + self.encoding = None + self.publicId = None + self.systemId = None + self.baseURI = None + + def _get_byteStream(self): + return self.byteStream + def _set_byteStream(self, byteStream): + self.byteStream = byteStream + + def _get_characterStream(self): + return self.characterStream + def _set_characterStream(self, characterStream): + self.characterStream = characterStream + + def _get_stringData(self): + return self.stringData + def _set_stringData(self, data): + self.stringData = data + + def _get_encoding(self): + return self.encoding + def _set_encoding(self, encoding): + self.encoding = encoding + + def _get_publicId(self): + return self.publicId + def _set_publicId(self, publicId): + self.publicId = publicId + + def _get_systemId(self): + return self.systemId + def _set_systemId(self, systemId): + self.systemId = systemId + + def _get_baseURI(self): + return self.baseURI + def _set_baseURI(self, uri): + self.baseURI = uri + + +class DOMBuilderFilter: + """Element filter which can be used to tailor construction of + a DOM instance. + """ + + # There's really no need for this class; concrete implementations + # should just implement the endElement() and startElement() + # methods as appropriate. Using this makes it easy to only + # implement one of them. + + FILTER_ACCEPT = 1 + FILTER_REJECT = 2 + FILTER_SKIP = 3 + FILTER_INTERRUPT = 4 + + whatToShow = NodeFilter.SHOW_ALL + + def _get_whatToShow(self): + return self.whatToShow + + def acceptNode(self, element): + return self.FILTER_ACCEPT + + def startContainer(self, element): + return self.FILTER_ACCEPT + +del NodeFilter + + +class DocumentLS: + """Mixin to create documents that conform to the load/save spec.""" + + async_ = False + + def _get_async(self): + return False + + def _set_async(self, flag): + if flag: + raise xml.dom.NotSupportedErr( + "asynchronous document loading is not supported") + + def abort(self): + # What does it mean to "clear" a document? Does the + # documentElement disappear? + raise NotImplementedError( + "haven't figured out what this means yet") + + def load(self, uri): + raise NotImplementedError("haven't written this yet") + + def loadXML(self, source): + raise NotImplementedError("haven't written this yet") + + def saveXML(self, snode): + if snode is None: + snode = self + elif snode.ownerDocument is not self: + raise xml.dom.WrongDocumentErr() + return snode.toxml() + + +class DOMImplementationLS: + MODE_SYNCHRONOUS = 1 + MODE_ASYNCHRONOUS = 2 + + def createDOMBuilder(self, mode, schemaType): + if schemaType is not None: + raise xml.dom.NotSupportedErr( + "schemaType not yet supported") + if mode == self.MODE_SYNCHRONOUS: + return DOMBuilder() + if mode == self.MODE_ASYNCHRONOUS: + raise xml.dom.NotSupportedErr( + "asynchronous builders are not supported") + raise ValueError("unknown value for mode") + + def createDOMWriter(self): + raise NotImplementedError( + "the writer interface hasn't been written yet!") + + def createDOMInputSource(self): + return DOMInputSource() diff --git a/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementPath.py b/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementPath.py new file mode 100644 index 0000000..dc6bd28 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementPath.py @@ -0,0 +1,423 @@ +# +# ElementTree +# $Id: ElementPath.py 3375 2008-02-13 08:05:08Z fredrik $ +# +# limited xpath support for element trees +# +# history: +# 2003-05-23 fl created +# 2003-05-28 fl added support for // etc +# 2003-08-27 fl fixed parsing of periods in element names +# 2007-09-10 fl new selection engine +# 2007-09-12 fl fixed parent selector +# 2007-09-13 fl added iterfind; changed findall to return a list +# 2007-11-30 fl added namespaces support +# 2009-10-30 fl added child element value filter +# +# Copyright (c) 2003-2009 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# http://www.pythonware.com +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2009 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +# Licensed to PSF under a Contributor Agreement. +# See https://www.python.org/psf/license for licensing details. + +## +# Implementation module for XPath support. There's usually no reason +# to import this module directly; the ElementTree does this for +# you, if needed. +## + +import re + +xpath_tokenizer_re = re.compile( + r"(" + r"'[^']*'|\"[^\"]*\"|" + r"::|" + r"//?|" + r"\.\.|" + r"\(\)|" + r"!=|" + r"[/.*:\[\]\(\)@=])|" + r"((?:\{[^}]+\})?[^/\[\]\(\)@!=\s]+)|" + r"\s+" + ) + +def xpath_tokenizer(pattern, namespaces=None): + default_namespace = namespaces.get('') if namespaces else None + parsing_attribute = False + for token in xpath_tokenizer_re.findall(pattern): + ttype, tag = token + if tag and tag[0] != "{": + if ":" in tag: + prefix, uri = tag.split(":", 1) + try: + if not namespaces: + raise KeyError + yield ttype, "{%s}%s" % (namespaces[prefix], uri) + except KeyError: + raise SyntaxError("prefix %r not found in prefix map" % prefix) from None + elif default_namespace and not parsing_attribute: + yield ttype, "{%s}%s" % (default_namespace, tag) + else: + yield token + parsing_attribute = False + else: + yield token + parsing_attribute = ttype == '@' + + +def get_parent_map(context): + parent_map = context.parent_map + if parent_map is None: + context.parent_map = parent_map = {} + for p in context.root.iter(): + for e in p: + parent_map[e] = p + return parent_map + + +def _is_wildcard_tag(tag): + return tag[:3] == '{*}' or tag[-2:] == '}*' + + +def _prepare_tag(tag): + _isinstance, _str = isinstance, str + if tag == '{*}*': + # Same as '*', but no comments or processing instructions. + # It can be a surprise that '*' includes those, but there is no + # justification for '{*}*' doing the same. + def select(context, result): + for elem in result: + if _isinstance(elem.tag, _str): + yield elem + elif tag == '{}*': + # Any tag that is not in a namespace. + def select(context, result): + for elem in result: + el_tag = elem.tag + if _isinstance(el_tag, _str) and el_tag[0] != '{': + yield elem + elif tag[:3] == '{*}': + # The tag in any (or no) namespace. + suffix = tag[2:] # '}name' + no_ns = slice(-len(suffix), None) + tag = tag[3:] + def select(context, result): + for elem in result: + el_tag = elem.tag + if el_tag == tag or _isinstance(el_tag, _str) and el_tag[no_ns] == suffix: + yield elem + elif tag[-2:] == '}*': + # Any tag in the given namespace. + ns = tag[:-1] + ns_only = slice(None, len(ns)) + def select(context, result): + for elem in result: + el_tag = elem.tag + if _isinstance(el_tag, _str) and el_tag[ns_only] == ns: + yield elem + else: + raise RuntimeError(f"internal parser error, got {tag}") + return select + + +def prepare_child(next, token): + tag = token[1] + if _is_wildcard_tag(tag): + select_tag = _prepare_tag(tag) + def select(context, result): + def select_child(result): + for elem in result: + yield from elem + return select_tag(context, select_child(result)) + else: + if tag[:2] == '{}': + tag = tag[2:] # '{}tag' == 'tag' + def select(context, result): + for elem in result: + for e in elem: + if e.tag == tag: + yield e + return select + +def prepare_star(next, token): + def select(context, result): + for elem in result: + yield from elem + return select + +def prepare_self(next, token): + def select(context, result): + yield from result + return select + +def prepare_descendant(next, token): + try: + token = next() + except StopIteration: + return + if token[0] == "*": + tag = "*" + elif not token[0]: + tag = token[1] + else: + raise SyntaxError("invalid descendant") + + if _is_wildcard_tag(tag): + select_tag = _prepare_tag(tag) + def select(context, result): + def select_child(result): + for elem in result: + for e in elem.iter(): + if e is not elem: + yield e + return select_tag(context, select_child(result)) + else: + if tag[:2] == '{}': + tag = tag[2:] # '{}tag' == 'tag' + def select(context, result): + for elem in result: + for e in elem.iter(tag): + if e is not elem: + yield e + return select + +def prepare_parent(next, token): + def select(context, result): + # FIXME: raise error if .. is applied at toplevel? + parent_map = get_parent_map(context) + result_map = {} + for elem in result: + if elem in parent_map: + parent = parent_map[elem] + if parent not in result_map: + result_map[parent] = None + yield parent + return select + +def prepare_predicate(next, token): + # FIXME: replace with real parser!!! refs: + # http://javascript.crockford.com/tdop/tdop.html + signature = [] + predicate = [] + while 1: + try: + token = next() + except StopIteration: + return + if token[0] == "]": + break + if token == ('', ''): + # ignore whitespace + continue + if token[0] and token[0][:1] in "'\"": + token = "'", token[0][1:-1] + signature.append(token[0] or "-") + predicate.append(token[1]) + signature = "".join(signature) + # use signature to determine predicate type + if signature == "@-": + # [@attribute] predicate + key = predicate[1] + def select(context, result): + for elem in result: + if elem.get(key) is not None: + yield elem + return select + if signature == "@-='" or signature == "@-!='": + # [@attribute='value'] or [@attribute!='value'] + key = predicate[1] + value = predicate[-1] + def select(context, result): + for elem in result: + if elem.get(key) == value: + yield elem + def select_negated(context, result): + for elem in result: + if (attr_value := elem.get(key)) is not None and attr_value != value: + yield elem + return select_negated if '!=' in signature else select + if signature == "-" and not re.match(r"\-?\d+$", predicate[0]): + # [tag] + tag = predicate[0] + def select(context, result): + for elem in result: + if elem.find(tag) is not None: + yield elem + return select + if signature == ".='" or signature == ".!='" or ( + (signature == "-='" or signature == "-!='") + and not re.match(r"\-?\d+$", predicate[0])): + # [.='value'] or [tag='value'] or [.!='value'] or [tag!='value'] + tag = predicate[0] + value = predicate[-1] + if tag: + def select(context, result): + for elem in result: + for e in elem.findall(tag): + if "".join(e.itertext()) == value: + yield elem + break + def select_negated(context, result): + for elem in result: + for e in elem.iterfind(tag): + if "".join(e.itertext()) != value: + yield elem + break + else: + def select(context, result): + for elem in result: + if "".join(elem.itertext()) == value: + yield elem + def select_negated(context, result): + for elem in result: + if "".join(elem.itertext()) != value: + yield elem + return select_negated if '!=' in signature else select + if signature == "-" or signature == "-()" or signature == "-()-": + # [index] or [last()] or [last()-index] + if signature == "-": + # [index] + index = int(predicate[0]) - 1 + if index < 0: + raise SyntaxError("XPath position >= 1 expected") + else: + if predicate[0] != "last": + raise SyntaxError("unsupported function") + if signature == "-()-": + try: + index = int(predicate[2]) - 1 + except ValueError: + raise SyntaxError("unsupported expression") + if index > -2: + raise SyntaxError("XPath offset from last() must be negative") + else: + index = -1 + def select(context, result): + parent_map = get_parent_map(context) + for elem in result: + try: + parent = parent_map[elem] + # FIXME: what if the selector is "*" ? + elems = list(parent.findall(elem.tag)) + if elems[index] is elem: + yield elem + except (IndexError, KeyError): + pass + return select + raise SyntaxError("invalid predicate") + +ops = { + "": prepare_child, + "*": prepare_star, + ".": prepare_self, + "..": prepare_parent, + "//": prepare_descendant, + "[": prepare_predicate, + } + +_cache = {} + +class _SelectorContext: + parent_map = None + def __init__(self, root): + self.root = root + +# -------------------------------------------------------------------- + +## +# Generate all matching objects. + +def iterfind(elem, path, namespaces=None): + # compile selector pattern + if path[-1:] == "/": + path = path + "*" # implicit all (FIXME: keep this?) + + cache_key = (path,) + if namespaces: + cache_key += tuple(sorted(namespaces.items())) + + try: + selector = _cache[cache_key] + except KeyError: + if len(_cache) > 100: + _cache.clear() + if path[:1] == "/": + raise SyntaxError("cannot use absolute path on element") + next = iter(xpath_tokenizer(path, namespaces)).__next__ + try: + token = next() + except StopIteration: + return + selector = [] + while 1: + try: + selector.append(ops[token[0]](next, token)) + except StopIteration: + raise SyntaxError("invalid path") from None + try: + token = next() + if token[0] == "/": + token = next() + except StopIteration: + break + _cache[cache_key] = selector + # execute selector pattern + result = [elem] + context = _SelectorContext(elem) + for select in selector: + result = select(context, result) + return result + +## +# Find first matching object. + +def find(elem, path, namespaces=None): + return next(iterfind(elem, path, namespaces), None) + +## +# Find all matching objects. + +def findall(elem, path, namespaces=None): + return list(iterfind(elem, path, namespaces)) + +## +# Find text for first matching object. + +def findtext(elem, path, default=None, namespaces=None): + try: + elem = next(iterfind(elem, path, namespaces)) + if elem.text is None: + return "" + return elem.text + except StopIteration: + return default diff --git a/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementTree.py b/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementTree.py new file mode 100644 index 0000000..9bb09ab --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/etree/ElementTree.py @@ -0,0 +1,2098 @@ +"""Lightweight XML support for Python. + + XML is an inherently hierarchical data format, and the most natural way to + represent it is with a tree. This module has two classes for this purpose: + + 1. ElementTree represents the whole XML document as a tree and + + 2. Element represents a single node in this tree. + + Interactions with the whole document (reading and writing to/from files) are + usually done on the ElementTree level. Interactions with a single XML element + and its sub-elements are done on the Element level. + + Element is a flexible container object designed to store hierarchical data + structures in memory. It can be described as a cross between a list and a + dictionary. Each Element has a number of properties associated with it: + + 'tag' - a string containing the element's name. + + 'attributes' - a Python dictionary storing the element's attributes. + + 'text' - a string containing the element's text content. + + 'tail' - an optional string containing text after the element's end tag. + + And a number of child elements stored in a Python sequence. + + To create an element instance, use the Element constructor, + or the SubElement factory function. + + You can also use the ElementTree class to wrap an element structure + and convert it to and from XML. + +""" + +#--------------------------------------------------------------------- +# Licensed to PSF under a Contributor Agreement. +# See https://www.python.org/psf/license for licensing details. +# +# ElementTree +# Copyright (c) 1999-2008 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# http://www.pythonware.com +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2008 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +__all__ = [ + # public symbols + "Comment", + "dump", + "Element", "ElementTree", + "fromstring", "fromstringlist", + "indent", "iselement", "iterparse", + "parse", "ParseError", + "PI", "ProcessingInstruction", + "QName", + "SubElement", + "tostring", "tostringlist", + "TreeBuilder", + "VERSION", + "XML", "XMLID", + "XMLParser", "XMLPullParser", + "register_namespace", + "canonicalize", "C14NWriterTarget", + ] + +VERSION = "1.3.0" + +import sys +import re +import warnings +import io +import collections +import collections.abc +import contextlib +import weakref + +from . import ElementPath + + +class ParseError(SyntaxError): + """An error when parsing an XML document. + + In addition to its exception value, a ParseError contains + two extra attributes: + 'code' - the specific exception code + 'position' - the line and column of the error + + """ + pass + +# -------------------------------------------------------------------- + + +def iselement(element): + """Return True if *element* appears to be an Element.""" + return hasattr(element, 'tag') + + +class Element: + """An XML element. + + This class is the reference implementation of the Element interface. + + An element's length is its number of subelements. That means if you + want to check if an element is truly empty, you should check BOTH + its length AND its text attribute. + + The element tag, attribute names, and attribute values can be either + bytes or strings. + + *tag* is the element name. *attrib* is an optional dictionary containing + element attributes. *extra* are additional element attributes given as + keyword arguments. + + Example form: + text...tail + + """ + + tag = None + """The element's name.""" + + attrib = None + """Dictionary of the element's attributes.""" + + text = None + """ + Text before first subelement. This is either a string or the value None. + Note that if there is no text, this attribute may be either + None or the empty string, depending on the parser. + + """ + + tail = None + """ + Text after this element's end tag, but before the next sibling element's + start tag. This is either a string or the value None. Note that if there + was no text, this attribute may be either None or an empty string, + depending on the parser. + + """ + + def __init__(self, tag, attrib={}, **extra): + if not isinstance(attrib, dict): + raise TypeError("attrib must be dict, not %s" % ( + attrib.__class__.__name__,)) + self.tag = tag + self.attrib = {**attrib, **extra} + self._children = [] + + def __repr__(self): + return "<%s %r at %#x>" % (self.__class__.__name__, self.tag, id(self)) + + def makeelement(self, tag, attrib): + """Create a new element with the same type. + + *tag* is a string containing the element name. + *attrib* is a dictionary containing the element attributes. + + Do not call this method, use the SubElement factory function instead. + + """ + return self.__class__(tag, attrib) + + def __copy__(self): + elem = self.makeelement(self.tag, self.attrib) + elem.text = self.text + elem.tail = self.tail + elem[:] = self + return elem + + def __len__(self): + return len(self._children) + + def __bool__(self): + warnings.warn( + "Testing an element's truth value will always return True in " + "future versions. " + "Use specific 'len(elem)' or 'elem is not None' test instead.", + DeprecationWarning, stacklevel=2 + ) + return len(self._children) != 0 # emulate old behaviour, for now + + def __getitem__(self, index): + return self._children[index] + + def __setitem__(self, index, element): + if isinstance(index, slice): + for elt in element: + self._assert_is_element(elt) + else: + self._assert_is_element(element) + self._children[index] = element + + def __delitem__(self, index): + del self._children[index] + + def append(self, subelement): + """Add *subelement* to the end of this element. + + The new element will appear in document order after the last existing + subelement (or directly after the text, if it's the first subelement), + but before the end tag for this element. + + """ + self._assert_is_element(subelement) + self._children.append(subelement) + + def extend(self, elements): + """Append subelements from a sequence. + + *elements* is a sequence with zero or more elements. + + """ + for element in elements: + self._assert_is_element(element) + self._children.append(element) + + def insert(self, index, subelement): + """Insert *subelement* at position *index*.""" + self._assert_is_element(subelement) + self._children.insert(index, subelement) + + def _assert_is_element(self, e): + # Need to refer to the actual Python implementation, not the + # shadowing C implementation. + if not isinstance(e, _Element_Py): + raise TypeError('expected an Element, not %s' % type(e).__name__) + + def remove(self, subelement): + """Remove matching subelement. + + Unlike the find methods, this method compares elements based on + identity, NOT ON tag value or contents. To remove subelements by + other means, the easiest way is to use a list comprehension to + select what elements to keep, and then use slice assignment to update + the parent element. + + ValueError is raised if a matching element could not be found. + + """ + # assert iselement(element) + self._children.remove(subelement) + + def find(self, path, namespaces=None): + """Find first matching element by tag name or path. + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return the first matching element, or None if no element was found. + + """ + return ElementPath.find(self, path, namespaces) + + def findtext(self, path, default=None, namespaces=None): + """Find text for first matching element by tag name or path. + + *path* is a string having either an element tag or an XPath, + *default* is the value to return if the element was not found, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return text content of first matching element, or default value if + none was found. Note that if an element is found having no text + content, the empty string is returned. + + """ + return ElementPath.findtext(self, path, default, namespaces) + + def findall(self, path, namespaces=None): + """Find all matching subelements by tag name or path. + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Returns list containing all matching elements in document order. + + """ + return ElementPath.findall(self, path, namespaces) + + def iterfind(self, path, namespaces=None): + """Find all matching subelements by tag name or path. + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return an iterable yielding all matching elements in document order. + + """ + return ElementPath.iterfind(self, path, namespaces) + + def clear(self): + """Reset element. + + This function removes all subelements, clears all attributes, and sets + the text and tail attributes to None. + + """ + self.attrib.clear() + self._children = [] + self.text = self.tail = None + + def get(self, key, default=None): + """Get element attribute. + + Equivalent to attrib.get, but some implementations may handle this a + bit more efficiently. *key* is what attribute to look for, and + *default* is what to return if the attribute was not found. + + Returns a string containing the attribute value, or the default if + attribute was not found. + + """ + return self.attrib.get(key, default) + + def set(self, key, value): + """Set element attribute. + + Equivalent to attrib[key] = value, but some implementations may handle + this a bit more efficiently. *key* is what attribute to set, and + *value* is the attribute value to set it to. + + """ + self.attrib[key] = value + + def keys(self): + """Get list of attribute names. + + Names are returned in an arbitrary order, just like an ordinary + Python dict. Equivalent to attrib.keys() + + """ + return self.attrib.keys() + + def items(self): + """Get element attributes as a sequence. + + The attributes are returned in arbitrary order. Equivalent to + attrib.items(). + + Return a list of (name, value) tuples. + + """ + return self.attrib.items() + + def iter(self, tag=None): + """Create tree iterator. + + The iterator loops over the element and all subelements in document + order, returning all elements with a matching tag. + + If the tree structure is modified during iteration, new or removed + elements may or may not be included. To get a stable set, use the + list() function on the iterator, and loop over the resulting list. + + *tag* is what tags to look for (default is to return all elements) + + Return an iterator containing all the matching elements. + + """ + if tag == "*": + tag = None + if tag is None or self.tag == tag: + yield self + for e in self._children: + yield from e.iter(tag) + + def itertext(self): + """Create text iterator. + + The iterator loops over the element and all subelements in document + order, returning all inner text. + + """ + tag = self.tag + if not isinstance(tag, str) and tag is not None: + return + t = self.text + if t: + yield t + for e in self: + yield from e.itertext() + t = e.tail + if t: + yield t + + +def SubElement(parent, tag, attrib={}, **extra): + """Subelement factory which creates an element instance, and appends it + to an existing parent. + + The element tag, attribute names, and attribute values can be either + bytes or Unicode strings. + + *parent* is the parent element, *tag* is the subelements name, *attrib* is + an optional directory containing element attributes, *extra* are + additional attributes given as keyword arguments. + + """ + attrib = {**attrib, **extra} + element = parent.makeelement(tag, attrib) + parent.append(element) + return element + + +def Comment(text=None): + """Comment element factory. + + This function creates a special element which the standard serializer + serializes as an XML comment. + + *text* is a string containing the comment string. + + """ + element = Element(Comment) + element.text = text + return element + + +def ProcessingInstruction(target, text=None): + """Processing Instruction element factory. + + This function creates a special element which the standard serializer + serializes as an XML comment. + + *target* is a string containing the processing instruction, *text* is a + string containing the processing instruction contents, if any. + + """ + element = Element(ProcessingInstruction) + element.text = target + if text: + element.text = element.text + " " + text + return element + +PI = ProcessingInstruction + + +class QName: + """Qualified name wrapper. + + This class can be used to wrap a QName attribute value in order to get + proper namespace handing on output. + + *text_or_uri* is a string containing the QName value either in the form + {uri}local, or if the tag argument is given, the URI part of a QName. + + *tag* is an optional argument which if given, will make the first + argument (text_or_uri) be interpreted as a URI, and this argument (tag) + be interpreted as a local name. + + """ + def __init__(self, text_or_uri, tag=None): + if tag: + text_or_uri = "{%s}%s" % (text_or_uri, tag) + self.text = text_or_uri + def __str__(self): + return self.text + def __repr__(self): + return '<%s %r>' % (self.__class__.__name__, self.text) + def __hash__(self): + return hash(self.text) + def __le__(self, other): + if isinstance(other, QName): + return self.text <= other.text + return self.text <= other + def __lt__(self, other): + if isinstance(other, QName): + return self.text < other.text + return self.text < other + def __ge__(self, other): + if isinstance(other, QName): + return self.text >= other.text + return self.text >= other + def __gt__(self, other): + if isinstance(other, QName): + return self.text > other.text + return self.text > other + def __eq__(self, other): + if isinstance(other, QName): + return self.text == other.text + return self.text == other + +# -------------------------------------------------------------------- + + +class ElementTree: + """An XML element hierarchy. + + This class also provides support for serialization to and from + standard XML. + + *element* is an optional root element node, + *file* is an optional file handle or file name of an XML file whose + contents will be used to initialize the tree with. + + """ + def __init__(self, element=None, file=None): + if element is not None and not iselement(element): + raise TypeError('expected an Element, not %s' % + type(element).__name__) + self._root = element # first node + if file: + self.parse(file) + + def getroot(self): + """Return root element of this tree.""" + return self._root + + def _setroot(self, element): + """Replace root element of this tree. + + This will discard the current contents of the tree and replace it + with the given element. Use with care! + + """ + if not iselement(element): + raise TypeError('expected an Element, not %s' + % type(element).__name__) + self._root = element + + def parse(self, source, parser=None): + """Load external XML document into element tree. + + *source* is a file name or file object, *parser* is an optional parser + instance that defaults to XMLParser. + + ParseError is raised if the parser fails to parse the document. + + Returns the root element of the given source document. + + """ + close_source = False + if not hasattr(source, "read"): + source = open(source, "rb") + close_source = True + try: + if parser is None: + # If no parser was specified, create a default XMLParser + parser = XMLParser() + if hasattr(parser, '_parse_whole'): + # The default XMLParser, when it comes from an accelerator, + # can define an internal _parse_whole API for efficiency. + # It can be used to parse the whole source without feeding + # it with chunks. + self._root = parser._parse_whole(source) + return self._root + while data := source.read(65536): + parser.feed(data) + self._root = parser.close() + return self._root + finally: + if close_source: + source.close() + + def iter(self, tag=None): + """Create and return tree iterator for the root element. + + The iterator loops over all elements in this tree, in document order. + + *tag* is a string with the tag name to iterate over + (default is to return all elements). + + """ + # assert self._root is not None + return self._root.iter(tag) + + def find(self, path, namespaces=None): + """Find first matching element by tag name or path. + + Same as getroot().find(path), which is Element.find() + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return the first matching element, or None if no element was found. + + """ + # assert self._root is not None + if path[:1] == "/": + path = "." + path + warnings.warn( + "This search is broken in 1.3 and earlier, and will be " + "fixed in a future version. If you rely on the current " + "behaviour, change it to %r" % path, + FutureWarning, stacklevel=2 + ) + return self._root.find(path, namespaces) + + def findtext(self, path, default=None, namespaces=None): + """Find first matching element by tag name or path. + + Same as getroot().findtext(path), which is Element.findtext() + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return the first matching element, or None if no element was found. + + """ + # assert self._root is not None + if path[:1] == "/": + path = "." + path + warnings.warn( + "This search is broken in 1.3 and earlier, and will be " + "fixed in a future version. If you rely on the current " + "behaviour, change it to %r" % path, + FutureWarning, stacklevel=2 + ) + return self._root.findtext(path, default, namespaces) + + def findall(self, path, namespaces=None): + """Find all matching subelements by tag name or path. + + Same as getroot().findall(path), which is Element.findall(). + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return list containing all matching elements in document order. + + """ + # assert self._root is not None + if path[:1] == "/": + path = "." + path + warnings.warn( + "This search is broken in 1.3 and earlier, and will be " + "fixed in a future version. If you rely on the current " + "behaviour, change it to %r" % path, + FutureWarning, stacklevel=2 + ) + return self._root.findall(path, namespaces) + + def iterfind(self, path, namespaces=None): + """Find all matching subelements by tag name or path. + + Same as getroot().iterfind(path), which is element.iterfind() + + *path* is a string having either an element tag or an XPath, + *namespaces* is an optional mapping from namespace prefix to full name. + + Return an iterable yielding all matching elements in document order. + + """ + # assert self._root is not None + if path[:1] == "/": + path = "." + path + warnings.warn( + "This search is broken in 1.3 and earlier, and will be " + "fixed in a future version. If you rely on the current " + "behaviour, change it to %r" % path, + FutureWarning, stacklevel=2 + ) + return self._root.iterfind(path, namespaces) + + def write(self, file_or_filename, + encoding=None, + xml_declaration=None, + default_namespace=None, + method=None, *, + short_empty_elements=True): + """Write element tree to a file as XML. + + Arguments: + *file_or_filename* -- file name or a file object opened for writing + + *encoding* -- the output encoding (default: US-ASCII) + + *xml_declaration* -- bool indicating if an XML declaration should be + added to the output. If None, an XML declaration + is added if encoding IS NOT either of: + US-ASCII, UTF-8, or Unicode + + *default_namespace* -- sets the default XML namespace (for "xmlns") + + *method* -- either "xml" (default), "html, "text", or "c14n" + + *short_empty_elements* -- controls the formatting of elements + that contain no content. If True (default) + they are emitted as a single self-closed + tag, otherwise they are emitted as a pair + of start/end tags + + """ + if self._root is None: + raise TypeError('ElementTree not initialized') + if not method: + method = "xml" + elif method not in _serialize: + raise ValueError("unknown method %r" % method) + if not encoding: + if method == "c14n": + encoding = "utf-8" + else: + encoding = "us-ascii" + with _get_writer(file_or_filename, encoding) as (write, declared_encoding): + if method == "xml" and (xml_declaration or + (xml_declaration is None and + encoding.lower() != "unicode" and + declared_encoding.lower() not in ("utf-8", "us-ascii"))): + write("\n" % ( + declared_encoding,)) + if method == "text": + _serialize_text(write, self._root) + else: + qnames, namespaces = _namespaces(self._root, default_namespace) + serialize = _serialize[method] + serialize(write, self._root, qnames, namespaces, + short_empty_elements=short_empty_elements) + + def write_c14n(self, file): + # lxml.etree compatibility. use output method instead + return self.write(file, method="c14n") + +# -------------------------------------------------------------------- +# serialization support + +@contextlib.contextmanager +def _get_writer(file_or_filename, encoding): + # returns text write method and release all resources after using + try: + write = file_or_filename.write + except AttributeError: + # file_or_filename is a file name + if encoding.lower() == "unicode": + encoding="utf-8" + with open(file_or_filename, "w", encoding=encoding, + errors="xmlcharrefreplace") as file: + yield file.write, encoding + else: + # file_or_filename is a file-like object + # encoding determines if it is a text or binary writer + if encoding.lower() == "unicode": + # use a text writer as is + yield write, getattr(file_or_filename, "encoding", None) or "utf-8" + else: + # wrap a binary writer with TextIOWrapper + with contextlib.ExitStack() as stack: + if isinstance(file_or_filename, io.BufferedIOBase): + file = file_or_filename + elif isinstance(file_or_filename, io.RawIOBase): + file = io.BufferedWriter(file_or_filename) + # Keep the original file open when the BufferedWriter is + # destroyed + stack.callback(file.detach) + else: + # This is to handle passed objects that aren't in the + # IOBase hierarchy, but just have a write method + file = io.BufferedIOBase() + file.writable = lambda: True + file.write = write + try: + # TextIOWrapper uses this methods to determine + # if BOM (for UTF-16, etc) should be added + file.seekable = file_or_filename.seekable + file.tell = file_or_filename.tell + except AttributeError: + pass + file = io.TextIOWrapper(file, + encoding=encoding, + errors="xmlcharrefreplace", + newline="\n") + # Keep the original file open when the TextIOWrapper is + # destroyed + stack.callback(file.detach) + yield file.write, encoding + +def _namespaces(elem, default_namespace=None): + # identify namespaces used in this tree + + # maps qnames to *encoded* prefix:local names + qnames = {None: None} + + # maps uri:s to prefixes + namespaces = {} + if default_namespace: + namespaces[default_namespace] = "" + + def add_qname(qname): + # calculate serialized qname representation + try: + if qname[:1] == "{": + uri, tag = qname[1:].rsplit("}", 1) + prefix = namespaces.get(uri) + if prefix is None: + prefix = _namespace_map.get(uri) + if prefix is None: + prefix = "ns%d" % len(namespaces) + if prefix != "xml": + namespaces[uri] = prefix + if prefix: + qnames[qname] = "%s:%s" % (prefix, tag) + else: + qnames[qname] = tag # default element + else: + if default_namespace: + # FIXME: can this be handled in XML 1.0? + raise ValueError( + "cannot use non-qualified names with " + "default_namespace option" + ) + qnames[qname] = qname + except TypeError: + _raise_serialization_error(qname) + + # populate qname and namespaces table + for elem in elem.iter(): + tag = elem.tag + if isinstance(tag, QName): + if tag.text not in qnames: + add_qname(tag.text) + elif isinstance(tag, str): + if tag not in qnames: + add_qname(tag) + elif tag is not None and tag is not Comment and tag is not PI: + _raise_serialization_error(tag) + for key, value in elem.items(): + if isinstance(key, QName): + key = key.text + if key not in qnames: + add_qname(key) + if isinstance(value, QName) and value.text not in qnames: + add_qname(value.text) + text = elem.text + if isinstance(text, QName) and text.text not in qnames: + add_qname(text.text) + return qnames, namespaces + +def _serialize_xml(write, elem, qnames, namespaces, + short_empty_elements, **kwargs): + tag = elem.tag + text = elem.text + if tag is Comment: + write("" % text) + elif tag is ProcessingInstruction: + write("" % text) + else: + tag = qnames[tag] + if tag is None: + if text: + write(_escape_cdata(text)) + for e in elem: + _serialize_xml(write, e, qnames, None, + short_empty_elements=short_empty_elements) + else: + write("<" + tag) + items = list(elem.items()) + if items or namespaces: + if namespaces: + for v, k in sorted(namespaces.items(), + key=lambda x: x[1]): # sort on prefix + if k: + k = ":" + k + write(" xmlns%s=\"%s\"" % ( + k, + _escape_attrib(v) + )) + for k, v in items: + if isinstance(k, QName): + k = k.text + if isinstance(v, QName): + v = qnames[v.text] + else: + v = _escape_attrib(v) + write(" %s=\"%s\"" % (qnames[k], v)) + if text or len(elem) or not short_empty_elements: + write(">") + if text: + write(_escape_cdata(text)) + for e in elem: + _serialize_xml(write, e, qnames, None, + short_empty_elements=short_empty_elements) + write("") + else: + write(" />") + if elem.tail: + write(_escape_cdata(elem.tail)) + +HTML_EMPTY = {"area", "base", "basefont", "br", "col", "embed", "frame", "hr", + "img", "input", "isindex", "link", "meta", "param", "source", + "track", "wbr"} + +def _serialize_html(write, elem, qnames, namespaces, **kwargs): + tag = elem.tag + text = elem.text + if tag is Comment: + write("" % _escape_cdata(text)) + elif tag is ProcessingInstruction: + write("" % _escape_cdata(text)) + else: + tag = qnames[tag] + if tag is None: + if text: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, qnames, None) + else: + write("<" + tag) + items = list(elem.items()) + if items or namespaces: + if namespaces: + for v, k in sorted(namespaces.items(), + key=lambda x: x[1]): # sort on prefix + if k: + k = ":" + k + write(" xmlns%s=\"%s\"" % ( + k, + _escape_attrib(v) + )) + for k, v in items: + if isinstance(k, QName): + k = k.text + if isinstance(v, QName): + v = qnames[v.text] + else: + v = _escape_attrib_html(v) + # FIXME: handle boolean attributes + write(" %s=\"%s\"" % (qnames[k], v)) + write(">") + ltag = tag.lower() + if text: + if ltag == "script" or ltag == "style": + write(text) + else: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, qnames, None) + if ltag not in HTML_EMPTY: + write("") + if elem.tail: + write(_escape_cdata(elem.tail)) + +def _serialize_text(write, elem): + for part in elem.itertext(): + write(part) + if elem.tail: + write(elem.tail) + +_serialize = { + "xml": _serialize_xml, + "html": _serialize_html, + "text": _serialize_text, +# this optional method is imported at the end of the module +# "c14n": _serialize_c14n, +} + + +def register_namespace(prefix, uri): + """Register a namespace prefix. + + The registry is global, and any existing mapping for either the + given prefix or the namespace URI will be removed. + + *prefix* is the namespace prefix, *uri* is a namespace uri. Tags and + attributes in this namespace will be serialized with prefix if possible. + + ValueError is raised if prefix is reserved or is invalid. + + """ + if re.match(r"ns\d+$", prefix): + raise ValueError("Prefix format reserved for internal use") + for k, v in list(_namespace_map.items()): + if k == uri or v == prefix: + del _namespace_map[k] + _namespace_map[uri] = prefix + +_namespace_map = { + # "well-known" namespace prefixes + "http://www.w3.org/XML/1998/namespace": "xml", + "http://www.w3.org/1999/xhtml": "html", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#": "rdf", + "http://schemas.xmlsoap.org/wsdl/": "wsdl", + # xml schema + "http://www.w3.org/2001/XMLSchema": "xs", + "http://www.w3.org/2001/XMLSchema-instance": "xsi", + # dublin core + "http://purl.org/dc/elements/1.1/": "dc", +} +# For tests and troubleshooting +register_namespace._namespace_map = _namespace_map + +def _raise_serialization_error(text): + raise TypeError( + "cannot serialize %r (type %s)" % (text, type(text).__name__) + ) + +def _escape_cdata(text): + # escape character data + try: + # it's worth avoiding do-nothing calls for strings that are + # shorter than 500 characters, or so. assume that's, by far, + # the most common case in most applications. + if "&" in text: + text = text.replace("&", "&") + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + except (TypeError, AttributeError): + _raise_serialization_error(text) + +def _escape_attrib(text): + # escape attribute value + try: + if "&" in text: + text = text.replace("&", "&") + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + # Although section 2.11 of the XML specification states that CR or + # CR LN should be replaced with just LN, it applies only to EOLNs + # which take part of organizing file into lines. Within attributes, + # we are replacing these with entity numbers, so they do not count. + # http://www.w3.org/TR/REC-xml/#sec-line-ends + # The current solution, contained in following six lines, was + # discussed in issue 17582 and 39011. + if "\r" in text: + text = text.replace("\r", " ") + if "\n" in text: + text = text.replace("\n", " ") + if "\t" in text: + text = text.replace("\t", " ") + return text + except (TypeError, AttributeError): + _raise_serialization_error(text) + +def _escape_attrib_html(text): + # escape attribute value + try: + if "&" in text: + text = text.replace("&", "&") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + return text + except (TypeError, AttributeError): + _raise_serialization_error(text) + +# -------------------------------------------------------------------- + +def tostring(element, encoding=None, method=None, *, + xml_declaration=None, default_namespace=None, + short_empty_elements=True): + """Generate string representation of XML element. + + All subelements are included. If encoding is "unicode", a string + is returned. Otherwise a bytestring is returned. + + *element* is an Element instance, *encoding* is an optional output + encoding defaulting to US-ASCII, *method* is an optional output which can + be one of "xml" (default), "html", "text" or "c14n", *default_namespace* + sets the default XML namespace (for "xmlns"). + + Returns an (optionally) encoded string containing the XML data. + + """ + stream = io.StringIO() if encoding == 'unicode' else io.BytesIO() + ElementTree(element).write(stream, encoding, + xml_declaration=xml_declaration, + default_namespace=default_namespace, + method=method, + short_empty_elements=short_empty_elements) + return stream.getvalue() + +class _ListDataStream(io.BufferedIOBase): + """An auxiliary stream accumulating into a list reference.""" + def __init__(self, lst): + self.lst = lst + + def writable(self): + return True + + def seekable(self): + return True + + def write(self, b): + self.lst.append(b) + + def tell(self): + return len(self.lst) + +def tostringlist(element, encoding=None, method=None, *, + xml_declaration=None, default_namespace=None, + short_empty_elements=True): + lst = [] + stream = _ListDataStream(lst) + ElementTree(element).write(stream, encoding, + xml_declaration=xml_declaration, + default_namespace=default_namespace, + method=method, + short_empty_elements=short_empty_elements) + return lst + + +def dump(elem): + """Write element tree or element structure to sys.stdout. + + This function should be used for debugging only. + + *elem* is either an ElementTree, or a single Element. The exact output + format is implementation dependent. In this version, it's written as an + ordinary XML file. + + """ + # debugging + if not isinstance(elem, ElementTree): + elem = ElementTree(elem) + elem.write(sys.stdout, encoding="unicode") + tail = elem.getroot().tail + if not tail or tail[-1] != "\n": + sys.stdout.write("\n") + + +def indent(tree, space=" ", level=0): + """Indent an XML document by inserting newlines and indentation space + after elements. + + *tree* is the ElementTree or Element to modify. The (root) element + itself will not be changed, but the tail text of all elements in its + subtree will be adapted. + + *space* is the whitespace to insert for each indentation level, two + space characters by default. + + *level* is the initial indentation level. Setting this to a higher + value than 0 can be used for indenting subtrees that are more deeply + nested inside of a document. + """ + if isinstance(tree, ElementTree): + tree = tree.getroot() + if level < 0: + raise ValueError(f"Initial indentation level must be >= 0, got {level}") + if not len(tree): + return + + # Reduce the memory consumption by reusing indentation strings. + indentations = ["\n" + level * space] + + def _indent_children(elem, level): + # Start a new indentation level for the first child. + child_level = level + 1 + try: + child_indentation = indentations[child_level] + except IndexError: + child_indentation = indentations[level] + space + indentations.append(child_indentation) + + if not elem.text or not elem.text.strip(): + elem.text = child_indentation + + for child in elem: + if len(child): + _indent_children(child, child_level) + if not child.tail or not child.tail.strip(): + child.tail = child_indentation + + # Dedent after the last child by overwriting the previous indentation. + if not child.tail.strip(): + child.tail = indentations[level] + + _indent_children(tree, 0) + + +# -------------------------------------------------------------------- +# parsing + + +def parse(source, parser=None): + """Parse XML document into element tree. + + *source* is a filename or file object containing XML data, + *parser* is an optional parser instance defaulting to XMLParser. + + Return an ElementTree instance. + + """ + tree = ElementTree() + tree.parse(source, parser) + return tree + + +def iterparse(source, events=None, parser=None): + """Incrementally parse XML document into ElementTree. + + This class also reports what's going on to the user based on the + *events* it is initialized with. The supported events are the strings + "start", "end", "start-ns" and "end-ns" (the "ns" events are used to get + detailed namespace information). If *events* is omitted, only + "end" events are reported. + + *source* is a filename or file object containing XML data, *events* is + a list of events to report back, *parser* is an optional parser instance. + + Returns an iterator providing (event, elem) pairs. + + """ + # Use the internal, undocumented _parser argument for now; When the + # parser argument of iterparse is removed, this can be killed. + pullparser = XMLPullParser(events=events, _parser=parser) + + if not hasattr(source, "read"): + source = open(source, "rb") + close_source = True + else: + close_source = False + + def iterator(source): + try: + while True: + yield from pullparser.read_events() + # load event buffer + data = source.read(16 * 1024) + if not data: + break + pullparser.feed(data) + root = pullparser._close_and_return_root() + yield from pullparser.read_events() + it = wr() + if it is not None: + it.root = root + finally: + if close_source: + source.close() + + gen = iterator(source) + class IterParseIterator(collections.abc.Iterator): + __next__ = gen.__next__ + def close(self): + if close_source: + source.close() + gen.close() + + def __del__(self): + # TODO: Emit a ResourceWarning if it was not explicitly closed. + # (When the close() method will be supported in all maintained Python versions.) + if close_source: + source.close() + + it = IterParseIterator() + it.root = None + wr = weakref.ref(it) + return it + + +class XMLPullParser: + + def __init__(self, events=None, *, _parser=None): + # The _parser argument is for internal use only and must not be relied + # upon in user code. It will be removed in a future release. + # See https://bugs.python.org/issue17741 for more details. + + self._events_queue = collections.deque() + self._parser = _parser or XMLParser(target=TreeBuilder()) + # wire up the parser for event reporting + if events is None: + events = ("end",) + self._parser._setevents(self._events_queue, events) + + def feed(self, data): + """Feed encoded data to parser.""" + if self._parser is None: + raise ValueError("feed() called after end of stream") + if data: + try: + self._parser.feed(data) + except SyntaxError as exc: + self._events_queue.append(exc) + + def _close_and_return_root(self): + # iterparse needs this to set its root attribute properly :( + root = self._parser.close() + self._parser = None + return root + + def close(self): + """Finish feeding data to parser. + + Unlike XMLParser, does not return the root element. Use + read_events() to consume elements from XMLPullParser. + """ + self._close_and_return_root() + + def read_events(self): + """Return an iterator over currently available (event, elem) pairs. + + Events are consumed from the internal event queue as they are + retrieved from the iterator. + """ + events = self._events_queue + while events: + event = events.popleft() + if isinstance(event, Exception): + raise event + else: + yield event + + def flush(self): + if self._parser is None: + raise ValueError("flush() called after end of stream") + self._parser.flush() + + +def XML(text, parser=None): + """Parse XML document from string constant. + + This function can be used to embed "XML Literals" in Python code. + + *text* is a string containing XML data, *parser* is an + optional parser instance, defaulting to the standard XMLParser. + + Returns an Element instance. + + """ + if not parser: + parser = XMLParser(target=TreeBuilder()) + parser.feed(text) + return parser.close() + + +def XMLID(text, parser=None): + """Parse XML document from string constant for its IDs. + + *text* is a string containing XML data, *parser* is an + optional parser instance, defaulting to the standard XMLParser. + + Returns an (Element, dict) tuple, in which the + dict maps element id:s to elements. + + """ + if not parser: + parser = XMLParser(target=TreeBuilder()) + parser.feed(text) + tree = parser.close() + ids = {} + for elem in tree.iter(): + id = elem.get("id") + if id: + ids[id] = elem + return tree, ids + +# Parse XML document from string constant. Alias for XML(). +fromstring = XML + +def fromstringlist(sequence, parser=None): + """Parse XML document from sequence of string fragments. + + *sequence* is a list of other sequence, *parser* is an optional parser + instance, defaulting to the standard XMLParser. + + Returns an Element instance. + + """ + if not parser: + parser = XMLParser(target=TreeBuilder()) + for text in sequence: + parser.feed(text) + return parser.close() + +# -------------------------------------------------------------------- + + +class TreeBuilder: + """Generic element structure builder. + + This builder converts a sequence of start, data, and end method + calls to a well-formed element structure. + + You can use this class to build an element structure using a custom XML + parser, or a parser for some other XML-like format. + + *element_factory* is an optional element factory which is called + to create new Element instances, as necessary. + + *comment_factory* is a factory to create comments to be used instead of + the standard factory. If *insert_comments* is false (the default), + comments will not be inserted into the tree. + + *pi_factory* is a factory to create processing instructions to be used + instead of the standard factory. If *insert_pis* is false (the default), + processing instructions will not be inserted into the tree. + """ + def __init__(self, element_factory=None, *, + comment_factory=None, pi_factory=None, + insert_comments=False, insert_pis=False): + self._data = [] # data collector + self._elem = [] # element stack + self._last = None # last element + self._root = None # root element + self._tail = None # true if we're after an end tag + if comment_factory is None: + comment_factory = Comment + self._comment_factory = comment_factory + self.insert_comments = insert_comments + if pi_factory is None: + pi_factory = ProcessingInstruction + self._pi_factory = pi_factory + self.insert_pis = insert_pis + if element_factory is None: + element_factory = Element + self._factory = element_factory + + def close(self): + """Flush builder buffers and return toplevel document Element.""" + assert len(self._elem) == 0, "missing end tags" + assert self._root is not None, "missing toplevel element" + return self._root + + def _flush(self): + if self._data: + if self._last is not None: + text = "".join(self._data) + if self._tail: + assert self._last.tail is None, "internal error (tail)" + self._last.tail = text + else: + assert self._last.text is None, "internal error (text)" + self._last.text = text + self._data = [] + + def data(self, data): + """Add text to current element.""" + self._data.append(data) + + def start(self, tag, attrs): + """Open new element and return it. + + *tag* is the element name, *attrs* is a dict containing element + attributes. + + """ + self._flush() + self._last = elem = self._factory(tag, attrs) + if self._elem: + self._elem[-1].append(elem) + elif self._root is None: + self._root = elem + self._elem.append(elem) + self._tail = 0 + return elem + + def end(self, tag): + """Close and return current Element. + + *tag* is the element name. + + """ + self._flush() + self._last = self._elem.pop() + assert self._last.tag == tag,\ + "end tag mismatch (expected %s, got %s)" % ( + self._last.tag, tag) + self._tail = 1 + return self._last + + def comment(self, text): + """Create a comment using the comment_factory. + + *text* is the text of the comment. + """ + return self._handle_single( + self._comment_factory, self.insert_comments, text) + + def pi(self, target, text=None): + """Create a processing instruction using the pi_factory. + + *target* is the target name of the processing instruction. + *text* is the data of the processing instruction, or ''. + """ + return self._handle_single( + self._pi_factory, self.insert_pis, target, text) + + def _handle_single(self, factory, insert, *args): + elem = factory(*args) + if insert: + self._flush() + self._last = elem + if self._elem: + self._elem[-1].append(elem) + self._tail = 1 + return elem + + +# also see ElementTree and TreeBuilder +class XMLParser: + """Element structure builder for XML source data based on the expat parser. + + *target* is an optional target object which defaults to an instance of the + standard TreeBuilder class, *encoding* is an optional encoding string + which if given, overrides the encoding specified in the XML file: + http://www.iana.org/assignments/character-sets + + """ + + def __init__(self, *, target=None, encoding=None): + try: + from xml.parsers import expat + except ImportError: + try: + import pyexpat as expat + except ImportError: + raise ImportError( + "No module named expat; use SimpleXMLTreeBuilder instead" + ) + parser = expat.ParserCreate(encoding, "}") + if target is None: + target = TreeBuilder() + # underscored names are provided for compatibility only + self.parser = self._parser = parser + self.target = self._target = target + self._error = expat.error + self._names = {} # name memo cache + # main callbacks + parser.DefaultHandlerExpand = self._default + if hasattr(target, 'start'): + parser.StartElementHandler = self._start + if hasattr(target, 'end'): + parser.EndElementHandler = self._end + if hasattr(target, 'start_ns'): + parser.StartNamespaceDeclHandler = self._start_ns + if hasattr(target, 'end_ns'): + parser.EndNamespaceDeclHandler = self._end_ns + if hasattr(target, 'data'): + parser.CharacterDataHandler = target.data + # miscellaneous callbacks + if hasattr(target, 'comment'): + parser.CommentHandler = target.comment + if hasattr(target, 'pi'): + parser.ProcessingInstructionHandler = target.pi + # Configure pyexpat: buffering, new-style attribute handling. + parser.buffer_text = 1 + parser.ordered_attributes = 1 + self._doctype = None + self.entity = {} + try: + self.version = "Expat %d.%d.%d" % expat.version_info + except AttributeError: + pass # unknown + + def _setevents(self, events_queue, events_to_report): + # Internal API for XMLPullParser + # events_to_report: a list of events to report during parsing (same as + # the *events* of XMLPullParser's constructor. + # events_queue: a list of actual parsing events that will be populated + # by the underlying parser. + # + parser = self._parser + append = events_queue.append + for event_name in events_to_report: + if event_name == "start": + parser.ordered_attributes = 1 + def handler(tag, attrib_in, event=event_name, append=append, + start=self._start): + append((event, start(tag, attrib_in))) + parser.StartElementHandler = handler + elif event_name == "end": + def handler(tag, event=event_name, append=append, + end=self._end): + append((event, end(tag))) + parser.EndElementHandler = handler + elif event_name == "start-ns": + # TreeBuilder does not implement .start_ns() + if hasattr(self.target, "start_ns"): + def handler(prefix, uri, event=event_name, append=append, + start_ns=self._start_ns): + append((event, start_ns(prefix, uri))) + else: + def handler(prefix, uri, event=event_name, append=append): + append((event, (prefix or '', uri or ''))) + parser.StartNamespaceDeclHandler = handler + elif event_name == "end-ns": + # TreeBuilder does not implement .end_ns() + if hasattr(self.target, "end_ns"): + def handler(prefix, event=event_name, append=append, + end_ns=self._end_ns): + append((event, end_ns(prefix))) + else: + def handler(prefix, event=event_name, append=append): + append((event, None)) + parser.EndNamespaceDeclHandler = handler + elif event_name == 'comment': + def handler(text, event=event_name, append=append, self=self): + append((event, self.target.comment(text))) + parser.CommentHandler = handler + elif event_name == 'pi': + def handler(pi_target, data, event=event_name, append=append, + self=self): + append((event, self.target.pi(pi_target, data))) + parser.ProcessingInstructionHandler = handler + else: + raise ValueError("unknown event %r" % event_name) + + def _raiseerror(self, value): + err = ParseError(value) + err.code = value.code + err.position = value.lineno, value.offset + raise err + + def _fixname(self, key): + # expand qname, and convert name string to ascii, if possible + try: + name = self._names[key] + except KeyError: + name = key + if "}" in name: + name = "{" + name + self._names[key] = name + return name + + def _start_ns(self, prefix, uri): + return self.target.start_ns(prefix or '', uri or '') + + def _end_ns(self, prefix): + return self.target.end_ns(prefix or '') + + def _start(self, tag, attr_list): + # Handler for expat's StartElementHandler. Since ordered_attributes + # is set, the attributes are reported as a list of alternating + # attribute name,value. + fixname = self._fixname + tag = fixname(tag) + attrib = {} + if attr_list: + for i in range(0, len(attr_list), 2): + attrib[fixname(attr_list[i])] = attr_list[i+1] + return self.target.start(tag, attrib) + + def _end(self, tag): + return self.target.end(self._fixname(tag)) + + def _default(self, text): + prefix = text[:1] + if prefix == "&": + # deal with undefined entities + try: + data_handler = self.target.data + except AttributeError: + return + try: + data_handler(self.entity[text[1:-1]]) + except KeyError: + from xml.parsers import expat + err = expat.error( + "undefined entity %s: line %d, column %d" % + (text, self.parser.ErrorLineNumber, + self.parser.ErrorColumnNumber) + ) + err.code = 11 # XML_ERROR_UNDEFINED_ENTITY + err.lineno = self.parser.ErrorLineNumber + err.offset = self.parser.ErrorColumnNumber + raise err + elif prefix == "<" and text[:9] == "": + self._doctype = None + return + text = text.strip() + if not text: + return + self._doctype.append(text) + n = len(self._doctype) + if n > 2: + type = self._doctype[1] + if type == "PUBLIC" and n == 4: + name, type, pubid, system = self._doctype + if pubid: + pubid = pubid[1:-1] + elif type == "SYSTEM" and n == 3: + name, type, system = self._doctype + pubid = None + else: + return + if hasattr(self.target, "doctype"): + self.target.doctype(name, pubid, system[1:-1]) + elif hasattr(self, "doctype"): + warnings.warn( + "The doctype() method of XMLParser is ignored. " + "Define doctype() method on the TreeBuilder target.", + RuntimeWarning) + + self._doctype = None + + def feed(self, data): + """Feed encoded data to parser.""" + try: + self.parser.Parse(data, False) + except self._error as v: + self._raiseerror(v) + + def close(self): + """Finish feeding data to parser and return element structure.""" + try: + self.parser.Parse(b"", True) # end of data + except self._error as v: + self._raiseerror(v) + try: + close_handler = self.target.close + except AttributeError: + pass + else: + return close_handler() + finally: + # get rid of circular references + del self.parser, self._parser + del self.target, self._target + + def flush(self): + was_enabled = self.parser.GetReparseDeferralEnabled() + try: + self.parser.SetReparseDeferralEnabled(False) + self.parser.Parse(b"", False) + except self._error as v: + self._raiseerror(v) + finally: + self.parser.SetReparseDeferralEnabled(was_enabled) + +# -------------------------------------------------------------------- +# C14N 2.0 + +def canonicalize(xml_data=None, *, out=None, from_file=None, **options): + """Convert XML to its C14N 2.0 serialised form. + + If *out* is provided, it must be a file or file-like object that receives + the serialised canonical XML output (text, not bytes) through its ``.write()`` + method. To write to a file, open it in text mode with encoding "utf-8". + If *out* is not provided, this function returns the output as text string. + + Either *xml_data* (an XML string) or *from_file* (a file path or + file-like object) must be provided as input. + + The configuration options are the same as for the ``C14NWriterTarget``. + """ + if xml_data is None and from_file is None: + raise ValueError("Either 'xml_data' or 'from_file' must be provided as input") + sio = None + if out is None: + sio = out = io.StringIO() + + parser = XMLParser(target=C14NWriterTarget(out.write, **options)) + + if xml_data is not None: + parser.feed(xml_data) + parser.close() + elif from_file is not None: + parse(from_file, parser=parser) + + return sio.getvalue() if sio is not None else None + + +_looks_like_prefix_name = re.compile(r'^\w+:\w+$', re.UNICODE).match + + +class C14NWriterTarget: + """ + Canonicalization writer target for the XMLParser. + + Serialises parse events to XML C14N 2.0. + + The *write* function is used for writing out the resulting data stream + as text (not bytes). To write to a file, open it in text mode with encoding + "utf-8" and pass its ``.write`` method. + + Configuration options: + + - *with_comments*: set to true to include comments + - *strip_text*: set to true to strip whitespace before and after text content + - *rewrite_prefixes*: set to true to replace namespace prefixes by "n{number}" + - *qname_aware_tags*: a set of qname aware tag names in which prefixes + should be replaced in text content + - *qname_aware_attrs*: a set of qname aware attribute names in which prefixes + should be replaced in text content + - *exclude_attrs*: a set of attribute names that should not be serialised + - *exclude_tags*: a set of tag names that should not be serialised + """ + def __init__(self, write, *, + with_comments=False, strip_text=False, rewrite_prefixes=False, + qname_aware_tags=None, qname_aware_attrs=None, + exclude_attrs=None, exclude_tags=None): + self._write = write + self._data = [] + self._with_comments = with_comments + self._strip_text = strip_text + self._exclude_attrs = set(exclude_attrs) if exclude_attrs else None + self._exclude_tags = set(exclude_tags) if exclude_tags else None + + self._rewrite_prefixes = rewrite_prefixes + if qname_aware_tags: + self._qname_aware_tags = set(qname_aware_tags) + else: + self._qname_aware_tags = None + if qname_aware_attrs: + self._find_qname_aware_attrs = set(qname_aware_attrs).intersection + else: + self._find_qname_aware_attrs = None + + # Stack with globally and newly declared namespaces as (uri, prefix) pairs. + self._declared_ns_stack = [[ + ("http://www.w3.org/XML/1998/namespace", "xml"), + ]] + # Stack with user declared namespace prefixes as (uri, prefix) pairs. + self._ns_stack = [] + if not rewrite_prefixes: + self._ns_stack.append(list(_namespace_map.items())) + self._ns_stack.append([]) + self._prefix_map = {} + self._preserve_space = [False] + self._pending_start = None + self._root_seen = False + self._root_done = False + self._ignored_depth = 0 + + def _iter_namespaces(self, ns_stack, _reversed=reversed): + for namespaces in _reversed(ns_stack): + if namespaces: # almost no element declares new namespaces + yield from namespaces + + def _resolve_prefix_name(self, prefixed_name): + prefix, name = prefixed_name.split(':', 1) + for uri, p in self._iter_namespaces(self._ns_stack): + if p == prefix: + return f'{{{uri}}}{name}' + raise ValueError(f'Prefix {prefix} of QName "{prefixed_name}" is not declared in scope') + + def _qname(self, qname, uri=None): + if uri is None: + uri, tag = qname[1:].rsplit('}', 1) if qname[:1] == '{' else ('', qname) + else: + tag = qname + + prefixes_seen = set() + for u, prefix in self._iter_namespaces(self._declared_ns_stack): + if u == uri and prefix not in prefixes_seen: + return f'{prefix}:{tag}' if prefix else tag, tag, uri + prefixes_seen.add(prefix) + + # Not declared yet => add new declaration. + if self._rewrite_prefixes: + if uri in self._prefix_map: + prefix = self._prefix_map[uri] + else: + prefix = self._prefix_map[uri] = f'n{len(self._prefix_map)}' + self._declared_ns_stack[-1].append((uri, prefix)) + return f'{prefix}:{tag}', tag, uri + + if not uri and '' not in prefixes_seen: + # No default namespace declared => no prefix needed. + return tag, tag, uri + + for u, prefix in self._iter_namespaces(self._ns_stack): + if u == uri: + self._declared_ns_stack[-1].append((uri, prefix)) + return f'{prefix}:{tag}' if prefix else tag, tag, uri + + if not uri: + # As soon as a default namespace is defined, + # anything that has no namespace (and thus, no prefix) goes there. + return tag, tag, uri + + raise ValueError(f'Namespace "{uri}" is not declared in scope') + + def data(self, data): + if not self._ignored_depth: + self._data.append(data) + + def _flush(self, _join_text=''.join): + data = _join_text(self._data) + del self._data[:] + if self._strip_text and not self._preserve_space[-1]: + data = data.strip() + if self._pending_start is not None: + args, self._pending_start = self._pending_start, None + qname_text = data if data and _looks_like_prefix_name(data) else None + self._start(*args, qname_text) + if qname_text is not None: + return + if data and self._root_seen: + self._write(_escape_cdata_c14n(data)) + + def start_ns(self, prefix, uri): + if self._ignored_depth: + return + # we may have to resolve qnames in text content + if self._data: + self._flush() + self._ns_stack[-1].append((uri, prefix)) + + def start(self, tag, attrs): + if self._exclude_tags is not None and ( + self._ignored_depth or tag in self._exclude_tags): + self._ignored_depth += 1 + return + if self._data: + self._flush() + + new_namespaces = [] + self._declared_ns_stack.append(new_namespaces) + + if self._qname_aware_tags is not None and tag in self._qname_aware_tags: + # Need to parse text first to see if it requires a prefix declaration. + self._pending_start = (tag, attrs, new_namespaces) + return + self._start(tag, attrs, new_namespaces) + + def _start(self, tag, attrs, new_namespaces, qname_text=None): + if self._exclude_attrs is not None and attrs: + attrs = {k: v for k, v in attrs.items() if k not in self._exclude_attrs} + + qnames = {tag, *attrs} + resolved_names = {} + + # Resolve prefixes in attribute and tag text. + if qname_text is not None: + qname = resolved_names[qname_text] = self._resolve_prefix_name(qname_text) + qnames.add(qname) + if self._find_qname_aware_attrs is not None and attrs: + qattrs = self._find_qname_aware_attrs(attrs) + if qattrs: + for attr_name in qattrs: + value = attrs[attr_name] + if _looks_like_prefix_name(value): + qname = resolved_names[value] = self._resolve_prefix_name(value) + qnames.add(qname) + else: + qattrs = None + else: + qattrs = None + + # Assign prefixes in lexicographical order of used URIs. + parse_qname = self._qname + parsed_qnames = {n: parse_qname(n) for n in sorted( + qnames, key=lambda n: n.split('}', 1))} + + # Write namespace declarations in prefix order ... + if new_namespaces: + attr_list = [ + ('xmlns:' + prefix if prefix else 'xmlns', uri) + for uri, prefix in new_namespaces + ] + attr_list.sort() + else: + # almost always empty + attr_list = [] + + # ... followed by attributes in URI+name order + if attrs: + for k, v in sorted(attrs.items()): + if qattrs is not None and k in qattrs and v in resolved_names: + v = parsed_qnames[resolved_names[v]][0] + attr_qname, attr_name, uri = parsed_qnames[k] + # No prefix for attributes in default ('') namespace. + attr_list.append((attr_qname if uri else attr_name, v)) + + # Honour xml:space attributes. + space_behaviour = attrs.get('{http://www.w3.org/XML/1998/namespace}space') + self._preserve_space.append( + space_behaviour == 'preserve' if space_behaviour + else self._preserve_space[-1]) + + # Write the tag. + write = self._write + write('<' + parsed_qnames[tag][0]) + if attr_list: + write(''.join([f' {k}="{_escape_attrib_c14n(v)}"' for k, v in attr_list])) + write('>') + + # Write the resolved qname text content. + if qname_text is not None: + write(_escape_cdata_c14n(parsed_qnames[resolved_names[qname_text]][0])) + + self._root_seen = True + self._ns_stack.append([]) + + def end(self, tag): + if self._ignored_depth: + self._ignored_depth -= 1 + return + if self._data: + self._flush() + self._write(f'') + self._preserve_space.pop() + self._root_done = len(self._preserve_space) == 1 + self._declared_ns_stack.pop() + self._ns_stack.pop() + + def comment(self, text): + if not self._with_comments: + return + if self._ignored_depth: + return + if self._root_done: + self._write('\n') + elif self._root_seen and self._data: + self._flush() + self._write(f'') + if not self._root_seen: + self._write('\n') + + def pi(self, target, data): + if self._ignored_depth: + return + if self._root_done: + self._write('\n') + elif self._root_seen and self._data: + self._flush() + self._write( + f'' if data else f'') + if not self._root_seen: + self._write('\n') + + +def _escape_cdata_c14n(text): + # escape character data + try: + # it's worth avoiding do-nothing calls for strings that are + # shorter than 500 character, or so. assume that's, by far, + # the most common case in most applications. + if '&' in text: + text = text.replace('&', '&') + if '<' in text: + text = text.replace('<', '<') + if '>' in text: + text = text.replace('>', '>') + if '\r' in text: + text = text.replace('\r', ' ') + return text + except (TypeError, AttributeError): + _raise_serialization_error(text) + + +def _escape_attrib_c14n(text): + # escape attribute value + try: + if '&' in text: + text = text.replace('&', '&') + if '<' in text: + text = text.replace('<', '<') + if '"' in text: + text = text.replace('"', '"') + if '\t' in text: + text = text.replace('\t', ' ') + if '\n' in text: + text = text.replace('\n', ' ') + if '\r' in text: + text = text.replace('\r', ' ') + return text + except (TypeError, AttributeError): + _raise_serialization_error(text) + + +# -------------------------------------------------------------------- + +# Import the C accelerators +try: + # Element is going to be shadowed by the C implementation. We need to keep + # the Python version of it accessible for some "creative" by external code + # (see tests) + _Element_Py = Element + + # Element, SubElement, ParseError, TreeBuilder, XMLParser, _set_factories + from _elementtree import * + from _elementtree import _set_factories +except ImportError: + pass +else: + _set_factories(Comment, ProcessingInstruction) diff --git a/crates/weavepy-vm/src/stdlib/python/xml/etree/__init__.py b/crates/weavepy-vm/src/stdlib/python/xml/etree/__init__.py new file mode 100644 index 0000000..e2ec534 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/etree/__init__.py @@ -0,0 +1,33 @@ +# $Id: __init__.py 3375 2008-02-13 08:05:08Z fredrik $ +# elementtree package + +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2008 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +# Licensed to PSF under a Contributor Agreement. +# See https://www.python.org/psf/license for licensing details. diff --git a/crates/weavepy-vm/src/stdlib/python/xml/parsers/__init__.py b/crates/weavepy-vm/src/stdlib/python/xml/parsers/__init__.py new file mode 100644 index 0000000..eb314a3 --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/parsers/__init__.py @@ -0,0 +1,8 @@ +"""Python interfaces to XML parsers. + +This package contains one module: + +expat -- Python wrapper for James Clark's Expat parser, with namespace + support. + +""" diff --git a/crates/weavepy-vm/src/stdlib/python/xml/parsers/expat.py b/crates/weavepy-vm/src/stdlib/python/xml/parsers/expat.py new file mode 100644 index 0000000..bcbe9fb --- /dev/null +++ b/crates/weavepy-vm/src/stdlib/python/xml/parsers/expat.py @@ -0,0 +1,8 @@ +"""Interface to the Expat non-validating XML parser.""" +import sys + +from pyexpat import * + +# provide pyexpat submodules as xml.parsers.expat submodules +sys.modules['xml.parsers.expat.model'] = model +sys.modules['xml.parsers.expat.errors'] = errors diff --git a/crates/weavepy-vm/src/stdlib/python/xml_etree.py b/crates/weavepy-vm/src/stdlib/python/xml_etree.py index ad1c60b..76bc2d5 100644 --- a/crates/weavepy-vm/src/stdlib/python/xml_etree.py +++ b/crates/weavepy-vm/src/stdlib/python/xml_etree.py @@ -52,12 +52,19 @@ def _escape(s): ) -def _iterfind(elem, path): +def _iterfind(elem, path, namespaces=None): """A small subset of ElementPath sufficient for the bundled stdlib. Supports ``.`` (self), a leading ``./``, the ``*`` child wildcard, exact tag steps, ``/``-separated multi-step paths, and the ``//`` descendant axis (e.g. ``.//tag``). Predicates (``[...]``) are not implemented. + + ``namespaces`` (a ``{prefix: uri}`` map) is accepted for API parity with + CPython — pandas' ``read_xml`` etree path passes it. WeavePy's parser is + namespace-naive (it keeps raw ``prefix:local`` tags rather than + ``{uri}local``), so a ``prefix:local`` step already matches the raw stored + tag whenever the document and the XPath share the prefix (the usual case); + the argument is otherwise advisory. """ if not path: return @@ -137,19 +144,25 @@ def keys(self): def items(self): return list(self.attrib.items()) - def find(self, path): - for c in _iterfind(self, path): + def clear(self): + self.attrib.clear() + self.text = None + self.tail = None + self._children = [] + + def find(self, path, namespaces=None): + for c in _iterfind(self, path, namespaces): return c return None - def findall(self, path): - return list(_iterfind(self, path)) + def findall(self, path, namespaces=None): + return list(_iterfind(self, path, namespaces)) - def iterfind(self, path): - return _iterfind(self, path) + def iterfind(self, path, namespaces=None): + return _iterfind(self, path, namespaces) - def findtext(self, path, default=None): - c = self.find(path) + def findtext(self, path, default=None, namespaces=None): + c = self.find(path, namespaces) if c is None: return default return c.text or "" @@ -320,11 +333,27 @@ def _parse_text(text): return root +class XMLParser: + """Minimal stand-in for :class:`xml.etree.ElementTree.XMLParser`. + + weavepy's ElementTree parses via the built-in recursive ``_parse_text`` + rather than an expat feed loop, so this class exists chiefly so callers + that *construct a parser explicitly* and pass it to ``parse(...)`` work — + pandas' ``read_xml`` does ``parse(data, parser=XMLParser(encoding=...))``. + The ``encoding`` is consulted by ``parse``/``ElementTree.parse`` when the + source yields bytes. + """ + + def __init__(self, *, encoding=None, target=None): + self.encoding = encoding + self.target = target + + class ElementTree: - def __init__(self, element=None, file=None): + def __init__(self, element=None, file=None, parser=None): self._root = element if file is not None: - self.parse(file) + self.parse(file, parser) def getroot(self): return self._root @@ -336,7 +365,8 @@ def parse(self, source, parser=None): with open(source, "rb") as f: text = f.read() if isinstance(text, (bytes, bytearray)): - text = text.decode("utf-8") + enc = getattr(parser, "encoding", None) or "utf-8" + text = text.decode(enc) self._root = _parse_text(text) return self._root @@ -354,14 +384,16 @@ def write(self, file, encoding=None, xml_declaration=None, default_namespace=Non with open(file, mode) as f: f.write(data) - def find(self, path): - return self._root.find(path) if self._root is not None else None + def find(self, path, namespaces=None): + return self._root.find(path, namespaces) if self._root is not None else None - def findall(self, path): - return self._root.findall(path) if self._root is not None else [] + def findall(self, path, namespaces=None): + return self._root.findall(path, namespaces) if self._root is not None else [] - def findtext(self, path, default=None): - return self._root.findtext(path, default) if self._root is not None else default + def findtext(self, path, default=None, namespaces=None): + if self._root is None: + return default + return self._root.findtext(path, default, namespaces) def iter(self, tag=None): if self._root is None: @@ -370,7 +402,7 @@ def iter(self, tag=None): def parse(source, parser=None): - return ElementTree(file=source) + return ElementTree(file=source, parser=parser) def iterparse(source, events=None): @@ -396,5 +428,5 @@ def dump(elem): __all__ = [ "Element", "SubElement", "Comment", "ProcessingInstruction", "PI", "CDATA", "ElementTree", "ParseError", "tostring", "fromstring", "parse", "iterparse", - "register_namespace", "dump", + "register_namespace", "dump", "XMLParser", ] diff --git a/crates/weavepy-vm/src/stdlib/secrets_mod.rs b/crates/weavepy-vm/src/stdlib/secrets_mod.rs index 0de7156..d3557e4 100644 --- a/crates/weavepy-vm/src/stdlib/secrets_mod.rs +++ b/crates/weavepy-vm/src/stdlib/secrets_mod.rs @@ -168,22 +168,42 @@ fn randbelow(args: &[Object]) -> Result { Ok(Object::Int((raw % n as u64) as i64)) } +/// `secrets.randbits(k)` — a non-negative int with `k` cryptographically +/// secure random bits (CPython's `SystemRandom.getrandbits`). Faithful at +/// any width: numpy's `SeedSequence` default-seeds with `randbits(128)`, +/// so we read `ceil(k/8)` OS-entropy bytes, trim the top byte to the exact +/// bit count, and normalise to a machine `Int` or a big `Long`. fn randbits(args: &[Object]) -> Result { let k = match args.first() { - Some(Object::Int(n)) => *n as u32, + Some(Object::Int(n)) => *n, + Some(Object::Bool(b)) => i64::from(*b), + Some(Object::Long(b)) => { + use num_traits::ToPrimitive; + b.to_i64() + .ok_or_else(|| value_error("number of bits is too large"))? + } _ => return Err(type_error("randbits: arg must be int")), }; + if k < 0 { + return Err(value_error("number of bits must be non-negative")); + } if k == 0 { return Ok(Object::Int(0)); } - if k > 63 { - return Err(value_error("randbits: max 63 in WeavePy")); - } - let mut bytes = [0u8; 8]; + let k = k as usize; + let nbytes = k.div_ceil(8); + let mut bytes = vec![0u8; nbytes]; os_random(&mut bytes)?; - let raw = u64::from_le_bytes(bytes); - let mask = if k == 64 { u64::MAX } else { (1u64 << k) - 1 }; - Ok(Object::Int((raw & mask) as i64)) + let rem = k % 8; + if rem != 0 { + let last = nbytes - 1; + bytes[last] &= (1u8 << rem) - 1; + } + let big = num_bigint::BigUint::from_bytes_le(&bytes); + Ok(Object::int_from_bigint(num_bigint::BigInt::from_biguint( + num_bigint::Sign::Plus, + big, + ))) } fn compare_digest(args: &[Object]) -> Result { diff --git a/crates/weavepy-vm/src/stdlib/struct_mod.rs b/crates/weavepy-vm/src/stdlib/struct_mod.rs index 02b25af..cbb369e 100644 --- a/crates/weavepy-vm/src/stdlib/struct_mod.rs +++ b/crates/weavepy-vm/src/stdlib/struct_mod.rs @@ -323,7 +323,15 @@ fn element_size(code: char, endian: Endian) -> Result { Ok(match code { 'x' | 'b' | 'B' | 'c' | 's' | 'p' | '?' => 1, 'h' | 'H' | 'e' => 2, - 'i' | 'I' | 'l' | 'L' | 'f' => 4, + 'i' | 'I' | 'f' => 4, + // C `long` is the one code whose native size differs from its + // standard size: 8 bytes on an LP64 target (native/`@` mode), 4 + // bytes in the standard/explicit-endian modes. This must match + // `sizeof(c_long)` so `ctypes._check_size(c_long)` agrees. + 'l' | 'L' => match endian { + Endian::Native => std::mem::size_of::(), + _ => 4, + }, 'q' | 'Q' | 'd' => 8, 'n' | 'N' => match endian { Endian::Native => std::mem::size_of::(), @@ -344,7 +352,8 @@ fn native_align(code: char) -> usize { match code { 'x' | 'b' | 'B' | 'c' | 's' | 'p' | '?' => 1, 'h' | 'H' | 'e' => 2, - 'i' | 'I' | 'l' | 'L' | 'f' => 4, + 'i' | 'I' | 'f' => 4, + 'l' | 'L' => std::mem::align_of::(), 'q' | 'Q' | 'd' => 8, 'n' | 'N' => std::mem::size_of::(), 'P' => std::mem::size_of::(), @@ -425,14 +434,28 @@ fn encode_one( } 'h' => write_int!(i16, write_i16, true), 'H' => write_int!(u16, write_u16, false), - 'i' | 'l' => write_int!(i32, write_i32, true), - 'I' | 'L' => write_int!(u32, write_u32, false), + 'i' => write_int!(i32, write_i32, true), + 'I' => write_int!(u32, write_u32, false), + // Native `long`/`unsigned long` are 8-byte on LP64; standard modes + // keep them 4-byte (see `element_size`). + 'l' => { + if matches!(endian, Endian::Native) { + write_int!(i64, write_i64, true) + } else { + write_int!(i32, write_i32, true) + } + } + 'L' => { + if matches!(endian, Endian::Native) { + write_int!(u64, write_u64, false) + } else { + write_int!(u32, write_u32, false) + } + } 'q' => write_int!(i64, write_i64, true), 'Q' => write_int!(u64, write_u64, false), 'f' => { - let f = value - .as_f64() - .ok_or_else(|| struct_error("required argument is not a float"))?; + let f = require_float(value)?; // A finite double whose magnitude rounds above `FLT_MAX` // overflows binary32. CPython's `_PyFloat_Pack4` reports this // as `OverflowError` (not `struct.error`), so the frozen @@ -451,9 +474,7 @@ fn encode_one( Ok(()) } 'd' => { - let f = value - .as_f64() - .ok_or_else(|| struct_error("required argument is not a float"))?; + let f = require_float(value)?; let mut buf = [0u8; 8]; match endian { Endian::Native => NativeEndian::write_f64(&mut buf, f), @@ -467,9 +488,7 @@ fn encode_one( // Half-precision IEEE 754, converted from the double with // round-half-to-even (CPython `_PyFloat_Pack2`), not via an // intermediate `f32` truncation. - let f = value - .as_f64() - .ok_or_else(|| struct_error("required argument is not a float"))?; + let f = require_float(value)?; let half = f64_to_half(f)?; let mut buf = [0u8; 2]; match endian { @@ -513,8 +532,28 @@ fn decode_one(code: char, endian: Endian, buf: &[u8]) -> Result<(Object, usize), '?' => Object::Bool(buf[0] != 0), 'h' => Object::Int(i64::from(read_i16(endian, &buf[..2]))), 'H' => Object::Int(i64::from(read_u16(endian, &buf[..2]))), - 'i' | 'l' => Object::Int(i64::from(read_i32(endian, &buf[..4]))), - 'I' | 'L' => Object::Int(i64::from(read_u32(endian, &buf[..4]))), + 'i' => Object::Int(i64::from(read_i32(endian, &buf[..4]))), + 'I' => Object::Int(i64::from(read_u32(endian, &buf[..4]))), + // Native `long` is 8-byte on LP64; standard modes keep it 4-byte. + 'l' => { + if matches!(endian, Endian::Native) { + Object::Int(read_i64(endian, &buf[..8])) + } else { + Object::Int(i64::from(read_i32(endian, &buf[..4]))) + } + } + 'L' => { + if matches!(endian, Endian::Native) { + let v = read_u64(endian, &buf[..8]); + if i64::try_from(v).is_ok() { + Object::Int(v as i64) + } else { + Object::int_from_bigint(num_bigint::BigInt::from(v)) + } + } else { + Object::Int(i64::from(read_u32(endian, &buf[..4]))) + } + } 'q' => Object::Int(read_i64(endian, &buf[..8])), 'Q' => { let v = read_u64(endian, &buf[..8]); @@ -729,10 +768,30 @@ fn require_int(v: &Object) -> Result { Object::Long(b) => num_traits::ToPrimitive::to_i128(&**b) .ok_or_else(|| struct_error("int too large to pack")), Object::Bool(b) => Ok(i128::from(i64::from(*b))), - _ => Err(struct_error(format!( - "required argument is not an integer (got '{}')", - v.type_name() - ))), + // CPython's `_struct` runs a non-int through `PyNumber_Index` + // (`__index__`) for the integer formats, so a numpy integer scalar + // (`np.int64`, as produced by pandas `Tick` offset arithmetic) or any + // `__index__`-bearing object packs correctly. A float or an object + // with no `__index__` still errors below. + _ => match crate::builtins::try_coerce_index_i64(v) { + Some(r) => r.map(i128::from), + None => Err(struct_error(format!( + "required argument is not an integer (got '{}')", + v.type_name() + ))), + }, + } +} + +/// Coerce a `struct.pack` float-format argument (`f`/`d`/`e`) to `f64`. +/// CPython's `_struct` runs these through `PyFloat_AsDouble`, honouring +/// `__float__`/`nb_float` (then `__index__`), so a numpy float scalar +/// (`np.float64`) or any real-number-like object packs correctly instead +/// of being rejected as "not a float". +fn require_float(v: &Object) -> Result { + match crate::builtins::coerce_f64_opt(v)? { + Some(f) => Ok(f), + None => Err(struct_error("required argument is not a float")), } } diff --git a/crates/weavepy-vm/src/stdlib/thread_real.rs b/crates/weavepy-vm/src/stdlib/thread_real.rs index 243cc32..cbae011 100644 --- a/crates/weavepy-vm/src/stdlib/thread_real.rs +++ b/crates/weavepy-vm/src/stdlib/thread_real.rs @@ -310,6 +310,40 @@ fn resolve_acquire_args( /// internal nanosecond deadline without overflow. const TIMEOUT_MAX_SECS: f64 = 9_223_372_036.0; +/// A *type-level* trampoline for a special method whose real implementation +/// lives in the **instance** dict. +/// +/// The lock / RLock methods capture per-instance `Arc` state in +/// closures, so they're stored on each instance rather than the shared type. +/// CPython, however, exposes a lock's `__enter__`/`__exit__` on the *type* — +/// and the implicit context-manager protocol is a *type-only* lookup +/// (`_PyType_Lookup`, which a Cython `with` statement performs directly). This +/// trampoline lives on the type, recovers `self` (the bound instance), and +/// forwards to that instance's own closure, so both the VM's instance-dict +/// path and a C extension's type-level lookup reach the identical +/// implementation. +fn instance_dunder_trampoline(name: &'static str) -> Object { + Object::Builtin(Rc::new(BuiltinFn { + name, + binds_instance: true, + call: Box::new(move |args: &[Object]| -> Result { + let Some(Object::Instance(inst)) = args.first() else { + return Err(type_error(format!("{name}() requires an instance"))); + }; + let closure = inst + .dict + .borrow() + .get(&DictKey(Object::from_static(name))) + .cloned(); + match closure { + Some(Object::Builtin(b)) => (b.call)(&args[1..]), + _ => Err(type_error(format!("instance has no {name}"))), + } + }), + call_kw: None, + })) +} + /// Returns the user-visible "lock" type. Built lazily so /// `type(lock).__name__ == 'lock'` matches CPython. fn lock_type() -> Rc { @@ -331,6 +365,18 @@ fn lock_type() -> Rc { call_kw: None, })), ); + // Expose the context-manager protocol on the *type* (CPython's + // `_thread.lock` does), so a C extension's type-only `_PyType_Lookup` + // for `with lock:` finds them. They forward to the per-instance + // closures that hold the `Arc`. + d.insert( + DictKey(Object::from_static("__enter__")), + instance_dunder_trampoline("__enter__"), + ); + d.insert( + DictKey(Object::from_static("__exit__")), + instance_dunder_trampoline("__exit__"), + ); let t = TypeObject::new_with_flags( "lock", vec![crate::builtin_types::builtin_types().object_.clone()], @@ -391,6 +437,16 @@ fn rlock_type() -> Rc { call_kw: None, })), ); + // Same as `lock`: surface the context-manager protocol on the type so + // `with rlock:` resolves through a C extension's type-only lookup. + d.insert( + DictKey(Object::from_static("__enter__")), + instance_dunder_trampoline("__enter__"), + ); + d.insert( + DictKey(Object::from_static("__exit__")), + instance_dunder_trampoline("__exit__"), + ); let t = TypeObject::new_with_flags( "RLock", vec![crate::builtin_types::builtin_types().object_.clone()], @@ -546,6 +602,7 @@ fn make_lock_object(lock: Arc) -> Object { slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }); Object::Instance(inst) } @@ -715,6 +772,7 @@ fn make_rlock_object(rlock: Arc) -> Object { slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }); Object::Instance(inst) } @@ -1556,6 +1614,7 @@ fn make_thread_handle_object(state: Arc, ident: Object) -> Ob slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }); Object::Instance(inst) } diff --git a/crates/weavepy-vm/src/stdlib/uuid_mod.rs b/crates/weavepy-vm/src/stdlib/uuid_mod.rs deleted file mode 100644 index 3a00422..0000000 --- a/crates/weavepy-vm/src/stdlib/uuid_mod.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! The `uuid` built-in module. -//! -//! Generates RFC 4122 UUIDs: `uuid4()` reads OS entropy via the same -//! `/dev/urandom` path as `secrets`; `uuid1()` uses node + clock -//! state; `uuid3` and `uuid5` are namespace-name UUIDs derived from -//! MD5 / SHA-1. -//! -//! The user-visible `UUID` class is intentionally small — a dict -//! exposing `bytes`, `hex`, `int`, `__str__`, `version`, and the -//! common `urn` shortcut. Code that needs the full CPython surface -//! (`fields`, `time_low`/`time_mid`/…) can reach into the byte -//! payload directly. - -use crate::sync::Rc; -use crate::sync::RefCell; - -use digest::Digest; -use md5::Md5; -use sha1::Sha1; - -use crate::error::{type_error, value_error, RuntimeError}; -use crate::import::ModuleCache; -use crate::object::{BuiltinFn, DictData, DictKey, Object, PyModule}; - -pub fn build(_cache: &ModuleCache) -> Rc { - let dict = Rc::new(RefCell::new(DictData::new())); - { - let mut d = dict.borrow_mut(); - d.insert( - DictKey(Object::from_static("__name__")), - Object::from_static("uuid"), - ); - d.insert( - DictKey(Object::from_static("__doc__")), - Object::from_static("UUID objects (universally unique identifiers)."), - ); - d.insert(DictKey(Object::from_static("uuid1")), b("uuid1", uuid1)); - d.insert(DictKey(Object::from_static("uuid3")), b("uuid3", uuid3)); - d.insert(DictKey(Object::from_static("uuid4")), b("uuid4", uuid4)); - d.insert(DictKey(Object::from_static("uuid5")), b("uuid5", uuid5)); - d.insert(DictKey(Object::from_static("UUID")), b("UUID", uuid_ctor)); - - // Common namespaces (RFC 4122 appendix C). - d.insert( - DictKey(Object::from_static("NAMESPACE_DNS")), - uuid_from_bytes([ - 0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, - 0x30, 0xc8, - ]), - ); - d.insert( - DictKey(Object::from_static("NAMESPACE_URL")), - uuid_from_bytes([ - 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, - 0x30, 0xc8, - ]), - ); - d.insert( - DictKey(Object::from_static("NAMESPACE_OID")), - uuid_from_bytes([ - 0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, - 0x30, 0xc8, - ]), - ); - d.insert( - DictKey(Object::from_static("NAMESPACE_X500")), - uuid_from_bytes([ - 0x6b, 0xa7, 0xb8, 0x14, 0x9d, 0xad, 0x11, 0xd1, 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, - 0x30, 0xc8, - ]), - ); - } - Rc::new(PyModule { - name: "uuid".to_owned(), - filename: None, - dict, - }) -} - -fn b(name: &'static str, body: fn(&[Object]) -> Result) -> Object { - Object::Builtin(Rc::new(BuiltinFn { - name, - binds_instance: false, - call: Box::new(body), - call_kw: None, - })) -} - -fn os_random_bytes(out: &mut [u8]) -> Result<(), RuntimeError> { - #[cfg(unix)] - { - use std::fs::File; - use std::io::Read; - let mut f = File::open("/dev/urandom").map_err(|e| value_error(e.to_string()))?; - f.read_exact(out).map_err(|e| value_error(e.to_string()))?; - Ok(()) - } - #[cfg(not(unix))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - let seed = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u64) - .unwrap_or(0xDEAD_BEEF_FEED_FACE); - let mut state = seed; - for byte in out.iter_mut() { - state = state - .wrapping_mul(6364136223846793005) - .wrapping_add(1442695040888963407); - *byte = (state >> 33) as u8; - } - Ok(()) - } -} - -fn uuid_from_bytes(bytes: [u8; 16]) -> Object { - let hex = format_uuid(&bytes); - let dict = Rc::new(RefCell::new(DictData::new())); - { - let mut d = dict.borrow_mut(); - d.insert( - DictKey(Object::from_static("bytes")), - Object::new_bytes(bytes.to_vec()), - ); - d.insert( - DictKey(Object::from_static("hex")), - Object::from_str(hex.replace('-', "")), - ); - d.insert( - DictKey(Object::from_static("urn")), - Object::from_str(format!("urn:uuid:{hex}")), - ); - d.insert( - DictKey(Object::from_static("version")), - Object::Int(i64::from((bytes[6] >> 4) & 0x0F)), - ); - d.insert( - DictKey(Object::from_static("__str__")), - Object::from_str(hex.clone()), - ); - d.insert( - DictKey(Object::from_static("__repr__")), - Object::from_str(hex), - ); - } - Object::Dict(dict) -} - -fn format_uuid(bytes: &[u8; 16]) -> String { - let mut s = String::with_capacity(36); - for (i, b) in bytes.iter().enumerate() { - use std::fmt::Write; - write!(s, "{b:02x}").unwrap(); - if matches!(i, 3 | 5 | 7 | 9) { - s.push('-'); - } - } - s -} - -fn uuid4(_args: &[Object]) -> Result { - let mut bytes = [0u8; 16]; - os_random_bytes(&mut bytes)?; - bytes[6] = (bytes[6] & 0x0F) | 0x40; - bytes[8] = (bytes[8] & 0x3F) | 0x80; - Ok(uuid_from_bytes(bytes)) -} - -fn uuid1(_args: &[Object]) -> Result { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos() as u128) - .unwrap_or(0); - // UUID v1: 60-bit timestamp in 100-ns intervals since 1582-10-15. - let intervals_since_1582 = (nanos / 100).wrapping_add(0x01B2_1DD2_1381_4000u128) as u64; - let mut bytes = [0u8; 16]; - bytes[0..4].copy_from_slice(&(intervals_since_1582 as u32).to_be_bytes()); - bytes[4..6].copy_from_slice(&((intervals_since_1582 >> 32) as u16).to_be_bytes()); - let mid_hi = (((intervals_since_1582 >> 48) as u16) & 0x0FFF) | 0x1000; - bytes[6..8].copy_from_slice(&mid_hi.to_be_bytes()); - // Random clock seq + node identifier. - let mut tail = [0u8; 8]; - os_random_bytes(&mut tail)?; - bytes[8..16].copy_from_slice(&tail); - bytes[8] = (bytes[8] & 0x3F) | 0x80; - Ok(uuid_from_bytes(bytes)) -} - -fn uuid3(args: &[Object]) -> Result { - let (ns_bytes, name) = parse_ns_name(args)?; - let mut h = Md5::new(); - h.update(ns_bytes); - h.update(name.as_bytes()); - let out = h.finalize(); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&out); - bytes[6] = (bytes[6] & 0x0F) | 0x30; - bytes[8] = (bytes[8] & 0x3F) | 0x80; - Ok(uuid_from_bytes(bytes)) -} - -fn uuid5(args: &[Object]) -> Result { - let (ns_bytes, name) = parse_ns_name(args)?; - let mut h = Sha1::new(); - h.update(ns_bytes); - h.update(name.as_bytes()); - let out = h.finalize(); - let mut bytes = [0u8; 16]; - bytes.copy_from_slice(&out[..16]); - bytes[6] = (bytes[6] & 0x0F) | 0x50; - bytes[8] = (bytes[8] & 0x3F) | 0x80; - Ok(uuid_from_bytes(bytes)) -} - -fn parse_ns_name(args: &[Object]) -> Result<([u8; 16], String), RuntimeError> { - let ns = args - .first() - .ok_or_else(|| type_error("missing namespace"))?; - let name = match args.get(1) { - Some(Object::Str(s)) => s.to_string(), - _ => return Err(type_error("name must be str")), - }; - let ns_bytes = match ns { - Object::Dict(d) => match d - .borrow() - .get(&DictKey(Object::from_static("bytes"))) - .cloned() - { - Some(Object::Bytes(b)) if b.len() == 16 => { - let mut arr = [0u8; 16]; - arr.copy_from_slice(&b); - arr - } - _ => return Err(type_error("namespace must be a UUID")), - }, - _ => return Err(type_error("namespace must be a UUID")), - }; - Ok((ns_bytes, name)) -} - -fn uuid_ctor(args: &[Object]) -> Result { - // Accept `hex=...` shape or first positional hex string. - let hex = match args.first() { - Some(Object::Str(s)) => s.to_string(), - _ => return Err(type_error("UUID() expects a hex string")), - }; - let clean: String = hex.chars().filter(|c| c.is_ascii_hexdigit()).collect(); - if clean.len() != 32 { - return Err(value_error("UUID hex must be 32 hex digits")); - } - let mut bytes = [0u8; 16]; - for i in 0..16 { - bytes[i] = u8::from_str_radix(&clean[i * 2..i * 2 + 2], 16) - .map_err(|_| value_error("invalid UUID hex"))?; - } - Ok(uuid_from_bytes(bytes)) -} diff --git a/crates/weavepy-vm/src/stdlib/weakref_real.rs b/crates/weavepy-vm/src/stdlib/weakref_real.rs index 38489ba..7687394 100644 --- a/crates/weavepy-vm/src/stdlib/weakref_real.rs +++ b/crates/weavepy-vm/src/stdlib/weakref_real.rs @@ -668,6 +668,7 @@ fn make_ref_object(target: Object, callback: Option, kind_tag: u8) -> Ob slots: crate::sync::RefCell::new(None), hash_cache: crate::sync::Cell::new(None), finalize_ran: crate::sync::Cell::new(false), + c_body: crate::types::CBody::default(), }); // Back-pointer so `obj.__weakref__` / `getweakrefs(obj)` can return // this same wrapper object. diff --git a/crates/weavepy-vm/src/type_surface.rs b/crates/weavepy-vm/src/type_surface.rs index 541f578..8051638 100644 --- a/crates/weavepy-vm/src/type_surface.rs +++ b/crates/weavepy-vm/src/type_surface.rs @@ -525,8 +525,25 @@ fn install_object_compare(bt: &BuiltinTypes) { } } fn obj_ne(args: &[Object]) -> Result { - match args { - [a, b] if a.is_same(b) => Ok(Object::Bool(false)), + let (a, b) = match args { + [a, b] => (a, b), + _ => return Ok(crate::vm_singletons::not_implemented()), + }; + // `object.__ne__` delegates to the forward `__eq__` and inverts it + // (CPython `object_richcompare`); a plain identity check is wrong when + // `__eq__` disagrees with identity (`Decimal("NaN")`, or any custom + // `__eq__` returning `False`). Do this through the interpreter so the + // real `__eq__` runs. + if let Some(ptr) = crate::vm_singletons::current_interpreter_ptr() { + // SAFETY: published by an enclosing VM frame on this thread. + let interp = unsafe { &mut *ptr }; + let globals = interp.builtins_dict(); + return interp.object_default_ne(a, b, &globals); + } + // No ambient interpreter (early startup): identity is the best we can + // do without being able to call `__eq__`. + match (a, b) { + (a, b) if a.is_same(b) => Ok(Object::Bool(false)), _ => Ok(crate::vm_singletons::not_implemented()), } } @@ -1302,6 +1319,10 @@ fn install_class_getitem(bt: &BuiltinTypes) { &bt.set_, &bt.frozenset_, &bt.type_, + // PEP 654 groups expose `__class_getitem__` in CPython (C + // `Py_GenericAlias`), e.g. `BaseExceptionGroup[T]` in hypothesis. + &bt.base_exception_group, + &bt.exception_group, ] { insert_if_absent( ty, diff --git a/crates/weavepy-vm/src/types.rs b/crates/weavepy-vm/src/types.rs index 44eacdb..6c95654 100644 --- a/crates/weavepy-vm/src/types.rs +++ b/crates/weavepy-vm/src/types.rs @@ -270,10 +270,26 @@ impl TypeObject { mut dict: DictData, flags: TypeFlags, ) -> Result, RuntimeError> { - // CPython `type_new`: `__qualname__` is removed from the class - // namespace and stored on the type itself. - let qualname = match dict.shift_remove(&DictKey(Object::from_static("__qualname__"))) { - Some(Object::Str(s)) => Some(s.to_string()), + // CPython `type_new`: a *string* `__qualname__` in the class + // namespace is removed and stored on the type itself. + // + // A getset/member *descriptor* named `__qualname__` is different: + // it describes the type's *instances* (Cython's + // `cython_function_or_method`, the generator/coroutine getsets, a + // `__slots__ = ["__qualname__"]` member) and must stay in the dict + // — the type's own qualname then falls back to its name, exactly as + // CPython does (those descriptors arrive via `tp_getset`/ + // `tp_members`, never the `type_new` namespace). The C-API spec/ + // ready bridge merges harvested descriptors into this same dict, so + // we recognise that case here rather than choking on it. + let qualname = match dict.get(&DictKey(Object::from_static("__qualname__"))) { + Some(Object::Str(_)) => { + match dict.shift_remove(&DictKey(Object::from_static("__qualname__"))) { + Some(Object::Str(s)) => Some(s.to_string()), + _ => None, + } + } + Some(v) if is_instance_descriptor(v) => None, Some(other) => { return Err(type_error(format!( "type __qualname__ must be a str, not {}", @@ -742,6 +758,22 @@ impl TypeObject { } } +/// Is `obj` a descriptor that describes a type's *instances* (rather than +/// being a plain class attribute)? Used by [`TypeObject::new_with_flags`] +/// to tell a getset/member `__qualname__` (which must stay in the dict) +/// from a class-body `__qualname__` string/value. +fn is_instance_descriptor(obj: &Object) -> bool { + match obj { + Object::Property(_) | Object::SlotDescriptor(_) => true, + Object::Instance(inst) => { + inst.cls().lookup("__get__").is_some() + || inst.cls().lookup("__set__").is_some() + || inst.cls().lookup("__delete__").is_some() + } + _ => false, + } +} + fn compute_c3( self_ty: &Rc, bases: &[Rc], @@ -784,6 +816,54 @@ fn compute_c3( Ok(merged) } +/// The stable, layout-faithful C "inline body" a C-extension instance +/// owns once it has crossed into an extension that reads its fields at +/// fixed offsets (RFC 0045, wave 3). Holds the body pointer as a +/// `usize` (`0` = no body). `Send + Sync` because the underlying +/// [`Cell`] is. +/// +/// **Excluded from the structural clone of [`PyInstance`].** A cloned +/// instance is a *distinct* object that owns no body — most importantly +/// the wave-2 finalizer-resurrection net (`PyInstance::drop`) shallow- +/// copies a dying instance, and duplicating the raw body pointer there +/// would double-free it. `Clone` therefore yields the empty state; the +/// freshly-cloned instance lazily mints its own body if it ever crosses +/// into C again. +#[derive(Debug, Default)] +pub struct CBody(Cell); + +impl CBody { + /// The body pointer (`0` when the instance has no faithful body). + #[inline] + pub fn get(&self) -> usize { + self.0.get() + } + /// Record the body pointer this instance now owns. + #[inline] + pub fn set(&self, p: usize) { + self.0.set(p); + } +} + +impl Clone for CBody { + fn clone(&self) -> Self { + CBody(Cell::new(0)) + } +} + +/// Process-global hook that frees a C-extension instance's faithful +/// inline body. Registered once by `weavepy-capi` at interpreter init +/// (the same additive-hook pattern wave 2 used for +/// `register_traverse`/`register_clear`); inert in a pure-VM build, so a +/// run with no C extension loaded is byte-for-byte unchanged. +static INSTANCE_BODY_FREE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Register the faithful-instance-body free hook (RFC 0045, wave 3). +/// Idempotent — a second registration is ignored. +pub fn register_instance_body_free(f: fn(usize)) { + let _ = INSTANCE_BODY_FREE.set(f); +} + /// An instance of a user-defined class. /// /// `dict` mirrors CPython's `__dict__` — attribute writes land here @@ -842,6 +922,13 @@ pub struct PyInstance { /// `test_multiprocessing_*` `test_release_task_refs` leaked one /// `CountedObject` per race). pub finalize_ran: Cell, + /// The stable C "inline body" this instance owns once it has crossed + /// into a C extension that reads its fields at fixed `tp_basicsize` + /// offsets (RFC 0045, wave 3). `0` for the overwhelmingly common case + /// (pure-Python instances, and C instances that store state in + /// `__dict__`). Freed exactly once, in [`PyInstance`]'s `Drop`, via + /// the [`register_instance_body_free`] hook. + pub c_body: CBody, } impl PyInstance { @@ -854,6 +941,7 @@ impl PyInstance { slots: RefCell::new(None), hash_cache: Cell::new(None), finalize_ran: Cell::new(false), + c_body: CBody::default(), } } @@ -868,6 +956,7 @@ impl PyInstance { slots: RefCell::new(None), hash_cache: Cell::new(None), finalize_ran: Cell::new(false), + c_body: CBody::default(), } } @@ -940,6 +1029,20 @@ impl Drop for PyInstance { /// a shallow copy that shares the dying instance's `__dict__`/slots/native /// value onto the VM's pending-finalizer queue so `__del__` still runs. fn drop(&mut self) { + // RFC 0045 (wave 3): release the faithful C inline body this + // instance owns, if it ever crossed into a C extension that reads + // its fields at fixed `tp_basicsize` offsets. Runs before the + // finalizer net (and its early returns) so the body is freed + // exactly once regardless of `__del__` state. Inert (one `Cell` + // read) for every instance that never grew a body — i.e. all + // pure-Python instances and all dict-backed C instances. + let body = self.c_body.get(); + if body != 0 { + self.c_body.set(0); + if let Some(free) = INSTANCE_BODY_FREE.get() { + free(body); + } + } // Already finalized (cascade/GC/this net's resurrected copy): the // common case for finalizable instances and the *only* path for the // overwhelmingly common finalizer-free instance — a single `Cell` @@ -967,6 +1070,9 @@ impl Drop for PyInstance { slots: RefCell::new(self.slots.borrow().clone()), hash_cache: Cell::new(self.hash_cache.get()), finalize_ran: Cell::new(true), + // The resurrected copy is a distinct object that owns no C + // body (the dying `self` already freed its own above). + c_body: CBody::default(), })); crate::vm_singletons::try_push_pending_finalizer(resurrected); } diff --git a/crates/weavepy-vm/src/vm_singletons.rs b/crates/weavepy-vm/src/vm_singletons.rs index 4952f81..59072ba 100644 --- a/crates/weavepy-vm/src/vm_singletons.rs +++ b/crates/weavepy-vm/src/vm_singletons.rs @@ -128,6 +128,36 @@ pub fn ellipsis() -> Object { .clone() } +/// `True` if `obj` is the canonical `Ellipsis` singleton — an instance of +/// the registry `ellipsis` type. Keyed on the type identity (there is only +/// ever one instance of it), mirroring `Object::repr`'s detection. The +/// C-API bridge uses this to hand stock extensions the static +/// `_Py_EllipsisObject` so code that tests `x == Py_Ellipsis` by pointer +/// (numpy's `prepare_index`) takes the right branch rather than rejecting a +/// freshly-boxed proxy with "only integers, slices … are valid indices". +pub fn is_ellipsis(obj: &Object) -> bool { + if let Object::Instance(inst) = obj { + return Rc::ptr_eq( + &inst.cls(), + &crate::builtin_types::builtin_types().ellipsis_, + ); + } + false +} + +/// `True` if `obj` is the canonical `NotImplemented` singleton. The C-API +/// bridge maps it to the static `_Py_NotImplementedStruct` so extensions +/// that compare against `Py_NotImplemented` by pointer behave correctly. +pub fn is_not_implemented(obj: &Object) -> bool { + if let Object::Instance(inst) = obj { + return Rc::ptr_eq( + &inst.cls(), + &crate::builtin_types::builtin_types().not_implemented_type_, + ); + } + false +} + /// CPython's `help`/`copyright`/`license`/`credits` builtins are /// `_Printer` instances: `repr(copyright)` returns the body, but /// `copyright()` also prints it. We model them as diff --git a/crates/weavepy-vm/src/weakref_registry.rs b/crates/weavepy-vm/src/weakref_registry.rs index a9675db..9f7b50e 100644 --- a/crates/weavepy-vm/src/weakref_registry.rs +++ b/crates/weavepy-vm/src/weakref_registry.rs @@ -408,6 +408,8 @@ pub fn id_of(obj: &Object) -> ObjectId { Object::DictView(v) => Rc::as_ptr(v) as usize as u64, Object::SimpleNamespace(d) => Rc::as_ptr(d) as usize as u64, Object::LazyIter(l) => Rc::as_ptr(l) as usize as u64, + Object::Capsule(c) => Rc::as_ptr(c) as usize as u64, + Object::Foreign(s) => s.ptr as u64, Object::Long(b) => Rc::as_ptr(b) as usize as u64, Object::Complex(c) => Rc::as_ptr(c) as usize as u64, } diff --git a/crates/weavepy/tests/fixtures/run/96_rfc0023_drop_in_parity.out b/crates/weavepy/tests/fixtures/run/96_rfc0023_drop_in_parity.out index 0b793d6..2db7c0f 100644 --- a/crates/weavepy/tests/fixtures/run/96_rfc0023_drop_in_parity.out +++ b/crates/weavepy/tests/fixtures/run/96_rfc0023_drop_in_parity.out @@ -42,5 +42,5 @@ True === sys.implementation === weavepy 3 -alias name: GenericAlias +alias name: TypeAliasType ok diff --git a/docs/rfcs/0043-cpython-binary-abi.md b/docs/rfcs/0043-cpython-binary-abi.md new file mode 100644 index 0000000..539e816 --- /dev/null +++ b/docs/rfcs/0043-cpython-binary-abi.md @@ -0,0 +1,491 @@ +# RFC 0043: CPython 3.13 binary-ABI compatibility (cpyext) - wave 1: the layout-faithful object bridge and stock-extension loading + +- **Status**: Accepted +- **Authors**: WeavePy authors +- **Created**: 2026-06-27 +- **Tracking issue**: TBD +- **Builds on**: RFC 0022 (the C-API foundation - `Python.h`, the `dlopen` + loader, the `PyObject` handle bridge), RFC 0028 (PEP 3118 buffer protocol, + PEP 590 vectorcall, the `PyType_FromSpec[WithBases]` slot surface), RFC 0029 + (the numpy-grade end-to-end path - PEP 451 import machinery, + `ExtensionFileLoader`, the ~120-symbol C-API tail, `_minipip` binary-wheel + install, the in-tree `_numpylike.c` fixture). + +## Summary + +Today WeavePy can `dlopen` and run C extensions, but only ones **recompiled +against WeavePy's own `Python.h`**. That header (RFC 0022) deliberately tracks +CPython's `Py_LIMITED_API` *shape* and routes every value access - even +`Py_INCREF` - through a *function call* into the host, because a WeavePy object +is a Rust `Object` enum (`Float(f64)`, `Long(Rc)`, `Str(Rc)`, +`Tuple(Rc<[Object]>)`, ...) with **none of CPython's struct fields at the +offsets a stock header poke**. + +A *stock* PyPI wheel (`numpy-*-cp313-cp313-macosx_11_0_arm64.whl`) is the +opposite: it was compiled against CPython's real headers, so it contains +**inlined macros that read struct fields at fixed offsets** - +`Py_INCREF`/`Py_DECREF` poking `ob_refcnt`, `Py_TYPE`/`Py_SIZE`, +`PyFloat_AS_DOUBLE`, `PyList_GET_ITEM`, `PyTuple_GET_ITEM`, +`PyBytes_AS_STRING`, the PEP 393 compact-string `PyUnicode_DATA`/`KIND`, and +direct reads of `PyTypeObject` slots. Those macros read WeavePy's Rust payload +as if it were a CPython struct and get garbage. + +This RFC opens the **binary-ABI** (cpyext-style) effort: make WeavePy's host +binary export a C-API that is **byte-for-byte and behaviourally faithful to +CPython 3.13's full (non-limited) ABI**, so that *unmodified* stock extensions +load and run. Because a single process exports exactly one set of `Py*` +symbols, this is necessarily a **conversion** of WeavePy's exported surface, not +an additive mode - and it is large enough to be sequenced across several waves. + +**Wave 1 (this commit)** lands the foundation and proves the thesis +hermetically: + +1. **Faithful concrete-object layouts.** Byte-exact CPython 3.13 structs for the + high-frequency types whose internals get inlined: `PyVarObject`, + `PyFloatObject`, `PyLongObject` (the 3.12+ `_PyLongValue` `lv_tag` + 30-bit + `ob_digit` form), `PyBytesObject`, `PyTupleObject`, `PyListObject`, and the + PEP 393 `PyASCIIObject`/`PyCompactUnicodeObject`/`PyUnicodeObject` compact + string forms - plus a full-layout `PyTypeObject`/`PyHeapTypeObject` and the + method-suite structs. +2. **The object mirror bridge.** A cpyext-style identity map between a native + `Object` and a heap-allocated **mirror** whose memory is laid out exactly + like the corresponding CPython struct. When a value crosses into C it is + mirrored (and cached); when C hands a pointer back, the mirror is resolved to + its native `Object`. Mutations sync across the boundary. The bridge owns the + refcount<->lifetime contract. +3. **Refcount/immortality fidelity.** The immortal-refcount sentinel switches to + CPython 3.13's "low 32 bits set" form (`_Py_IMMORTAL_REFCNT`), so a stock + *inlined* `Py_INCREF`/`Py_DECREF` (which special-cases immortals by testing + the low half-word) treats WeavePy singletons/static types correctly. +4. **Stock-header symbol surface.** The high-frequency core of the full C-API + (module init, arg parsing, the `PyFloat_*`/`PyLong_*`/`PyList_*`/`PyTuple_*`/ + `PyBytes_*`/`PyUnicode_*` constructors and accessors) exported with + CPython-faithful signatures and semantics over the mirror bridge. +5. **A hermetic proof.** A C extension compiled against the host's **stock + CPython 3.13 headers** (full API, real inlined macros, a static + `PyModuleDef`/`PyMethodDef`, returning and consuming the core types) is + `dlopen`ed into WeavePy and exercised end-to-end - the first time a stock + ABI artifact (rather than a WeavePy-header artifact) runs under WeavePy. +6. **Loader/installer recognition.** The extension loader and `_minipip` accept + the stock `cp313-cp313`/`abi3` wheel tags (not just `weavepy-cp313`), so a + stock binary wheel resolves to the matching `.so` and is handed to the + faithful loader. + +What wave 1 does **not** do: import real numpy. numpy's `_multiarray_umath` +links a large, partly-private slice of the full C-API plus the array-object +C-API capsule; that surface is sequenced into waves 2-5 (see Roadmap). Wave 1's +acceptance bar is "a stock-compiled extension that uses the core object/type/ +module surface loads and runs, proven by an in-tree fixture and a bundled +regrtest", with the whole workspace `build`/`fmt`/`clippy`/regrtest green. + +## Motivation + +The README's promise is to "run existing Python code, packages, tools, and +workflows unchanged." For *pure-Python* packages WeavePy already delivers +(RFC 0030's `pip`/`numpy` facade/`pytest`). For *native* packages the story +stops at "recompile against WeavePy's `Python.h`" (RFC 0022/0028/0029). But the +binary wheels users actually `pip install` - numpy, pandas, pillow, lxml, +cryptography, pydantic-core - are shipped **pre-compiled against stock CPython**. +Loading them unchanged is the single highest-ceiling drop-in lever (named as +such in RFC 0041's Future work), and it is the one capability gap that no +amount of frozen-Python porting can close. + +The reason it is hard - and why it is its own multi-wave arc - is the +**representation gap**. CPython's extensions are not merely *callers* of an API; +they are *readers of memory*. A decade of CPython headers inline the hot path: + +```c +/* stock CPython 3.13, floatobject.h */ +static inline double PyFloat_AS_DOUBLE(PyObject *op) { + return _PyFloat_CAST(op)->ob_fval; /* reads *(double*)(op+16) */ +} +/* stock CPython 3.13, listobject.h */ +#define PyList_GET_ITEM(op, i) (_PyList_CAST(op)->ob_item[i]) +/* stock CPython 3.13, object.h */ +static inline void Py_INCREF(PyObject *op) { + if (_Py_IsImmortal(op)) return; /* tests op->ob_refcnt low half */ + op->ob_refcnt++; +} +``` + +WeavePy's value for a float is `Object::Float(f64)` inside a `PayloadCell` that +begins *after* the 16-byte object head - so `*(double*)(op+16)` reads the first +8 bytes of a Rust enum, not the IEEE-754 double. There is no way to satisfy +these inlined readers except to **make the memory at those offsets be what +CPython says it is**. That is precisely what PyPy's `cpyext` and GraalPy's +C-API layer do: they maintain a parallel, layout-faithful "mirror" of each +object that has crossed into C, and keep it coherent with the runtime's native +object. + +This RFC commits WeavePy to that model and lands its load-bearing core. + +## The central problem, precisely + +Three sub-problems, each of which wave 1 must address for the core types: + +1. **Field layout.** For every concrete type whose header inlines field access, + the mirror's bytes must match CPython 3.13 exactly: offsets, sizes, + bit-field packing (the PEP 393 `state` word), and the variable-length tail + (`ob_item[]`, `ob_digit[]`, `ob_sval[]`, the inline character buffer). + +2. **Coherence + lifetime.** A mirror and its native `Object` must stay in sync + for as long as C holds a reference. Immutable scalars (float/int/bytes/str) + are filled once at mirror time. Mutable containers (list) need write-through + on `PyList_SET_ITEM`/`PyList_Append`. The C-side `ob_refcnt` governs when the + mirror (and the native reference it pins) may be released. + +3. **Identity.** `x is y` in Python must remain true after a round trip through + C, and `Py_TYPE(op)` must return the *same* `PyTypeObject*` the extension + compares against (`Py_TYPE(op) == &PyFloat_Type`). The bridge therefore keys + mirrors by native identity where identity is observable, and exports the + built-in type objects as faithful statics. + +## CPython reference + +Wave 1 matches **CPython 3.13** as installed on the build host +(`python3.13`, 3.13.x) and cross-checked against the vendored +`vendor/cpython/` tree. The authoritative layouts: + +- `Include/object.h` - `PyObject`, `PyVarObject`, `_Py_IMMORTAL_REFCNT` + (`UINT_MAX` on 64-bit), the `Py_INCREF`/`Py_DECREF`/`Py_SIZE`/`Py_TYPE` + inline forms. +- `Include/cpython/object.h` - the full `PyTypeObject` field order through + `tp_versions_used`, and `PyHeapTypeObject` (method suites + `ht_name`/ + `ht_qualname`/`ht_module`/`_ht_tpname`/`_spec_cache`). +- `Include/floatobject.h` - `PyFloatObject { PyObject_HEAD; double ob_fval; }`. +- `Include/cpython/longintrepr.h` - `_PyLongValue { uintptr_t lv_tag; digit + ob_digit[1]; }` with 30-bit digits; `lv_tag` packs `ndigits << 3` with sign + in the low 2 bits (0 positive, 1 zero, 2 negative). +- `Include/cpython/tupleobject.h` / `Include/cpython/listobject.h` - + `PyTupleObject { PyObject_VAR_HEAD; PyObject *ob_item[1]; }`, + `PyListObject { PyObject_VAR_HEAD; PyObject **ob_item; Py_ssize_t allocated; }`. +- `Include/cpython/bytesobject.h` - `PyBytesObject { PyObject_VAR_HEAD; + Py_hash_t ob_shash; char ob_sval[1]; }`. +- `Include/cpython/unicodeobject.h` - the PEP 393 `PyASCIIObject` / + `PyCompactUnicodeObject` / `PyUnicodeObject` forms and the `state` bit-field + (`interned:2, kind:3, compact:1, ascii:1, statically_allocated:1`). +- `Include/methodobject.h`, `Include/moduleobject.h` - `PyMethodDef`, + `PyModuleDef`/`PyModuleDef_Base` (already faithful in WeavePy's header). +- PEP 3123 (standard layout for `PyObject`), PEP 393 (flexible string repr), + PEP 683 (immortal objects). + +Explicit non-references (out of scope for the binary ABI, here and later): +PEP 703 free-threading (`Py_GIL_DISABLED` layouts), the `Py_TRACE_REFS` debug +head, and Windows `.pyd` loading. + +## Current baseline (measured starting point) + +- `cargo build --workspace` is green. +- Bundled `tests/regrtest/` suite is `--check` clean; the CPython `Lib/test/` + allowlist sweep stands at the RFC 0042 numbers (180 pass / 32 fail / 13 skip / + 2 timeout over 227 tracked, 1 known pre-existing flake). +- `weavepy-capi` exports the `Py_LIMITED_API`-shaped surface: `PyObject`/ + `PyVarObject` heads are faithful, but `PyTypeObject` is a *subset* (head then + `tp_name`/`tp_basicsize`/`tp_itemsize`/`tp_flags`/`tp_slots`/`bridge`), the + concrete object structs are **not** exposed (payload is a Rust `Object`), and + every accessor macro is a function call. +- Extensions are built against WeavePy's `include/Python.h`; the proof fixtures + (`_smalltest.c`, `_ndarray.c`, `_numpylike.c`) all use that header. **No + artifact compiled against stock CPython headers has ever been loaded.** +- `IMMORTAL_REFCNT = (isize::MAX/2) - 1` - whose low 32 bits are `0xFFFF_FFFE`, + *not* CPython's `0xFFFF_FFFF`, so a stock inlined immortality check would + misclassify WeavePy statics. + +## Roadmap (the multi-wave arc) + +D1 is large; it is sequenced so each wave is independently green and +fixture-proven: + +- **Wave 1 (this RFC).** Faithful core object/type/module layouts; the mirror + bridge for scalars + the core containers; immortality fidelity; the + high-frequency symbol core; a stock-headers proof extension; loader/installer + tag recognition. +- **Wave 2** (detailed in **RFC 0044**, *Binary ABI: the type suite*). The full + type-suite round trip: `PyNumberMethods`/`PySequenceMethods`/`PyMappingMethods`/ + `PyAsyncMethods`/`PyBufferProcs` read from stock static types and heap types; + descriptor (`tp_descr_get/set`), `tp_call`, `tp_iter`/`tp_iternext`, + `tp_richcompare` dispatch from the faithful slots; the classic static + `PyTypeObject` + `PyType_Ready` finalisation path; GC integration + (`tp_traverse`/`tp_clear` participating in WeavePy's cycle collector). Landed + and fixture-proven by `_stocktype` - see RFC 0044 for the design and measured + outcome. +- **Wave 3** (detailed in **RFC 0045**, *Inline instance storage + the numpy + array C-API surface*). The faithful **inline `tp_basicsize` instance storage** + wave 2 deferred (a stock type reading `self->field` at a fixed offset in its + own instance block - the `PyArrayObject` shape), real `tp_members` at those + offsets, plus the numpy *C-API surface* that rides on it: the array interchange + protocols (`__array_struct__`/`__array_interface__`) and the array-C-API import + *capsule* pattern (`import_array()` -> + `PyCapsule_Import(...._ARRAY_API)` -> a `void **` table). Landed and + fixture-proven by `_stockarray` - see RFC 0045 for the design and measured + outcome. (Real numpy from source, the full ufunc-loop machinery, and the + complete private `_multiarray_umath` symbol tail are wave 4.) +- **Wave 4** (detailed in **RFC 0046**, *Real numpy from source against the + faithful host ABI*). Build **real numpy** from source against the + now-faithful host ABI; gate CI on + `import numpy; numpy.zeros((3,3)) @ numpy.ones((3,3))`. Landed against stock + **numpy 2.5.0** with `import numpy`'s self-checks live and unpatched: the + discovered `_multiarray_umath` C-API leaf tail (`wave4.rs` + the C-variadic + members), plus the faithfulness hardening real numpy surfaced that the + wave-3 fixture had not (builtin-subclass scalar `tp_new`, `np._NoValue` + pointer identity, the foreign C truthiness protocol, the inlined-`Py_DECREF` + lifecycle interaction, a foreign double-free, foreign subscript dispatch, + and foreign `repr`/`str` slot dispatch). See RFC 0046 for the design and + measured outcome. +- **Wave 5** (detailed in **RFC 0047**, *The Cython-generated extension + surface (pandas), faithful `inherit_slots`, and the manylinux/macOS/ + musllinux wheel matrix*). The full-API, heavily-macro'd **Cython** idiom + pandas (and most of the wheel ecosystem) is built on, whose defining move + is reading type slots **directly off the C struct** + (`Py_TYPE(self)->tp_as_number->nb_add`) on subclasses it defines. Landed + as: faithful **`inherit_slots`** baked into `PyType_Ready` (the wave-4 + deferred item - every inherited `tp_*` slot and method-suite entry copied + down into the subtype's faithful struct + decoded table), the discovered + Cython C-API leaf tail (`wave5.rs`), a real vectorcall + `PY_VECTORCALL_ARGUMENTS_OFFSET` decoder fix every Cython method-call + shim exposed, and the **musllinux** + binary-wheel **provenance** wheel + matrix. Fixture-proven by `_stockcython` (an extension that subclasses an + extension-defined base and reads the inherited slots off `Py_TYPE(self)`). + See RFC 0047 for the design and measured outcome. + +## Detailed design (wave 1) + +Six workstreams, in dependency order. Line-count estimates include Rust, the C +fixture, the faithful header work, and tests. + +### WS1 - Faithful layouts + a CPython-faithful header path (~3K LOC) + +A new `layout` module in `weavepy-capi` defines `#[repr(C)]` Rust structs that +are byte-identical to CPython 3.13's, with compile-time +`const _: () = assert!(size_of/offset_of ...)` guards pinned to the values read +out of the host's stock headers (so a CPython point-release layout drift fails +the build loudly rather than silently corrupting memory): + +- `PyVarObject` (head + `ob_size`), and the immortal sentinel constant moved to + the CPython form. +- `PyFloatObject`, `PyLongObject` + `_PyLongValue`, `PyComplexObject`, + `PyBytesObject`, `PyByteArrayObject`, `PyTupleObject`, `PyListObject`, + `PyASCIIObject`/`PyCompactUnicodeObject`/`PyUnicodeObject`. +- The full `PyTypeObject` (all slots through `tp_versions_used`) and + `PyHeapTypeObject`, plus `PyNumberMethods`/`PySequenceMethods`/ + `PyMappingMethods`/`PyAsyncMethods`/`PyBufferProcs` (defined faithfully now; + *dispatched* from in wave 2). + +The header strategy: rather than maintain a hand-written faithful `Python.h` +(thousands of lines that must track CPython exactly), wave 1 makes the proof +extension build against the **host's own stock CPython 3.13 headers** and has +the host satisfy the symbols + layouts. WeavePy's `include/Python.h` is kept for +the existing WeavePy-header fixtures during the transition and is migrated +field-by-field to the faithful layout (the `PyTypeObject` widening is the first +step). A `build.rs` probe records the stock include dir +(`python3.13 -c "import sysconfig; print(sysconfig.get_path('include'))"`) when +present, gated so a host without CPython 3.13 simply skips the stock-headers +fixture (the WeavePy-header fixtures still run). + +### WS2 - The object mirror bridge (~4K LOC) + +The heart of the wave. A `mirror` module owns the bidirectional bridge: + +- **`Object -> *mut PyObject` (mirror-out).** Given a native `Object`, return a + pointer to a layout-faithful box. For immutable scalars the box is filled + once: `Float -> PyFloatObject{ob_fval}`, `Int`/`Long -> PyLongObject` (encode + i64/BigInt into the `lv_tag` + 30-bit `ob_digit[]` tail), + `Bytes -> PyBytesObject{ob_size, ob_shash, ob_sval[]}`, + `Str -> PyUnicode*` (choose ASCII/UCS1/UCS2/UCS4 by max code point; fill the + `state` bit-field + inline character buffer; lazily populate the `utf8` + cache on `PyUnicode_AsUTF8`). For `Tuple`/`List`, the `ob_item[]` slots are + themselves mirrors, produced lazily. +- **`*mut PyObject -> Object` (mirror-in).** Resolve a pointer the extension + hands back. Pointers WeavePy minted carry a back-reference to their native + `Object` (stored in a header *prefix* immediately before the faithful + `PyObject`, at a negative offset, so the public pointer stays byte-faithful); + pointers the extension *created* (via `PyFloat_FromDouble` etc., which WeavePy + implements, so they are also WeavePy mirrors) resolve the same way. A + process-wide identity map (`PyObject* -> Object`) preserves `is` identity for + mirrored containers and types. +- **Lifetime.** The mirror prefix holds the owning `Object` (an `Rc` clone), so + while C holds a reference the native value is pinned. `Py_DecRef` to zero + drops the prefix (and its `Rc`), running any registered destructor. Immortal + mirrors (singletons, static types) never free. +- **Coherence.** Mutating container ops implemented in C + (`PyList_SET_ITEM`/`PyList_Append`/`PyList_SetSlice`) write through to the + native `List` store; read ops (`PyList_GET_ITEM`) return the cached element + mirror. (The general mutable-after-share case for arbitrary slot writes is a + wave-2 concern; wave 1 covers the constructor-then-fill pattern stock + extensions use to *build* return values, which is the dominant case.) + +### WS3 - Refcount + immortality fidelity (~0.5K LOC) + +Move `IMMORTAL_REFCNT` to CPython 3.13's `_Py_IMMORTAL_REFCNT` (low 32 bits set, +i.e. `0xFFFF_FFFF` as a `Py_ssize_t` on 64-bit), and make `Py_IncRef`/`Py_DecRef` +match CPython's saturating-immortal semantics so a *stock inlined* refcount op +(which the host can't intercept) and the *function-call* form agree on the same +object. Audit the singleton/static initialisers (`_Py_NoneStruct`, +`_Py_TrueStruct`, the static type table) to the new sentinel. + +### WS4 - High-frequency symbol surface over the bridge (~3K LOC) + +Re-implement the load-bearing constructors/accessors so they speak the faithful +layout: `PyFloat_FromDouble`/`PyFloat_AsDouble`, `PyLong_FromLong`/ +`FromLongLong`/`FromSsize_t`/`FromSize_t`/`AsLong`/`AsLongLong`/`AsSsize_t`, +`PyBool_FromLong`, `PyBytes_FromStringAndSize`/`AsString`/`Size`, +`PyUnicode_FromStringAndSize`/`FromString`/`AsUTF8AndSize`/`GetLength`, +`PyTuple_New`/`Pack`/`GetItem`/`SetItem`/`Size`, `PyList_New`/`Append`/ +`GetItem`/`SetItem`/`Size`, `PyModule_Create2`/`AddObject`/`AddIntConstant`/ +`AddStringConstant`, and the `PyArg_ParseTuple`/`Py_BuildValue` variadic core +(the existing `varargs.c` shim, re-pointed at the faithful constructors). Each +gets a faithful-layout unit test. + +### WS5 - The stock-headers proof extension + loader path (~1.5K LOC) + +- **`tests/capi_ext/_stockabi.c`** - a C extension authored to compile against + **stock CPython 3.13 headers** (no WeavePy header), using a static + `PyModuleDef`/`PyMethodDef`, `Py_RETURN_NONE`, the inlined macros + (`PyFloat_AS_DOUBLE`, `PyList_GET_ITEM`, `Py_SIZE`, `Py_TYPE` comparisons + against `&PyFloat_Type`/`&PyLong_Type`), and round-tripping every core type + through a function (`roundtrip`, `list_sum`, `make_pair`, `echo_str`, + `alloc_free_cycle`). +- **`build.rs`** compiles it with `cc -I$(python3.13 include)` when CPython 3.13 + is present, emitting an env var the test reads; absent CPython 3.13, the + fixture is skipped (CI on a bare host still passes). +- **Loader.** `weavepy-capi::loader` already resolves `PyInit_` and runs + it under an `ActiveContext`; wave 1 confirms the returned faithful + `PyModule_Create2` object bridges back correctly and the module's functions + are callable from WeavePy. +- **`_minipip`/`ext_loader`.** Accept the stock `cp313-cp313-` and + `cp313-abi3-` wheel tags (in addition to `weavepy-cp313-*`), so a stock + binary wheel's `.so` is discovered and handed to the loader. (Resolving every + symbol a *real* numpy wheel needs is waves 3-5; wave 1 wires the path and + proves it on the core surface.) + +### WS6 - Fixtures, integration tests, measured baseline (~1K LOC) + +- `crates/weavepy-capi/tests/capi_stockabi.rs` - Rust integration tests that + `dlopen` the stock-headers `.so` and assert the round trips (gated on the + CPython-3.13 env var; skips cleanly when the host has no `python3.13` + headers so a bare CI host still passes). **This is the delivered proof + harness** — 9 cases covering inlined reads, type identity, the function API, + C-side dealloc, and module init. +- A measured `expectations.toml` pass: wave 1 is C-API-only infrastructure, so + no CPython `Lib/test` row flips and the sweep stays unchanged. _(A bundled + Python-level `test_stock_abi_smoke.py` that imports the extension through the + regrtest subprocess is deferred — it needs the loader on a subprocess import + path, which is orthogonal to the ABI thesis the Rust harness already proves.)_ + +## Measured targets + +The commit-acceptance bar for wave 1: + +- A stock-CPython-3.13-headers extension (`_stockabi`) loads via `dlopen` and + its functions run correctly under WeavePy - proven by `capi_stockabi.rs`. +- The faithful layouts carry compile-time size/offset assertions against the + values in the host's stock headers. +- The existing WeavePy-header fixtures (`_smalltest`/`_ndarray`/`_numpylike`) + and their tests stay green through the `PyTypeObject` widening + sentinel + change. +- `cargo build --workspace`, `cargo fmt --check`, and + `cargo clippy --workspace --all-targets -- -D warnings` are green; the + regrtest sweep stays `--check` clean. + +## Measured outcome + +_As-landed (wave 1):_ + +- **Thesis proven.** `tests/capi_ext/_stockabi.c` is compiled by `build.rs` + against the host's **stock CPython 3.13 headers** (resolved via + `sysconfig.get_path('include')`), then `dlopen`ed and driven by + `crates/weavepy-capi/tests/capi_stockabi.rs`. All 9 cases pass: + - `inlined_float_read` — stock `PyFloat_AS_DOUBLE` (an inlined + `*(double*)((char*)op + 16)`) reads a WeavePy float mirror. + - `inlined_size_read` / `inlined_tuple_item_read` — inlined `Py_SIZE` + and `PyTuple_GET_ITEM` read `ob_size`/`ob_item[]` at the right offsets. + - `type_identity` — `Py_TYPE(o) == &PyFloat_Type` holds across the boundary. + - `roundtrip_incref` — inlined `Py_INCREF`/`Py_DECREF` poke the head refcount. + - `function_api` — `PyArg_ParseTuple`, the `Py*_From*` constructors, and + `Py_BuildValue` work. + - `c_side_dealloc` — a C-side `Py_DECREF`→0 reaches the external `_Py_Dealloc`, + which reads `Py_TYPE(op)->tp_dealloc` at offset 48 and frees the mirror. + - `module_loads_with_constants` — a stock `PyModuleDef`/`PyMethodDef` + initialises and its module-level constants resolve. +- **No regression.** The full `weavepy-capi` suite is green (77 tests incl. the + 9 above), as are the WeavePy-header fixtures (`capi_loader`, `capi_ndarray`, + `capi_wheel_endtoend`) and the `weavepy` behavioural `fixtures` harness, + through the `PyTypeObject` widening (now 416 bytes, `tp_flags: u64`) and the + `_Py_IMMORTAL_REFCNT = 0xFFFF_FFFF` sentinel change. +- **Clean gates.** `cargo build --workspace`, `cargo fmt`, and + `cargo clippy -p weavepy-capi --all-targets` are green. +- **Regrtest unaffected.** The sweep is unchanged by this C-API-only work; the + one observed divergence (`test_list::test_deopt_from_append_list`) reproduces + identically with the changes stashed — it is a pre-existing `weavepy-cli` + `-I -c` subprocess-isolation gap, not a binary-ABI regression, and is left for + a separate CLI fix rather than a baseline rewrite. + +## Non-goals / Drawbacks + +- **Real numpy/pandas do not import in wave 1.** Their C-API dependency surface + (much of it private/internal) is sequenced into waves 3-5. Wave 1's claim is + narrowly "a stock-compiled extension using the core object/type/module surface + runs", not "the headline wheels work". +- **The mirror layer has a cost.** Crossing an object into C now allocates (or + looks up) a faithful mirror; hot extension loops pay for it. CPython pays + zero here because its objects *are* the structs. Optimising the mirror + (caching, arena allocation, avoiding round trips) is deferred; correctness + first. +- **Mutable-aliasing coherence is partial in wave 1.** The constructor-then-fill + build pattern is covered; arbitrary in-place mutation of a shared object via + raw slot writes from C, concurrently observed from Python, is a wave-2 + coherence task. +- **rustls/OpenSSL-style "we are not CPython" gaps remain.** Anything that + reads CPython *internal* (`pycore_*`) structures, or assumes the exact + bytecode/`PyFrameObject`/`PyCodeObject` internals, is out of scope - those are + not part of the documented extension ABI. +- **Free-threading and Windows are out of scope.** `Py_GIL_DISABLED` changes the + object head; the `.pyd` loader path is unchanged from RFC 0022. +- **One ABI per binary.** Because the conversion replaces the exported surface, + WeavePy-header extensions and stock extensions must agree on the faithful + layout once the migration completes; during wave 1 both are kept green by + widening the shared structs rather than forking the ABI. + +## Alternatives + +1. **Stay limited-API-only and require recompilation (status quo).** Lowest + effort, but it can never load the pre-built wheels users actually install - + the entire point of D1. Rejected by the option choice. +2. **abi3 / stable-ABI only.** Far smaller surface (opaque handles, no struct + pokes beyond the head), and real abi3 wheels exist (PyO3/maturin). But it + excludes numpy/pandas (which do not ship abi3) - i.e. the headline targets. + This was offered as scope option D3 and not chosen; its symbol surface is a + strict subset of the faithful ABI, so wave 1's core also advances it. +3. **Translate/patch wheels at install time.** Rewriting a wheel's machine code + to call functions instead of inlining is a non-starter (it would be a JIT + recompiler for arbitrary native code). +4. **Emulate via a shadow heap only at call boundaries (pure cpyext).** This is + essentially the chosen design; the only WeavePy-specific twist is the + negative-offset native back-reference prefix, which keeps the public + `PyObject*` byte-faithful while letting the host resolve a pointer to its + native `Object` in O(1) without a global lookup on the hot path. + +## Prior art + +- **PyPy `cpyext`.** The canonical "non-CPython runtime hosts CPython native + extensions" layer. Its `to_cpyext`/`from_ref` object-mirror with an + identity map is the direct model for WS2; its per-type "attach" handlers map + onto our per-`Object`-variant mirror-out. +- **GraalPy's C-API (`Sulong`/native).** Maintains native mirror structs for + CPython objects with a managed back-reference; validates the + "faithful-layout mirror + identity map" approach at scale. +- **`pythoncapi-compat`** and CPython's own `Include/cpython/*.h` - the + authoritative layouts the wave-1 structs are pinned to. +- **RFC 0022/0028/0029** - the in-tree foundation (loader, `PyType_FromSpec`, + the import machinery, `_minipip`) this wave converts and builds on. + +## Future work + +- Waves 2-5 above (full type-suite dispatch, GC integration, the numpy C-API, + real numpy from source, pandas/Cython). +- Mirror-layer performance (arena allocation, cache, fewer round trips). +- A faithful vendored `Python.h` (or adopting CPython's headers wholesale) once + the exported ABI is fully migrated, so extensions need neither WeavePy's + header nor a separately-installed CPython. +- Windows `.pyd` and `Py_GIL_DISABLED` layouts. + diff --git a/docs/rfcs/0044-binary-abi-type-suite.md b/docs/rfcs/0044-binary-abi-type-suite.md new file mode 100644 index 0000000..d2b7327 --- /dev/null +++ b/docs/rfcs/0044-binary-abi-type-suite.md @@ -0,0 +1,461 @@ +# RFC 0044: CPython 3.13 binary-ABI compatibility (cpyext) - wave 2: the full type-suite round trip + GC integration + +- **Author**: WeavePy core +- **Status**: Accepted +- **Part of**: the D1 binary-ABI arc whose roadmap lives in + [RFC 0043](0043-cpython-binary-abi.md). RFC 0043 is the umbrella/roadmap + RFC; this is the detailed-design RFC for **wave 2**. +- **Builds on**: RFC 0043 (wave 1 - the layout-faithful object mirror, the + byte-faithful `PyTypeObject`, the immortal-refcount sentinel, the + stock-headers proof harness), RFC 0028 (the `PyType_FromSpec` slot surface, + the `SlotTable`, the `dunder_shim` bridge, vectorcall, PEP 3118 buffers), + RFC 0024 (the generational tracing cycle collector). + +## Summary + +Wave 1 made WeavePy *objects* byte-faithful so a stock extension's **inlined +field reads** (`PyFloat_AS_DOUBLE`, `Py_SIZE`, `PyTuple_GET_ITEM`) land on real +CPython-shaped memory. That is the *data* direction. Wave 2 lands the +*behaviour* direction: when Python code drives an object whose type was +**defined by a stock extension**, WeavePy must read the type's method suites and +`tp_*` slots at their faithful offsets and *call into the extension*. + +The load-bearing discovery that scopes this wave: **`PyType_Ready` is currently +a no-op**, and the method-suite structs (`PyNumberMethods`, …) are modelled as +opaque byte blobs. So the single most common way a real C extension defines a +type - a statically-initialised `PyTypeObject` with `tp_as_number = &my_number`, +`tp_call = …`, `tp_richcompare = …`, then `PyType_Ready(&MyType)` - does +*nothing* on WeavePy today: no bridge, no dispatch, no instantiation. Every +in-tree fixture sidesteps this by using `PyType_FromSpec` (the `PyType_Slot[]` +array path RFC 0028 built); stock wheels overwhelmingly do **not**. + +Wave 2 therefore: + +1. **Spells out the five method suites** (`PyNumberMethods` / `PySequenceMethods` + / `PyMappingMethods` / `PyAsyncMethods` / `PyBufferProcs`) field-by-field, + byte-faithful and offset-asserted against the host's stock 3.13 headers. +2. **Makes `PyType_Ready` real**: it harvests a faithfully-laid-out + `PyTypeObject` plus its method suites into the same `SlotTable` → + `dunder_shim` → `Rc` machinery that `PyType_FromSpec` already + uses, then bridges and registers the type. The two type-definition styles + converge on one finalisation path. +3. **Fills the dispatch gaps** the shim layer didn't cover: descriptors + (`tp_descr_get`/`tp_descr_set` → `__get__`/`__set__`), the async suite + (`am_await`/`am_aiter`/`am_anext` → `__await__`/`__aiter__`/`__anext__`), + and `tp_new` → `__new__`. +4. **Integrates the GC**: a readied type that advertises `tp_traverse`/`tp_clear` + participates in WeavePy's generational cycle collector. + +The acceptance proof is a second hermetic fixture compiled against the **stock +CPython 3.13 headers** that defines its types the static-`PyTypeObject` way and +exercises numeric, sequence, mapping, comparison, call, iteration, descriptor, +and GC behaviour through WeavePy's dispatcher. + +Faithful *inline instance storage* (a stock type reading `self->field` at a +fixed offset in its own `tp_basicsize` block) is explicitly **out of scope** and +deferred to wave 3, where real numpy forces it; see Non-goals. + +## Motivation + +A C extension does two distinct things with the runtime: + +- It **reads object data** through inlined accessors. Wave 1 handled this with + the mirror: a WeavePy `float` crossing into C looks byte-for-byte like a + `PyFloatObject`. +- It **defines behaviour** - new types whose operators, call protocol, + iteration, comparison, and lifecycle are C functions the runtime must invoke + at the right moments. This is the half wave 2 lands. + +The behaviour half is what makes an extension a *first-class participant* rather +than a passive data producer. `numpy.ndarray.__add__`, `decimal.Decimal`'s +arithmetic, `datetime`'s comparisons, a Cython class's `__call__` - all of these +are C slots hanging off a type the extension defined. Until WeavePy reads those +slots and dispatches to them, `import numpy` can at best hand back arrays nobody +can compute with. + +RFC 0028 already built the dispatch *mechanism* for one definition style +(`PyType_FromSpec`): decode a `PyType_Slot[]` array into a `SlotTable`, then +synthesise `__add__`/`__call__`/… shims (`dunder_shim`) into the type dict so +the VM's ordinary dunder dispatch reaches the C function. That machinery is +sound and stays. What it lacks is a *source*: the other - and far more common - +definition style, where the slots live in a statically-initialised +`PyTypeObject` and its method-suite sub-structs, finalised by `PyType_Ready`. +Wave 2 adds that source and reuses the mechanism. + +## The central problem, precisely + +Consider the canonical stock type definition (verbatim from countless real +extensions and Cython output): + +```c +static PyNumberMethods Vec_as_number = { + .nb_add = Vec_add, /* offset 0 in PyNumberMethods */ + .nb_multiply = Vec_mul, /* offset 16 */ +}; +static PyTypeObject VecType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "vec.Vec", + .tp_basicsize = sizeof(VecObject), + .tp_as_number = &Vec_as_number, /* offset 96 in PyTypeObject */ + .tp_richcompare = Vec_richcompare,/* offset 200 */ + .tp_call = Vec_call, /* offset 128 */ + .tp_new = PyType_GenericNew, + .tp_init = Vec_init, +}; +/* in module init: */ +if (PyType_Ready(&VecType) < 0) return NULL; +PyModule_AddObject(m, "Vec", (PyObject *)&VecType); +``` + +For `Vec(1) + Vec(2)` to work, WeavePy must, at `PyType_Ready` time: + +1. Read `VecType.tp_as_number` (offset 96) → a `PyNumberMethods*`, then read + `nb_add` (offset 0 within it). The struct must be **spelled out** so offset + 96-then-0 resolves to `Vec_add` and not a misaligned blob byte. +2. Read the direct slots `tp_richcompare` (200), `tp_call` (128), `tp_init` + (296), `tp_new` (312), `tp_iter`/`tp_iternext`, `tp_getattro`/`tp_setattro`, + `tp_descr_get`/`tp_descr_set`, `tp_traverse`/`tp_clear`, `tp_methods`, + `tp_members`, `tp_getset`, `tp_doc`, `tp_base`. +3. Fold all of them into a `SlotTable`, build the bridged `Rc` with + the right base/MRO, synthesise the dunder shims, and register the type so + `type(Vec(1)) is Vec` round-trips. + +Today step 1 is impossible (opaque suites), step 2 never happens (`PyType_Ready` +returns 0 without looking at `*t`), and step 3 only runs for `PyType_FromSpec`. +Wave 2 closes all three. + +The mirror image - WeavePy *calling out* through these slots - already exists +once the `SlotTable` is populated, because the `dunder_shim` `BuiltinFn` +closures marshal `Object → *mut PyObject`, invoke the slot under +`interp::ensure_active`, and bridge the result back. Wave 2's job is to *fill +the table from the faithful struct* and to *extend the shim coverage* to the +slots RFC 0028 left out. + +## CPython reference + +Targets **CPython 3.13** as installed on the build host. The method-suite +layouts wave 2 pins (read out of the host's stock headers with an +`offsetof`/`sizeof` probe on the 64-bit LP64 / arm64 + x86-64 ABI): + +| Suite | Size | Key offsets (bytes) | +|-------|------|---------------------| +| `PyNumberMethods` | 288 | `nb_add` 0, `nb_subtract` 8, `nb_multiply` 16, `nb_remainder` 24, `nb_divmod` 32, `nb_power` 40, `nb_negative` 48, `nb_bool` 72, `nb_int` 128, `nb_reserved` 136, `nb_float` 144, `nb_inplace_add` 152, `nb_floor_divide` 232, `nb_true_divide` 240, `nb_index` 264, `nb_matrix_multiply` 272 | +| `PySequenceMethods` | 80 | `sq_length` 0, `sq_concat` 8, `sq_repeat` 16, `sq_item` 24, `was_sq_slice` 32, `sq_ass_item` 40, `sq_contains` 56, `sq_inplace_concat` 64, `sq_inplace_repeat` 72 | +| `PyMappingMethods` | 24 | `mp_length` 0, `mp_subscript` 8, `mp_ass_subscript` 16 | +| `PyAsyncMethods` | 32 | `am_await` 0, `am_aiter` 8, `am_anext` 16, `am_send` 24 | +| `PyBufferProcs` | 16 | `bf_getbuffer` 0, `bf_releasebuffer` 8 | + +Note the two reserved holes the faithful structs must preserve: +`PyNumberMethods` keeps `nb_reserved` (offset 136, historically `nb_long`), and +`PySequenceMethods` keeps `was_sq_slice` (32) and `was_sq_ass_slice` (48). The +`tp_*` slot offsets in `PyTypeObject` are unchanged from wave 1 (e.g. +`tp_as_number` 96, `tp_call` 128, `tp_richcompare` 200, `tp_descr_get` 272, +`tp_traverse` 184, `tp_init` 296, `tp_new` 312) and remain machine-checked in +`layout::PyTypeObjectFull`. + +## Current baseline (measured starting point) + +- Wave 1 green: the `_stockabi` proof (9 cases) plus the full `weavepy-capi` + suite (77 tests), the WeavePy-header fixtures, and the behavioural `fixtures` + harness. +- `PyType_FromSpec` types dispatch operators/call/iter/compare via `dunder_shim` + (RFC 0028); the buffer protocol and vectorcall dispatch directly off the + `SlotTable`. +- `PyType_Ready` is a no-op; the method suites are opaque blobs; `tp_descr_*`, + `am_*`, and `tp_new` have no shim; no C `tp_traverse`/`tp_clear` reaches the + collector. + +## Roadmap context + +This is wave 2 of the five-wave D1 arc defined in +[RFC 0043 §Roadmap](0043-cpython-binary-abi.md). Wave 1 landed the object/type +layouts and the mirror. Wave 3 is the numpy C-API surface (and the inline +instance storage this wave defers); wave 4 builds real numpy from source; wave 5 +is pandas/Cython + the wheel matrix. + +## Detailed design (wave 2) + +Seven workstreams in dependency order. + +### WS1 - Spell out the method suites (~0.6K LOC) + +Replace the `opaque_suite!` blobs in `crate::layout` with `#[repr(C)]` structs +that name every slot, each typed `*mut c_void` (the ABI is pointer-width and the +harvest stores the raw pointer in the `SlotTable`, casting to the concrete +`unsafe extern "C" fn` only at the call site - matching how `PyTypeObject` +already types its `tp_*` slots), and pin them with +`const _: () = assert!(size_of/offset_of ...)` against the values in the table +above. The reserved holes (`nb_reserved`, `was_sq_slice`, `was_sq_ass_slice`) +are named fields so the asserts cover the whole struct, not just the slots we +read. The live `crate::types::PyTypeObject` keeps `tp_as_number` (etc.) typed as +`*mut c_void` for ABI width; the harvest casts to the spelled-out suite. + +### WS2 - A real `PyType_Ready` + a shared finalisation path (~1.5K LOC) + +Factor the back half of `PyType_FromMetaclass` - "given a `SlotTable`, a doc, +method/getset/member descriptor lists, and a base list, build the +`Rc`, synthesise dunders, box + register" - into a +`finalize_type(...)` helper. Then: + +- **`harvest_faithful(ty) -> HarvestedSlots`** reads a faithfully-laid-out + `PyTypeObject`: every direct `tp_*` function slot into its canonical + `Py_tp_*` id, and each non-null method suite (`tp_as_number` → + `PyNumberMethods`, …) decomposed into its `Py_nb_*`/`Py_sq_*`/`Py_mp_*`/ + `Py_am_*`/`Py_bf_*` ids. `tp_methods`/`tp_getset`/`tp_members`/`tp_doc`/ + `tp_base` are harvested for the dict + base resolution. +- **`PyType_Ready(t)`** becomes: if `t` already has a `bridge` (a WeavePy static + built-in or an already-readied type), return 0; otherwise harvest `*t`, + resolve the base from `tp_base` (defaulting to `object`), `finalize_type`, + then **write the bridge and the `SlotTable` back into the caller's `*t`** (the + extension keeps using *its* `&MyType` pointer, so the bridge must live on that + struct, not only on a fresh box) and register it. Set `Py_TPFLAGS_READY`. + +`PyType_FromMetaclass` is refactored to call `finalize_type` so both paths stay +identical from the dunder-synthesis point on. + +### WS3 - Close the dunder-shim gaps (~0.8K LOC) + +`install_dunder_shims` gains coverage for the slots RFC 0028 omitted, all +following the established `BuiltinFn`-closure pattern: + +- **Descriptors**: `Py_tp_descr_get` → `__get__(self, obj, type)`, + `Py_tp_descr_set` → `__set__(self, obj, value)`. These make an extension- + defined descriptor work when placed on a class. +- **Async**: `Py_am_await` → `__await__`, `Py_am_aiter` → `__aiter__`, + `Py_am_anext` → `__anext__` (unary slots returning an iterator/awaitable). +- **Construction**: `Py_tp_new` → `__new__`. The shim calls the C `tp_new(type, + args, kwds)` and bridges the result; types without a custom `tp_new` keep the + VM's default instance creation. + +### WS4 - GC integration (~0.7K LOC) + +Register one traverse handler (`gc_trace::register_traverse`) that matches an +`Object::Instance` whose class bridges to a readied type carrying a non-null +`tp_traverse`, and a clear path for `tp_clear`. At collection time the handler: + +- materialises the instance as a borrowed `*mut PyObject`, +- calls the C `tp_traverse(self, visit_trampoline, arg)` where + `visit_trampoline` is an `extern "C"` `visitproc` that bridges each visited + child `*mut PyObject` back to an `Object` and forwards it to the collector's + `&mut dyn FnMut(&Object)` (threaded through the `void *arg`), +- and, when breaking a cycle, invokes `tp_clear(self)`. + +Because `register_traverse` takes plain `fn` pointers, the handler re-derives +the type's slots from the object at call time (no captured state). Re-entrancy +into C during collection is bounded by `interp::ensure_active`, matching the +existing dunder-call discipline. The clear path needs a companion VM hook - +`gc_trace::register_clear` (mirroring `register_traverse`) - invoked from +`clear_object_fields` *before* the instance dict is wiped, so a `tp_clear` that +reads its own identity back out of `__dict__` still sees it. + +This wave also lands the **GC allocation + tracking C-API** a stock +`Py_TPFLAGS_HAVE_GC` type needs end-to-end: `_PyObject_GC_New` / +`_PyObject_GC_NewVar` (storage via the existing `PyType_GenericAlloc` model), +`PyObject_GC_Track` / `PyObject_GC_UnTrack` (enrol/withdraw the bridged +`Object::Instance` with the collector, over a new `gc_trace::untrack` +convenience), `PyObject_GC_IsTracked`, and `PyObject_GC_Del` (untrack + release +the box). All are added to the force-link table so a `dlopen`'d extension +resolves them against the host. + +### WS5 - Instantiation + state for readied types (~0.5K LOC) + +So a readied stock type is actually *constructible*: `PyType_GenericAlloc`, when +its `ty` bridges to a native `TypeObject`, initialises the box payload to a +real `Object::Instance(PyInstance::new(cls))` (rather than `Object::None`), so +`tp_new`/`tp_init` and `PyObject_SetAttrString` operate on a genuine instance +whose `__dict__` round-trips. Combined with the `__new__`/`__init__` shims, this +lets a stock type construct instances and store per-instance state (via its +`__dict__`, the same side-channel the in-tree fixtures use). Inline +`tp_basicsize` field storage remains a wave-3 concern (Non-goals). + +### WS6 - The stock-headers proof (~1K LOC) + +`tests/capi_ext/_stocktype.c`, compiled by `build.rs` against the **stock +CPython 3.13 headers** (like `_stockabi`, gated + self-skipping when absent), +defines its types the static-`PyTypeObject` way and calls `PyType_Ready`. The +breadth is split across small, focused types (each storing its payload in a +side-allocated `*Core` block whose address is stashed in +`self.__dict__["_core_addr"]`, the `_ndarray` pattern): + +- **`Vec2`** - `tp_as_number` (`nb_add`/`nb_subtract`) + `tp_richcompare` + (`==`/`!=`) + `tp_repr` + `tp_init`. Also the *construct-a-readied-type-by- + calling-it-from-C* path: `nb_add`/`nb_subtract` build their result with + `PyObject_CallFunction((PyObject *)&Vec2_Type, "ll", …)` (re-entrantly, from + inside a slot), and the module-level `make_vec2()` does the same at top level. +- **`Seq`** - `tp_as_sequence` (`sq_length`/`sq_item`) + `tp_as_mapping` + (`mp_length`/`mp_subscript`) + `tp_iter`/`tp_iternext` (a self-iterating + `[0, n)` view). +- **`Adder`** - `tp_call` (`Adder(base)(x) == base + x`). +- **`Const`** - a data descriptor with `tp_descr_get`/`tp_descr_set`. +- **`Aw`** - `tp_as_async` (`am_await`/`am_aiter`/`am_anext`), a hermetic + *dispatch* proof (no event loop): the slots return integer sentinels / `self` + so the test can confirm the synthesised `__await__`/`__aiter__`/`__anext__` + reach the genuine `PyAsyncMethods`. +- **`Proxy`** - custom attribute access: `tp_getattro` synthesises a value for + one name and falls back to `PyObject_GenericGetAttr` otherwise (no recursion - + the generic path does not re-enter `getattro`); `tp_setattro` records the + write in a module global and then stores it via `PyObject_GenericSetAttr`, so + the value round-trips back out. +- **`Node`** - a `Py_TPFLAGS_HAVE_GC` container whose single child reference + lives in C-managed memory (the side core, invisible to the dict walker) and + is surfaced/broken only through `tp_traverse`/`tp_clear`; allocated via + `PyObject_GC_New` and enrolled with `PyObject_GC_Track`. + +`crates/weavepy-capi/tests/capi_stocktype.rs` loads the module and asserts each +behaviour through WeavePy's evaluator (`__add__`/`__sub__`, `__eq__`, `len`, +subscription, iteration, call, descriptor get/set, async +`__await__`/`__aiter__`/`__anext__`, custom `__getattribute__`/`__setattr__`, +and construction by calling the readied type object from C), and that a two-node +`Node` cycle held together *only* by the C-side child pointers is reclaimed by a +full `gc.collect()` - observed through C counters showing `tp_traverse`/ +`tp_clear` fired and the live-node count returned to zero. + +### WS7 - Measured baseline (~0.2K LOC) + +Run the `weavepy-capi` suite, the WeavePy-header fixtures, the behavioural +`fixtures` harness, and a regrtest slice; ensure `cargo build --workspace`, +`cargo fmt`, and `cargo clippy -p weavepy-capi --all-targets` are green; fill in +the Measured outcome. + +## Measured targets + +The commit-acceptance bar for wave 2: + +- A stock-CPython-3.13-headers extension (`_stocktype`) that defines its types + as static `PyTypeObject`s + method suites and calls `PyType_Ready` loads via + `dlopen` and dispatches numeric, sequence, mapping, comparison, call, + iteration, descriptor, async, and custom-attribute-access behaviour correctly + under WeavePy - including constructing a readied type by calling the type + object from C (`PyObject_CallFunction`) - proven by `capi_stocktype.rs`. +- A `Py_TPFLAGS_HAVE_GC` type's `tp_traverse`/`tp_clear` participate in the + cycle collector: a constructed reference cycle through such instances is + reclaimed by `gc.collect()`. +- The faithful suites carry compile-time size/offset assertions against the + host's stock headers. +- The wave-1 `_stockabi` proof, the WeavePy-header fixtures + (`_smalltest`/`_ndarray`/`_numpylike`), and their tests stay green through the + suite spell-out and the `PyType_Ready` change. +- `cargo build --workspace`, `cargo fmt`, and + `cargo clippy -p weavepy-capi --all-targets` are green; the regrtest sweep is + unchanged. + +## Measured outcome + +Landed as designed, then hardened (see below). ~1.2K LOC of production change +(≈964 lines across `build.rs`, `dunder_shim.rs`, `force_link_table.rs`, +`genericalloc.rs`, `interp.rs`, `layout.rs`, `slottable.rs`, `types.rs`, and +`gc_trace.rs`, plus a 242-line `gc_bridge.rs`) and ~1.2K LOC of proof +(`_stocktype.c` + `capi_stocktype.rs`). The hardening pass additionally +corrected `varargs.c` (the `Py_VaBuildValue` multi-unit bug, below). + +- **Stock-headers proof green.** `tests/capi_ext/_stocktype.c`, compiled against + the host's stock CPython 3.13 headers (3.13.13 on the dev box), `dlopen`s into + WeavePy and `capi_stocktype.rs` passes **11/11**: a module of seven static + `PyTypeObject`s finalised by `PyType_Ready` dispatches numeric + (`nb_add`/`nb_subtract`), rich comparison, sequence (`sq_length`/`sq_item`), + mapping (`mp_subscript`), iteration (`tp_iter`/`tp_iternext`), call (`tp_call`), + descriptor (`tp_descr_get`/`tp_descr_set`), async + (`am_await`/`am_aiter`/`am_anext`), and custom attribute access + (`tp_getattro`/`tp_setattro`) behaviour through WeavePy's evaluator. +- **Constructing a readied type by calling it from C works.** `Vec2` instances + are built by `PyObject_CallFunction((PyObject *)&Vec2_Type, "ll", …)` - both at + the top level (`make_vec2()`) and re-entrantly from inside `nb_add`/ + `nb_subtract`. Hardening this surfaced and fixed a real, general bug: + `Py_VaBuildValue` built only the *first* unit of a multi-unit format string, + so **every** `PyObject_CallFunction`/`PyObject_CallMethod` with a multi-arg + format (`"ll"`, `"OO"`, …) silently dropped all arguments past the first and + called with a 1-tuple. It now shares `Py_BuildValue`'s multi-unit tuple logic. + (This is squarely on the path numpy & friends use to build call arguments, so + the fix matters well beyond type construction.) +- **GC through C-managed memory works.** A two-node cycle whose only edges live + in C side allocations (invisible to the dict walker) is reclaimed by + `gc_trace::collect_all()`: the C counters confirm `tp_traverse` and `tp_clear` + fired and the live-node count returned to zero. The nodes allocate via + `PyObject_GC_New` and enrol via `PyObject_GC_Track`. +- **No regressions.** The full `weavepy-capi` suite is green - **88 tests** + across the library unit tests and the `capi_buffer` / `capi_loader` / + `capi_ndarray` / `capi_numpylike` / `capi_wheel_endtoend` fixtures, plus the + wave-1 `capi_stockabi` proof (**9/9**, unchanged through the suite spell-out + and the `PyType_Ready` overhaul). The `capi_numpylike` / `capi_wheel_endtoend` + fixtures - which lean on multi-arg `PyObject_CallFunction`/`PyObject_CallMethod` + - confirm the `Py_VaBuildValue` fix is a strict improvement. The method-suite + structs carry compile-time size/offset assertions against the faithful layout. +- **Tooling green.** `cargo build` (workspace, incl. the release `weavepy` + binary), `cargo fmt --all`, and `cargo clippy --workspace --all-targets` are + clean. +- **Conformance unchanged.** The GC-relevant regrtest slice (subprocess mode + against the release `weavepy`) is unaffected: bundled `test_gc_basic` / + `test_ws4_gc_cascade` and CPython's `Lib/test/test_gc.py` pass, and the + `extension` / `weakref` / `finalizers` / `objmodel` / `iter_gen` slices report + **0 unexpected** results. (The VM-side `register_traverse`/`register_clear` + hooks are inert until `weavepy-capi` initialises, so a pure-VM run is + byte-for-byte unchanged.) + +## Non-goals / Drawbacks + +- **Inline `tp_basicsize` instance storage is deferred to wave 3.** A stock type + that reads `self->field` at a fixed byte offset inside its own instance block + (the `PyArrayObject` shape, and any type using `tp_members` with `T_INT`/ + `T_DOUBLE` at an offset) is *not* supported here. Wave 2 instances remain + `Object::Instance` values that store state in `__dict__` (the same model the + in-tree fixtures already use). This is a deliberate cut: the roadmap places + inline instance layout with the numpy work (wave 3), where it is unavoidable, + and keeping it out lets wave 2 land green without reworking the instance + representation. The consequence is that wave 2 proves *slot dispatch* for + stock-defined types, not *arbitrary stock instance layouts*. +- **Multiple inheritance from two C types, metaclass slots, and `__slots__` + offset packing** are not addressed beyond what `TypeObject::new_user` + linearisation already gives. +- **The shim layer has a cost.** Each operator on a readied type crosses + Rust→C→Rust with `Object`↔`*mut PyObject` marshalling. Hot loops pay for it; + a faithful-slot fast path (calling the C slot without rebuilding the dunder + closure) is future work. +- **`tp_traverse` during collection re-enters C.** The handler runs extension + code while the collector holds its state; we bound this with the existing + `ensure_active` discipline and only traverse (read child edges), but a + badly-behaved `tp_traverse` that mutates the object graph mid-collection is + outside what we defend against (CPython has the same hazard). + +## Alternatives + +1. **Require `PyType_FromSpec` (status quo).** Works only for extensions that + adopt the limited-API spec style; excludes the static-`PyTypeObject` + + `PyType_Ready` majority and all of numpy's core. Rejected - it is exactly the + gap. +2. **Translate static type defs into a synthetic `PyType_Spec` and reuse + `PyType_FromSpec` verbatim.** Tempting, but a `PyType_Spec` cannot express + everything a readied static type carries (e.g. an already-`PyType_Ready`'d + base by pointer, `tp_dictoffset`/`tp_weaklistoffset`), and `PyType_Ready` + must mutate the *caller's* struct in place. Harvesting into the shared + `SlotTable` finalisation path gives the reuse without the impedance mismatch. +3. **Lazily harvest slots on first dispatch instead of at `PyType_Ready`.** + Defers cost but breaks `type(x)` identity and `PyModule_AddObject(m, "T", + &T)` (which needs the bridge present immediately). Rejected. + +## Prior art + +- **PyPy `cpyext`** readies static types by reading their slots into the + interpreter's `W_TypeObject`, synthesising app-level methods - the direct + model for WS2. +- **GraalPy** harvests `PyTypeObject` slots into managed type info at + `PyType_Ready`, including the method suites; validates the approach at scale. +- **CPython `Objects/typeobject.c::type_ready`** - the authoritative semantics + for slot inheritance, `tp_new`/`tp_alloc` defaulting, and the + `tp_as_number`/`…` fix-up that WS2 mirrors (a faithful subset). +- **RFC 0028** - the `SlotTable`/`dunder_shim`/vectorcall/buffer machinery wave + 2 fills from a new source. + +## Future work + +- Inline `tp_basicsize` instance storage with a negative-offset native + back-reference (extending the wave-1 mirror to user-type instances) - wave 3, + for numpy's `PyArrayObject`. +- Slot inheritance fix-up for subclassing a C type from Python (inherit the + base's suites unless overridden), matching `type_ready`'s `inherit_slots`. +- A faithful-slot fast path that bypasses the dunder-closure rebuild on hot + numeric loops. +- `tp_members`/`T_*` descriptor reads/writes against inline storage (pairs with + wave 3). + + + diff --git a/docs/rfcs/0045-binary-abi-inline-storage-numpy.md b/docs/rfcs/0045-binary-abi-inline-storage-numpy.md new file mode 100644 index 0000000..28d2e54 --- /dev/null +++ b/docs/rfcs/0045-binary-abi-inline-storage-numpy.md @@ -0,0 +1,585 @@ +# RFC 0045: CPython 3.13 binary-ABI compatibility (cpyext) - wave 3: faithful inline instance storage + the numpy array C-API surface + +- **Author**: WeavePy core +- **Status**: Accepted +- **Part of**: the D1 binary-ABI arc whose roadmap lives in + [RFC 0043](0043-cpython-binary-abi.md). RFC 0043 is the umbrella/roadmap + RFC; this is the detailed-design RFC for **wave 3**. +- **Builds on**: RFC 0043 (wave 1 - the layout-faithful object mirror with its + negative-offset prefix, the byte-faithful `PyTypeObject`, the immortal-refcount + sentinel), RFC 0044 (wave 2 - the full type-suite round trip, real + `PyType_Ready`, the `SlotTable` -> `dunder_shim` finalisation path, GC + integration), RFC 0029 (the numpy-grade end-to-end path - the PEP 3118 buffer + protocol, the complete `PyCapsule_*` surface incl. `PyCapsule_Import`'s dotted + semantics, the `_numpylike.c` fixture). + +## Summary + +Wave 1 made WeavePy *objects* byte-faithful (a `float` crossing into C looks like +a `PyFloatObject`). Wave 2 made *behaviour* faithful (a stock-defined type's +`tp_*` slots and method suites dispatch through WeavePy's VM). Both waves left one +load-bearing thing deferred, and wave 2 named it explicitly as a wave-3 +non-goal: **inline `tp_basicsize` instance storage** - a stock type reading +`self->field` at a fixed byte offset *inside its own instance block*. + +This is exactly the shape numpy's `PyArrayObject` is built on. `PyArray_DATA(arr)` +expands (in the stock, inlined header) to `((PyArrayObject *)arr)->data` - a read +at offset 16; `PyArray_NDIM`, `PyArray_DIMS`, `PyArray_STRIDES` are the same. A +runtime that cannot present a stock array instance as a stable C struct whose +fields live at the right offsets cannot host numpy at all, no matter how complete +its symbol table is. Wave 3 closes that gap and then lands the numpy-specific +*C-API surface* that rides on top of it. + +Wave 3 therefore: + +1. **Gives C-extension instances a single, stable, layout-faithful body.** An + instance of a stock-defined type (a `PyType_FromSpec` heap type or a + `PyType_Ready`'d static type) that declares inline fields + (`tp_basicsize > sizeof(PyObject)`) is materialised once into a + `tp_basicsize`-sized faithful block - `[PyObject head | inline fields | inline + var-data]` - reusing wave 1's negative-offset prefix. The body is **owned by + the native instance** and presents the **same pointer** on every crossing + into C, so a field written in one C call is still there in the next. +2. **Implements `tp_members` for real.** `T_INT` / `T_DOUBLE` / `T_OBJECT` / + `T_LONGLONG` / ... members project to/from their declared offset *in that + faithful body*, so `obj.field` (Python) and `self->field` (C) read and write + the same bytes - the wave-2 stub that returned `None` is replaced. +3. **Lands the numpy array interchange + C-API-capsule pattern.** The + `__array_interface__` / `__array_struct__` protocols (so a consumer can read a + producer's array buffer without numpy linked), and the *array-C-API capsule* + shape (`import_array()` -> `PyCapsule_Import("pkg._core._multiarray_umath._ARRAY_API")` + -> a `void **` function-pointer table) that every numpy-consuming extension + uses, proven by a producer/consumer pair. Making this work required closing a + latent gap: a `PyCapsule` collapsed to `None` when it crossed into the VM (a + module dict / attribute), so wave 3 gives it an identity-stable + `Object::Capsule` *soul* that round-trips back to the same box. + +The acceptance proof is a third hermetic fixture, `_stockarray.c`, compiled +against the **stock CPython 3.13 headers**, defining a `PyArrayObject`-shaped +type with real inline fields and `tp_members`, publishing an array-C-API capsule, +and exposing `__array_interface__` - exercised end-to-end through WeavePy. + +Building **real numpy** from source, the full ufunc-loop registration machinery, +and the complete private `_multiarray_umath` symbol tail remain **wave 4** (see +Non-goals); wave 3 makes them *possible* by landing the instance-layout +foundation and the interchange surface they stand on. + +## Motivation + +A stock C extension's relationship to *its own* instances is the one place +WeavePy's mirror model had not yet reached. Waves 1-2 covered the two directions +that flow *through* the type system: + +- **Reading built-in data** (wave 1): a WeavePy `float`/`tuple`/`str` crossing + into C is a faithful mirror. +- **Dispatching to C behaviour** (wave 2): a stock-defined type's operators, + call, iteration, comparison, descriptors, and GC hooks fire through the VM. + +But the *instance* of a stock type was still a WeavePy `Object::Instance` whose +storage was a Rust `PyInstance` (a `__dict__`, slots, a class pointer). When such +an instance crossed into C, wave 1/2 minted a fresh `PyObjectBox` - a Rust +payload, not a C struct - and minted a **new one on every crossing**. So: + +- `((MyType *)self)->field` read a Rust enum, not the field. +- A field written by C in one call was on a box that was thrown away; the next + call got a different box with different bytes. +- `tp_members` (which names a field by *offset*) had nothing real to point at, so + wave 2 left it a `None`-returning stub. + +The in-tree fixtures sidestep this with the `_core_addr` idiom: `malloc` a side +struct, stash a `PyLong`-encoded pointer to it in `self.__dict__["_core_addr"]`, +and chase that pointer on every method. That works for a hand-written fixture but +it is **not** what a stock wheel does, and it is the diametric opposite of +numpy's hot path: numpy reads `arr->data` directly, inlined, with zero function +calls and zero dict lookups. There is no way to satisfy that reader except to +make the bytes at `self + offset` *be* the field - the same thesis wave 1 applied +to `float`, now applied to an extension's own instances. + +Down-tree this unblocks the headline target. numpy's `_multiarray_umath`: + +- defines `PyArrayObject` as a C struct read through inlined `PyArray_*` macros + (the instance-layout problem wave 3 solves); +- publishes its entire C-API as a `void **` table wrapped in a capsule named + `numpy._core._multiarray_umath._ARRAY_API`, which every consumer (`scipy`, + `pandas`, a user's Cython module) pulls in via the `import_array()` macro + (the capsule-pattern problem wave 3 proves); +- interoperates with non-numpy producers through `__array_interface__` / + `__array_struct__` (the interchange problem wave 3 lands). + +## The central problem, precisely + +Consider the canonical stock array type (verbatim shape from numpy and a hundred +Cython extensions): + +```c +typedef struct { + PyObject_HEAD + double *data; /* offset 16 */ + Py_ssize_t size; /* offset 24 */ + int ndim; /* offset 32 */ + Py_ssize_t shape[NPY_MAXDIMS]; /* offset 40 ... */ +} ArrayObject; + +static PyMemberDef Array_members[] = { + {"size", T_PYSSIZET, offsetof(ArrayObject, size), READONLY, NULL}, + {"ndim", T_INT, offsetof(ArrayObject, ndim), READONLY, NULL}, + {NULL} +}; +static PyTypeObject ArrayType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "mod.Array", + .tp_basicsize = sizeof(ArrayObject), /* > sizeof(PyObject) */ + .tp_members = Array_members, + /* ... */ +}; +``` + +For `a = mod.Array(...); a.size` (Python) and `((ArrayObject *)a)->data` +(C, inlined) to agree, WeavePy must, for the lifetime of that one instance: + +1. Allocate **one** `tp_basicsize`-byte block laid out as the C struct, zeroed, + with `ob_refcnt`/`ob_type` at offsets 0/8. +2. Hand C the **same** pointer to that block every time the instance crosses the + boundary (so writes persist and `a is a` survives a round trip). +3. Resolve that pointer **back** to the native `Object::Instance` (so the + instance's `__dict__`, its class, its slot dispatch still work). +4. Read/write `tp_members` at their declared offsets *in that block*. +5. Free the block - exactly once - when the instance dies. + +Today step 1 reserves the bytes (`PyType_GenericAlloc` already sizes to +`tp_basicsize`) but lays a `PyObjectBox` over them; step 2 fails (fresh box per +crossing); step 4 is a stub. Wave 3 closes 1-5 without disturbing the wave-1/2 +mirror for built-ins or the dict-backed instances the existing fixtures use. + +### The lifetime knot (and how wave 3 ties it) + +The hard part of 1-5 is ownership. A faithful body must (a) live as long as the +*instance* (not merely as long as a transient C reference, or fields vanish +between calls), and (b) resolve back to the instance, **without** forming an +ownership cycle (body owns instance owns body => neither is ever freed). Wave 3's +rule, matching PyPy `cpyext`'s borrow model: + +- The **native instance owns the body.** `PyInstance` carries a `c_body` pointer; + the body is freed - exactly once - when the instance's last `Arc` drops (via a + capi-registered finalizer hook, the same mechanism wave 2 used to register + `tp_traverse`/`tp_clear`). +- The **body's prefix holds a `Weak`**, not a strong reference - so + `clone_object` resolves the pointer back to `Object::Instance` by upgrading the + weak, and there is no cycle. +- A **C reference is a borrow.** The C `ob_refcnt` rising and falling does not + free the body; reaching zero merely ends C's *interest*. A process-wide + strong-holder map represents the one case where C is the *sole* owner (a body + minted by `PyType_GenericAlloc` and not yet handed to the VM); dropping that + entry at refcount zero is what lets a never-returned C-built instance die. + +The bounded consequence (a deliberate cut, stated in Non-goals): a C reference +does **not** extend the instance's life *beyond* the VM's. This covers the +construct-fill-return and read-during-call patterns that numpy consumers use +(the object is a live Python value while C operates on it); the pathological +"C stashes a raw borrowed pointer and dereferences it after the owning Python +object is gone" case is the same strong-keepalive-across-the-boundary fidelity +wave 2 also deferred. + +## CPython reference + +Targets **CPython 3.13** as installed on the build host. The layouts wave 3 +pins (read out of the host's stock headers): + +- **`Include/object.h`** - `PyObject_HEAD` is `ob_refcnt` (offset 0) + + `ob_type` (offset 8); the first extension field therefore lands at offset 16, + which is where `tp_basicsize`-block readers begin. +- **`Include/structmember.h`** (now folded into `Include/descrobject.h`) - the + `PyMemberDef { name; type; offset; flags; doc }` layout and the `T_*` type + codes (`T_SHORT`=0, `T_INT`=1, `T_LONG`=2, `T_FLOAT`=3, `T_DOUBLE`=4, + `T_STRING`=5, `T_OBJECT`=6, `T_CHAR`=7, `T_BYTE`=8, `T_UBYTE`=9, `T_USHORT`=10, + `T_UINT`=11, `T_ULONG`=12, `T_BOOL`=14, `T_OBJECT_EX`=16, `T_LONGLONG`=17, + `T_ULONGLONG`=18, `T_PYSSIZET`=19). These already exist in WeavePy's + `getset::member_types`; wave 3 makes them *do* something. +- **`PyType_GenericAlloc`** semantics (`Objects/typeobject.c`): allocate + `tp_basicsize + nitems * tp_itemsize` bytes, zeroed, refcount 1, `ob_type` + set and incref'd. +- **The array interface** (numpy's `__array_interface__` version 3 dict: + `shape`, `typestr`, `data` (a `(addr:int, readonly:bool)` pair), `strides`, + `version`; and `__array_struct__`, a `PyArrayInterface`-bearing capsule). +- **The array C-API import protocol**: `import_array()` expands to + `_import_array()`, which calls + `PyCapsule_Import("numpy._core._multiarray_umath._ARRAY_API", 0)` and stores + the returned `void **` in the consumer's static `PyArray_API`. WeavePy's + `PyCapsule_Import` (RFC 0029) already implements the dotted-import + + name-verification semantics this needs - but the capsule it fetches must first + *survive* being stored in (and read back out of) the producer module's dict, + which is the VM-crossing gap WS5 closes. + +Explicit non-references (unchanged from waves 1-2): `Py_GIL_DISABLED` layouts, +the `Py_TRACE_REFS` debug head, Windows `.pyd`, and any read of CPython +*internal* (`pycore_*`) structs. + +## Current baseline (measured starting point) + +- Waves 1-2 green: the `_stockabi` proof (9 cases), the `_stocktype` proof + (11 cases), the WeavePy-header fixtures (`_smalltest` / `_ndarray` / + `_numpylike` + the wheel-install round trip), and the full `weavepy-capi` + suite (88 tests). +- `PyType_GenericAlloc` sizes to `max(tp_basicsize, sizeof(PyObjectBox))` but + overlays a `PyObjectBox`; `into_owned` mints a fresh box per crossing for + `Object::Instance`; `tp_members` decodes names but returns `None`. +- `PyCapsule_*` (incl. `PyCapsule_Import` dotted semantics) and the PEP 3118 + buffer protocol are complete *at the C level*; only `datetime.datetime_CAPI` is + wired into the lazy well-known-capsule path. But a capsule **collapsed to + `None`** the moment it crossed into the VM (its state lives in the box's + `user_data`, not in `payload.obj`), so a producer-built capsule stored via + `PyModule_AddObject` and re-fetched by `PyCapsule_Import` came back a + non-capsule - the array-C-API round trip never actually worked (the existing + `_numpylike` test only asserts `_API` is *present*, which `None` passes). WS5 + fixes this. +- No `PyArray_*` / `PyUFunc_*` / `__array_interface__` / `__array_struct__` + symbol or protocol exists anywhere in the tree. +- All in-tree array fixtures declare `tp_basicsize <= sizeof(PyObject)` (`_stocktype` + = 16, `_numpylike` = 0) and store per-instance state in `__dict__`, so the + wave-3 gate (below) leaves every one of them on the existing path. + +## Roadmap context + +This is wave 3 of the five-wave D1 arc defined in +[RFC 0043 Roadmap](0043-cpython-binary-abi.md). Wave 1 landed the object/type +layouts + mirror; wave 2 the type-suite dispatch + GC; **wave 3 (this RFC)** the +inline instance storage that wave 2 deferred, plus the numpy array C-API surface; +wave 4 builds real numpy from source against the now-faithful host ABI; wave 5 is +pandas / Cython + the wheel matrix. + +## Detailed design (wave 3) + +Seven workstreams in dependency order. + +### WS1 - The gate: which instances get a faithful body (~0.2K LOC) + +Not every `Object::Instance` should become a faithful inline body - pure-Python +class instances have no C struct, and the existing dict-backed fixtures must stay +exactly as they are. Wave 3 adds a precise, opt-in discriminator: + +> A type is an **inline-instance type** iff it was finalised by `PyType_FromSpec` +> or `PyType_Ready` (i.e. defined by a C extension) **and** declares +> `tp_basicsize > sizeof(PyObject)` (i.e. it has inline fields beyond the head). + +A thread-local `INLINE_TYPES: HashSet<*PyTypeObject>` in `crate::types` is +populated at finalisation; `is_inline_instance_type(ty)` is the O(1) query. This +gate has three deliberate properties: it is *additive* (a `PyTypeObject *` that +isn't registered behaves exactly as before); it *excludes* `install_user_type` +(the pure-Python-class fallback path, which is not `FromSpec`/`Ready` and which +sets `tp_basicsize = sizeof(PyObjectBox)` through a different code path); and it +*excludes every current fixture* (all declare `tp_basicsize <= sizeof(PyObject)`), +so the change carries no regression surface for landed tests. + +### WS2 - The faithful instance body + its lifetime (~1.2K LOC) + +The native instance owns one faithful body for its whole life. + +- **`PyInstance` gains `c_body`** (`crates/weavepy-vm`): a `Send + Sync` cell + holding the body pointer as a `usize` (0 = none). It is excluded from the + derived `Clone` (a clone is a *distinct* instance that owns no body) via a thin + `CBody` wrapper whose `Clone` yields the empty state - this keeps the wave-2 + finalizer-resurrection path (which shallow-clones a dying instance) from + duplicating a pointer and double-freeing. +- **A finalizer hook.** `weavepy-vm` exposes + `register_instance_body_free(fn(usize))`; `weavepy-capi` registers a function + that **runs the type's custom `tp_dealloc` first** (for external-resource + cleanup - e.g. `free(self->data)`), then releases the block. `PyInstance::drop` + calls it (before the existing `__del__`-resurrection net) when `c_body != 0`, + so the body and anything its `tp_dealloc` owns are released exactly when the + instance is collected - the same hook pattern wave 2 used for + `register_traverse`/`register_clear`. A stock `tp_dealloc`'s own + `tp_free`/`PyObject_Free`/`PyObject_GC_Del` tail on the body is absorbed (the + block is VM-owned), so it neither double-frees nor leaks. +- **The body + prefix** (`crate::mirror`). `MirrorPrefix` gains an + `inst: Option>`. A `BodyKind::Instance { size }` body is a + `tp_basicsize (+ nitems * tp_itemsize)` zeroed block; the prefix's `inst` is a + `Weak` downgrade of the owning instance and `obj` is `None`. `native_of` + upgrades the weak to return `Object::Instance(rc)`; `is_mirror` recognises an + inline-type pointer so `clone_object`/`free_box` route through the mirror path. +- **Get-or-create** (`crate::instance`, new). `instance_body_out(rc, ty, nitems)` + returns `rc.c_body` (incref'd) if set, else allocates the faithful body, stores + it on the instance, and downgrades the instance into the prefix. This is the + single chokepoint that guarantees pointer stability across crossings. +- **C-sole-ownership.** A process-wide `STRONG: HashMap>` + holds the strong reference for an instance minted *by* `PyType_GenericAlloc` + (where C, not the VM, is the initial owner). On C refcount -> 0, `free_box` + removes the `STRONG` entry (ending C's ownership) but does **not** deallocate - + the instance's `Drop` does, if that was the last reference. A VM-owned instance + borrowed into C has no `STRONG` entry, so its body simply persists across the + borrow. + +### WS3 - `into_owned` / `PyType_GenericAlloc` routing (~0.4K LOC) + +The two paths that turn an instance into a C pointer learn the inline route: + +- **`into_owned` / `into_owned_with_type`** (`crate::object`): for an + `Object::Instance` whose type `is_inline_instance_type`, call + `instance_body_out` instead of boxing; for an `Object::Capsule`, return the + soul's retained box (WS5). Built-ins still mirror; pure-Python instances and + modules still box. (Each fork is one `match` arm plus one predicate, so the hot + path for every other object is unchanged.) +- **`PyType_GenericAlloc`** (`crate::genericalloc`): for an inline type, allocate + the faithful body (sized `tp_basicsize + nitems*tp_itemsize`), seed a fresh + `Object::Instance(PyInstance::new(cls))`, register it in `STRONG`, and return + the body. For every other type the wave-1/2 `PyObjectBox` path is untouched. +- **`free_box`** (`crate::object`): an instance body (recognised by its prefix's + `inst`) routes to "end C ownership" (drop the `STRONG` entry) rather than + `free_mirror`'s deallocate-now path. + +### WS4 - Real `tp_members` (~0.4K LOC) + +`getset::collect_members` stops emitting `None` stubs. Each `PyMemberDef` +becomes an `Object::Property` (so the VM's data-descriptor protocol governs it, +exactly like `collect_getsets`) whose getter/setter: + +1. resolve the instance to its stable body via `into_owned` (the same pointer C + sees), then +2. read/write the C field at `entry.offset` with the width/signedness dictated by + `entry.ty` - `T_INT`/`T_UINT` (4 bytes), `T_LONG`/`T_LONGLONG`/`T_PYSSIZET` + (8), `T_FLOAT` (f32) / `T_DOUBLE` (f64), `T_BOOL`/`T_BYTE`/`T_UBYTE` (1), + `T_SHORT`/`T_USHORT` (2), `T_OBJECT`/`T_OBJECT_EX` (a `*mut PyObject` at the + offset, bridged via `clone_object` on read and `into_owned` on write), + honouring `READONLY`. + +The result: `obj.size` (Python read), `obj.size = n` (Python write, unless +`READONLY`), and `((ArrayObject *)obj)->size` (C, inlined) are the same eight +bytes. + +### WS5 - The numpy array interchange + C-API-capsule surface (~0.8K LOC) + +Built on the now-stable body and the capsule machinery: + +- **`__array_interface__`** - a producer type whose instances expose the v3 + dict (`{"shape": ..., "typestr": ..., "data": (addr, ro), "strides": ..., + "version": 3}`) lets any consumer (WeavePy-side or another extension) read the + buffer without numpy. The `data` address is the *stable body's* inline data + pointer - only possible because WS2 made it stable. +- **`__array_struct__`** - the same information as a `PyArrayInterface`-bearing + capsule, for the C-level fast path. +- **The capsule round-trip (the missing soul).** `PyCapsule_*` exists since + RFC 0029, but a capsule is a legacy `PyObjectBox` whose state (the wrapped + `void *`, name, …) lives in `user_data` while its `payload.obj` is `None`. + That made it **collapse to `None`** the moment it crossed into the VM - and a + capsule's whole job is to live in a place the VM owns: a module dict + (`module._API`, the `import_array()` idiom) or an attribute + (`obj.__array_struct__`). So `PyModule_AddObject(m, "_API", capsule)` stored + `None`, and a later `PyCapsule_Import(...)` fetched a non-capsule and failed - + the array-C-API pattern was silently broken (the existing `_numpylike` test + only checked `_API` was *present*, which `None` satisfies). Wave 3 fixes it the + same way it stabilised instance bodies: the capsule keeps its box, but the VM + holds an identity-stable `Object::Capsule(Rc)` **soul** that maps + back to the **same** box. The soul retains one C reference on the box for its + whole life and hands that same pointer back out on every `into_owned`; a + `register_capsule_free` hook (the additive-hook pattern again) releases the + retain when the last soul drops, so the box is freed - running any + `PyCapsule` destructor - exactly once. `PyCapsuleSoul` stores the box pointer + as a `usize` (not a pointer), so `Object: Send + Sync` still holds. +- **The array-C-API capsule pattern** - the load-bearing numpy idiom. A producer + module publishes a `void **` function-pointer table (its "array API") wrapped + in a named capsule (numpy uses `numpy._core._multiarray_umath._ARRAY_API`); a + consumer's `import_array()` resolves it through `PyCapsule_Import` and indexes + the table. Wave 3 proves the *whole loop* hermetically: `_stockarray.c` both + *publishes* such a capsule and *consumes* it (the `import_array()` shape) to + read an array's fields through the imported table. (`PyCapsule_Import`'s dotted + semantics already exist; wave 3 adds the soul that lets the capsule survive the + module dict, the producer/consumer proof, and the `tp_basicsize`-stable + instances the table hands around.) + +### WS6 - The stock-headers proof fixture + loader path (~1.2K LOC C + tests) + +- **`tests/capi_ext/_stockarray.c`** - authored against the **stock CPython + 3.13 headers** (no WeavePy header). It defines `StockArray`, a + `PyArrayObject`-shaped static type: `PyObject_HEAD` + `int nd` + + `Py_ssize_t length` + `double *data` + `int typenum`, with + `tp_basicsize = sizeof(StockArrayObject)`, `tp_members` for `nd`/`length` + (read-only) and `typenum` (writable), `tp_methods` for constructors/accessors + (`sum`, `fill`, …), an `__array_interface__` / `__array_struct__` getset pair, + and an `_ARRAY_API` capsule with a tiny function-pointer table + (`StockArray_FromLength`, `StockArray_DATA`, `StockArray_LENGTH`). A + module-level `capi_roundtrip` function exercises the `import_array()` shape: + `PyCapsule_Import` the table and build a fresh array through it. The inline + fields are written by `tp_init` straight into the body + (`a->length = n; a->data = malloc(...)`) and read back in a *later* C call + (`sum()`) at the same raw offsets, proving the offsets are faithful and the + body is stable. Crucially the element buffer is **separately `malloc`'d** (not + an inline tail), so `tp_dealloc`'s `free(a->data)` is a real external-buffer + free that the body-free hook must run on collection - which it does (see the + drawback below, now resolved for the synchronous case). +- **`build.rs`** compiles it with the stock include dir (the existing + `stock_python_include()` probe), emitting `WEAVEPY_CAPI_STOCKARRAY_EXTENSION`; + absent CPython 3.13 the fixture is skipped (a bare CI host still passes), and a + `rerun-if-changed` keeps it rebuilt. +- **`force_link_table`** registers any newly `#[no_mangle]` symbol the fixture + resolves (so a missing symbol is a loud link failure, never a silent "false + pass"). + +### WS7 - Integration tests + measured baseline (~0.5K LOC) + +`crates/weavepy-capi/tests/capi_stockarray.rs` `dlopen`s the fixture and asserts: +inline field reads (a value set from Python via a member is read by C at the raw +offset and vice-versa), `tp_members` round trips (read + write + `READONLY` +rejection), pointer stability (two crossings of the same instance see one +address; a field written in call 1 is read in call 2), the buffer export, the +`__array_interface__` dict shape, and the array-C-API capsule round trip +(publish + `import_array()`-style consume). Gated on the CPython-3.13 env var so a +bare host skips cleanly. The CPython `Lib/test` sweep is unaffected (wave 3 is +C-API-only infrastructure; no `expectations.toml` row flips). + +## Measured targets + +The commit-acceptance bar for wave 3: + +- A stock-CPython-3.13-headers extension (`_stockarray`) defining a + `PyArrayObject`-shaped type with **inline `tp_basicsize` fields** loads via + `dlopen` and runs: C reads/writes those fields at their raw offsets, the same + bytes Python sees through `tp_members`. +- The same instance presents a **stable pointer** across crossings: a field + written by C in one call is observed in the next. +- The numpy array interchange (`__array_interface__` / `__array_struct__`) and + the array-C-API capsule pattern (publish + `import_array()`-style consume) work + end-to-end. +- The wave-1/2 fixtures (`_stockabi`, `_stocktype`, `_smalltest`, `_ndarray`, + `_numpylike` + wheel install) and the whole `weavepy-capi` suite stay green + through the instance-representation change. +- `cargo build --workspace`, `cargo fmt --check`, and + `cargo clippy --workspace --all-targets -- -D warnings` are green; the regrtest + sweep stays behaviourally `--check` clean on the release binary (no output/ + status regressions; any parallel-run wall-clock timeouts must resolve to their + expected status when re-run serially). + +## Measured outcome + +Landed as designed. The hermetic proof fixture `_stockarray` (stock CPython 3.13 +headers, no WeavePy header) passes **11/11** in `capi_stockarray.rs`: + +- `inline_storage_persists_across_calls`, `data_pointer_is_stable`, + `fill_then_sum` - `tp_init` writes `nd`/`length`/`data`/`typenum` straight into + the body; a *later* C call (`sum`, `fill`) reads them back at the same raw + `tp_basicsize` offsets through the **same** pointer. +- `members_read_inline_fields`, `member_write_roundtrips` - `tp_members` project + `nd`/`length` (read-only) and `typenum` (writable) at their `offsetof`; the + Python view and the C inlined read/write are the same bytes; a write to a + `READONLY` member raises. +- `array_interface`, `array_struct_capsule` - the `__array_interface__` v3 dict + and the `PyArrayInterface`-bearing `__array_struct__` capsule both expose the + stable inline buffer. +- `import_array_capsule_roundtrip` - `capi_roundtrip(4)` runs the full + `import_array()` loop in C (`PyCapsule_Import("_stockarray._ARRAY_API")` -> a + `void **` table -> `StockArray_FromLength`) and the resulting array's + `sum() == 6.0`. **This is the test that the capsule-soul (WS5) makes possible; + before it, the `_ARRAY_API` capsule collapsed to `None` in the module dict.** +- `dealloc_frees_buffer` - dropping the sole reference collects the native + instance, whose free hook runs the extension `tp_dealloc` (`free(self->data)`, + a real *external* buffer) and absorbs its `PyObject_Free` tail; proven race-free + via a monotonic `dealloc_count()` (the `.so`'s counters are shared across the + parallel test process). + +No regressions: the entire `weavepy-capi` suite is green - **99 tests, 0 failed** +across the lib unit tests (23) and every fixture binary (`capi_buffer` 10, +`capi_loader` 6, `capi_ndarray` 14, `capi_numpylike` 14, `capi_stockabi` 9, +`capi_stockarray` 11, `capi_stocktype` 11, `capi_wheel_endtoend` 1). The wave-3 +instance-representation fork and the `Object::Capsule` soul left the wave-1/2 +fixtures and the wheel install/import round-trip untouched. + +Hygiene: `cargo build --workspace --all-targets`, `cargo fmt --all`, and +`cargo clippy --workspace --all-targets` are all clean. Adding `Object::Capsule` +to the VM enum surfaced exactly **six** exhaustive `match` sites (`class_of`, +`object_identity`, `is_truthy`, `type_name`, `repr`, `id_of`) - all now handled; +no other workspace crate matches `Object` exhaustively. + +The curated regrtest conformance sweep shows **zero behavioural regressions** +from wave 3. On the **release** binary (the build `expectations.toml`'s per-test +wall budgets are calibrated for) the 227-row sweep grades pass 173 / fail 32 +(every `fail` is a pre-existing expected `fail`) / skip 13. A `--jobs 6` parallel +run on a loaded host flagged 9 rows, but all 9 are wall-clock contention +artifacts, not output divergences: re-run **serially** (`--jobs 1`, same release +binary) every one resolves to its expected status - 8 compute/IO/multiprocessing +-heavy tests (`test_json` 27.7s, `test_queue`, `test_statistics`, `test_zipfile`, +`test_set` 63s, `test_tarfile`, `test_multiprocessing_main_handling`, +`test_concurrent_futures` - the last a benign `resource_tracker` leaked-semaphore +*shutdown warning* under load) grade `pass`, and `test_pathlib` grades its +expected `fail`. (Grading the **debug** binary instead inflates this to ~41 +spurious timeouts purely because debug Rust runs the heavy numeric/IO suites +10-30x slower than the release-calibrated budgets - e.g. `test_math` is ~13s +release vs >60s debug; not a behaviour change.) None of these tests reach the +C-API instance/capsule path: wave 3 is C-API-only infrastructure that no +pure-Python path exercises. + +## Non-goals / Drawbacks + +- **Real numpy does not import in wave 3.** Wave 3 lands the *foundation* numpy + needs (faithful instance layout, `tp_members`, the array interchange + C-API + capsule pattern) and proves it hermetically; building numpy from source against + the host ABI and gating CI on `import numpy; numpy.zeros((3,3)) @ ...` is + wave 4. The `_stockarray` fixture is numpy-*shaped*, not numpy. +- **The full ufunc-loop registration machinery is wave 4.** `PyUFunc_FromFuncAndData` + with its inner-loop dispatch, type resolution, and casting tables is large and + numpy-specific; wave 3 proves the array-C-API *capsule* mechanism a ufunc + registration would ride on, not the loop machinery itself. +- **External `data`-buffer `tp_dealloc`-on-finalize works for the synchronous + case; deep re-entrancy is bounded.** The body-free hook runs the type's custom + `tp_dealloc` before releasing the block, so a type that frees a + *separately-`malloc`'d* `data` block (real numpy's shape) is reclaimed + correctly - `_stockarray` does exactly this (`free(a->data)` in `tp_dealloc`) + and the `stockarray_dealloc_frees_buffer` test proves the buffer is freed on + collection. What remains a wave-4 concern is the *exotic re-entrancy* hazard: a + `tp_dealloc` that itself triggers further collection (the array-object analogue + of the wave-2 `tp_traverse`-during-collection case). The synchronous + construct/use/drop lifecycle numpy consumers exercise is covered. +- **A C reference does not outlive the owning Python object.** The borrow model + (WS2) keeps a body alive for the instance's life, not for a raw C pointer + stashed past it - the same strong-keepalive-across-the-boundary fidelity wave 2 + deferred. Synchronous construct/fill/read patterns (what numpy consumers use) + are covered. +- **The inline gate is opt-in by `tp_basicsize`.** A C-extension type that wants a + faithful instance body must declare `tp_basicsize > sizeof(PyObject)` - which + every real field-bearing type does. Types that store everything in `__dict__` + (the `_core_addr` fixtures) keep the wave-1/2 box, by design. + +## Alternatives + +1. **Keep the `_core_addr` side-allocation idiom (status quo).** Works for a + hand-written fixture but is exactly what a stock wheel does *not* do - numpy + reads `arr->data` inlined, never through a dict. Rejected: it is the gap. +2. **Lay the faithful fields *after* a `PyObjectBox` (extend the existing box).** + Tempting (no new allocation path), but the fields would then sit at + `sizeof(PyObjectBox) + offset`, not `PyObject_HEAD + offset`, so every stock + inlined accessor would read the wrong bytes. The body must begin with a real + `PyObject_HEAD` and nothing else before the fields. Rejected. +3. **A process-wide `Object`->pointer cache instead of owning the body on the + instance.** A cache keyed by `Rc::as_ptr` gives pointer stability, but without + the instance owning (and freeing) the body the lifetime is unanchored - either + it leaks (cache never evicts) or it frees on C refcount zero (fields vanish + between calls). Anchoring ownership on `PyInstance` with a `Weak` back-ref is + what makes the lifetime correct and cycle-free. +4. **Strong `Object::Instance` in the prefix (like built-in mirrors).** That is + the natural extension of wave 1, but for an instance body it forms an + ownership cycle (body -> instance -> `c_body` -> body) that never collects. + The `Weak` prefix + instance-owned body is the cycle-free dual. + +## Prior art + +- **PyPy `cpyext`.** Its `W_Root <-> py_obj` link - the app object owns the C + mirror, the mirror holds a borrow back, the link is broken at the right + refcount - is the direct model for wave 3's instance-owned body + `Weak` + prefix + `STRONG`-map borrow accounting. +- **GraalPy's native C-API.** Maintains native mirror structs for managed objects + with a managed back-reference and frees them on managed collection; validates + the "instance owns its faithful body" lifetime at scale. +- **numpy's `Include/numpy/arrayobject.h` / `ndarraytypes.h`** - the authoritative + `PyArrayObject` layout and the `_ARRAY_API`/`import_array()` capsule protocol + the fixture is shaped to. +- **RFC 0043 (wave 1)** the mirror + negative-offset prefix wave 3 extends; + **RFC 0044 (wave 2)** the `register_traverse`/`register_clear` hook pattern + wave 3's body-free hook mirrors; **RFC 0029** the capsule + buffer surface + wave 3's interchange stands on. + +## Future work + +- Wave 4: build real numpy from source; the ufunc-loop machinery; the external + `data`-buffer `tp_dealloc`-on-finalize; the complete private + `_multiarray_umath` symbol tail. +- Wave 5: pandas / Cython + the manylinux/macOS wheel matrix. +- Mirror/instance-body performance (arena allocation, fewer crossings). +- Strong-keepalive-across-the-boundary fidelity (a C reference that outlives the + owning Python object), if a real wheel is found to need it. diff --git a/docs/rfcs/0046-binary-abi-real-numpy.md b/docs/rfcs/0046-binary-abi-real-numpy.md new file mode 100644 index 0000000..271e445 --- /dev/null +++ b/docs/rfcs/0046-binary-abi-real-numpy.md @@ -0,0 +1,436 @@ +# RFC 0046: CPython 3.13 binary-ABI compatibility (cpyext) - wave 4: real numpy from source against the faithful host ABI + +- **Author**: WeavePy core +- **Status**: Accepted +- **Part of**: the D1 binary-ABI arc whose roadmap lives in + [RFC 0043](0043-cpython-binary-abi.md). RFC 0043 is the umbrella/roadmap + RFC; this is the detailed-design RFC for **wave 4**. +- **Builds on**: RFC 0043 (wave 1 - the layout-faithful object mirror, the + byte-faithful `PyTypeObject`, the immortal-refcount sentinel), RFC 0044 + (wave 2 - the full type-suite round trip, real `PyType_Ready`, the + `SlotTable` -> `dunder_shim` finalisation path, GC integration), RFC 0045 + (wave 3 - faithful inline `tp_basicsize` instance storage, real + `tp_members`, the array-interchange + C-API-capsule surface, the + `_stockarray.c` fixture). + +## Summary + +Waves 1-3 built the faithful binary ABI *and proved it against a hermetic, +hand-written fixture* (`_stockarray.c`) that is shaped like numpy but is not +numpy. Wave 4 removes the fixture and points the same ABI at the real thing: +**a from-source build of numpy 2.5.0's `_multiarray_umath` / +`_umath_linalg` C extensions, loaded with the pure-Python numpy shim +disabled (`WEAVEPY_NO_NUMPY_SHIM=1`)**, and gates CI on the RFC 0043 +acceptance one-liner: + +```python +import numpy +numpy.zeros((3, 3)) @ numpy.ones((3, 3)) +``` + +`import numpy` runs the stock package unmodified - including its `__init__` +self-checks (`_core._multiarray_umath._sanity_check`, `_mac_os_check`'s +`polyfit`/`lstsq` round-trip, and the BLAS FP-exception probe). The matmul +flows through numpy's real ufunc dispatch into the linked BLAS `dgemm`, and +the result is a real `ndarray` with the correct contents (verified beyond the +all-zeros gate: `arange(9).reshape(3,3) @ (eye(3)*2)` returns the expected +`2x` scaling, `eye(3) @ ones((3,3))` sums to 9.0). + +Getting there was two pieces of work: + +1. **The symbol tail.** The leaf C-API entry points `_multiarray_umath` + links that waves 1-3 had not yet exported - discovered by diffing the + extension's undefined `Py*`/`_Py*` symbols against the host binary's + dynamic symbol table. Most delegate to the existing abstract/number/ + container surface; a handful are sound no-ops under WeavePy's + single-threaded-GIL, non-tracemalloc runtime; the variadic members + (`PyOS_snprintf`, `PyErr_WarnFormat`, ...) live in C (`src/varargs.c`) + because they cannot be expressed as Rust `extern "C"` definitions. + (`crates/weavepy-capi/src/wave4.rs`, `src/varargs.c`, + `src/force_link_table.rs`.) + +2. **The faithfulness hardening.** Real numpy exercises corners of the ABI + that the fixture never did - builtin-subclass scalar construction, + singleton pointer identity (`np._NoValue`), the C truthiness protocol on + foreign scalars, the object-lifecycle interaction between numpy's inlined + `Py_DECREF` and WeavePy's instance bodies, subscript dispatch onto foreign + iterators, and `repr`/`str` slot dispatch on foreign objects. Each was a + real soundness or correctness gap in the wave 1-3 bridge; wave 4 closes + them. These are the substance of this RFC. + +The acceptance proof is the stock package itself: no numpy source is patched, +no self-check is gated, and the process exits 0. + +## Motivation + +The wave-3 fixture (`_stockarray.c`) was written *to the ABI we had built*. +It is honest about layout and capsules, but a fixture cannot surprise its +author: it never constructs a `float`-subclass scalar, never compares a +sentinel by identity across the boundary, never asks "is this foreign scalar +truthy?" through the number protocol, and never frees an array from inside an +inlined `Py_DECREF` that numpy emitted from a macro. Real numpy does all of +these in the first 200 milliseconds of `import numpy`, before the user types +a single expression. + +So wave 4's value is not "more symbols" (though it is that too). It is that +running the real extension is the only test that exercises the ABI the way a +wheel does, and it surfaced a cluster of latent defects - several of them +memory-safety bugs (a double-free, a use-after-free, a leaked pending +exception) that the fixture's narrower usage had hidden. Fixing them is what +makes the host ABI *actually* faithful rather than fixture-faithful. + +This is the last wave before third-party wheels at large (pandas, Cython +extensions, the wheel matrix - wave 5): numpy is the densest single consumer +of the CPython C-API in the ecosystem, so an ABI that hosts it unmodified is +the credible foundation for the rest. + +## Workstream 1: the symbol tail + +### How the tail was found + +A from-source `_multiarray_umath.cpython-313-darwin.so` has an undefined-symbol +list (`nm -u`) of every `Py*`/`_Py*` it expects the host to provide. Diffing +that against the symbols WeavePy already exports (waves 1-3) yields the exact +*leaf* tail wave 4 must add - nothing more. This keeps the surface honest: +every function in `wave4.rs` is there because a real wheel references it, not +because the C-API has it. + +### Three kinds of leaf + +1. **Delegators.** The majority forward to the surface waves 1-3 already + implement. `PySys_GetObject`, `PyImport_GetModuleDict`, + `PyObject_GetAttrString`-family helpers, the `PyNumber_*`/`PySequence_*` + spellings numpy uses internally, the `PyUnicode_*` accessors the dtype + machinery calls - all reduce to existing entry points. + +2. **Sound no-ops.** A handful name subsystems that have no behavioural + meaning under WeavePy's runtime model and that CPython itself short-circuits + when the subsystem is disabled: `tracemalloc` domain hooks + (`_PyTraceMalloc_*`), the per-thread GIL-state dance + (`PyGILState_Ensure`/`Release` collapse to the single interpreter thread), + and the free-threading build's mutex shims. Each returns the value CPython + returns with the feature off, so numpy's `#ifdef`-guarded fast paths are + taken correctly. + +3. **Borrowed-reference pins.** `PySys_GetObject` and `PyEval_GetBuiltins` + return *borrowed* references in CPython. WeavePy mints a fresh `PyObject` + box each time a VM value crosses the boundary, so there is no persistent + owner to borrow from - decref'ing the freshly-minted box (as "borrowed" + would imply) would free it and hand the caller a dangle. `wave4.rs` mints + each such object once and **pins it for the process lifetime** (a bounded, + per-key leak), returning the same stable pointer on every call. This both + satisfies the borrowed contract (the caller must not decref) and gives the + object the stable identity numpy depends on. + +### The variadic members live in C + +`PyOS_snprintf`, `PyErr_WarnFormat`, `PyErr_Format`'s `printf`-family and the +other C-variadic entry points cannot be written as Rust `extern "C"` +definitions (Rust has no stable variadic-definition ABI). They are defined in +`crates/weavepy-capi/src/varargs.c`, compiled by the crate's build script, +and forward into the Rust core after formatting with the platform `vsnprintf`. + +### Forcing the tail to link + +Because most tail functions are referenced only by the *dynamically* loaded +extension and never by WeavePy's own Rust code, the linker would garbage-collect +them from the host binary. `src/force_link_table.rs` builds a `#[used]` table of +their addresses so they survive into the dynamic symbol table and are resolvable +by `dlopen`. + +## Workstream 2: faithfulness hardening (the substance) + +Each subsection is a defect real numpy exposed, the root cause in the wave 1-3 +bridge, and the fix. They are ordered the way `import numpy` hits them. + +### 2.1 Builtin-subclass scalar construction (`np.float64(...)` -> SIGSEGV) + +numpy's scalar types (`np.float64`, `np.int32`, ...) are **subclasses of the +host builtins** (`float`, `int`). Constructing one runs the builtin base's +`tp_new` (`float`'s `tp_new` is at the faithful `PyTypeObject` offset +`0x138`). Waves 1-3 left the builtin types' `tp_new` slot NULL - WeavePy +constructs its own `float`/`int`/`str` through the VM, never through a C slot +- so numpy's `float64.__new__` jumping through `PyFloat_Type->tp_new` jumped +through NULL and segfaulted. + +**Fix.** `crates/weavepy-capi/src/builtin_new.rs` implements a faithful +`tp_new` for the numeric builtins: it parses the single positional argument +through the same coercion the VM `float()`/`int()` constructors use and +returns a faithful boxed scalar. The slot is installed on the builtin +`PyTypeObject`s during interpreter bring-up so a C subclass that delegates to +`PyFloat_Type->tp_new(subtype, args, kwds)` lands in real code. + +### 2.2 Singleton pointer identity (`np._NoValue` -> "a float is required") + +numpy uses a module-level sentinel, `np._NoValue`, as the default for +reduction arguments (`a.sum(initial=np._NoValue)`), and dispatches on it **by +pointer identity** (`if initial is _NoValue`). The sentinel is an instance of +a plain Python class, so crossing it into C produced an `Object::Instance`. +Wave 1-3's `into_owned` minted a *fresh* `PyObjectBox` on every crossing, so +`_NoValue` had a different address each time it entered C; numpy's +`initial == _NoValue` (a `PyObject*` compare) was never true, so the sentinel +was treated as a literal initial value and fed to `PyFloat_AsDouble` -> "a +float is required". This surfaced during `_mac_os_check`'s `polyfit`. + +**Fix.** `crates/weavepy-capi/src/object.rs` caches the boxed identity of a +non-inline `Object::Instance` in the instance's `c_body` cell. The first +crossing mints the box (`mint_instance_box`), stores +`Object::Instance(inst.clone())` in its payload, registers it, and records the +pointer in `inst.c_body`; subsequent crossings return that same pointer with +an incremented refcount (`cached_instance_box`). `free_box` clears the cell +when the identity box is freed. The sentinel now has one stable address for +its lifetime, so numpy's identity check works. + +### 2.3 The C truthiness protocol on foreign scalars (`np.bool_` -> spurious `RankWarning`) + +After 2.2, `polyfit(cov=True)` raised a `RankWarning` it should not. The +culprit: `polyfit` evaluates `if rank != order and not full:`. The +sub-expression `rank != order` is an `np.bool_` scalar (a *foreign* object), +and WeavePy's truthiness for `Object::Foreign` unconditionally returned +`true`, so the warning branch fired. + +**Fix.** `PyObject_IsTrue` in `crates/weavepy-capi/src/abstract_.rs` now +dispatches a foreign operand through CPython's truth protocol in order: +`nb_bool` (from `tp_as_number`), then `mp_length` (from `tp_as_mapping`), then +`sq_length` (from `tp_as_sequence`), defaulting to true only when none is +defined - exactly `PyObject_IsTrue`'s own slot order. `np.bool_(False)` now +reports false through its `nb_bool`. + +### 2.4 Object lifecycle: numpy's inlined `Py_DECREF` vs. faithful bodies + +`import numpy`'s `_sanity_check` (`numpy.zeros` through the ufunc path) +crashed in `convert_ufunc_arguments` reading `op->descr == NULL`. Root cause: +numpy frees arrays through the **inlined** `Py_DECREF` macro, which - when the +refcount hits zero - calls `_Py_Dealloc` directly. For a WeavePy faithful +instance body that bypassed WeavePy's lifecycle control and ran the extension +`tp_dealloc`, tearing down the array payload (`data`/`dims`/`descr`) while +WeavePy still believed it owned the object; the next access read a freed +`descr`. + +**Fix.** `_Py_Dealloc` in `object.rs` routes an object that is a WeavePy +*instance body* to `free_box` instead of letting `tp_dealloc` run, so the +faithful body is reclaimed under WeavePy's ownership rules (the body's block +is owned by the native instance, not by the extension). + +### 2.5 `free_box` ordering: a foreign double-free (SIGBUS) + +A non-trivial matmul (`numpy.eye(3)`) crashed with SIGBUS. `free_box` decided +*how* to free a pointer by consulting the type-keyed `is_mirror` check **before** +checking whether WeavePy even owns the allocation. A foreign numpy type that +WeavePy had `PyType_Ready`'d answered `is_mirror == true`, so `free_box` called +`free_mirror` on memory numpy had malloc'd - a double-free. + +**Fix.** `free_box` now checks `!is_weavepy_owned(p)` **first** (right after +invalidating the borrowed-pointer cache) and bails to the foreign path before +any type-keyed mirror/instance-body logic runs. `_Py_Dealloc` is likewise +hardened to gate its `is_instance_body` probe behind `is_weavepy_owned`, so it +never dereferences foreign-owned memory to decide a lifetime. + +### 2.6 Subscript dispatch onto foreign objects (`m.flat[i::M+1] = 1`) + +`numpy.eye(3)` assigns through a flat iterator: `m[:M-k].flat[i::M+1] = 1`. +`m.flat` is a foreign `numpy.flatiter`. The VM's `STORE_SUBSCR` / +`BINARY_SUBSCR` opcode handlers had no arm for `Object::Foreign`, so they fell +straight to the generic "object does not support item assignment" `TypeError`. +(A direct `f.__setitem__(...)` worked because it resolved the method through +`load_attr` and bypassed the opcode.) + +**Fix.** Both opcode handlers in `crates/weavepy-vm/src/lib.rs` now special-case +a foreign target: they `load_attr` `__setitem__` (writes) / `__getitem__` +(reads) and call the bound method, falling back to the generic path only when +the method is genuinely absent (so the canonical `TypeError` is still produced +for non-subscriptable foreign objects). + +### 2.7 `repr`/`str` of foreign objects, and a leaked pending exception + +A foreign object's `repr`/`str` came back as the debug placeholder +`` because `PyObject_Repr`/`PyObject_Str` only knew the VM +`repr_for`, which sees an opaque `Object::Foreign`. So `repr(np.float64(2.5))` +and `repr(array)` were wrong. + +**Fix.** `PyObject_Repr`/`PyObject_Str` detect a foreign operand and dispatch +through its `tp_repr` / `tp_str` slot (with `str` falling back to `tp_repr`, +as CPython does). Because WeavePy's `PyType_Ready` does not run CPython's +`inherit_slots` step, a stock subclass can carry a NULL `tp_repr`; the dispatch +therefore **walks the `tp_base` chain** to recover the inherited slot, +reproducing the *effect* of `inherit_slots` for this path. With this, +`np.float64(2.5)` -> `np.float64(2.5)`, `np.int32(7)` -> `np.int32(7)`, and an +array -> `array([[...]])`. + +A subtle memory-safety corollary: if the dispatched slot *raises* (returns +NULL with a pending exception) and we fall back to the placeholder, the pending +exception must be consumed - otherwise it leaks into the next VM operation and +surfaces as a spurious error far from its origin. The fallback path now takes +the pending exception before returning the placeholder. + +### 2.8 Thread-local teardown at process exit (exit code 133) + +Once the scalar `tp_new` worked, the process aborted at exit (rc 133): the +instance-pinning thread-local map (`STRONG`) was being touched during +thread-local destruction, after it had itself been dropped. `instance.rs`'s +`free_instance_body_hook` / `release_c_ownership` now use `STRONG.try_with(..)` +and treat a destroyed map as "nothing to unpin", so teardown is panic-free. + +### 2.9 GC collection of a cycle held through C-managed memory + +The wave-4 non-inline-instance identity cache (Section 2.2) lets a single +instance be reached both by its GC-tracked allocation box (from +`PyType_GenericAlloc` / `_PyObject_GC_New`) and by the cached identity box +stashed in `c_body`. A stock cycle-collecting GC type breaks a reference cycle +inside `tp_clear` with the **inlined** `Py_CLEAR(child)`, whose stock +`Py_DECREF` -> `_Py_Dealloc` -> `tp_dealloc` cascade is what runs each node's +destructor (decrementing a live-node counter, freeing its C core) exactly once. + +The collector's `tp_traverse` / `tp_clear` bridge (`gc_bridge.rs`) materialised +`self` into C through the identity cache, i.e. it handed `tp_clear` *the very +box a cycle edge pointed at*, with the usual `+1`. That extra reference stopped +the `Py_CLEAR` cascade from driving the cached child box's refcount to zero +through `_Py_Dealloc`; the node was instead reclaimed later through WeavePy's +`free_box` - which is `tp_free`, **not** `tp_dealloc` - so the extension's +destructor never ran and one node leaked (`stocktype_gc_cycle_through_c_memory` +regressed to `live=1`). + +**Fix.** The GC bridge borrows `self` through a new +`into_owned_with_type_uncached`, which mints a *fresh* box for a non-inline +instance and never consults or populates `c_body`. The cached cycle-child boxes +keep exactly the refcount the extension expects, so the stock `Py_CLEAR` +cascade runs each node's `tp_dealloc` once and the cycle is fully reclaimed. + +The tempting alternative - routing *every* WeavePy-side `Py_DecRef`-to-zero +through `_Py_Dealloc` (CPython's `Py_DECREF` -> `_Py_Dealloc` -> `tp_dealloc` +contract) - was tried and rejected: WeavePy mints many transient per-crossing +boxes for one logical instance (method receiver, each argument, traverse/clear +borrows), and running the extension `tp_dealloc` as each transient box hits +zero over-counts the destructor catastrophically (the live counter went +*negative*). The asymmetry **"the extension's own inlined `Py_DECREF` runs +`tp_dealloc`; WeavePy's internal `free_box` does not"** is therefore +load-bearing, and the fix preserves it by keeping the bridge's borrow off the +shared identity box. + +### 2.10 Foreign-metaclass attribute resolution (`repr(dtype)` / `dtype.name`) + +numpy's dtype `repr`/`str`/`.name`/`.kind` all read `type(dtype)._legacy` - a +getset **property on numpy's DType metaclass** `_DTypeMeta` +(`PyArrayDTypeMeta_Type`), not on the dtype class or its MRO. WeavePy readies +each DType class (`Float64DType`) into an `Object::Type`, but recorded its +metaclass as the plain `type`, so the VM's `load_attr_type` metatype lookup +never saw `_legacy`; the access raised `AttributeError`, numpy's +`arraydescr_repr` returned NULL, and dtype display fell back to the foreign +placeholder (``). + +**Fix (two parts).** + +1. `PyType_Ready` (`types.rs`) now reflects a *foreign* metatype onto the + bridged type. After readying a stock type, when `Py_TYPE(t)` is a foreign + extension metatype (not WeavePy's `PyType_Type`, and not `t` itself), WeavePy + readies that metatype on demand - harvesting its getsets, including + `_legacy` / `_abstract` / the `type` property - and `set_metaclass`es it onto + the bridged type. `load_attr_type` then resolves `type(dtype)._legacy` + through the metatype's harvested getset, invoking the C getter with the + DType's `ext_ptr` as `self` (`into_owned` already round-trips an + `Object::Type` back to its registered `PyTypeObject*`, so the getter reads + numpy's genuine `PyArray_DTypeMeta` struct). This also makes `type(dtype)` + correctly report `numpy.dtypes.Float64DType`'s real metaclass. + +2. A companion VM fix: `str()` of a foreign object had gone through `repr` + (`Object::to_str` only knows `repr`), collapsing `str(dtype)` to the repr + form. `Interpreter::stringify` now routes a foreign operand through the + `tp_str` hook (`foreign::str_`), so `str` and `repr` stay distinct. + +With both, `repr(dtype) == "dtype('float64')"`, `str(dtype) == "float64"`, and +`dtype.name` / `dtype.kind` are byte-correct - while `str`/`repr` of an +`ndarray` (which has its own `tp_str`/`tp_repr`) remain correct. + +## The CI gate + +The acceptance target builds **numpy 2.5.0** from the published sdist against +the stock CPython 3.13 headers, installs it into a venv's `site-packages`, and +runs - with WeavePy's bundled pure-Python numpy shim disabled so the real C +extension is the one imported: + +```bash +PYTHONPATH="$SITE_PACKAGES" WEAVEPY_NO_NUMPY_SHIM=1 \ + weavepy -c 'import numpy; numpy.zeros((3,3)) @ numpy.ones((3,3))' +``` + +The gate requires: + +- `import numpy` completes with **no source patches** and **no self-check + gating** - `_sanity_check`, `_mac_os_check` (which runs `polyfit`/`lstsq`), + and the BLAS FP-exception probe all run and pass; +- the matmul returns an `ndarray` (not the shim, not a proxy); +- the process exits 0. + +Correctness is checked beyond the all-zeros one-liner (which a broken matmul +could pass by coincidence): `arange(9, dtype=float).reshape(3,3) @ (eye(3)*2)` +returns the `2x`-scaled matrix (checksum 72.0) and `eye(3) @ ones((3,3))` sums +to 9.0, confirming the product flows through numpy's ufunc dispatch into the +linked BLAS `dgemm` and back. + +## Testing / acceptance + +- **The gate itself** is the headline acceptance: stock numpy 2.5.0, + self-checks live, exits clean. +- **Non-trivial numerics**: `eye`, `arange`/`reshape`, scalar reductions + (`sum`), `polyfit`/`lstsq` (via the import-time self-check) all execute. +- **Scalar/array display**: `repr`/`str` of arrays and of `np.float64` / + `np.int32` scalars are byte-correct (regression-guarded against the foreign + placeholder), and `repr(dtype)` (`dtype('float64')`), `str(dtype)` + (`float64`), `dtype.name`, `dtype.kind` all resolve through the foreign + metaclass (Section 2.10). +- **GC of C-held cycles**: a reference cycle routed through extension-managed + memory and broken by the type's `tp_clear` is fully reclaimed - each node's + `tp_dealloc` runs exactly once (`stocktype_gc_cycle_through_c_memory`, + Section 2.9). +- **No regressions** in the existing capi suite or the CPython conformance + subset (see Verification). +- **Diagnostics removed**: the temporary `WEAVEPY_DEBUG_SEGV` fault handler, + `WEAVEPY_DEBUG_ARR`/`NCALL`/`FOREIGN`/`REPR` trace gates, and the + `libc` dev-dependency they needed are all gone; the build is warning-clean. + +## Known limitations / deferred + +These are cosmetic or non-load-bearing and explicitly **not** on the wave-4 +gate. They are good first issues for wave 5 polish. + +- **`inherit_slots`** is approximated, not implemented. Section 2.7 walks the + `tp_base` chain for `tp_repr`/`tp_str` on demand; a full wave-5 fix would + bake every inherited slot into each subtype at `PyType_Ready` time as + CPython does, removing the need for per-call base walks. +- **`add_newdoc` warnings.** numpy emits `UserWarning: add_newdoc was used on + a pure-python object ` (and similar) at import, + because WeavePy's bridged C types present a writable `__doc__`. Harmless - + numpy continues - but a faithful read-only `__doc__` on bridged static types + would silence them. + +## Non-goals (deferred to wave 5) + +- pandas and the broad Cython-generated extension surface (the full-API, + heavily-macro'd Cython idiom). +- The manylinux / macOS wheel-matrix matrix and binary-wheel provenance. +- Faithful baked-in `inherit_slots` (Section 2.7 approximates it with a + per-call `tp_base` walk; see Known limitations). Foreign-metaclass attribute + resolution is now implemented for the getset-on-metatype case (Section 2.10); + a fully general metaclass `__getattribute__`/`tp_call` path is wave 5. +- Multi-threaded / free-threaded (`Py_GIL_DISABLED`) numpy. + +## Files + +New: + +- `crates/weavepy-capi/src/wave4.rs` - the discovered C-API leaf tail. +- `crates/weavepy-capi/src/varargs.c` - the C-variadic members. +- `crates/weavepy-capi/src/force_link_table.rs` - `#[used]` link anchors. +- `crates/weavepy-capi/src/builtin_new.rs` - faithful builtin `tp_new`. +- `crates/weavepy-capi/src/foreign.rs` / `crates/weavepy-vm/src/foreign.rs` - + the foreign-object soul + hook bridge (repr/str/hash/truth/call/getattr/ + setattr/getitem/setitem/iter/binop/compare/get_type). + +Materially changed: `object.rs` (identity caching, `_Py_Dealloc` routing, +`free_box` ordering, `into_owned_with_type_uncached` for the GC bridge), +`abstract_.rs` (foreign truthiness + repr/str dispatch), `types.rs` +(`PyType_Ready` foreign-metaclass linkage; `HEAP_TYPES` made process-global), +`gc_bridge.rs` (uncached `traverse`/`clear` borrows), `instance.rs` (TLS-safe +teardown), `weavepy-vm/src/lib.rs` (foreign subscript opcodes; `stringify` +foreign `tp_str` dispatch), `module.rs`/`numbers.rs`/`containers.rs`/`mirror.rs` +(tail support). diff --git a/docs/rfcs/0047-binary-abi-cython-pandas.md b/docs/rfcs/0047-binary-abi-cython-pandas.md new file mode 100644 index 0000000..7bbeb9b --- /dev/null +++ b/docs/rfcs/0047-binary-abi-cython-pandas.md @@ -0,0 +1,495 @@ +# RFC 0047: CPython 3.13 binary-ABI compatibility (cpyext) - wave 5: the Cython-generated extension surface (pandas), faithful `inherit_slots`, and the manylinux/macOS/musllinux wheel matrix + +- **Author**: WeavePy core +- **Status**: Accepted +- **Part of**: the D1 binary-ABI arc whose roadmap lives in + [RFC 0043](0043-cpython-binary-abi.md). RFC 0043 is the umbrella/roadmap + RFC; this is the detailed-design RFC for **wave 5**, the final wave. +- **Builds on**: RFC 0043 (wave 1 - the layout-faithful object mirror, the + byte-faithful `PyTypeObject`, the immortal-refcount sentinel), RFC 0044 + (wave 2 - the full type-suite round trip, real `PyType_Ready`, the + `SlotTable` -> `dunder_shim` finalisation, GC integration), RFC 0045 + (wave 3 - faithful inline `tp_basicsize` instance storage, real + `tp_members`, the array-interchange + C-API-capsule surface), RFC 0046 + (wave 4 - real numpy from source, the foreign-object soul, the + faithfulness hardening, and the `tp_base`-walk approximation of + `inherit_slots`). + +## Summary + +Wave 4 made the single densest C-API consumer in the ecosystem - **real +numpy** - import and compute against WeavePy's faithful host ABI. Wave 5 +closes the gap between "numpy imports" and "the rest of the binary-wheel +ecosystem runs", whose dominant shape is **Cython-generated** code: +pandas is ~70% Cython by line count, and Cython's runtime is what lxml, +pydantic-core's helpers, scikit-learn, pyarrow's Python layer, and +thousands of smaller wheels are built on. Cython exercises one corner of +the ABI that hand-written extensions and even numpy mostly avoid, and it +exercises it *constantly*: it reads type slots **directly off the C +struct** (`Py_TYPE(self)->tp_as_number->nb_add`, `…->tp_repr`, +`…->tp_as_sequence->sq_length`), with no MRO walk, on instances of +**subclasses it defines**. + +Wave 5 is four workstreams plus a hermetic proof: + +1. **Faithful `inherit_slots`** (the wave-4 deferred item, RFC 0046 + §2.7 / Known limitations). CPython finishes `PyType_Ready` by copying + every `tp_*` function slot and method-suite entry a subtype leaves + NULL down from its base, so an inlined `Py_TYPE(sub)->tp_repr` on a + subclass resolves to the base's function. Waves 1-4 did not: a readied + subtype carried only the slots it spelled out itself, and inherited + behaviour was reachable *only* through the bridged MRO (the synthesised + dunder shims) - correct for Python-level dispatch, a NULL-deref for the + Cython idiom. Wave 5 bakes the inherited slots into both the decoded + `SlotTable` and the faithful `PyTypeObject` struct at ready time. + (`crates/weavepy-capi/src/inherit.rs`, `src/types.rs`.) + +2. **The Cython C-API runtime tail.** The leaf entry points Cython's + `Cython/Utility/*.c` runtime links that waves 1-4 had not yet exported + - found the same way wave 4 found numpy's tail: diff a Cython + `.cpython-313-*.so`'s undefined `Py*`/`_Py*` symbols against the host's + dynamic table. (`crates/weavepy-capi/src/wave5.rs`.) + +3. **A real vectorcall defect** Cython's method-call shims exposed. Every + `obj.method(arg)` Cython emits goes through the stock inline + `PyObject_CallMethodOneArg`, which calls our `PyObject_VectorcallMethod` + with `PY_VECTORCALL_ARGUMENTS_OFFSET` set. WeavePy's vectorcall + argument decoder mis-read that flag as a one-slot **shift** of the + `args` array (it only marks `args[-1]` as scratch), so it read one past + the end and dereferenced garbage. Fixed in + `crates/weavepy-capi/src/vectorcall.rs`. + +4. **The wheel matrix.** `_packaging` already enumerated the manylinux and + macOS platform tags; wave 5 adds **musllinux** (PEP 656, the Alpine/musl + sibling numpy and pandas both ship) and a WeavePy **provenance** tag so a + publisher can ship a build verified against WeavePy specifically, plus + the matching musl SOABI suffixes in the extension loader. + (`crates/weavepy-vm/src/stdlib/python/_packaging.py`, + `crates/weavepy-capi/src/loader.rs`.) + +The proof is hermetic: `tests/capi_ext/_stockcython.c`, compiled against +the host's **stock CPython 3.13 headers** and shaped like a Cython +extension (an extension-defined base, a pure subclass, and a +partial-override subclass that reads inherited slots straight off +`Py_TYPE(self)`), `dlopen`ed into WeavePy and driven by +`capi_stockcython.rs` (7 tests). + +## Motivation + +The binary-ABI arc's ceiling is "the wheels users actually `pip install` +run unmodified." Wave 4 cleared numpy, but numpy is hand-written C: it +reads its *own* inline fields (wave 3) and a large flat C-API surface +(wave 4), yet it rarely defines a *subclass of an extension type* and then +reads that subclass's slots off the struct. Cython does both, by +construction, for every `cdef class`: + +- A `cdef class Sub(Base)` compiles to a static `PyTypeObject` whose + `tp_base = &Base_Type` and whose slots are mostly NULL when `Sub` + doesn't override them. +- Cython's generated call sites do **not** go through `PyObject_GetAttr` + + the MRO. They inline the slot read: `Py_TYPE(self)->tp_as_number->nb_add`, + `Py_TYPE(self)->tp_iternext`, `Py_TYPE(self)->tp_descr_get`. This is the + whole point of Cython - it bypasses Python's dispatch. + +On a subclass whose `tp_as_number` was left NULL, that inlined read is a +NULL-deref before the first line of user code. RFC 0046 shipped a per-call +`tp_base` walk for the `tp_repr`/`tp_str` path as a stop-gap and named the +real fix - bake the inherited slots in at ready time, as CPython does - as +wave-5 work. This RFC is that fix, generalised to *every* slot and method +suite, plus the leaf symbols and the method-call fast path Cython needs to +link and run. + +The wheel-matrix piece is the distribution dual: an ABI that hosts a +Cython wheel is only useful if the resolver recognises the wheel. numpy +and pandas publish across manylinux, macOS (`universal2`/`x86_64`/`arm64`), +**and** musllinux; the last was the one platform family `_packaging` did +not enumerate. + +## The central problem, precisely + +CPython's `PyType_Ready` runs `inherit_slots` (`Objects/typeobject.c`) as +its final step. For every slot the subtype leaves NULL, it copies the +base's value down - the `COPYSLOT` / `COPYNUM` / `COPYSEQ` / `COPYMAP` +macro family. The result is that a finalised subtype's struct is +**flattened**: `Sub_Type.tp_repr`, `Sub_Type.tp_as_number->nb_add`, and so +on all point at the function that will actually run, whether the subtype or +an ancestor defined it. Inlined reads off the struct therefore always land +on real code. + +WeavePy's `PyType_Ready` (wave 2) instead harvests the subtype's *own* +slots into a `SlotTable`, builds a bridged native `TypeObject` whose MRO +carries the inherited behaviour as synthesised dunder shims, and writes +`ob_type` + the ready flag back into the struct. It never flattened the +struct. Two consequences: + +- **Python-level dispatch is correct.** `sub + other` resolves + `Base.__add__` through the bridged MRO exactly as CPython resolves it + through the type's `__mro__`. Wave 2's fixtures (all of which subclass + `object`) never noticed the gap. +- **Direct struct reads on a subclass are wrong.** `Py_TYPE(sub)->tp_repr` + is NULL if `Sub` didn't define `__repr__`; `…->tp_as_number` is NULL if + `Sub` declared no number suite. The Cython idiom dereferences exactly + these. + +The instance side is already faithful for this: an instance of a readied +subclass crosses into C with `ob_type` set to the subclass's own +`PyTypeObject*` (`find_type_ptr` resolves a readied type's `ext_ptr`), so +`Py_TYPE(sub_instance)` *is* `&Sub_Type`. The only missing piece is making +`&Sub_Type`'s slots non-NULL - i.e. `inherit_slots`. + +## CPython reference + +- **`inherit_slots`** (`Objects/typeobject.c`). Runs once per type at the + end of `PyType_Ready`, after `mro` and `inherit_special`. Copies every + function slot and method-suite entry from `tp_base` (more precisely, from + the MRO, but because each base is itself readied-and-flattened first, the + immediate base suffices) when the subtype's is NULL. The method suites + (`tp_as_number`, `tp_as_sequence`, `tp_as_mapping`, `tp_as_async`, + `tp_as_buffer`) are merged field-by-field via the `COPYSLOT` macros. +- **The `PY_VECTORCALL_ARGUMENTS_OFFSET` contract** (`Include/cpython/ + abstract.h`, `Objects/call.c`). The flag in `nargsf` tells the callee it + may temporarily overwrite the scratch slot at `args[-1]` (CPython's trick + for prepending `self` without reallocating). It does **not** shift + `args`: `args[0]` is always the first argument, and + `PyVectorcall_NARGS(nargsf) = nargsf & ~OFFSET` counts the elements at + `args[0..nargs]`. `PyObject_CallMethodOneArg(self, name, arg)` calls + `PyObject_VectorcallMethod(name, {self, arg}, 2 | OFFSET, NULL)`: + `args[0]` is `self`, `args[1]` is the argument, `nargs == 2`. +- **`_PyObject_GetMethod`** (`Objects/object.c`). Returns 1 and an + *unbound* function when `name` is a plain method on the type (so the + caller passes `self` as arg 0), 0 and an already-*bound* attribute + otherwise. Both returns are valid; the unbound case is a micro-opt. + +## Detailed design (wave 5) + +### Workstream 1: faithful `inherit_slots` + +`crates/weavepy-capi/src/inherit.rs` adds one entry point, called from +`PyType_Ready` immediately after the bridged `TypeObject` is built (so the +type dict still carries only the subtype's *own* dunders - inherited +behaviour stays reachable through the MRO, exactly as CPython keeps it): + +```rust +pub unsafe fn inherit_slots( + t: *mut PyTypeObject, + table: &mut SlotTable, + base: *mut PyTypeObject, +) +``` + +It copies, **from the immediate base only**: + +1. **The decoded `SlotTable`.** Every slot id the subtype left NULL is + filled from the base's table, so the direct-table-read dispatch paths + (the buffer protocol, vectorcall, `tp_descr_get`/`set`, the GC bridge) + and the `has_*_protocol` queries see the inherited slot. +2. **The faithful `PyTypeObject` struct.** Every NULL direct function slot + (`tp_repr`, `tp_hash`, `tp_call`, `tp_iter`/`tp_iternext`, + `tp_richcompare`, `tp_descr_get`/`set`, `tp_init`/`tp_new`, + `tp_traverse`/`tp_clear`, the `tp_dealloc` destructor, …), the + instance-layout offsets (`tp_dictoffset`, `tp_weaklistoffset`, + `tp_vectorcall_offset`) when the subtype adds no storage of its own, and + every method suite is filled in place. A suite is **shared** (pointer + copied) when the subtype has none, or **merged word-by-word** (every + NULL field filled from the base) when the subtype declares its own + partial suite - CPython's per-slot `COPYSLOT`, exploiting that every + method-suite field is pointer-width. + +Copying only from the *immediate* base is sufficient and complete: +`PyType_Ready` readies a type's base before the type itself +(`bridge_or_ready(tp_base)` during harvest), and each base was itself run +through `inherit_slots`, so the immediate base's table and struct are +already fully flattened. One level of copy therefore carries the whole +ancestor chain - the same invariant CPython relies on. When the base is a +WeavePy-native builtin (whose behaviour the VM provides through the MRO, +not through C slots) or null, this is a no-op. + +This **supersedes** RFC 0046 §2.7's per-call `tp_base` walk for +`repr`/`str`: with the slots baked in, `PyObject_Repr`/`Str` on a foreign +subclass read the inherited slot directly off the (now-flattened) struct. +The §2.7 walk is left in place as a defensive fallback (it is a no-op once +the slot is non-NULL) and is a candidate for removal in cleanup. + +### Workstream 2: the Cython C-API runtime tail + +`crates/weavepy-capi/src/wave5.rs` adds the leaf functions Cython's +runtime links, each a thin delegator onto the wave-1/2/3 surface or a +sound no-op under WeavePy's object model: + +- **`_PyObject_GetDictPtr`** -> `NULL`. WeavePy keeps an instance's + `__dict__` in a managed Rust cell, not at a fixed `tp_dictoffset` inside + the object body, so there is no in-body `PyObject **dictptr` to hand + back. CPython itself returns NULL for any type with `tp_dictoffset == 0`, + and every caller (Cython's `__Pyx_GetAttr*`, CPython's + `_PyObject_GenericGetAttrWithDict`) treats NULL as "no fast dict" and + falls back to `tp_getattro` / `PyObject_GenericGetAttr`, which WeavePy + services. A faithful in-body `tp_dictoffset` dict is a documented + Non-goal. +- **`PyObject_GetOptionalAttrString` / `PyMapping_GetOptionalItem` / + `PyMapping_GetOptionalItemString`** (CPython 3.13 additions). The + "present -> 1, absent -> 0 with the error cleared" probes Cython uses for + optional attribute/item access. Each delegates to the existing + get-attr/get-item and maps a missing value to absence. +- **`_PyObject_GetMethod` + `PyObject_CallMethodOneArg`** - the fast + method path. `_PyObject_GetMethod` resolves through the VM binding + protocol and returns 0 ("bound"); Cython's `__Pyx_PyObject_GetMethod` + handles both the bound and unbound returns, so never taking the unbound + micro-opt branch is sound. (`PyObject_CallMethodOneArg` itself is the + stock inline shim -> `PyObject_VectorcallMethod`; see WS3.) +- **`_PyDict_NewPresized`** -> `PyDict_New` (the presize hint is + informational - WeavePy's dict grows on demand). +- **`PyLong_AsInt`** - the 3.13 public spelling of the bounds-checked + `int` conversion (`__Pyx_PyInt_As_int`), delegating to `PyLong_AsLong` + with a range check. +- **`PyImport_ImportModuleLevelObject`** - the entry behind Cython's + `__Pyx_Import`. Services the absolute-import form (`level == 0`); the + relative form (`level > 0`) is a documented bound. + +(The interned-string fast-compares `PyUnicode_EqualToUTF8[AndSize]` Cython +also links were already implemented in `strings.rs`; wave 5 does not +duplicate them.) `src/force_link_table.rs` gains a `#[used]` anchor for +each new leaf so it survives into the dynamic symbol table for `dlopen`. + +### Workstream 3: the vectorcall `ARGUMENTS_OFFSET` defect + +Cython compiles `obj.method(arg)` to the stock inline +`PyObject_CallMethodOneArg`, which calls +`PyObject_VectorcallMethod(name, {self, arg}, 2 | PY_VECTORCALL_ARGUMENTS_OFFSET, NULL)`. +WeavePy's three vectorcall argument decoders (`collect_positional`, +`collect_positional_after`, `kwnames_to_dict`) treated the OFFSET bit as a +**+1 index shift** of the `args` array - reading `args[offset..]` rather +than `args[0..]`. That is a misreading of the contract: the bit only marks +`args[-1]` as scratch; `args[0]` is still the first argument. With OFFSET +set, the method decoder read one element past the end of `{self, arg}` and +fed the garbage pointer to `clone_object`, faulting on a misaligned +dereference. + +The fix removes the shift entirely (the corrected contract is documented +on the const and module): `collect_positional` reads `args[0..nargs]`, +`collect_positional_after` (the `VectorcallMethod` receiver-skip) reads +`args[1..nargs]`, and keyword values sit at `args[nargs..nargs+nkw]`. This +was latent because WeavePy's *own* vectorcall callers +(`call_via_vectorcall`) never set the OFFSET bit and never reserve a +leading slot - so the bug only triggered for external (stock/Cython +inline-shim) callers, which is precisely the wave-5 surface. + +### Workstream 4: the wheel matrix + +`crates/weavepy-vm/src/stdlib/python/_packaging.py`: + +- **musllinux** (PEP 656). `_platform_tags()` now emits + `musllinux_1_{0..5}_{machine}` on Linux alongside the manylinux range - + numpy and pandas both publish `musllinux_1_1` and `musllinux_1_2` wheels, + and without these the resolver skipped every binary wheel on a musl host. + `_platform_tags()` also gained optional `plat`/`machine` parameters for + host-independent testing. +- **Provenance.** `compatible_tags()` emits a WeavePy interpreter tag + (`weavepy`) ahead of the stock `cp313`/`abi3` tags, and `wheel_score()` + ranks a `weavepy`-tagged wheel above the generic stock build it shadows. + This lets a project ship a build verified against WeavePy specifically + (e.g. `pkg-1.0-weavepy-cp313-.whl`) that stock CPython never sees + (it emits no `weavepy` tag) but WeavePy prefers. The provenance tags are + gated on `sys.implementation.name == 'weavepy'`, so the module stays + byte-for-byte CPython-faithful if vendored elsewhere. + +`crates/weavepy-capi/src/loader.rs`: `extension_suffixes()` recognises the +musl SOABI suffixes (`.cpython-313-{x86_64,aarch64}-linux-musl.so`) +alongside the existing glibc ones, so a wheel from either Linux ABI +resolves to its `.so`. + +## The hermetic proof: `_stockcython` + +`tests/capi_ext/_stockcython.c` is compiled against the host's stock +CPython 3.13 headers (`build.rs`, skipped with a warning when the dev +headers aren't present, so a bare CI host still builds) and is shaped like +a Cython extension: + +- **`CyBase`** - a base type defining a number suite (`nb_add`), a sequence + suite (`sq_length`), `tp_repr`, `tp_hash`, and `tp_richcompare`. +- **`CySub(CyBase)`** - a *pure* subclass that declares **nothing**. Every + slot it dispatches must come from `CyBase` via `inherit_slots`. +- **`CySub2(CyBase)`** - a *partial-override* subclass with its own + `tp_repr` and a number suite carrying only `nb_subtract`. `inherit_slots` + must keep its own `tp_repr`/`nb_subtract` and **merge** `nb_add` into that + same suite from the base (the in-place suite-merge path). + +`probe_slots(obj)` reads the slots **directly off `Py_TYPE(obj)`** - the +inlined Cython idiom, no MRO - and invokes them, returning a result dict. +`cython_runtime_surface(obj)` exercises the WS2 tail (the optional-attr/item +probes, `_PyObject_GetMethod`, the method-one-arg call that flows through +the WS3-fixed vectorcall path, the presized dict, `PyLong_AsInt`, and the +NULL `_PyObject_GetDictPtr`). `capi_stockcython.rs` drives both and asserts +the inherited/own slot split per subclass, that the directly-read slots +compute correctly, and that the Python-level MRO dispatch on a subclass is +undisturbed. + +## Measured targets + +The commit-acceptance bar for wave 5: + +- A stock-CPython-3.13-headers extension (`_stockcython`) that **subclasses + an extension-defined base** and reads the inherited slots directly off + `Py_TYPE(self)` loads via `dlopen` and runs: a pure subclass resolves + every inherited slot off its own struct, and a partial-override subclass + keeps its own slots while the base's are merged in. +- The Cython C-API runtime tail links and behaves (optional probes, the + method fast path, presized dict, bounds-checked int, the NULL dict-ptr + fallback). +- A Cython-style method call (`obj.method(arg)` through the stock inline + `PyObject_CallMethodOneArg`) dispatches correctly - the vectorcall + `ARGUMENTS_OFFSET` defect is fixed. +- The wheel resolver recognises the full manylinux / macOS / **musllinux** + matrix, plus the WeavePy provenance tag, and the loader recognises the + musl SOABI suffixes. +- The wave-1/2/3/4 fixtures and the whole `weavepy-capi` suite stay green + through the `inherit_slots` and vectorcall changes. +- `cargo build --workspace`, `cargo fmt --check`, and + `cargo clippy --workspace --all-targets -- -D warnings` are green; the + regrtest sweep stays behaviourally `--check` clean on the release binary. + +## Measured outcome + +Landed as designed. The hermetic proof fixture `_stockcython` (stock +CPython 3.13 headers, no WeavePy header) passes **7/7** in +`capi_stockcython.rs`: + +- `stockcython_base_slots_direct` - baseline: `CyBase`'s own slots are + directly readable off `Py_TYPE(self)` and compute correctly (the probe is + sound). +- `stockcython_pure_subclass_inherits_all_slots` - the headline: `CySub`, + which declares nothing, has `tp_repr`/`tp_hash`/`tp_richcompare` and the + `nb_add`/`sq_length` suite entries all non-NULL on its **own** struct + (each was NULL pre-wave-5), and invoking them directly yields the base's + results (`repr "CyBase(7)"`, `hash 7`, `len 7`, `nb_add(7,7) == 14`). +- `stockcython_partial_subclass_merges_suite` - `CySub2` keeps its own + `tp_repr` (`"CySub2(9)"`) and `nb_subtract` (`9-9 == 0`) while `nb_add` + is merged **into its own number suite** from the base (`9+9 == 18`) and + `tp_hash`/`sq_length` are inherited - the in-place `COPYSLOT` merge. +- `stockcython_python_level_dispatch_on_subclass` - `inherit_slots` does + not disturb the MRO path: `len(sub)`, `sub + sub`, `repr(sub)`, + `sub == sub` all still resolve through the bridged dunders. +- `stockcython_runtime_surface` - the WS2 tail + the WS3-fixed method call: + `dictptr_null`, optional-present/absent, `_PyObject_GetMethod`, + `PyObject_CallMethodOneArg` (`sub.__eq__(sub)` truthy), + `_PyDict_NewPresized` + `PyMapping_GetOptionalItemString`, and + `PyLong_AsInt(4242)` all return the expected values. +- `stockcython_module_loads_with_types`, + `stockcython_skipped_when_extension_missing` - module/skip plumbing. + +No regressions: the entire `weavepy-capi` suite is green - **106 tests, 0 +failed** across the lib unit tests (23) and every fixture binary +(`capi_buffer` 10, `capi_loader` 6, `capi_ndarray` 14, `capi_numpylike` 14, +`capi_stockabi` 9, `capi_stockcython` 7, `capi_stocktype` 11, +`capi_stockarray` 11, `capi_wheel_endtoend` 1). The wave-5 `inherit_slots` +flattening and the vectorcall decoder fix left the wave-1/2/3/4 fixtures +untouched; the vectorcall fix in particular only changed behaviour for +external callers that set `ARGUMENTS_OFFSET`, which WeavePy's own paths +never do. + +The wheel matrix is covered by `tests/regrtest/test_packaging_pep440.py` +(`test_wheel_matrix_wave5` asserts manylinux + musllinux + macOS tags; +`test_wheel_provenance_wave5` asserts the `weavepy` provenance tag is +emitted, accepted, and out-scores the stock wheel it shadows) - **8/8** +green under WeavePy. + +Hygiene: `cargo build --workspace --all-targets`, `cargo fmt --all +--check`, and `cargo clippy --workspace --all-targets -- -D warnings` are +clean. The curated regrtest conformance sweep shows zero behavioural +regressions (wave 5's C-API changes are reached only through the binary-ABI +path; the packaging additions are exercised by the PEP 440/425 regrtest). + +## Non-goals / deferred + +- **Building pandas from source in CI** is not the gate. pandas's build + graph (a full Cython + C + C++ toolchain, plus numpy as a build-time + dependency) is heavier than the wave-4 numpy gate and adds little ABI + coverage beyond the hermetic `_stockcython` proof and the live numpy + gate. Wave 5's acceptance is the hermetic fixture + the retained numpy + gate; a pandas-from-source CI lane is good follow-up infrastructure, not + an ABI question. +- **In-body `tp_dictoffset` `__dict__`.** WeavePy stores an instance's + `__dict__` in a managed Rust cell, so `_PyObject_GetDictPtr` returns NULL + and callers take the generic-getattr fallback (which is correct). A + faithful in-body dict at a declared `tp_dictoffset` is not implemented. +- **Relative imports through `PyImport_ImportModuleLevelObject`** + (`level > 0`). The absolute form is serviced; the relative form (Cython's + `from . cimport`) is bounded, as a hermetic single-module extension does + not use it. +- **The unbound `_PyObject_GetMethod` micro-optimisation.** WeavePy always + returns the bound form (return 0); correct, just never the fast unbound + branch. +- **Multi-threaded / free-threaded (`Py_GIL_DISABLED`)** Cython extensions. +- **A fully general foreign-metaclass `__getattribute__`/`tp_call` path.** + Wave 4 implemented the getset-on-metatype case (RFC 0046 §2.10); the + general metaclass-call path remains future work. + +## Alternatives + +1. **Keep the per-call `tp_base` walk (RFC 0046 §2.7) and generalise it to + every slot.** Rejected: it pays an MRO-length walk on every slot read + (Cython reads slots in hot loops), it has to be duplicated at every + dispatch site, and it does not help a consumer that reads the struct + field *directly* (the Cython idiom) rather than going through a WeavePy + dispatch function. Baking the slots in at ready time is what CPython + does and is both faster and complete. +2. **Honour `PY_VECTORCALL_ARGUMENTS_OFFSET` as a real array shift** (the + status-quo decoder behaviour). Rejected: it is a misreading of the + contract - the bit concerns the scratch slot `args[-1]`, never the index + of `args[0]` - and it reads out of bounds for every external method-call + shim. The fix is to stop shifting. +3. **Implement an in-body `tp_dictoffset` dict to satisfy + `_PyObject_GetDictPtr` literally.** Rejected for wave 5: returning NULL + is itself a faithful CPython answer (for `tp_dictoffset == 0`) and every + caller has a correct fallback, so the in-body dict buys no Cython + compatibility for the cost of a second instance-layout model. + +## Prior art + +- **PyPy `cpyext`.** Faces the same direct-slot-read problem and solves it + by materialising a flattened `PyTypeObject` for any type that crosses + into C, copying the slots from the app-level type. Wave 5's `inherit_slots` + is the static-type analogue: flatten the readied struct once at ready + time. +- **CPython `Objects/typeobject.c::inherit_slots`** is the reference; wave 5 + reproduces its immediate-base `COPYSLOT` flattening over WeavePy's split + representation (decoded `SlotTable` + faithful struct). +- **Cython's `Cython/Utility/ObjectHandling.c`** is the consumer whose + inlined `Py_TYPE(o)->tp_*` reads motivate the whole workstream. + +## Future work + +- Remove the now-redundant §2.7 `tp_base` walk once a release has shipped + with baked-in `inherit_slots`. +- A pandas-from-source CI lane (analogous to the wave-4 numpy gate). +- The relative-import (`level > 0`) form of + `PyImport_ImportModuleLevelObject`. +- Free-threaded Cython extensions, paired with the broader + `Py_GIL_DISABLED` work. + +## Files + +New: + +- `crates/weavepy-capi/src/inherit.rs` - faithful `inherit_slots` + (decoded-table + faithful-struct flattening from the immediate base). +- `crates/weavepy-capi/src/wave5.rs` - the Cython C-API leaf tail. +- `tests/capi_ext/_stockcython.c` - the hermetic Cython-shaped proof + (stock CPython 3.13 headers). +- `crates/weavepy-capi/tests/capi_stockcython.rs` - the integration test + (7 tests). + +Materially changed: + +- `crates/weavepy-capi/src/types.rs` - call `inherit_slots` from + `PyType_Ready` after the bridged type is built. +- `crates/weavepy-capi/src/vectorcall.rs` - the `ARGUMENTS_OFFSET` + decoder fix (no array shift) + corrected docs. +- `crates/weavepy-capi/src/loader.rs` - musl SOABI extension suffixes. +- `crates/weavepy-capi/src/force_link_table.rs` - `#[used]` anchors for the + wave-5 tail; `crates/weavepy-capi/src/lib.rs` - module wiring; + `crates/weavepy-capi/build.rs` - compile `_stockcython` against the stock + headers. +- `crates/weavepy-vm/src/stdlib/python/_packaging.py` - musllinux tags, + the WeavePy provenance tag, host-parameterised `_platform_tags`. +- `tests/regrtest/test_packaging_pep440.py` - wave-5 wheel-matrix + + provenance tests. diff --git a/tests/capi_ext/_stockabi.c b/tests/capi_ext/_stockabi.c new file mode 100644 index 0000000..dc69664 --- /dev/null +++ b/tests/capi_ext/_stockabi.c @@ -0,0 +1,230 @@ +/* + * _stockabi — the RFC 0043 wave-1 hermetic proof. + * + * Unlike every other in-tree fixture (`_smalltest`, `_ndarray`, + * `_numpylike`), this module is compiled against the **stock CPython + * 3.13 headers** (`#include ` resolved via the host's real + * include directory) with the *full* (non-limited) API. That means the + * compiler inlines CPython's hot-path macros directly into this object + * file: + * + * - `Py_INCREF`/`Py_DECREF` → poke `op->ob_refcnt` (+ immortal check) + * - `Py_TYPE`/`Py_SIZE`/`Py_REFCNT` → read the object head + * - `PyFloat_AS_DOUBLE(op)` → `*(double*)((char*)op + 16)` + * - `PyTuple_GET_ITEM(op, i)` → `((PyTupleObject*)op)->ob_item[i]` + * - `Py_TYPE(o) == &PyFloat_Type` → compares against the host's + * exported static type symbol + * + * When WeavePy loads this `.so` and calls its functions, those inlined + * reads land on WeavePy's *layout-faithful mirrors* (RFC 0043 WS2). If + * the mirror bytes match CPython's structs, the wheel "just works"; if + * they don't, it reads garbage. This is the first time an artifact + * compiled against stock CPython headers — rather than WeavePy's own + * `Python.h` — runs under WeavePy. + * + * The functions are grouped by which ABI property they prove. + */ + +#define PY_SSIZE_T_CLEAN +#include + +/* ----- inlined head ops: refcount poke + ownership transfer ----- */ + +/* `Py_INCREF(o)` is inlined here (pokes ob_refcnt); returning `o` + * transfers the new reference back to the caller. Proves the faithful + * 16-byte head + the immortal-refcount sentinel. */ +static PyObject *sa_roundtrip(PyObject *self, PyObject *o) { + (void)self; + Py_INCREF(o); + return o; +} + +/* ----- inlined type identity across the boundary ----- */ + +/* `Py_TYPE(o)` reads ob_type at offset 8 and compares against the + * host's exported `&PyFloat_Type`. Proves type-object identity. */ +static PyObject *sa_is_float(PyObject *self, PyObject *o) { + (void)self; + return PyBool_FromLong(Py_TYPE(o) == &PyFloat_Type); +} + +static PyObject *sa_is_long(PyObject *self, PyObject *o) { + (void)self; + return PyBool_FromLong(Py_TYPE(o) == &PyLong_Type); +} + +static PyObject *sa_type_name(PyObject *self, PyObject *o) { + (void)self; + /* tp_name lives at the faithful offset 24. */ + return PyUnicode_FromString(Py_TYPE(o)->tp_name); +} + +/* ----- inlined concrete-field reads (the core of the thesis) ----- */ + +/* `PyFloat_AS_DOUBLE(o)` is inlined to read `ob_fval` at offset 16. */ +static PyObject *sa_double_it(PyObject *self, PyObject *o) { + (void)self; + double x = PyFloat_AS_DOUBLE(o); + return PyFloat_FromDouble(x * 2.0); +} + +/* `Py_SIZE(o)` is inlined to read `ob_size` at offset 16 of the var + * head. Works for tuples/bytes (immutable, filled once at mirror time). */ +static PyObject *sa_size(PyObject *self, PyObject *o) { + (void)self; + return PyLong_FromSsize_t(Py_SIZE(o)); +} + +/* `PyTuple_GET_ITEM(o, i)` is inlined to read `ob_item[i]`. Returns a + * new reference to the first element. */ +static PyObject *sa_tuple_first(PyObject *self, PyObject *o) { + (void)self; + if (Py_SIZE(o) == 0) { + Py_RETURN_NONE; + } + PyObject *first = PyTuple_GET_ITEM(o, 0); + Py_INCREF(first); + return first; +} + +/* Sum a tuple by inlined `PyTuple_GET_ITEM` + the function-API + * `PyLong_AsLong`. Proves the faithful `ob_item[]` tail end-to-end. */ +static PyObject *sa_tuple_sum(PyObject *self, PyObject *o) { + (void)self; + Py_ssize_t n = Py_SIZE(o); + long total = 0; + for (Py_ssize_t i = 0; i < n; i++) { + PyObject *it = PyTuple_GET_ITEM(o, i); /* borrowed */ + long v = PyLong_AsLong(it); + if (v == -1 && PyErr_Occurred()) { + return NULL; + } + total += v; + } + return PyLong_FromLong(total); +} + +/* ----- function-API constructors / parsing ----- */ + +static PyObject *sa_add(PyObject *self, PyObject *args) { + (void)self; + long a = 0, b = 0; + if (!PyArg_ParseTuple(args, "ll", &a, &b)) { + return NULL; + } + return PyLong_FromLong(a + b); +} + +static PyObject *sa_add_doubles(PyObject *self, PyObject *args) { + (void)self; + double a = 0, b = 0; + if (!PyArg_ParseTuple(args, "dd", &a, &b)) { + return NULL; + } + return PyFloat_FromDouble(a + b); +} + +static PyObject *sa_echo_str(PyObject *self, PyObject *args) { + (void)self; + const char *s = NULL; + Py_ssize_t n = 0; + if (!PyArg_ParseTuple(args, "s#", &s, &n)) { + return NULL; + } + return PyUnicode_FromStringAndSize(s, n); +} + +static PyObject *sa_make_pair(PyObject *self, PyObject *args) { + (void)self; + PyObject *x = NULL, *y = NULL; + if (!PyArg_ParseTuple(args, "OO", &x, &y)) { + return NULL; + } + return Py_BuildValue("(OO)", x, y); +} + +/* Sum a list via the function-call container API (lists are not + * inline-read in wave 1). */ +static PyObject *sa_list_sum(PyObject *self, PyObject *o) { + (void)self; + Py_ssize_t n = PyList_Size(o); + if (n < 0) { + return NULL; + } + long total = 0; + for (Py_ssize_t i = 0; i < n; i++) { + PyObject *it = PyList_GetItem(o, i); /* borrowed */ + if (!it) { + return NULL; + } + long v = PyLong_AsLong(it); + if (v == -1 && PyErr_Occurred()) { + return NULL; + } + total += v; + } + return PyLong_FromLong(total); +} + +/* ----- C-side allocation + last-ref drop (exercises tp_dealloc) ----- */ + +/* Build a temporary, then `Py_DECREF` it to zero entirely inside C. + * The inlined `Py_DECREF` calls the external `_Py_Dealloc`, which reads + * `Py_TYPE(tmp)->tp_dealloc` at offset 48 and frees the WeavePy mirror. + * Returns the value the temporary held, proving no corruption. */ +static PyObject *sa_alloc_free_cycle(PyObject *self, PyObject *args) { + (void)self; + (void)args; + long sum = 0; + for (int i = 0; i < 100; i++) { + PyObject *tmp = PyLong_FromLong(i); + if (!tmp) { + return NULL; + } + sum += PyLong_AsLong(tmp); + Py_DECREF(tmp); /* drops to zero → _Py_Dealloc → tp_dealloc */ + } + return PyLong_FromLong(sum); +} + +/* ----- module definition (static, single-phase) ----- */ + +static PyMethodDef sa_methods[] = { + {"roundtrip", sa_roundtrip, METH_O, "Py_INCREF + return (head poke)"}, + {"is_float", sa_is_float, METH_O, "Py_TYPE(o) == &PyFloat_Type"}, + {"is_long", sa_is_long, METH_O, "Py_TYPE(o) == &PyLong_Type"}, + {"type_name", sa_type_name, METH_O, "Py_TYPE(o)->tp_name"}, + {"double_it", sa_double_it, METH_O, "PyFloat_AS_DOUBLE (inlined)"}, + {"size", sa_size, METH_O, "Py_SIZE (inlined)"}, + {"tuple_first", sa_tuple_first, METH_O, "PyTuple_GET_ITEM[0] (inlined)"}, + {"tuple_sum", sa_tuple_sum, METH_O, "sum via PyTuple_GET_ITEM (inlined)"}, + {"add", sa_add, METH_VARARGS, "a + b (long)"}, + {"add_doubles", sa_add_doubles, METH_VARARGS, "a + b (double)"}, + {"echo_str", sa_echo_str, METH_VARARGS, "echo a str"}, + {"make_pair", sa_make_pair, METH_VARARGS, "Py_BuildValue (OO)"}, + {"list_sum", sa_list_sum, METH_O, "sum a list via function API"}, + {"alloc_free_cycle", sa_alloc_free_cycle, METH_NOARGS, "C-side alloc + Py_DECREF to zero"}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef sa_module = { + PyModuleDef_HEAD_INIT, + "_stockabi", + "RFC 0043 wave-1 stock-CPython-3.13-ABI proof extension.", + -1, + sa_methods, + NULL, + NULL, + NULL, + NULL, +}; + +PyMODINIT_FUNC PyInit__stockabi(void) { + PyObject *m = PyModule_Create(&sa_module); + if (!m) { + return NULL; + } + PyModule_AddIntConstant(m, "ANSWER", 42); + PyModule_AddStringConstant(m, "ABI", "cp313"); + return m; +} diff --git a/tests/capi_ext/_stockarray.c b/tests/capi_ext/_stockarray.c new file mode 100644 index 0000000..c74cf6d --- /dev/null +++ b/tests/capi_ext/_stockarray.c @@ -0,0 +1,399 @@ +/* + * _stockarray — the RFC 0045 (binary-ABI inline storage + the numpy + * array C-API surface, wave 3) hermetic proof. + * + * Like `_stockabi.c` / `_stocktype.c`, this module is compiled against + * the host's **stock CPython 3.13 headers** with the *full* (non-limited) + * API, so it sees the genuine 416-byte `PyTypeObject`, the real + * `PyMemberDef` layout + `T_*` codes (``), the inlined + * head macros, and the real `PyCapsule_*` surface. Where `_stockabi` + * proved object *mirrors* and `_stocktype` proved the *type* machinery + * (with state kept in `__dict__`), this fixture proves the piece wave 2 + * explicitly deferred: a stock type that reads its own fields **inline**, + * `((StockArrayObject *)self)->field`, at fixed `tp_basicsize` offsets — + * the `PyArrayObject` shape — and the numpy array C-API surface that + * rides on it. + * + * It exercises, all against WeavePy's faithful inline instance body: + * + * - **Inline `tp_basicsize` storage**: `StockArray(n)` writes its + * fields in `tp_init`; a *later, separate* C call (`sum()`) reads + * them back — proof that the body is the *same* block across + * crossings (a fresh per-crossing box would read zeros / crash). + * - **`tp_members`**: `nd` / `length` (READONLY) and `typenum` + * (writable) project the inline fields to/from Python at their + * declared `offsetof`, reading the very bytes `tp_init` wrote. + * - **A faithful `tp_dealloc`** that frees `self->data` and then calls + * `PyObject_Free(self)` — the canonical stock shape; WeavePy absorbs + * the `tp_free` on an instance body (the body is owned by the native + * instance) and frees the buffer. + * - **Array interchange**: `__array_interface__` (a dict) and + * `__array_struct__` (a `PyCapsule` wrapping a `PyArrayInterface`), + * both reading the inline `data` pointer. + * - **The array-C-API *capsule* pattern**: the module installs a + * `void **` function table at the well-known dotted name + * `_stockarray._ARRAY_API`; `capi_roundtrip()` re-imports it the way + * `import_array()` does — `PyCapsule_Import("_stockarray._ARRAY_API")` + * → a `void **` table → call through `table[i]` — and builds a fresh + * array through it. + */ + +#define PY_SSIZE_T_CLEAN +#include +#include + +#include +#include +#include +#include + +/* ================================================================== */ +/* StockArray — a PyArrayObject-shaped inline-storage type. */ +/* ================================================================== */ + +typedef struct { + PyObject_HEAD + int nd; /* number of dimensions (always 1 here) */ + Py_ssize_t length; /* element count */ + double *data; /* malloc'd buffer of `length` doubles */ + int typenum; /* dtype sentinel (writable, like numpy dtype) */ +} StockArrayObject; + +/* Live-instance counter so the test can observe that `tp_dealloc` + * (and therefore the faithful buffer free) actually runs. */ +static long g_live = 0; + +/* Monotonic total-`tp_dealloc` counter. Unlike `g_live` (which other, + * concurrently-running tests in the same process push up and down through + * this same shared `.so`), this only ever increases, so a death test can + * prove *its* instance was collected with a race-free `after >= before + 1` + * — concurrent deallocs by other tests can only push it higher. */ +static long g_deallocs = 0; + +static PyTypeObject StockArray_Type; /* forward */ + +static int StockArray_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + Py_ssize_t n = 0; + if (!PyArg_ParseTuple(args, "n", &n)) { + return -1; + } + if (n < 0) { + PyErr_SetString(PyExc_ValueError, "StockArray: length must be >= 0"); + return -1; + } + StockArrayObject *a = (StockArrayObject *)self; + double *buf = (double *)malloc((size_t)(n > 0 ? n : 1) * sizeof(double)); + if (!buf) { + PyErr_NoMemory(); + return -1; + } + for (Py_ssize_t i = 0; i < n; i++) { + buf[i] = (double)i; /* [0, 1, 2, ... n-1] */ + } + /* Write straight into the inline fields of our own body. */ + a->nd = 1; + a->length = n; + a->data = buf; + a->typenum = 12; /* sentinel for "float64" */ + g_live += 1; + return 0; +} + +static void StockArray_dealloc(PyObject *self) { + StockArrayObject *a = (StockArrayObject *)self; + free(a->data); + a->data = NULL; + g_live -= 1; + g_deallocs += 1; + /* The canonical stock tail. Under CPython this releases the object + * storage; under WeavePy the body is owned by the native instance, + * so this `tp_free`-equivalent is absorbed (and the block is freed + * when the instance is collected). */ + PyObject_Free(self); +} + +/* sum() — read the inline `data`/`length` fields (written by a *prior* + * `tp_init` call) and total them. The headline inline-storage proof. */ +static PyObject *StockArray_sum(PyObject *self, PyObject *ignored) { + (void)ignored; + StockArrayObject *a = (StockArrayObject *)self; + double acc = 0.0; + for (Py_ssize_t i = 0; i < a->length; i++) { + acc += a->data[i]; + } + return PyFloat_FromDouble(acc); +} + +/* fill(value) — write every element inline, so a following sum() + * observes the mutation through the same stable body. */ +static PyObject *StockArray_fill(PyObject *self, PyObject *value) { + double v = PyFloat_AsDouble(value); + if (v == -1.0 && PyErr_Occurred()) { + return NULL; + } + StockArrayObject *a = (StockArrayObject *)self; + for (Py_ssize_t i = 0; i < a->length; i++) { + a->data[i] = v; + } + Py_RETURN_NONE; +} + +/* data_addr() — expose the inline `data` pointer so the test can assert + * it is *stable* across crossings (same address every call). */ +static PyObject *StockArray_data_addr(PyObject *self, PyObject *ignored) { + (void)ignored; + StockArrayObject *a = (StockArrayObject *)self; + return PyLong_FromLongLong((long long)(intptr_t)a->data); +} + +static PyMethodDef StockArray_methods[] = { + {"sum", StockArray_sum, METH_NOARGS, "sum the elements (reads inline fields)"}, + {"fill", StockArray_fill, METH_O, "set every element to value (writes inline)"}, + {"data_addr", StockArray_data_addr, METH_NOARGS, "address of the inline data buffer"}, + {NULL, NULL, 0, NULL}, +}; + +/* tp_members: project inline fields at their real offsets. `nd` and + * `length` are read-only; `typenum` is writable (a member-set proof). */ +static PyMemberDef StockArray_members[] = { + {"nd", T_INT, offsetof(StockArrayObject, nd), READONLY, "number of dimensions"}, + {"length", T_PYSSIZET, offsetof(StockArrayObject, length), READONLY, "element count"}, + {"typenum", T_INT, offsetof(StockArrayObject, typenum), 0, "dtype code (writable)"}, + {NULL, 0, 0, 0, NULL}, +}; + +/* ------------------------------------------------------------------ */ +/* Array interchange: __array_interface__ + __array_struct__. */ +/* ------------------------------------------------------------------ */ + +/* The documented numpy `PyArrayInterface` (array interface v3). Defined + * locally because this fixture is built without numpy's headers — the + * layout is the ABI contract every `__array_struct__` consumer reads. */ +typedef struct { + int two; /* sanity check: always 2 */ + int nd; /* number of dimensions */ + char typekind; /* 'f', 'i', ... */ + int itemsize; /* bytes per element */ + int flags; /* interpretation flags */ + Py_intptr_t *shape; /* length-nd shape */ + Py_intptr_t *strides; /* length-nd strides */ + void *data; /* first element */ + PyObject *descr; /* optional, NULL here */ +} PyArrayInterface; + +static PyObject *StockArray_get_array_interface(PyObject *self, void *closure) { + (void)closure; + StockArrayObject *a = (StockArrayObject *)self; + PyObject *shape = Py_BuildValue("(n)", a->length); + if (!shape) { + return NULL; + } + /* data is (address:int, read_only:bool) per the array interface. */ + PyObject *data = Py_BuildValue("(LO)", (long long)(intptr_t)a->data, Py_False); + if (!data) { + Py_DECREF(shape); + return NULL; + } + PyObject *dict = Py_BuildValue("{s:i, s:N, s:s, s:N}", + "version", 3, + "shape", shape, + "typestr", "shape); + free(iface->strides); + free(iface); + } +} + +static PyObject *StockArray_get_array_struct(PyObject *self, void *closure) { + (void)closure; + StockArrayObject *a = (StockArrayObject *)self; + PyArrayInterface *iface = (PyArrayInterface *)calloc(1, sizeof(PyArrayInterface)); + if (!iface) { + return PyErr_NoMemory(); + } + iface->shape = (Py_intptr_t *)malloc(sizeof(Py_intptr_t)); + iface->strides = (Py_intptr_t *)malloc(sizeof(Py_intptr_t)); + if (!iface->shape || !iface->strides) { + free(iface->shape); + free(iface->strides); + free(iface); + return PyErr_NoMemory(); + } + iface->two = 2; + iface->nd = a->nd; + iface->typekind = 'f'; + iface->itemsize = (int)sizeof(double); + iface->flags = 0; + iface->shape[0] = (Py_intptr_t)a->length; + iface->strides[0] = (Py_intptr_t)sizeof(double); + iface->data = a->data; + iface->descr = NULL; + /* The protocol uses a NULL-named capsule. */ + return PyCapsule_New((void *)iface, NULL, array_struct_destructor); +} + +static PyGetSetDef StockArray_getset[] = { + {"__array_interface__", StockArray_get_array_interface, NULL, "numpy array interface v3", NULL}, + {"__array_struct__", StockArray_get_array_struct, NULL, "numpy C array interface capsule", NULL}, + {NULL, NULL, NULL, NULL, NULL}, +}; + +static PyTypeObject StockArray_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stockarray.StockArray", + .tp_basicsize = sizeof(StockArrayObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "fixed 1-D float64 array with inline tp_basicsize storage", + .tp_new = PyType_GenericNew, + .tp_init = StockArray_init, + .tp_dealloc = StockArray_dealloc, + .tp_methods = StockArray_methods, + .tp_members = StockArray_members, + .tp_getset = StockArray_getset, +}; + +/* ================================================================== */ +/* The array C-API capsule (`import_array()` shape). */ +/* ================================================================== */ + +/* `StockArray_FromLength(n)` — a C-level constructor exported through + * the API table (the analogue of `PyArray_SimpleNew`). Builds an array + * by calling the readied type object, so it drives the same inline-body + * `tp_new`/`tp_init` path. */ +static PyObject *StockArray_FromLength(Py_ssize_t n) { + return PyObject_CallFunction((PyObject *)&StockArray_Type, "n", n); +} + +/* The exported function table. Index 0 is the type object, index 1 is + * the constructor — the same "array of `void *`" shape numpy publishes + * as `PyArray_API`. */ +enum { + STOCKARRAY_API_TYPE = 0, + STOCKARRAY_API_FROMLENGTH = 1, + STOCKARRAY_API_NUMPOINTERS = 2, +}; +static void *StockArray_API[STOCKARRAY_API_NUMPOINTERS]; + +/* capi_roundtrip(n) — the consumer side of `import_array()`: resolve the + * well-known capsule, recover the `void **` table, and call through it + * to build a fresh array. Proves the whole import-capsule round trip. */ +static PyObject *sa_capi_roundtrip(PyObject *self, PyObject *args) { + (void)self; + Py_ssize_t n = 0; + if (!PyArg_ParseTuple(args, "n", &n)) { + return NULL; + } + void **api = (void **)PyCapsule_Import("_stockarray._ARRAY_API", 0); + if (!api) { + return NULL; + } + PyObject *(*from_length)(Py_ssize_t) = + (PyObject * (*)(Py_ssize_t)) api[STOCKARRAY_API_FROMLENGTH]; + return from_length(n); +} + +/* read_array_struct(arr) — a consumer of the `__array_struct__` capsule: + * pull the `PyArrayInterface` back out and report a few fields, proving + * the C array-interchange struct round-trips with the right layout. */ +static PyObject *sa_read_array_struct(PyObject *self, PyObject *arr) { + (void)self; + PyObject *cap = PyObject_GetAttrString(arr, "__array_struct__"); + if (!cap) { + return NULL; + } + PyArrayInterface *iface = (PyArrayInterface *)PyCapsule_GetPointer(cap, NULL); + if (!iface) { + Py_DECREF(cap); + return NULL; + } + /* (two, nd, typekind_as_int, length, data_addr) */ + PyObject *out = Py_BuildValue("(iiinL)", + iface->two, + iface->nd, + (int)iface->typekind, + (Py_ssize_t)iface->shape[0], + (long long)(intptr_t)iface->data); + Py_DECREF(cap); + return out; +} + +/* live_count() — number of live StockArray instances (dealloc proof). */ +static PyObject *sa_live_count(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return PyLong_FromLong(g_live); +} + +/* dealloc_count() — monotonic count of `tp_dealloc` runs (race-free + * dealloc proof; see `g_deallocs`). */ +static PyObject *sa_dealloc_count(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return PyLong_FromLong(g_deallocs); +} + +static PyMethodDef sa_methods[] = { + {"capi_roundtrip", sa_capi_roundtrip, METH_VARARGS, + "import the array C-API capsule and build an array through it"}, + {"read_array_struct", sa_read_array_struct, METH_O, + "read back fields from an array's __array_struct__ capsule"}, + {"live_count", sa_live_count, METH_NOARGS, "live StockArray instance count"}, + {"dealloc_count", sa_dealloc_count, METH_NOARGS, + "monotonic count of tp_dealloc runs"}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef sa_module = { + PyModuleDef_HEAD_INIT, + "_stockarray", + "RFC 0045 wave-3 stock-CPython-3.13 inline-storage + array C-API proof.", + -1, + sa_methods, + NULL, + NULL, + NULL, + NULL, +}; + +PyMODINIT_FUNC PyInit__stockarray(void) { + PyObject *m = PyModule_Create(&sa_module); + if (!m) { + return NULL; + } + if (PyType_Ready(&StockArray_Type) < 0) { + Py_DECREF(m); + return NULL; + } + Py_INCREF(&StockArray_Type); + if (PyModule_AddObject(m, "StockArray", (PyObject *)&StockArray_Type) < 0) { + Py_DECREF(&StockArray_Type); + Py_DECREF(m); + return NULL; + } + + /* Publish the array C-API function table as a capsule, exactly as a + * numpy-like producer does (`numpy.core.multiarray._ARRAY_API`). */ + StockArray_API[STOCKARRAY_API_TYPE] = (void *)&StockArray_Type; + StockArray_API[STOCKARRAY_API_FROMLENGTH] = (void *)StockArray_FromLength; + PyObject *c_api = PyCapsule_New((void *)StockArray_API, "_stockarray._ARRAY_API", NULL); + if (!c_api) { + Py_DECREF(m); + return NULL; + } + if (PyModule_AddObject(m, "_ARRAY_API", c_api) < 0) { + Py_DECREF(c_api); + Py_DECREF(m); + return NULL; + } + + PyModule_AddStringConstant(m, "ABI", "cp313"); + return m; +} diff --git a/tests/capi_ext/_stockcython.c b/tests/capi_ext/_stockcython.c new file mode 100644 index 0000000..3d42e88 --- /dev/null +++ b/tests/capi_ext/_stockcython.c @@ -0,0 +1,456 @@ +/* + * _stockcython — the RFC 0047 (binary-ABI, wave 5) hermetic proof. + * + * Compiled against the host's **stock CPython 3.13 headers** (full, + * non-limited API) like `_stockabi`/`_stocktype`/`_stockarray`, this + * fixture stands in for a **Cython-generated** extension (the shape + * pandas and the wider Cython ecosystem ship). It proves the two + * load-bearing wave-5 capabilities: + * + * 1. **Faithful `inherit_slots`.** A base type (`CyBase`) defines a + * number suite (`nb_add`), a sequence suite (`sq_length`), + * `tp_repr`, `tp_hash`, and `tp_richcompare`. Two subclasses + * define (almost) nothing: + * - `CySub` — a *pure* behaviour subclass (no slots of its own); + * - `CySub2` — a *partial-override* subclass: its own `tp_repr` + * and a number suite carrying only `nb_subtract`, inheriting + * `nb_add` into that same suite. + * The proof reads the slots **directly off `Py_TYPE(instance)`** — + * the inlined idiom Cython emits everywhere + * (`Py_TYPE(o)->tp_as_number->nb_add(...)`), with no MRO walk — and + * invokes them. Before wave-5 those reads were NULL on a subclass; + * `probe_slots` asserts they now resolve to the inherited (or, for + * `CySub2.tp_repr`/`nb_subtract`, the own) function. + * + * 2. **The Cython C-API runtime tail.** `cython_runtime_surface` + * exercises the leaf helpers the Cython runtime links that wave 5 + * adds (`_PyObject_GetDictPtr`, `PyObject_GetOptionalAttrString`, + * `_PyObject_GetMethod`, `PyObject_CallMethodOneArg`, + * `_PyDict_NewPresized`, `PyMapping_GetOptionalItemString`, + * `PyLong_AsInt`). + * + * ## Storage model + * + * Like `_stocktype.c`, each instance stashes its state in a malloc'd + * `*Core` block whose pointer lives in `self.__dict__["_core_addr"]` + * (the `_ndarray` idiom); `tp_basicsize == sizeof(PyObject)`, so this is + * the dict-backed path, *not* wave-3 inline storage. The whole point is + * the slot *inheritance*, which is orthogonal to instance layout. + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include +#include +#include + +/* Cython-generated code vendors its own `extern` declarations for the + * private CPython helpers it links (they are not all in the public + * headers). We mirror that here so the fixture is a faithful stand-in: + * `_PyObject_GetMethod` and `_PyDict_NewPresized` are internal and not + * declared by `Python.h`. Their signatures have been stable across the + * 3.x series. */ +#ifdef __cplusplus +extern "C" { +#endif +extern int _PyObject_GetMethod(PyObject *obj, PyObject *name, PyObject **method); +extern PyObject *_PyDict_NewPresized(Py_ssize_t minused); +#ifdef __cplusplus +} +#endif + +/* ================================================================== */ +/* Shared helpers. */ +/* ================================================================== */ + +static void dict_set_int(PyObject *d, const char *k, long v) { + PyObject *o = PyLong_FromLong(v); + if (o) { + PyDict_SetItemString(d, k, o); + Py_DECREF(o); + } +} + +static int set_core_addr(PyObject *self, void *core) { + PyObject *addr = PyLong_FromLongLong((long long)(intptr_t)core); + if (!addr) { + return -1; + } + int rc = PyObject_SetAttrString(self, "_core_addr", addr); + Py_DECREF(addr); + return rc; +} + +static void *core_addr_noerr(PyObject *self) { + PyObject *attr = PyObject_GetAttrString(self, "_core_addr"); + if (!attr) { + PyErr_Clear(); + return NULL; + } + long long v = PyLong_AsLongLong(attr); + Py_DECREF(attr); + if (v == -1 && PyErr_Occurred()) { + PyErr_Clear(); + return NULL; + } + return (void *)(intptr_t)v; +} + +/* ================================================================== */ +/* CyBase — the base whose slots get inherited. */ +/* ================================================================== */ + +typedef struct { + long value; +} CyBaseCore; + +static CyBaseCore *cybase_core(PyObject *self) { + void *p = core_addr_noerr(self); + if (!p) { + if (!PyErr_Occurred()) { + PyErr_SetString(PyExc_RuntimeError, "CyBase: missing core"); + } + return NULL; + } + return (CyBaseCore *)p; +} + +static int CyBase_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + long value = 0; + if (!PyArg_ParseTuple(args, "l", &value)) { + return -1; + } + CyBaseCore *core = (CyBaseCore *)malloc(sizeof(CyBaseCore)); + if (!core) { + PyErr_NoMemory(); + return -1; + } + core->value = value; + if (set_core_addr(self, core) != 0) { + free(core); + return -1; + } + return 0; +} + +static PyObject *CyBase_repr(PyObject *self) { + CyBaseCore *core = cybase_core(self); + if (!core) { + return NULL; + } + char buf[64]; + snprintf(buf, sizeof(buf), "CyBase(%ld)", core->value); + return PyUnicode_FromString(buf); +} + +static Py_hash_t CyBase_hash(PyObject *self) { + CyBaseCore *core = cybase_core(self); + if (!core) { + return -1; + } + return (Py_hash_t)core->value; +} + +static PyObject *CyBase_add(PyObject *a, PyObject *b) { + CyBaseCore *ca = cybase_core(a); + CyBaseCore *cb = cybase_core(b); + if (!ca || !cb) { + return NULL; + } + return PyLong_FromLong(ca->value + cb->value); +} + +static Py_ssize_t CyBase_length(PyObject *self) { + CyBaseCore *core = cybase_core(self); + if (!core) { + return -1; + } + return (Py_ssize_t)core->value; +} + +static PyObject *CyBase_richcompare(PyObject *a, PyObject *b, int op) { + if (op != Py_EQ && op != Py_NE) { + Py_RETURN_NOTIMPLEMENTED; + } + CyBaseCore *ca = cybase_core(a); + if (!ca) { + return NULL; + } + CyBaseCore *cb = cybase_core(b); + if (!cb) { + PyErr_Clear(); + Py_RETURN_NOTIMPLEMENTED; + } + int eq = (ca->value == cb->value); + if (op == Py_NE) { + eq = !eq; + } + if (eq) { + Py_RETURN_TRUE; + } + Py_RETURN_FALSE; +} + +static PyNumberMethods CyBase_as_number = { + .nb_add = CyBase_add, +}; + +static PySequenceMethods CyBase_as_sequence = { + .sq_length = CyBase_length, +}; + +static PyTypeObject CyBase_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stockcython.CyBase", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "base type whose slots are inherited (inherit_slots proof)", + .tp_new = PyType_GenericNew, + .tp_init = CyBase_init, + .tp_repr = CyBase_repr, + .tp_hash = CyBase_hash, + .tp_richcompare = CyBase_richcompare, + .tp_as_number = &CyBase_as_number, + .tp_as_sequence = &CyBase_as_sequence, +}; + +/* ================================================================== */ +/* CySub — pure behaviour subclass: defines NOTHING of its own. */ +/* Every slot it dispatches must come from CyBase via inherit_slots. */ +/* ================================================================== */ + +static PyTypeObject CySub_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stockcython.CySub", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "pure subclass (inherits every slot from CyBase)", + .tp_base = &CyBase_Type, +}; + +/* ================================================================== */ +/* CySub2 — partial-override subclass: its own tp_repr + a number */ +/* suite carrying ONLY nb_subtract. inherit_slots must (a) keep its */ +/* own tp_repr / nb_subtract, and (b) fill nb_add into its *existing* */ +/* suite from CyBase (the in-place suite merge path). */ +/* ================================================================== */ + +static PyObject *CySub2_repr(PyObject *self) { + CyBaseCore *core = cybase_core(self); + if (!core) { + return NULL; + } + char buf[64]; + snprintf(buf, sizeof(buf), "CySub2(%ld)", core->value); + return PyUnicode_FromString(buf); +} + +static PyObject *CySub2_sub(PyObject *a, PyObject *b) { + CyBaseCore *ca = cybase_core(a); + CyBaseCore *cb = cybase_core(b); + if (!ca || !cb) { + return NULL; + } + return PyLong_FromLong(ca->value - cb->value); +} + +static PyNumberMethods CySub2_as_number = { + .nb_subtract = CySub2_sub, +}; + +static PyTypeObject CySub2_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stockcython.CySub2", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "partial subclass (own tp_repr + nb_subtract; inherits the rest)", + .tp_base = &CyBase_Type, + .tp_repr = CySub2_repr, + .tp_as_number = &CySub2_as_number, +}; + +/* ================================================================== */ +/* probe_slots(obj) — read the slots DIRECTLY off Py_TYPE(obj) (the */ +/* inlined Cython idiom) and invoke them, returning a result dict. */ +/* ================================================================== */ + +static PyObject *probe_slots(PyObject *self, PyObject *arg) { + (void)self; + PyTypeObject *t = Py_TYPE(arg); + PyObject *res = PyDict_New(); + if (!res) { + return NULL; + } + + int has_repr = (t->tp_repr != NULL); + int has_hash = (t->tp_hash != NULL); + int has_nb_add = (t->tp_as_number != NULL && t->tp_as_number->nb_add != NULL); + int has_nb_sub = (t->tp_as_number != NULL && t->tp_as_number->nb_subtract != NULL); + int has_sq_len = (t->tp_as_sequence != NULL && t->tp_as_sequence->sq_length != NULL); + int has_cmp = (t->tp_richcompare != NULL); + dict_set_int(res, "has_repr", has_repr); + dict_set_int(res, "has_hash", has_hash); + dict_set_int(res, "has_nb_add", has_nb_add); + dict_set_int(res, "has_nb_sub", has_nb_sub); + dict_set_int(res, "has_sq_len", has_sq_len); + dict_set_int(res, "has_cmp", has_cmp); + + if (has_repr) { + PyObject *r = t->tp_repr(arg); + if (r) { + PyDict_SetItemString(res, "repr", r); + Py_DECREF(r); + } else { + PyErr_Clear(); + } + } + if (has_hash) { + dict_set_int(res, "hash", (long)t->tp_hash(arg)); + } + if (has_sq_len) { + dict_set_int(res, "len", (long)t->tp_as_sequence->sq_length(arg)); + } + if (has_nb_add) { + PyObject *s = t->tp_as_number->nb_add(arg, arg); + if (s) { + dict_set_int(res, "add", PyLong_AsLong(s)); + Py_DECREF(s); + } else { + PyErr_Clear(); + } + } + if (has_nb_sub) { + PyObject *s = t->tp_as_number->nb_subtract(arg, arg); + if (s) { + dict_set_int(res, "sub", PyLong_AsLong(s)); + Py_DECREF(s); + } else { + PyErr_Clear(); + } + } + return res; +} + +/* ================================================================== */ +/* cython_runtime_surface(obj) — exercise the wave-5 Cython tail. */ +/* ================================================================== */ + +static PyObject *cython_runtime_surface(PyObject *self, PyObject *obj) { + (void)self; + PyObject *res = PyDict_New(); + if (!res) { + return NULL; + } + + /* _PyObject_GetDictPtr → NULL (no tp_dictoffset); the Cython idiom + * then falls back to generic getattr. */ + PyObject **dictptr = _PyObject_GetDictPtr(obj); + dict_set_int(res, "dictptr_null", dictptr == NULL); + + /* PyObject_GetOptionalAttrString: present vs. missing. */ + PyObject *present = NULL; + dict_set_int(res, "opt_present", + PyObject_GetOptionalAttrString(obj, "__class__", &present)); + Py_XDECREF(present); + PyObject *absent = NULL; + dict_set_int(res, "opt_absent", + PyObject_GetOptionalAttrString(obj, "no_such_attr_zzz", &absent)); + Py_XDECREF(absent); + + /* _PyObject_GetMethod: resolve a method handle. */ + PyObject *mname = PyUnicode_FromString("__class__"); + PyObject *method = NULL; + int gm = _PyObject_GetMethod(obj, mname, &method); + Py_XDECREF(mname); + dict_set_int(res, "get_method_rc", gm); + dict_set_int(res, "get_method_ok", method != NULL); + Py_XDECREF(method); + + /* PyObject_CallMethodOneArg: obj.__eq__(obj) is truthy. */ + PyObject *eqname = PyUnicode_FromString("__eq__"); + PyObject *eqres = PyObject_CallMethodOneArg(obj, eqname, obj); + Py_XDECREF(eqname); + if (eqres) { + dict_set_int(res, "call_eq_true", PyObject_IsTrue(eqres)); + Py_DECREF(eqres); + } else { + PyErr_Clear(); + dict_set_int(res, "call_eq_true", -1); + } + + /* _PyDict_NewPresized + PyMapping_GetOptionalItemString. */ + PyObject *d = _PyDict_NewPresized(8); + PyObject *v = PyLong_FromLong(99); + PyDict_SetItemString(d, "k", v); + Py_DECREF(v); + PyObject *got = NULL; + dict_set_int(res, "map_present", PyMapping_GetOptionalItemString(d, "k", &got)); + dict_set_int(res, "map_value", got ? PyLong_AsLong(got) : -1); + Py_XDECREF(got); + PyObject *got2 = NULL; + dict_set_int(res, "map_absent", PyMapping_GetOptionalItemString(d, "missing", &got2)); + Py_XDECREF(got2); + Py_DECREF(d); + + /* PyLong_AsInt. */ + PyObject *n = PyLong_FromLong(4242); + dict_set_int(res, "as_int", PyLong_AsInt(n)); + Py_DECREF(n); + + return res; +} + +/* ================================================================== */ +/* Module. */ +/* ================================================================== */ + +static PyMethodDef cy_methods[] = { + {"probe_slots", probe_slots, METH_O, + "read Py_TYPE(obj)'s slots directly and invoke them (inherit_slots proof)"}, + {"cython_runtime_surface", cython_runtime_surface, METH_O, + "exercise the wave-5 Cython C-API tail"}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef cy_module = { + PyModuleDef_HEAD_INIT, + "_stockcython", + "RFC 0047 wave-5 stock-CPython-3.13 Cython-surface proof.", + -1, + cy_methods, + NULL, + NULL, + NULL, + NULL, +}; + +static int add_type(PyObject *m, PyTypeObject *t, const char *name) { + if (PyType_Ready(t) < 0) { + return -1; + } + Py_INCREF(t); + if (PyModule_AddObject(m, name, (PyObject *)t) < 0) { + Py_DECREF(t); + return -1; + } + return 0; +} + +PyMODINIT_FUNC PyInit__stockcython(void) { + PyObject *m = PyModule_Create(&cy_module); + if (!m) { + return NULL; + } + /* Ready the base first so the subclasses' tp_base resolves to an + * already-flattened type (inherit_slots copies one level). */ + if (add_type(m, &CyBase_Type, "CyBase") < 0 || + add_type(m, &CySub_Type, "CySub") < 0 || + add_type(m, &CySub2_Type, "CySub2") < 0) { + Py_DECREF(m); + return NULL; + } + PyModule_AddStringConstant(m, "ABI", "cp313"); + return m; +} diff --git a/tests/capi_ext/_stockdatetime.c b/tests/capi_ext/_stockdatetime.c new file mode 100644 index 0000000..31dc67d --- /dev/null +++ b/tests/capi_ext/_stockdatetime.c @@ -0,0 +1,207 @@ +/* + * _stockdatetime — the RFC 0029 (wave 5) faithful-datetime ABI proof. + * + * Compiled against the host's **stock CPython 3.13 headers**, including + * the real `datetime.h`. That means the compiler inlines CPython's + * datetime accessor macros directly into this object file — exactly the + * way Cython's `cimport datetime` does inside pandas' `tslibs`: + * + * - `PyDateTime_IMPORT` → PyCapsule_Import("datetime.datetime_CAPI") + * - `PyDateTime_GET_YEAR(o)` → ((PyDateTime_Date*)o)->data[0..1] (big-endian) + * - `PyDateTime_DATE_GET_HOUR(o)` → ((PyDateTime_DateTime*)o)->data[4] + * - `PyDateTime_DELTA_GET_DAYS(o)` → ((PyDateTime_Delta*)o)->days + * - `PyDate_Check(o)` → PyObject_TypeCheck(o, PyDateTimeAPI->DateType) + * - `PyDate_FromDate(y,m,d)` → PyDateTimeAPI->Date_FromDate(...) + * + * When WeavePy loads this `.so`, those inlined reads land on WeavePy's + * byte-faithful datetime instance bodies (RFC 0029), and the capsule's + * type slots must report the CPython `tp_basicsize` (date 32, datetime + * 48, time 40, timedelta 40). If the layout is right, a real Cython + * datetime consumer "just works"; if not, it reads garbage / size-checks + * fail (`datetime.datetime size changed`). + */ + +#define PY_SSIZE_T_CLEAN +#include +#include + +/* ---- weavepy -> C read path (what pandas does to our datetimes) ---- */ + +static PyObject *sd_read_date(PyObject *self, PyObject *o) { + (void)self; + return Py_BuildValue("(iii)", PyDateTime_GET_YEAR(o), PyDateTime_GET_MONTH(o), + PyDateTime_GET_DAY(o)); +} + +static PyObject *sd_read_datetime(PyObject *self, PyObject *o) { + (void)self; + return Py_BuildValue("(iiiiiiii)", PyDateTime_GET_YEAR(o), PyDateTime_GET_MONTH(o), + PyDateTime_GET_DAY(o), PyDateTime_DATE_GET_HOUR(o), + PyDateTime_DATE_GET_MINUTE(o), PyDateTime_DATE_GET_SECOND(o), + PyDateTime_DATE_GET_MICROSECOND(o), PyDateTime_DATE_GET_FOLD(o)); +} + +static PyObject *sd_read_time(PyObject *self, PyObject *o) { + (void)self; + return Py_BuildValue("(iiiii)", PyDateTime_TIME_GET_HOUR(o), PyDateTime_TIME_GET_MINUTE(o), + PyDateTime_TIME_GET_SECOND(o), PyDateTime_TIME_GET_MICROSECOND(o), + PyDateTime_TIME_GET_FOLD(o)); +} + +static PyObject *sd_read_delta(PyObject *self, PyObject *o) { + (void)self; + return Py_BuildValue("(iii)", PyDateTime_DELTA_GET_DAYS(o), PyDateTime_DELTA_GET_SECONDS(o), + PyDateTime_DELTA_GET_MICROSECONDS(o)); +} + +/* `PyDateTime_DATE_GET_TZINFO` — naive returns Py_None; aware returns the + * tzinfo. Returns 1 when the macro yields Py_None, else 0. */ +static PyObject *sd_datetime_tz_is_none(PyObject *self, PyObject *o) { + (void)self; + PyObject *tz = PyDateTime_DATE_GET_TZINFO(o); + return PyBool_FromLong(tz == Py_None); +} + +/* ---- C construct-then-read path (the capsule constructors) ---- */ + +static PyObject *sd_construct_date(PyObject *self, PyObject *args) { + (void)self; + int y, mo, d; + if (!PyArg_ParseTuple(args, "iii", &y, &mo, &d)) { + return NULL; + } + PyObject *o = PyDate_FromDate(y, mo, d); + if (!o) { + return NULL; + } + PyObject *r = Py_BuildValue("(iii)", PyDateTime_GET_YEAR(o), PyDateTime_GET_MONTH(o), + PyDateTime_GET_DAY(o)); + Py_DECREF(o); + return r; +} + +static PyObject *sd_construct_datetime(PyObject *self, PyObject *args) { + (void)self; + int y, mo, d, hh, mi, ss, us; + if (!PyArg_ParseTuple(args, "iiiiiii", &y, &mo, &d, &hh, &mi, &ss, &us)) { + return NULL; + } + PyObject *o = PyDateTime_FromDateAndTime(y, mo, d, hh, mi, ss, us); + if (!o) { + return NULL; + } + PyObject *r = Py_BuildValue( + "(iiiiiii)", PyDateTime_GET_YEAR(o), PyDateTime_GET_MONTH(o), PyDateTime_GET_DAY(o), + PyDateTime_DATE_GET_HOUR(o), PyDateTime_DATE_GET_MINUTE(o), PyDateTime_DATE_GET_SECOND(o), + PyDateTime_DATE_GET_MICROSECOND(o)); + Py_DECREF(o); + return r; +} + +static PyObject *sd_construct_delta(PyObject *self, PyObject *args) { + (void)self; + int days, secs, us; + if (!PyArg_ParseTuple(args, "iii", &days, &secs, &us)) { + return NULL; + } + PyObject *o = PyDelta_FromDSU(days, secs, us); + if (!o) { + return NULL; + } + PyObject *r = Py_BuildValue("(iii)", PyDateTime_DELTA_GET_DAYS(o), + PyDateTime_DELTA_GET_SECONDS(o), PyDateTime_DELTA_GET_MICROSECONDS(o)); + Py_DECREF(o); + return r; +} + +/* ---- type checks via the capsule type slots ---- */ + +static PyObject *sd_checks(PyObject *self, PyObject *o) { + (void)self; + return Py_BuildValue("(iiiii)", PyDate_Check(o), PyDate_CheckExact(o), PyDateTime_Check(o), + PyDateTime_CheckExact(o), PyDelta_Check(o)); +} + +/* ---- the __Pyx_ImportType size-check path ---- + * Reads `Py_TYPE`-style `tp_basicsize` straight off the class objects the + * `datetime` module exports, exactly as Cython's generated + * `__Pyx_ImportType("datetime", "datetime", sizeof(PyDateTime_DateTime))` + * does before erroring with "datetime.datetime size changed". */ +static PyObject *sd_module_basicsizes(PyObject *self, PyObject *args) { + (void)self; + (void)args; + PyObject *m = PyImport_ImportModule("datetime"); + if (!m) { + return NULL; + } + Py_ssize_t bs_date = -1, bs_dt = -1, bs_time = -1, bs_delta = -1; + PyObject *c; + if ((c = PyObject_GetAttrString(m, "date"))) { + bs_date = ((PyTypeObject *)c)->tp_basicsize; + Py_DECREF(c); + } + if ((c = PyObject_GetAttrString(m, "datetime"))) { + bs_dt = ((PyTypeObject *)c)->tp_basicsize; + Py_DECREF(c); + } + if ((c = PyObject_GetAttrString(m, "time"))) { + bs_time = ((PyTypeObject *)c)->tp_basicsize; + Py_DECREF(c); + } + if ((c = PyObject_GetAttrString(m, "timedelta"))) { + bs_delta = ((PyTypeObject *)c)->tp_basicsize; + Py_DECREF(c); + } + Py_DECREF(m); + return Py_BuildValue("(nnnn)", bs_date, bs_dt, bs_time, bs_delta); +} + +/* ---- module definition (static, single-phase) ---- */ + +static PyMethodDef sd_methods[] = { + {"read_date", sd_read_date, METH_O, "read a date via PyDateTime_GET_* macros"}, + {"read_datetime", sd_read_datetime, METH_O, "read a datetime via inlined macros"}, + {"read_time", sd_read_time, METH_O, "read a time via PyDateTime_TIME_GET_* macros"}, + {"read_delta", sd_read_delta, METH_O, "read a timedelta via PyDateTime_DELTA_GET_* macros"}, + {"datetime_tz_is_none", sd_datetime_tz_is_none, METH_O, "PyDateTime_DATE_GET_TZINFO == Py_None"}, + {"construct_date", sd_construct_date, METH_VARARGS, "PyDate_FromDate then read back"}, + {"construct_datetime", sd_construct_datetime, METH_VARARGS, "PyDateTime_FromDateAndTime + read"}, + {"construct_delta", sd_construct_delta, METH_VARARGS, "PyDelta_FromDSU + read"}, + {"checks", sd_checks, METH_O, "PyDate_Check/PyDateTime_Check/PyDelta_Check via capsule"}, + {"module_basicsizes", sd_module_basicsizes, METH_NOARGS, "tp_basicsize size-check path"}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef sd_module = { + PyModuleDef_HEAD_INIT, + "_stockdatetime", + "RFC 0029 (wave 5) faithful-datetime stock-ABI proof extension.", + -1, + sd_methods, + NULL, + NULL, + NULL, + NULL, +}; + +PyMODINIT_FUNC PyInit__stockdatetime(void) { + PyObject *m = PyModule_Create(&sd_module); + if (!m) { + return NULL; + } + /* The headline call: import the datetime C-API capsule. Expands to + * `PyDateTimeAPI = PyCapsule_Import("datetime.datetime_CAPI", 0)`. */ + PyDateTime_IMPORT; + PyModule_AddIntConstant(m, "imported", PyDateTimeAPI != NULL ? 1 : 0); + if (PyDateTimeAPI != NULL) { + PyModule_AddIntConstant(m, "cap_date_basicsize", + (long)PyDateTimeAPI->DateType->tp_basicsize); + PyModule_AddIntConstant(m, "cap_datetime_basicsize", + (long)PyDateTimeAPI->DateTimeType->tp_basicsize); + PyModule_AddIntConstant(m, "cap_time_basicsize", + (long)PyDateTimeAPI->TimeType->tp_basicsize); + PyModule_AddIntConstant(m, "cap_delta_basicsize", + (long)PyDateTimeAPI->DeltaType->tp_basicsize); + } + return m; +} diff --git a/tests/capi_ext/_stocktype.c b/tests/capi_ext/_stocktype.c new file mode 100644 index 0000000..dcce0fa --- /dev/null +++ b/tests/capi_ext/_stocktype.c @@ -0,0 +1,774 @@ +/* + * _stocktype — the RFC 0044 (binary-ABI type suite, wave 2) hermetic + * proof. + * + * Like `_stockabi.c`, this module is compiled against the host's **stock + * CPython 3.13 headers** with the *full* (non-limited) API, so it sees + * the genuine 416-byte `PyTypeObject`, the real method-suite structs + * (`PyNumberMethods`, `PySequenceMethods`, `PyMappingMethods`), and the + * inlined head macros. Where `_stockabi` proved WeavePy's object + * *mirrors*, this fixture proves WeavePy's *type* machinery: + * + * - the classic **static `PyTypeObject` + `PyType_Ready`** pattern + * (NOT `PyType_FromSpec`), with method suites referenced by direct + * pointer — the shape every hand-written C extension and every + * Cython-pre-3.0 wheel still uses; + * - number (`nb_add`/`nb_subtract`) + rich comparison (`tp_richcompare`), + * including *constructing a readied type by calling it through the + * C call protocol* (`PyObject_CallFunction((PyObject *)&Type, …)`), + * both at top level and re-entrantly from inside a slot; + * - sequence (`sq_length`/`sq_item`) + mapping (`mp_length`/ + * `mp_subscript`) + iteration (`tp_iter`/`tp_iternext`); + * - calling (`tp_call`); + * - the descriptor protocol (`tp_descr_get`/`tp_descr_set`); + * - the async protocol (`am_await`/`am_aiter`/`am_anext`), as a + * hermetic dispatch proof; + * - custom attribute access (`tp_getattro`/`tp_setattro`); + * - a `Py_TPFLAGS_HAVE_GC` type whose children live in **C-managed + * memory** and are surfaced/broken only through `tp_traverse` / + * `tp_clear`, allocated via `PyObject_GC_New` and enrolled with + * `PyObject_GC_Track`. + * + * ## Storage model + * + * WeavePy stores a readied type's instance state in the instance + * `__dict__`, not in inline C struct fields (those are not yet stable + * across the C boundary — a wave-3 concern). So, exactly like + * `_ndarray.c`, each instance side-allocates its state in a malloc'd + * `*Core` block and stashes a `PyLong`-encoded pointer to it in + * `self.__dict__["_core_addr"]`; every slot reads the address back out. + * This keeps the C-held child pointers (the whole point of the GC + * proof) invisible to WeavePy's dict walker, so only `tp_traverse` can + * reveal them. + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include +#include +#include + +/* ================================================================== */ +/* Shared helpers: stash/fetch a side-allocated core pointer in the */ +/* instance dict (the `_ndarray` pattern). */ +/* ================================================================== */ + +static int set_core_addr(PyObject *self, void *core) { + PyObject *addr = PyLong_FromLongLong((long long)(intptr_t)core); + if (!addr) { + return -1; + } + int rc = PyObject_SetAttrString(self, "_core_addr", addr); + Py_DECREF(addr); + return rc; +} + +/* Fetch the core pointer; returns NULL *without* setting an error when + * the attribute is missing (used from `tp_dealloc`, where the dict may + * already be torn down). */ +static void *core_addr_noerr(PyObject *self) { + PyObject *attr = PyObject_GetAttrString(self, "_core_addr"); + if (!attr) { + PyErr_Clear(); + return NULL; + } + long long v = PyLong_AsLongLong(attr); + Py_DECREF(attr); + if (v == -1 && PyErr_Occurred()) { + PyErr_Clear(); + return NULL; + } + return (void *)(intptr_t)v; +} + +/* ================================================================== */ +/* Vec2 — number protocol (nb_add / nb_subtract) + tp_richcompare. */ +/* ================================================================== */ + +typedef struct { + long x; + long y; +} Vec2Core; + +static PyTypeObject Vec2_Type; /* forward */ + +static Vec2Core *vec2_core(PyObject *self) { + void *p = core_addr_noerr(self); + if (!p) { + if (!PyErr_Occurred()) { + PyErr_SetString(PyExc_RuntimeError, "Vec2: missing core"); + } + return NULL; + } + return (Vec2Core *)p; +} + +static int Vec2_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + long x = 0, y = 0; + if (!PyArg_ParseTuple(args, "ll", &x, &y)) { + return -1; + } + Vec2Core *core = (Vec2Core *)malloc(sizeof(Vec2Core)); + if (!core) { + PyErr_NoMemory(); + return -1; + } + core->x = x; + core->y = y; + if (set_core_addr(self, core) != 0) { + free(core); + return -1; + } + return 0; +} + +/* Build a fresh Vec2 by *calling the readied type object through the C + * call protocol* — the natural extension idiom + * (`PyObject_CallFunction((PyObject *)&SomeType, ...)`) and the RFC + * 0044 proof that a static type finalised by `PyType_Ready` is callable + * from C: this drives `tp_new` (`PyType_GenericNew`) + `tp_init` + * (`Vec2_init`). Note this fires *re-entrantly* from inside `nb_add` / + * `nb_subtract`, which are themselves running under a VM dispatch — so + * it also exercises nested `call_object` from a C slot. */ +static PyObject *vec2_build(long x, long y) { + return PyObject_CallFunction((PyObject *)&Vec2_Type, "ll", x, y); +} + +static PyObject *Vec2_add(PyObject *a, PyObject *b) { + Vec2Core *ca = vec2_core(a); + Vec2Core *cb = vec2_core(b); + if (!ca || !cb) { + return NULL; + } + return vec2_build(ca->x + cb->x, ca->y + cb->y); +} + +static PyObject *Vec2_sub(PyObject *a, PyObject *b) { + Vec2Core *ca = vec2_core(a); + Vec2Core *cb = vec2_core(b); + if (!ca || !cb) { + return NULL; + } + return vec2_build(ca->x - cb->x, ca->y - cb->y); +} + +static PyObject *Vec2_richcompare(PyObject *a, PyObject *b, int op) { + if (op != Py_EQ && op != Py_NE) { + Py_RETURN_NOTIMPLEMENTED; + } + /* Only Vec2 == Vec2 is defined; anything else is NotImplemented so + * the VM can fall back to identity. */ + if (Py_TYPE(b) != &Vec2_Type) { + Py_RETURN_NOTIMPLEMENTED; + } + Vec2Core *ca = vec2_core(a); + Vec2Core *cb = vec2_core(b); + if (!ca || !cb) { + return NULL; + } + int eq = (ca->x == cb->x) && (ca->y == cb->y); + if (op == Py_NE) { + eq = !eq; + } + if (eq) { + Py_RETURN_TRUE; + } + Py_RETURN_FALSE; +} + +static PyObject *Vec2_repr(PyObject *self) { + Vec2Core *core = vec2_core(self); + if (!core) { + return NULL; + } + char buf[64]; + snprintf(buf, sizeof(buf), "Vec2(%ld, %ld)", core->x, core->y); + return PyUnicode_FromString(buf); +} + +static PyNumberMethods Vec2_as_number = { + .nb_add = Vec2_add, + .nb_subtract = Vec2_sub, +}; + +static PyTypeObject Vec2_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Vec2", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "2D integer vector (number + richcompare proof)", + .tp_new = PyType_GenericNew, + .tp_init = Vec2_init, + .tp_repr = Vec2_repr, + .tp_richcompare = Vec2_richcompare, + .tp_as_number = &Vec2_as_number, +}; + +/* ================================================================== */ +/* Seq — sequence (sq_length/sq_item) + mapping (mp_*) + iteration. */ +/* A read-only view over [0, n); iterates itself with a cursor. */ +/* ================================================================== */ + +typedef struct { + Py_ssize_t n; + Py_ssize_t cursor; +} SeqCore; + +static PyTypeObject Seq_Type; /* forward */ + +static SeqCore *seq_core(PyObject *self) { + void *p = core_addr_noerr(self); + if (!p) { + if (!PyErr_Occurred()) { + PyErr_SetString(PyExc_RuntimeError, "Seq: missing core"); + } + return NULL; + } + return (SeqCore *)p; +} + +static int Seq_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + Py_ssize_t n = 0; + if (!PyArg_ParseTuple(args, "n", &n)) { + return -1; + } + if (n < 0) { + PyErr_SetString(PyExc_ValueError, "Seq: n must be >= 0"); + return -1; + } + SeqCore *core = (SeqCore *)malloc(sizeof(SeqCore)); + if (!core) { + PyErr_NoMemory(); + return -1; + } + core->n = n; + core->cursor = 0; + if (set_core_addr(self, core) != 0) { + free(core); + return -1; + } + return 0; +} + +static Py_ssize_t Seq_length(PyObject *self) { + SeqCore *core = seq_core(self); + if (!core) { + return -1; + } + return core->n; +} + +static PyObject *Seq_item(PyObject *self, Py_ssize_t i) { + SeqCore *core = seq_core(self); + if (!core) { + return NULL; + } + if (i < 0 || i >= core->n) { + PyErr_SetString(PyExc_IndexError, "Seq index out of range"); + return NULL; + } + return PyLong_FromSsize_t(i); +} + +static PyObject *Seq_subscript(PyObject *self, PyObject *key) { + if (!PyLong_Check(key)) { + PyErr_SetString(PyExc_TypeError, "Seq indices must be integers"); + return NULL; + } + Py_ssize_t i = PyLong_AsSsize_t(key); + if (i == -1 && PyErr_Occurred()) { + return NULL; + } + return Seq_item(self, i); +} + +static PyObject *Seq_iter(PyObject *self) { + /* Self-iterator: reset the cursor and hand back a new reference. */ + SeqCore *core = seq_core(self); + if (!core) { + return NULL; + } + core->cursor = 0; + Py_INCREF(self); + return self; +} + +static PyObject *Seq_iternext(PyObject *self) { + SeqCore *core = seq_core(self); + if (!core) { + return NULL; + } + if (core->cursor >= core->n) { + /* Exhausted: the canonical "raise StopIteration" is to return + * NULL with no error set (CPython's `tp_iternext` protocol). */ + return NULL; + } + Py_ssize_t v = core->cursor; + core->cursor += 1; + return PyLong_FromSsize_t(v); +} + +static PySequenceMethods Seq_as_sequence = { + .sq_length = Seq_length, + .sq_item = Seq_item, +}; + +static PyMappingMethods Seq_as_mapping = { + .mp_length = Seq_length, + .mp_subscript = Seq_subscript, +}; + +static PyTypeObject Seq_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Seq", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "read-only [0,n) view (sequence + mapping + iter proof)", + .tp_new = PyType_GenericNew, + .tp_init = Seq_init, + .tp_iter = Seq_iter, + .tp_iternext = Seq_iternext, + .tp_as_sequence = &Seq_as_sequence, + .tp_as_mapping = &Seq_as_mapping, +}; + +/* ================================================================== */ +/* Adder — tp_call. `Adder(base)(x) == base + x`. */ +/* ================================================================== */ + +static PyTypeObject Adder_Type; /* forward */ + +static int Adder_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + long base = 0; + if (!PyArg_ParseTuple(args, "l", &base)) { + return -1; + } + long *core = (long *)malloc(sizeof(long)); + if (!core) { + PyErr_NoMemory(); + return -1; + } + *core = base; + if (set_core_addr(self, core) != 0) { + free(core); + return -1; + } + return 0; +} + +static PyObject *Adder_call(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + long *core = (long *)core_addr_noerr(self); + if (!core) { + if (!PyErr_Occurred()) { + PyErr_SetString(PyExc_RuntimeError, "Adder: missing core"); + } + return NULL; + } + long x = 0; + if (!PyArg_ParseTuple(args, "l", &x)) { + return NULL; + } + return PyLong_FromLong(*core + x); +} + +static PyTypeObject Adder_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Adder", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "callable accumulator (tp_call proof)", + .tp_new = PyType_GenericNew, + .tp_init = Adder_init, + .tp_call = Adder_call, +}; + +/* ================================================================== */ +/* Const — descriptor protocol (tp_descr_get / tp_descr_set). */ +/* `__get__` returns the stored constant; `__set__` records the last */ +/* value it was handed in a module global so the test can observe it. */ +/* ================================================================== */ + +static long g_last_descr_set = 0; + +static PyTypeObject Const_Type; /* forward */ + +static int Const_init(PyObject *self, PyObject *args, PyObject *kwds) { + (void)kwds; + long val = 0; + if (!PyArg_ParseTuple(args, "l", &val)) { + return -1; + } + long *core = (long *)malloc(sizeof(long)); + if (!core) { + PyErr_NoMemory(); + return -1; + } + *core = val; + if (set_core_addr(self, core) != 0) { + free(core); + return -1; + } + return 0; +} + +static PyObject *Const_descr_get(PyObject *self, PyObject *obj, PyObject *type) { + (void)obj; + (void)type; + long *core = (long *)core_addr_noerr(self); + if (!core) { + if (!PyErr_Occurred()) { + PyErr_SetString(PyExc_RuntimeError, "Const: missing core"); + } + return NULL; + } + return PyLong_FromLong(*core); +} + +static int Const_descr_set(PyObject *self, PyObject *obj, PyObject *value) { + (void)self; + (void)obj; + if (value == NULL) { + g_last_descr_set = -1; /* deletion */ + return 0; + } + long v = PyLong_AsLong(value); + if (v == -1 && PyErr_Occurred()) { + return -1; + } + g_last_descr_set = v; + return 0; +} + +static PyTypeObject Const_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Const", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "data descriptor (tp_descr_get / tp_descr_set proof)", + .tp_new = PyType_GenericNew, + .tp_init = Const_init, + .tp_descr_get = Const_descr_get, + .tp_descr_set = Const_descr_set, +}; + +/* ================================================================== */ +/* Aw — async protocol (am_await / am_aiter / am_anext). */ +/* */ +/* A hermetic *dispatch* proof: no event loop is involved, so the */ +/* awaitables are stand-in integer sentinels. The point is only to */ +/* show that the VM's synthesised `__await__` / `__aiter__` / */ +/* `__anext__` dunders are harvested from `tp_as_async` and routed to */ +/* the genuine `PyAsyncMethods` slots: */ +/* - `am_await` → returns 11 (sentinel for "await dispatched"); */ +/* - `am_aiter` → returns self (the async-iterator is itself); */ +/* - `am_anext` → returns 7 (sentinel) and bumps a counter. */ +/* ================================================================== */ + +static long g_aw_anext_calls = 0; + +static PyObject *Aw_await(PyObject *self) { + (void)self; + return PyLong_FromLong(11); +} + +static PyObject *Aw_aiter(PyObject *self) { + Py_INCREF(self); + return self; +} + +static PyObject *Aw_anext(PyObject *self) { + (void)self; + g_aw_anext_calls += 1; + return PyLong_FromLong(7); +} + +static PyAsyncMethods Aw_as_async = { + .am_await = Aw_await, + .am_aiter = Aw_aiter, + .am_anext = Aw_anext, +}; + +static PyTypeObject Aw_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Aw", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "async protocol (am_await/am_aiter/am_anext proof)", + .tp_new = PyType_GenericNew, + .tp_as_async = &Aw_as_async, +}; + +/* ================================================================== */ +/* Proxy — custom attribute access (tp_getattro / tp_setattro). */ +/* */ +/* `getattr(p, "magic")` is synthesised in C (returns 4242); every */ +/* other name falls back to the *generic* instance-dict lookup */ +/* (`PyObject_GenericGetAttr`, which does NOT re-enter `getattro`, so */ +/* there is no recursion). `setattr` records the (name, value) in */ +/* module globals and then stores normally, so a written value round- */ +/* trips back out through `getattro`. */ +/* ================================================================== */ + +static char g_last_setattr_name[64] = {0}; +static long g_last_setattr_value = 0; + +static PyObject *Proxy_getattro(PyObject *self, PyObject *name) { + const char *n = PyUnicode_AsUTF8(name); + if (n && strcmp(n, "magic") == 0) { + return PyLong_FromLong(4242); + } + return PyObject_GenericGetAttr(self, name); +} + +static int Proxy_setattro(PyObject *self, PyObject *name, PyObject *value) { + const char *n = PyUnicode_AsUTF8(name); + if (n) { + strncpy(g_last_setattr_name, n, sizeof(g_last_setattr_name) - 1); + g_last_setattr_name[sizeof(g_last_setattr_name) - 1] = '\0'; + } + if (value && PyLong_Check(value)) { + g_last_setattr_value = PyLong_AsLong(value); + } + return PyObject_GenericSetAttr(self, name, value); +} + +static PyTypeObject Proxy_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Proxy", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + .tp_doc = "custom attribute access (tp_getattro/tp_setattro proof)", + .tp_new = PyType_GenericNew, + .tp_getattro = Proxy_getattro, + .tp_setattro = Proxy_setattro, +}; + +/* ================================================================== */ +/* Node — Py_TPFLAGS_HAVE_GC. Holds one child reference in C-managed */ +/* memory (the side core), invisible to WeavePy's dict walker, and */ +/* surfaces/breaks it only through tp_traverse / tp_clear. */ +/* ================================================================== */ + +typedef struct { + PyObject *child; /* strong ref, or NULL */ +} NodeCore; + +/* Observability counters for the test. */ +static long g_node_traverses = 0; +static long g_node_clears = 0; +static long g_node_live = 0; + +static PyTypeObject Node_Type; /* forward */ + +static NodeCore *node_core_noerr(PyObject *self) { + return (NodeCore *)core_addr_noerr(self); +} + +/* tp_new: allocate via the GC allocator, initialise the core, then + * enrol with the cycle collector. The classic stock GC-type pattern. */ +static PyObject *Node_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + (void)args; + (void)kwds; + PyObject *self = _PyObject_GC_New(type); + if (!self) { + return NULL; + } + NodeCore *core = (NodeCore *)calloc(1, sizeof(NodeCore)); + if (!core) { + PyObject_GC_Del(self); + PyErr_NoMemory(); + return NULL; + } + core->child = NULL; + if (set_core_addr(self, core) != 0) { + free(core); + PyObject_GC_Del(self); + return NULL; + } + g_node_live += 1; + PyObject_GC_Track(self); + return self; +} + +static int Node_traverse(PyObject *self, visitproc visit, void *arg) { + g_node_traverses += 1; + NodeCore *core = node_core_noerr(self); + if (core && core->child) { + Py_VISIT(core->child); + } + return 0; +} + +static int Node_clear(PyObject *self) { + g_node_clears += 1; + NodeCore *core = node_core_noerr(self); + if (core) { + Py_CLEAR(core->child); + } + return 0; +} + +static void Node_dealloc(PyObject *self) { + PyObject_GC_UnTrack(self); + NodeCore *core = node_core_noerr(self); + if (core) { + Py_CLEAR(core->child); + /* The core block itself is intentionally leaked (a small, fixed + * allocation), exactly as `_ndarray.c` leaks its cores. */ + } + g_node_live -= 1; + PyObject_GC_Del(self); +} + +/* Node.set_child(other) — replace the C-held child reference. */ +static PyObject *Node_set_child(PyObject *self, PyObject *other) { + NodeCore *core = node_core_noerr(self); + if (!core) { + PyErr_SetString(PyExc_RuntimeError, "Node: missing core"); + return NULL; + } + PyObject *old = core->child; + if (other == Py_None) { + core->child = NULL; + } else { + Py_INCREF(other); + core->child = other; + } + Py_XDECREF(old); + Py_RETURN_NONE; +} + +/* Node.get_child() — return a new reference to the C-held child. */ +static PyObject *Node_get_child(PyObject *self, PyObject *ignored) { + (void)ignored; + NodeCore *core = node_core_noerr(self); + if (!core) { + PyErr_SetString(PyExc_RuntimeError, "Node: missing core"); + return NULL; + } + if (!core->child) { + Py_RETURN_NONE; + } + Py_INCREF(core->child); + return core->child; +} + +static PyMethodDef Node_methods[] = { + {"set_child", Node_set_child, METH_O, "store a child reference in C memory"}, + {"get_child", Node_get_child, METH_NOARGS, "return the C-held child or None"}, + {NULL, NULL, 0, NULL}, +}; + +static PyTypeObject Node_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "_stocktype.Node", + .tp_basicsize = sizeof(PyObject), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + .tp_doc = "GC node holding a child in C memory (tp_traverse/tp_clear proof)", + .tp_new = Node_new, + .tp_dealloc = Node_dealloc, + .tp_traverse = Node_traverse, + .tp_clear = Node_clear, + .tp_methods = Node_methods, +}; + +/* ================================================================== */ +/* Module-level helpers for the GC proof. */ +/* ================================================================== */ + +/* Return (traverses, clears, live_nodes) so the test can observe that + * the collector reached the C `tp_traverse`/`tp_clear` slots and that + * the nodes were reclaimed. */ +static PyObject *st_gc_counters(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return Py_BuildValue("(lll)", g_node_traverses, g_node_clears, g_node_live); +} + +/* Return the last value handed to `Const.__set__`. */ +static PyObject *st_last_descr_set(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return PyLong_FromLong(g_last_descr_set); +} + +/* make_vec2(x, y) — construct a Vec2 by calling the readied type object + * through the C call protocol, at the *top level* of a C entry point + * (the non-re-entrant counterpart to `vec2_build`'s in-slot use). */ +static PyObject *st_make_vec2(PyObject *self, PyObject *args) { + (void)self; + long x = 0, y = 0; + if (!PyArg_ParseTuple(args, "ll", &x, &y)) { + return NULL; + } + return vec2_build(x, y); +} + +/* Return (last_name, last_value) handed to `Proxy.__setattr__`. */ +static PyObject *st_last_setattr(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return Py_BuildValue("(sl)", g_last_setattr_name, g_last_setattr_value); +} + +/* Number of times `Aw.__anext__` (am_anext) reached the C slot. */ +static PyObject *st_aw_anext_calls(PyObject *self, PyObject *args) { + (void)self; + (void)args; + return PyLong_FromLong(g_aw_anext_calls); +} + +static PyMethodDef st_methods[] = { + {"gc_counters", st_gc_counters, METH_NOARGS, "(traverses, clears, live_nodes)"}, + {"last_descr_set", st_last_descr_set, METH_NOARGS, "last value passed to Const.__set__"}, + {"make_vec2", st_make_vec2, METH_VARARGS, "construct a Vec2 by calling the type from C"}, + {"last_setattr", st_last_setattr, METH_NOARGS, "(name, value) last passed to Proxy.__setattr__"}, + {"aw_anext_calls", st_aw_anext_calls, METH_NOARGS, "times Aw.__anext__ reached the C slot"}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef st_module = { + PyModuleDef_HEAD_INIT, + "_stocktype", + "RFC 0044 wave-2 stock-CPython-3.13 static-type-suite proof.", + -1, + st_methods, + NULL, + NULL, + NULL, + NULL, +}; + +/* Ready a type and add it to the module, decref'ing on the error path. */ +static int add_type(PyObject *m, PyTypeObject *t, const char *name) { + if (PyType_Ready(t) < 0) { + return -1; + } + Py_INCREF(t); + if (PyModule_AddObject(m, name, (PyObject *)t) < 0) { + Py_DECREF(t); + return -1; + } + return 0; +} + +PyMODINIT_FUNC PyInit__stocktype(void) { + PyObject *m = PyModule_Create(&st_module); + if (!m) { + return NULL; + } + if (add_type(m, &Vec2_Type, "Vec2") < 0 || add_type(m, &Seq_Type, "Seq") < 0 || + add_type(m, &Adder_Type, "Adder") < 0 || add_type(m, &Const_Type, "Const") < 0 || + add_type(m, &Aw_Type, "Aw") < 0 || add_type(m, &Proxy_Type, "Proxy") < 0 || + add_type(m, &Node_Type, "Node") < 0) { + Py_DECREF(m); + return NULL; + } + PyModule_AddStringConstant(m, "ABI", "cp313"); + return m; +} diff --git a/tests/regrtest/test_packaging_pep440.py b/tests/regrtest/test_packaging_pep440.py index bd8ed6c..f8b8bcc 100644 --- a/tests/regrtest/test_packaging_pep440.py +++ b/tests/regrtest/test_packaging_pep440.py @@ -16,11 +16,13 @@ SpecifierSet, Version, canonicalize_name, + compatible_tags, default_environment, parse_wheel_filename, wheel_is_compatible, wheel_score, ) +from _packaging import _platform_tags def assert_eq(a, b, label=''): @@ -119,6 +121,37 @@ def test_wheel_filename(): assert_true(wheel_score('numpy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl') > 0) +def test_wheel_matrix_wave5(): + # RFC 0047 (wave 5): the full manylinux / macOS / musllinux platform + # matrix a real numpy/pandas wheel ships. Parameterised so the test is + # host-independent. + linux = _platform_tags('linux', 'x86_64') + assert_true('manylinux_2_17_x86_64' in linux, 'manylinux present') + assert_true('manylinux2014_x86_64' in linux, 'legacy manylinux present') + assert_true('musllinux_1_1_x86_64' in linux, 'musllinux_1_1 present') + assert_true('musllinux_1_2_x86_64' in linux, 'musllinux_1_2 present') + mac = _platform_tags('darwin', 'arm64') + assert_true('macosx_11_0_arm64' in mac, 'macOS arm64 present') + assert_true('macosx_11_0_universal2' in mac, 'macOS universal2 present') + + +def test_wheel_provenance_wave5(): + # RFC 0047 (wave 5): the WeavePy provenance interpreter tag. Only + # meaningful when running under WeavePy (it is gated on the + # interpreter); on stock CPython `compatible_tags` never emits it. + import sys + if sys.implementation.name != 'weavepy': + return + accept = {(t.python, t.abi, t.platform) for t in compatible_tags()} + assert_true(('weavepy', 'none', 'any') in accept, 'weavepy-none-any accepted') + assert_true(wheel_is_compatible('foo-1.0-weavepy-none-any.whl'), + 'weavepy provenance wheel compatible') + # A provenance wheel outranks the stock cp313 wheel it shadows. + prov = wheel_score('numpy-2.0.0-weavepy-cp313-manylinux_2_17_x86_64.whl') + stock = wheel_score('numpy-2.0.0-cp313-cp313-manylinux_2_17_x86_64.whl') + assert_true(prov > stock, 'provenance outranks stock ({} vs {})'.format(prov, stock)) + + def main(): tests = [v for k, v in globals().items() if k.startswith('test_')] failures = 0