From c08c676342bb410cfc0c38fa47ad488ebaba016d Mon Sep 17 00:00:00 2001 From: Emma Harper Smith Date: Mon, 20 Jul 2026 02:35:46 -0700 Subject: [PATCH] Initial sketch of a Rust API for CPython This commit introduces a rough sketch of a Rust API for CPython. The overall design goals are 1. Should be very similar to PyO3 API 2. Threadstate should be passed explicitly to ensure safety 3. Should feel familiar to C API users 4. If a C API for something is missing, we can add new internal C APIs (e.g. an operation where we need to pass the threadstate explicitly where it is fetched implicitly from TLS) 5. Should be minimal to what we need, we are not re-implementing all of PyO3 This is still a work in progress so things are still subject to change significantly. --- Cargo.lock | 19 +- Cargo.toml | 8 +- Modules/_base64/Cargo.toml | 2 +- Modules/_base64/src/lib.rs | 294 ++------ Modules/cpython-api-macros/Cargo.toml | 12 + Modules/cpython-api-macros/src/lib.rs | 729 +++++++++++++++++++ Modules/cpython-api/Cargo.toml | 11 + Modules/cpython-api/build.rs | 57 ++ Modules/cpython-api/src/args.rs | 232 ++++++ Modules/cpython-api/src/buffer.rs | 106 +++ Modules/cpython-api/src/class.rs | 295 ++++++++ Modules/cpython-api/src/conversion.rs | 253 +++++++ Modules/cpython-api/src/err.rs | 137 ++++ Modules/cpython-api/src/instance.rs | 238 ++++++ Modules/cpython-api/src/lib.rs | 68 ++ Modules/cpython-api/src/module.rs | 503 +++++++++++++ Modules/cpython-api/src/sys_calls.rs | 273 +++++++ Modules/cpython-api/src/threadstate.rs | 118 +++ Modules/cpython-api/src/types/any.rs | 22 + Modules/cpython-api/src/types/bytes.rs | 61 ++ Modules/cpython-api/src/types/mod.rs | 11 + Modules/cpython-api/src/types/module.rs | 42 ++ Modules/cpython-api/src/types/type_object.rs | 37 + Modules/cpython-api/tests/define_module.rs | 304 ++++++++ Modules/cpython-rust-staticlib/src/lib.rs | 10 +- Modules/cpython-sys/build.rs | 3 + Modules/cpython-sys/src/lib.rs | 9 + 27 files changed, 3624 insertions(+), 230 deletions(-) create mode 100644 Modules/cpython-api-macros/Cargo.toml create mode 100644 Modules/cpython-api-macros/src/lib.rs create mode 100644 Modules/cpython-api/Cargo.toml create mode 100644 Modules/cpython-api/build.rs create mode 100644 Modules/cpython-api/src/args.rs create mode 100644 Modules/cpython-api/src/buffer.rs create mode 100644 Modules/cpython-api/src/class.rs create mode 100644 Modules/cpython-api/src/conversion.rs create mode 100644 Modules/cpython-api/src/err.rs create mode 100644 Modules/cpython-api/src/instance.rs create mode 100644 Modules/cpython-api/src/lib.rs create mode 100644 Modules/cpython-api/src/module.rs create mode 100644 Modules/cpython-api/src/sys_calls.rs create mode 100644 Modules/cpython-api/src/threadstate.rs create mode 100644 Modules/cpython-api/src/types/any.rs create mode 100644 Modules/cpython-api/src/types/bytes.rs create mode 100644 Modules/cpython-api/src/types/mod.rs create mode 100644 Modules/cpython-api/src/types/module.rs create mode 100644 Modules/cpython-api/src/types/type_object.rs create mode 100644 Modules/cpython-api/tests/define_module.rs diff --git a/Cargo.lock b/Cargo.lock index 132b913b06d5e5f..6c0b127a84075dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,8 +6,8 @@ version = 4 name = "_base64" version = "0.1.0" dependencies = [ + "cpython-api", "cpython-build-helper", - "cpython-sys", ] [[package]] @@ -71,6 +71,23 @@ dependencies = [ "libloading", ] +[[package]] +name = "cpython-api" +version = "0.1.0" +dependencies = [ + "cpython-api-macros", + "cpython-sys", +] + +[[package]] +name = "cpython-api-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "cpython-build-helper" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 768d289fd77653e..03f9c4d28986a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ [workspace] resolver = "3" members = [ - "Modules/_base64", "Modules/cpython-build-helper", "Modules/cpython-rust-staticlib", - "Modules/cpython-sys" + "Modules/_base64", + "Modules/cpython-api", + "Modules/cpython-api-macros", + "Modules/cpython-build-helper", + "Modules/cpython-rust-staticlib", + "Modules/cpython-sys", ] diff --git a/Modules/_base64/Cargo.toml b/Modules/_base64/Cargo.toml index f90350624729d65..63c2b167131ab35 100644 --- a/Modules/_base64/Cargo.toml +++ b/Modules/_base64/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -cpython-sys ={ path = "../cpython-sys" } +cpython-api = { path = "../cpython-api" } [build-dependencies] cpython-build-helper = { path = "../cpython-build-helper" } diff --git a/Modules/_base64/src/lib.rs b/Modules/_base64/src/lib.rs index 64e4d718789372c..3f97eec7ca6faf7 100644 --- a/Modules/_base64/src/lib.rs +++ b/Modules/_base64/src/lib.rs @@ -1,249 +1,93 @@ -use std::cell::UnsafeCell; -use std::ffi::{c_char, c_int, c_void}; -use std::mem::MaybeUninit; -use std::ptr; -use std::slice; - -use cpython_sys::METH_FASTCALL; -use cpython_sys::Py_DecRef; -use cpython_sys::Py_buffer; -use cpython_sys::Py_ssize_t; -use cpython_sys::PyBuffer_Release; -use cpython_sys::PyBytes_AsString; -use cpython_sys::PyBytes_FromStringAndSize; -use cpython_sys::PyErr_NoMemory; -use cpython_sys::PyErr_SetString; -use cpython_sys::PyExc_TypeError; -use cpython_sys::PyMethodDef; -use cpython_sys::PyMethodDefFuncPointer; -use cpython_sys::PyModuleDef; -use cpython_sys::PyModuleDef_HEAD_INIT; -use cpython_sys::PyModuleDef_Init; -use cpython_sys::PyObject; -use cpython_sys::PyObject_GetBuffer; - -const PYBUF_SIMPLE: c_int = 0; -const PAD_BYTE: u8 = b'='; -const ENCODE_TABLE: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -#[inline] -fn encoded_output_len(input_len: usize) -> Option { - input_len - .checked_add(2) - .map(|n| n / 3) - .and_then(|blocks| blocks.checked_mul(4)) -} - -#[inline] -fn encode_into(input: &[u8], output: &mut [u8]) -> usize { - let mut src_index = 0; - let mut dst_index = 0; - let len = input.len(); - - while src_index + 3 <= len { - let chunk = (u32::from(input[src_index]) << 16) - | (u32::from(input[src_index + 1]) << 8) - | u32::from(input[src_index + 2]); - output[dst_index] = ENCODE_TABLE[((chunk >> 18) & 0x3f) as usize]; - output[dst_index + 1] = ENCODE_TABLE[((chunk >> 12) & 0x3f) as usize]; - output[dst_index + 2] = ENCODE_TABLE[((chunk >> 6) & 0x3f) as usize]; - output[dst_index + 3] = ENCODE_TABLE[(chunk & 0x3f) as usize]; - src_index += 3; - dst_index += 4; - } - - match len - src_index { - 0 => {} - 1 => { - let chunk = u32::from(input[src_index]) << 16; - output[dst_index] = ENCODE_TABLE[((chunk >> 18) & 0x3f) as usize]; - output[dst_index + 1] = ENCODE_TABLE[((chunk >> 12) & 0x3f) as usize]; - output[dst_index + 2] = PAD_BYTE; - output[dst_index + 3] = PAD_BYTE; - dst_index += 4; - } - 2 => { - let chunk = - (u32::from(input[src_index]) << 16) | (u32::from(input[src_index + 1]) << 8); - output[dst_index] = ENCODE_TABLE[((chunk >> 18) & 0x3f) as usize]; - output[dst_index + 1] = ENCODE_TABLE[((chunk >> 12) & 0x3f) as usize]; - output[dst_index + 2] = ENCODE_TABLE[((chunk >> 6) & 0x3f) as usize]; - output[dst_index + 3] = PAD_BYTE; - dst_index += 4; - } - _ => unreachable!("len - src_index cannot exceed 2"), - } - - dst_index -} - -struct BorrowedBuffer { - view: Py_buffer, -} +//! The `_base64` module, implemented against `cpython-api`. -impl BorrowedBuffer { - fn from_object(obj: &PyObject) -> Result { - let mut view = MaybeUninit::::uninit(); - let buffer = unsafe { - if PyObject_GetBuffer(obj.as_raw(), view.as_mut_ptr(), PYBUF_SIMPLE) != 0 { - return Err(()); - } - Self { - view: view.assume_init(), - } - }; - Ok(buffer) - } +use std::mem::MaybeUninit; - fn len(&self) -> Py_ssize_t { - self.view.len - } +use cpython_api::prelude::*; - fn as_ptr(&self) -> *const u8 { - self.view.buf.cast::() as *const u8 - } -} +/// Per-module state, empty since the base64 module has no state +struct Base64State; -impl Drop for BorrowedBuffer { - fn drop(&mut self) { - unsafe { - PyBuffer_Release(&mut self.view); - } +impl ModuleState for Base64State { + fn new<'py>(_ts: &ThreadState<'py>, _module: &Bound<'py, PyModule>) -> PyResult { + Ok(Base64State) } } -/// # Safety -/// `module` must be a valid pointer of PyObject representing the module. -/// `args` must be a valid pointer to an array of valid PyObject pointers with length `nargs`. -pub unsafe extern "C" fn standard_b64encode( - _module: *mut PyObject, - args: *mut *mut PyObject, - nargs: Py_ssize_t, -) -> *mut PyObject { - if nargs != 1 { - unsafe { - PyErr_SetString( - PyExc_TypeError, - c"standard_b64encode() takes exactly one argument".as_ptr(), - ); - } - return ptr::null_mut(); - } - - let source = unsafe { &**args }; +const PAD_BYTE: u8 = b'='; +const ENCODE_TABLE: [u8; 64] = *b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - // Safe cast by Safety - match standard_b64encode_impl(source) { - Ok(result) => result, - Err(_) => ptr::null_mut(), - } +/// 4 output bytes per started block of 3 input bytes; `None` on overflow. +#[inline] +fn encoded_output_len(input_len: usize) -> Option { + input_len.div_ceil(3).checked_mul(4) } -fn standard_b64encode_impl(source: &PyObject) -> Result<*mut PyObject, ()> { - let buffer = match BorrowedBuffer::from_object(source) { - Ok(buf) => buf, - Err(_) => return Err(()), +/// Encode `input` into `output`, which must be exactly +/// `encoded_output_len(input.len())` bytes and is fully initialized on +/// return. +fn encode_into(input: &[u8], output: &mut [MaybeUninit]) { + let mut chunks = input.chunks_exact(3); + let mut out = output.iter_mut(); + let mut put = |b: u8| { + out.next() + .expect("output sized to encoded_output_len") + .write(b); }; - let view_len = buffer.len(); - if view_len < 0 { - unsafe { - PyErr_SetString( - PyExc_TypeError, - c"standard_b64encode() argument has negative length".as_ptr(), - ); - } - return Err(()); + for chunk in &mut chunks { + let group = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]); + put(ENCODE_TABLE[(group >> 18 & 0x3f) as usize]); + put(ENCODE_TABLE[(group >> 12 & 0x3f) as usize]); + put(ENCODE_TABLE[(group >> 6 & 0x3f) as usize]); + put(ENCODE_TABLE[(group & 0x3f) as usize]); } - let input_len = view_len as usize; - let input = unsafe { slice::from_raw_parts(buffer.as_ptr(), input_len) }; - - let Some(output_len) = encoded_output_len(input_len) else { - unsafe { - PyErr_NoMemory(); + match *chunks.remainder() { + [] => {} + [a] => { + let group = u32::from(a) << 16; + put(ENCODE_TABLE[(group >> 18 & 0x3f) as usize]); + put(ENCODE_TABLE[(group >> 12 & 0x3f) as usize]); + put(PAD_BYTE); + put(PAD_BYTE); } - return Err(()); - }; - - if output_len > isize::MAX as usize { - unsafe { - PyErr_NoMemory(); + [a, b] => { + let group = (u32::from(a) << 16) | (u32::from(b) << 8); + put(ENCODE_TABLE[(group >> 18 & 0x3f) as usize]); + put(ENCODE_TABLE[(group >> 12 & 0x3f) as usize]); + put(ENCODE_TABLE[(group >> 6 & 0x3f) as usize]); + put(PAD_BYTE); } - return Err(()); + _ => unreachable!("chunks_exact(3) remainder is at most 2 bytes"), } - - let result = unsafe { PyBytes_FromStringAndSize(ptr::null(), output_len as Py_ssize_t) }; - if result.is_null() { - return Err(()); - } - - let dest_ptr = unsafe { PyBytes_AsString(result) }; - if dest_ptr.is_null() { - unsafe { - Py_DecRef(result); - } - return Err(()); - } - let dest = unsafe { slice::from_raw_parts_mut(dest_ptr.cast::(), output_len) }; - - let written = encode_into(input, dest); - debug_assert_eq!(written, output_len); - Ok(result) -} - -pub extern "C" fn _base64_clear(_obj: *mut PyObject) -> c_int { - //TODO - 0 -} - -pub extern "C" fn _base64_free(_o: *mut c_void) { - //TODO } -pub struct ModuleDef { - ffi: UnsafeCell, +/// Encode a bytes-like object with the standard Base64 alphabet. +#[pyfunction(signature = (data, /))] +fn standard_b64encode<'py>( + ts: &ThreadState<'py>, + _state: &Base64State, + data: PyBuffer<'py>, +) -> PyResult> { + let input = data.as_bytes(); + let Some(output_len) = encoded_output_len(input.len()) else { + return Err(PyMemoryError::raise(ts, "encoded result too long")); + }; + // Write straight into the bytes object's buffer — no intermediate Vec. + PyBytes::new_with(ts, output_len, |output| { + encode_into(input, output); + Ok(()) + }) } -impl ModuleDef { - fn init_multi_phase(&'static self) -> *mut PyObject { - unsafe { PyModuleDef_Init(self.ffi.get()) } - } +fn base64_exec<'py>(_ts: &ThreadState<'py>, _module: &Bound<'py, PyModule>) -> PyResult<()> { + Ok(()) } -unsafe impl Sync for ModuleDef {} - -pub static _BASE64_MODULE_METHODS: [PyMethodDef; 2] = { - [ - PyMethodDef { - ml_name: c"standard_b64encode".as_ptr() as *mut c_char, - ml_meth: PyMethodDefFuncPointer { - PyCFunctionFast: standard_b64encode, - }, - ml_flags: METH_FASTCALL, - ml_doc: c"Demo for the _base64 module".as_ptr() as *mut c_char, - }, - PyMethodDef::zeroed(), - ] -}; - -pub static _BASE64_MODULE: ModuleDef = { - ModuleDef { - ffi: UnsafeCell::new(PyModuleDef { - m_base: PyModuleDef_HEAD_INIT, - m_name: c"_base64".as_ptr() as *mut _, - m_doc: c"A test Rust module".as_ptr() as *mut _, - m_size: 0, - m_methods: &_BASE64_MODULE_METHODS as *const PyMethodDef as *mut _, - m_slots: std::ptr::null_mut(), - m_traverse: None, - m_clear: Some(_base64_clear), - m_free: Some(_base64_free), - }), - } -}; - -#[unsafe(no_mangle)] -pub extern "C" fn PyInit__base64() -> *mut PyObject { - _BASE64_MODULE.init_multi_phase() +export_module! { + name: _base64, + doc: c"Base64 encoding implemented in Rust", + state: Base64State, + methods: [standard_b64encode], + exec: base64_exec, } diff --git a/Modules/cpython-api-macros/Cargo.toml b/Modules/cpython-api-macros/Cargo.toml new file mode 100644 index 000000000000000..374ded3c806df0d --- /dev/null +++ b/Modules/cpython-api-macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cpython-api-macros" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/Modules/cpython-api-macros/src/lib.rs b/Modules/cpython-api-macros/src/lib.rs new file mode 100644 index 000000000000000..562e0b4e5fa3966 --- /dev/null +++ b/Modules/cpython-api-macros/src/lib.rs @@ -0,0 +1,729 @@ +//! Proc macros for `cpython-api`: `#[pyfunction]` and `#[pymethods]`. +//! +//! These generate the `extern "C"` trampolines and argument-parsing glue — +//! the mechanical, error-prone part of binding Rust functions to Python. +//! Module and class *definitions* stay explicit (`export_module!`, +//! `ClassDef`); see RUST_API.md. +//! +//! Parameter classification is positional, verified by the type system +//! rather than by name matching (a proc macro sees only tokens — it cannot +//! resolve types or trait impls, so anything name-based would be a +//! heuristic): +//! +//! - after an optional `&self`, the **first** parameter is the thread-state +//! token (`ts: &ThreadState<'py>`); +//! - the **second** is the module state (`state: &T` where `T: ModuleState`) +//! — always present, mirroring how C module functions always receive the +//! module object; stateless functions name it `_state`; +//! - the remaining parameters are Python-visible arguments extracted with +//! `FromPyObject`. +//! +//! The macro only checks the *shape* (shared references in the right +//! positions); the generated code pins the real types, so a wrong type shows +//! up as an ordinary compile error at the user signature. +//! +//! ```ignore +//! #[pyfunction(signature = (data, value = 1, /))] +//! fn adler32(ts: &ThreadState<'_>, _state: &ZlibState, +//! data: PyBuffer<'_>, value: u32) -> PyResult { .. } +//! // => mod adler32 { pub const DEF: PyMethodDef; ... } +//! ``` +//! +//! `#[pymethods]` on an inherent impl block processes `#[pyfunction]`, +//! `#[getter]` and `#[new]` items and emits `{TYPE}_METHODS`, +//! `{TYPE}_GETSETS` and (if `#[new]` is present) `{TYPE}_TP_NEW` statics for +//! use with `ClassDef`. Methods are uniformly compiled as `METH_METHOD` +//! (PyCMethod), so the defining class — and through it the module state — is +//! always available. `#[getter]`s stay minimal (`&self, ts`), as does +//! `#[new]` (`ts, args...`). +//! +//! Note the inner attributes never expand on their own inside `#[pymethods]`: +//! attribute macros expand outermost-first, so the impl-block macro consumes +//! the markers before rustc would try to expand them. + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::{ + Error, Expr, FnArg, Ident, ImplItem, ItemFn, ItemImpl, LitByteStr, LitStr, Pat, Result, Token, + Type, +}; + +// --- signature attribute parsing ------------------------------------------- + +enum SigEntry { + Param { name: Ident, default: Option }, + PosOnlyMarker, +} + +impl Parse for SigEntry { + fn parse(input: ParseStream<'_>) -> Result { + if input.peek(Token![/]) { + input.parse::()?; + return Ok(SigEntry::PosOnlyMarker); + } + let name: Ident = input.parse()?; + let default = if input.peek(Token![=]) { + input.parse::()?; + Some(input.parse::()?) + } else { + None + }; + Ok(SigEntry::Param { name, default }) + } +} + +/// The `signature = (...)` attribute payload. +struct PyFunctionAttr { + entries: Option>, +} + +impl Parse for PyFunctionAttr { + fn parse(input: ParseStream<'_>) -> Result { + if input.is_empty() { + return Ok(PyFunctionAttr { entries: None }); + } + let key: Ident = input.parse()?; + if key != "signature" { + return Err(Error::new(key.span(), "expected `signature = (...)`")); + } + input.parse::()?; + let content; + syn::parenthesized!(content in input); + let entries: Punctuated = + content.parse_terminated(SigEntry::parse, Token![,])?; + Ok(PyFunctionAttr { + entries: Some(entries.into_iter().collect()), + }) + } +} + +// --- function model -------------------------------------------------------- + +struct PyParam { + name: Ident, + default: Option, +} + +/// Which special leading parameters the function shape carries. +#[derive(PartialEq, Clone, Copy)] +enum FnShape { + /// `([&self,] ts, state, python-args...)` + Regular, + /// `(&self, ts)` + Getter, + /// `(ts, python-args...)` + New, +} + +struct FnModel { + rust_name: Ident, + has_receiver: bool, + /// The token was declared `&mut ThreadState` (needed for `detach`). + ts_mut: bool, + /// Type behind the state reference (`Some` iff shape is `Regular`). + state_ty: Option, + params: Vec, + pos_only: usize, +} + +impl FnModel { + /// `let [mut] __ts = ...` binding and the `&[mut] __ts` expression passed + /// to the user function. + fn ts_tokens(&self) -> (TokenStream2, TokenStream2) { + if self.ts_mut { + (quote!(mut __ts), quote!(&mut __ts)) + } else { + (quote!(__ts), quote!(&__ts)) + } + } +} + +/// Positional slot for a special leading parameter: require a shared +/// reference and return its target type. The *actual* type is pinned by the +/// generated code, so this only guards the shape for a clear early error. +fn shared_ref_target(ty: &Type, what: &str) -> Result { + let Type::Reference(r) = ty else { + return Err(Error::new( + ty.span(), + format!("this parameter must be {what}"), + )); + }; + if r.mutability.is_some() { + return Err(Error::new( + ty.span(), + format!("this parameter must be a shared reference: {what}"), + )); + } + Ok(r.elem.as_ref().clone()) +} + +/// Analyze a function signature and reconcile it with the `signature = (...)` +/// attribute. +fn analyze_fn(sig: &syn::Signature, attr: &PyFunctionAttr, shape: FnShape) -> Result { + let mut has_receiver = false; + let mut saw_ts = false; + let mut ts_mut = false; + let mut state_ty: Option = None; + let mut py_names: Vec = Vec::new(); + + for input in sig.inputs.iter() { + match input { + FnArg::Receiver(recv) => { + if recv.mutability.is_some() || recv.reference.is_none() { + return Err(Error::new( + recv.span(), + "methods must take `&self`; use interior mutability (e.g. Mutex) for state", + )); + } + has_receiver = true; + } + FnArg::Typed(pat_ty) => { + if !saw_ts { + // `&ThreadState` or `&mut ThreadState` (the latter allows + // `ts.detach(..)` in the body). + let Type::Reference(r) = &*pat_ty.ty else { + return Err(Error::new( + pat_ty.ty.span(), + "the first parameter must be the thread-state token \ + (`ts: &ThreadState` or `ts: &mut ThreadState`)", + )); + }; + ts_mut = r.mutability.is_some(); + saw_ts = true; + continue; + } + if shape == FnShape::Regular && state_ty.is_none() { + state_ty = Some(shared_ref_target( + &pat_ty.ty, + "the module state (`state: &YourModuleState`)", + )?); + continue; + } + let Pat::Ident(pi) = pat_ty.pat.as_ref() else { + return Err(Error::new( + pat_ty.pat.span(), + "parameter patterns are not supported; use a plain name", + )); + }; + py_names.push(pi.ident.clone()); + } + } + } + if !saw_ts { + return Err(Error::new( + sig.span(), + "a `ts: &ThreadState` parameter is required (first after `&self`)", + )); + } + if shape == FnShape::Regular && state_ty.is_none() { + return Err(Error::new( + sig.span(), + "a module-state parameter is required after `ts` (`state: &YourModuleState`; \ + name it `_state` if unused)", + )); + } + if shape == FnShape::Getter && !py_names.is_empty() { + return Err(Error::new( + sig.span(), + "#[getter] methods must be `fn name(&self, ts: &ThreadState) -> T`", + )); + } + + // Reconcile with the signature attribute. + let (params, pos_only) = match &attr.entries { + None => ( + py_names + .into_iter() + .map(|name| PyParam { + name, + default: None, + }) + .collect::>(), + 0, + ), + Some(entries) => { + let mut params = Vec::new(); + let mut pos_only = None; + for entry in entries { + match entry { + SigEntry::PosOnlyMarker => { + if pos_only.is_some() { + return Err(Error::new(sig.span(), "duplicate `/` in signature")); + } + pos_only = Some(params.len()); + } + SigEntry::Param { name, default } => params.push(PyParam { + name: name.clone(), + default: default.clone(), + }), + } + } + let sig_names: Vec = params.iter().map(|p| p.name.to_string()).collect(); + let fn_names: Vec = py_names.iter().map(|i| i.to_string()).collect(); + if sig_names != fn_names { + return Err(Error::new( + sig.span(), + format!( + "signature parameters ({}) do not match function parameters ({})", + sig_names.join(", "), + fn_names.join(", ") + ), + )); + } + (params, pos_only.unwrap_or(0)) + } + }; + + Ok(FnModel { + rust_name: sig.ident.clone(), + has_receiver, + ts_mut, + state_ty, + params, + pos_only, + }) +} + +// --- code generation ------------------------------------------------------- + +fn cstr_lit(s: &str, span: proc_macro2::Span) -> LitByteStr { + LitByteStr::new(format!("{s}\0").as_bytes(), span) +} + +fn gen_spec(model: &FnModel) -> TokenStream2 { + let fn_name = LitStr::new(&model.rust_name.to_string(), model.rust_name.span()); + let pos_only = model.pos_only; + let params = model.params.iter().map(|p| { + let name = cstr_lit(&p.name.to_string(), p.name.span()); + let required = p.default.is_none(); + quote! { + ::cpython_api::args::Param { + name: ::cpython_api::const_cstr(#name), + required: #required, + } + } + }); + quote! { + pub const SPEC: ::cpython_api::args::ParamSpec = ::cpython_api::args::ParamSpec { + fn_name: #fn_name, + params: &[#(#params),*], + pos_only: #pos_only, + }; + } +} + +/// Generate the `let vN = ...` extraction statements (shared by all +/// trampoline kinds). +fn gen_extractions(model: &FnModel) -> (Vec, Vec) { + let mut stmts = Vec::new(); + let mut vars = Vec::new(); + for (i, param) in model.params.iter().enumerate() { + let var = format_ident!("__arg{i}"); + let stmt = match ¶m.default { + Some(default) => quote! { + let #var = match ::cpython_api::args::arg_bound(&__slots, #i) { + Some(obj) => ::cpython_api::FromPyObject::extract(&__ts, obj)?, + None => #default, + }; + }, + None => quote! { + let #var = match ::cpython_api::args::arg_bound(&__slots, #i) { + Some(obj) => ::cpython_api::FromPyObject::extract(&__ts, obj)?, + None => unreachable!("required argument enforced by parse"), + }; + }, + }; + stmts.push(stmt); + vars.push(var); + } + (stmts, vars) +} + +enum TrampolineKind<'a> { + /// Free function; `self` is the module object (state source). + Function, + /// Instance method of `self_ty`; METH_METHOD, state via defining class. + Method { self_ty: &'a Type }, +} + +/// Generate the trampoline plus `DEF` for a function or method. +fn gen_trampoline_and_def(model: &FnModel, kind: TrampolineKind<'_>) -> Result { + let n = model.params.len(); + let (extractions, vars) = gen_extractions(model); + let name = &model.rust_name; + let ml_name = cstr_lit(&model.rust_name.to_string(), model.rust_name.span()); + let state_ty = model + .state_ty + .as_ref() + .expect("Regular shape always has a state type"); + + let (ts_binding, ts_arg) = model.ts_tokens(); + let (state_setup, call) = match &kind { + TrampolineKind::Function => ( + quote! { + let __state: &#state_ty = unsafe { + ::cpython_api::module::state_from_module_ptr::<#state_ty>(&__ts, __slf)? + }; + }, + quote! { super::#name(#ts_arg, __state, #(#vars),*) }, + ), + TrampolineKind::Method { self_ty } => ( + quote! { + let __this: &#self_ty = + unsafe { ::cpython_api::class::payload_ref::<#self_ty>(__slf) }; + let __state: &#state_ty = unsafe { + ::cpython_api::class::state_from_defining_class::<#state_ty>(&__ts, __cls)? + }; + }, + quote! { super::#self_ty::#name(__this, #ts_arg, __state, #(#vars),*) }, + ), + }; + + let body = quote! { + let #ts_binding = unsafe { ::cpython_api::ThreadState::current_unchecked() }; + let __result: ::cpython_api::PyResult<*mut ::cpython_api::ffi::PyObject> = (|| { + #state_setup + let __slots = unsafe { + ::cpython_api::args::parse_fastcall::<#n>( + &__ts, &SPEC, __args as *const _, __nargs, __kwnames, + ) + }?; + #(#extractions)* + let __ret = #call; + ::cpython_api::conversion::IntoPyCallbackOutput::convert(__ret, &__ts) + })(); + match __result { + Ok(ptr) => ptr, + Err(_raised) => ::std::ptr::null_mut(), + } + }; + + let (trampoline, def) = match &kind { + TrampolineKind::Method { .. } => ( + quote! { + pub(crate) unsafe extern "C" fn trampoline( + __slf: *mut ::cpython_api::ffi::PyObject, + __cls: *mut ::cpython_api::ffi::PyTypeObject, + __args: *mut *mut ::cpython_api::ffi::PyObject, + __nargs: ::cpython_api::ffi::Py_ssize_t, + __kwnames: *mut ::cpython_api::ffi::PyObject, + ) -> *mut ::cpython_api::ffi::PyObject { + #body + } + }, + quote! { + pub const DEF: ::cpython_api::ffi::PyMethodDef = ::cpython_api::ffi::PyMethodDef { + ml_name: ::cpython_api::const_cstr(#ml_name).as_ptr().cast_mut(), + ml_meth: ::cpython_api::ffi::PyMethodDefFuncPointer { + PyCMethod: trampoline, + }, + ml_flags: ::cpython_api::ffi::METH_FASTCALL + | ::cpython_api::ffi::METH_KEYWORDS + | ::cpython_api::ffi::METH_METHOD, + ml_doc: ::std::ptr::null_mut(), + }; + }, + ), + TrampolineKind::Function => ( + quote! { + pub(crate) unsafe extern "C" fn trampoline( + __slf: *mut ::cpython_api::ffi::PyObject, + __args: *mut *mut ::cpython_api::ffi::PyObject, + __nargs: ::cpython_api::ffi::Py_ssize_t, + __kwnames: *mut ::cpython_api::ffi::PyObject, + ) -> *mut ::cpython_api::ffi::PyObject { + #body + } + }, + quote! { + pub const DEF: ::cpython_api::ffi::PyMethodDef = ::cpython_api::ffi::PyMethodDef { + ml_name: ::cpython_api::const_cstr(#ml_name).as_ptr().cast_mut(), + ml_meth: ::cpython_api::ffi::PyMethodDefFuncPointer { + PyCFunctionFastWithKeywords: trampoline, + }, + ml_flags: ::cpython_api::ffi::METH_FASTCALL + | ::cpython_api::ffi::METH_KEYWORDS, + ml_doc: ::std::ptr::null_mut(), + }; + }, + ), + }; + + let spec = gen_spec(model); + Ok(quote! { + #spec + #trampoline + #def + }) +} + +// --- #[pyfunction] on free functions --------------------------------------- + +#[proc_macro_attribute] +pub fn pyfunction(attr: TokenStream, item: TokenStream) -> TokenStream { + let attr = syn::parse_macro_input!(attr as PyFunctionAttr); + let func = syn::parse_macro_input!(item as ItemFn); + + let model = match analyze_fn(&func.sig, &attr, FnShape::Regular) { + Ok(m) => m, + Err(e) => return e.to_compile_error().into(), + }; + if model.has_receiver { + return Error::new( + func.sig.span(), + "#[pyfunction] on methods must be used inside #[pymethods]", + ) + .to_compile_error() + .into(); + } + + let generated = match gen_trampoline_and_def(&model, TrampolineKind::Function) { + Ok(g) => g, + Err(e) => return e.to_compile_error().into(), + }; + + let vis = &func.vis; + let mod_name = &model.rust_name; + let out = quote! { + #func + + #[doc(hidden)] + #[allow(non_upper_case_globals)] + #vis mod #mod_name { + use super::*; + #generated + } + }; + out.into() +} + +// --- #[pymethods] on impl blocks ------------------------------------------- + +enum MethodKind { + Regular, + Getter, + New, +} + +#[proc_macro_attribute] +pub fn pymethods(_attr: TokenStream, item: TokenStream) -> TokenStream { + let mut block = syn::parse_macro_input!(item as ItemImpl); + match expand_pymethods(&mut block) { + Ok(extra) => quote! { #block #extra }.into(), + Err(e) => e.to_compile_error().into(), + } +} + +fn expand_pymethods(block: &mut ItemImpl) -> Result { + if block.trait_.is_some() { + return Err(Error::new( + block.span(), + "#[pymethods] only supports inherent impl blocks", + )); + } + let self_ty = block.self_ty.as_ref().clone(); + let Type::Path(self_path) = &self_ty else { + return Err(Error::new(block.self_ty.span(), "unsupported self type")); + }; + let type_ident = &self_path.path.segments.last().unwrap().ident; + let prefix = type_ident.to_string().to_uppercase(); + + let mut method_defs: Vec = Vec::new(); + let mut getset_entries: Vec = Vec::new(); + let mut generated_mods: Vec = Vec::new(); + let mut tp_new: Option = None; + + for item in block.items.iter_mut() { + let ImplItem::Fn(method) = item else { continue }; + + // Recognize and strip our marker attributes. + let mut kind = MethodKind::Regular; + let mut fn_attr = PyFunctionAttr { entries: None }; + let mut keep = Vec::new(); + let mut is_py = false; + for attr in method.attrs.drain(..) { + if attr.path().is_ident("pyfunction") { + is_py = true; + fn_attr = match &attr.meta { + syn::Meta::Path(_) => PyFunctionAttr { entries: None }, + _ => attr.parse_args::()?, + }; + } else if attr.path().is_ident("getter") { + is_py = true; + kind = MethodKind::Getter; + } else if attr.path().is_ident("new") { + is_py = true; + kind = MethodKind::New; + } else { + keep.push(attr); + } + } + method.attrs = keep; + if !is_py { + continue; + } + + let shape = match kind { + MethodKind::Regular => FnShape::Regular, + MethodKind::Getter => FnShape::Getter, + MethodKind::New => FnShape::New, + }; + let model = analyze_fn(&method.sig, &fn_attr, shape)?; + let rust_name = model.rust_name.clone(); + let mod_name = format_ident!("__cpy_{}_{}", type_ident, rust_name); + + match kind { + MethodKind::Regular => { + if !model.has_receiver { + return Err(Error::new( + method.sig.span(), + "#[pyfunction] methods must take &self (use #[new] for constructors)", + )); + } + let generated = + gen_trampoline_and_def(&model, TrampolineKind::Method { self_ty: &self_ty })?; + generated_mods.push(quote! { + #[doc(hidden)] + #[allow(non_snake_case, non_upper_case_globals)] + mod #mod_name { + use super::*; + #generated + } + }); + method_defs.push(quote!(#mod_name::DEF)); + } + MethodKind::Getter => { + if !model.has_receiver { + return Err(Error::new( + method.sig.span(), + "#[getter] methods must take &self", + )); + } + let name_lit = cstr_lit(&rust_name.to_string(), rust_name.span()); + let (ts_binding, ts_arg) = model.ts_tokens(); + generated_mods.push(quote! { + #[doc(hidden)] + #[allow(non_snake_case)] + mod #mod_name { + use super::*; + pub(crate) unsafe extern "C" fn getter( + __slf: *mut ::cpython_api::ffi::PyObject, + _closure: *mut ::std::os::raw::c_void, + ) -> *mut ::cpython_api::ffi::PyObject { + let #ts_binding = unsafe { ::cpython_api::ThreadState::current_unchecked() }; + let __result: ::cpython_api::PyResult<*mut ::cpython_api::ffi::PyObject> = (|| { + let __this: &#self_ty = + unsafe { ::cpython_api::class::payload_ref::<#self_ty>(__slf) }; + let __ret = super::#self_ty::#rust_name(__this, #ts_arg); + ::cpython_api::conversion::IntoPyCallbackOutput::convert(__ret, &__ts) + })(); + match __result { + Ok(ptr) => ptr, + Err(_raised) => ::std::ptr::null_mut(), + } + } + } + }); + getset_entries.push(quote! { + ::cpython_api::ffi::PyGetSetDef { + name: ::cpython_api::const_cstr(#name_lit).as_ptr(), + get: Some(#mod_name::getter), + set: None, + doc: ::std::ptr::null(), + closure: ::std::ptr::null_mut(), + } + }); + } + MethodKind::New => { + if model.has_receiver { + return Err(Error::new( + method.sig.span(), + "#[new] must not take a receiver; it returns PyResult", + )); + } + if tp_new.is_some() { + return Err(Error::new(method.sig.span(), "duplicate #[new]")); + } + let n = model.params.len(); + let (extractions, vars) = gen_extractions(&model); + let spec = gen_spec(&model); + let (ts_binding, ts_arg) = model.ts_tokens(); + generated_mods.push(quote! { + #[doc(hidden)] + #[allow(non_snake_case)] + mod #mod_name { + use super::*; + #spec + pub(crate) unsafe extern "C" fn tp_new( + __subtype: *mut ::cpython_api::ffi::PyTypeObject, + __args: *mut ::cpython_api::ffi::PyObject, + __kwargs: *mut ::cpython_api::ffi::PyObject, + ) -> *mut ::cpython_api::ffi::PyObject { + let #ts_binding = unsafe { ::cpython_api::ThreadState::current_unchecked() }; + let __result: ::cpython_api::PyResult<*mut ::cpython_api::ffi::PyObject> = (|| { + let __slots = unsafe { + ::cpython_api::args::parse_tuple_dict::<#n>( + &__ts, &SPEC, __args, __kwargs, + ) + }?; + #(#extractions)* + let __payload: #self_ty = + super::#self_ty::#rust_name(#ts_arg, #(#vars),*)?; + unsafe { + ::cpython_api::class::alloc_with::<#self_ty>( + &__ts, __subtype, __payload, + ) + } + })(); + match __result { + Ok(ptr) => ptr, + Err(_raised) => ::std::ptr::null_mut(), + } + } + } + }); + tp_new = Some(quote!(#mod_name::tp_new)); + } + } + } + + let methods_ident = format_ident!("{prefix}_METHODS"); + let getsets_ident = format_ident!("{prefix}_GETSETS"); + let n_methods = method_defs.len() + 1; + let n_getsets = getset_entries.len() + 1; + + let tp_new_item = tp_new.map(|path| { + let ident = format_ident!("{prefix}_TP_NEW"); + quote! { + pub(crate) const #ident: ::cpython_api::class::NewFunc = #path; + } + }); + + Ok(quote! { + #(#generated_mods)* + + pub(crate) static #methods_ident: ::cpython_api::module::MethodDefs<#n_methods> = + ::cpython_api::module::MethodDefs([ + #(#method_defs,)* + ::cpython_api::ffi::PyMethodDef::zeroed(), + ]); + + pub(crate) static #getsets_ident: ::cpython_api::module::GetSetDefs<#n_getsets> = + ::cpython_api::module::GetSetDefs([ + #(#getset_entries,)* + ::cpython_api::ffi::PyGetSetDef { + name: ::std::ptr::null(), + get: None, + set: None, + doc: ::std::ptr::null(), + closure: ::std::ptr::null_mut(), + }, + ]); + + #tp_new_item + }) +} diff --git a/Modules/cpython-api/Cargo.toml b/Modules/cpython-api/Cargo.toml new file mode 100644 index 000000000000000..be541d53b66a9aa --- /dev/null +++ b/Modules/cpython-api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cpython-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +cpython-sys = { path = "../cpython-sys" } +cpython-api-macros = { path = "../cpython-api-macros" } + +[lib] +name = "cpython_api" diff --git a/Modules/cpython-api/build.rs b/Modules/cpython-api/build.rs new file mode 100644 index 000000000000000..fbc2dc545443def --- /dev/null +++ b/Modules/cpython-api/build.rs @@ -0,0 +1,57 @@ +use std::path::PathBuf; + +fn main() { + // Test binaries reference Python C API symbols through the crate's glue + // (trampolines, statics). Link test executables the way an extension + // module is linked — Python symbols may stay unresolved — so `cargo + // test` still compiles in a tree where libpython has not been built yet + // (as in CI, which runs configure + cargo test without make). When the + // build dir does contain a static libpython, link it so the symbols the + // test binary touches at load time resolve for real and the tests can + // run. + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "windows" { + return; + } + println!("cargo:rustc-link-arg-tests=-Wl,--unresolved-symbols=ignore-all"); + // Rust links with `-z now` by default, which would make the dynamic + // loader abort on any interpreter symbol the link left unresolved even + // though the tests never call one. Lazy binding defers function + // resolution to first call. + println!("cargo:rustc-link-arg-tests=-Wl,-z,lazy"); + + println!("cargo:rerun-if-env-changed=PYTHON_BUILD_DIR"); + let Ok(build_dir) = std::env::var("PYTHON_BUILD_DIR") else { + return; + }; + let build_dir = PathBuf::from(build_dir); + println!( + "cargo:rerun-if-changed={}", + build_dir.join("libpython3.15.a").display() + ); + let mut libs: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&build_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(stem) = name.strip_prefix("lib").and_then(|n| n.strip_suffix(".a")) + && stem.starts_with("python") + { + libs.push(stem.to_owned()); + } + } + } + // Highest version wins if several are lying around. + libs.sort(); + println!("cargo:rustc-check-cfg=cfg(has_libpython)"); + if let Some(lib) = libs.last() { + println!("cargo:rustc-link-arg-tests=-L{}", build_dir.display()); + println!("cargo:rustc-link-arg-tests=-l{lib}"); + for sys in ["m", "dl", "pthread", "util"] { + println!("cargo:rustc-link-arg-tests=-l{sys}"); + } + // Tests that need real interpreter symbols at load time are gated on + // this cfg, so `cargo test` stays green in a tree where CPython + // itself hasn't been built. + println!("cargo:rustc-cfg=has_libpython"); + } +} diff --git a/Modules/cpython-api/src/args.rs b/Modules/cpython-api/src/args.rs new file mode 100644 index 000000000000000..2118cf55f40a4c0 --- /dev/null +++ b/Modules/cpython-api/src/args.rs @@ -0,0 +1,232 @@ +//! Runtime argument binding for generated trampolines. +//! +//! The `#[pyfunction]` macro emits a static [`ParamSpec`] describing the +//! Python-visible signature and calls [`parse_fastcall`] (vectorcall +//! functions/methods) or [`parse_tuple_dict`] (`tp_new`) to bind incoming +//! arguments to parameter slots. Extraction to Rust types then happens in +//! generated code via `FromPyObject`. + +use std::ffi::CStr; +use std::ptr::NonNull; + +use crate::err::{PyErrRaised, PyResult, PyTypeError}; +use crate::ffi; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyAny; + +/// One Python-visible parameter. +pub struct Param { + /// Keyword name (also used in error messages). + pub name: &'static CStr, + /// False if the parameter has a default in the generated code. + pub required: bool, +} + +/// The Python-visible signature of a function. +pub struct ParamSpec { + /// Function name for error messages (e.g. `"compress"`). + pub fn_name: &'static str, + pub params: &'static [Param], + /// The first `pos_only` parameters are positional-only (`/` marker). + pub pos_only: usize, +} + +/// Bound argument slots: a borrowed object pointer per parameter, `None` +/// where the default applies. +pub type ArgSlots = [Option>; N]; + +/// View a bound slot as a borrowed `&Bound`. +#[inline] +pub fn arg_bound<'py, const N: usize>( + slots: &ArgSlots, + index: usize, +) -> Option<&Bound<'py, PyAny>> { + slots[index] + .as_ref() + .map(|p| unsafe { Bound::ref_from_ptr(p) }) +} + +fn raise_type_error(ts: &ThreadState<'_>, msg: std::fmt::Arguments<'_>) -> PyErrRaised { + PyTypeError::raise(ts, msg) +} + +/// Bind a vectorcall argument array (`METH_FASTCALL | METH_KEYWORDS`) to +/// parameter slots. +/// +/// # Safety +/// +/// `args`/`nargs`/`kwnames` must be exactly what the interpreter passed to a +/// fastcall entry point; the returned pointers are borrowed from that array +/// and valid for the duration of the call. +pub unsafe fn parse_fastcall<'py, const N: usize>( + ts: &ThreadState<'py>, + spec: &ParamSpec, + args: *const *mut ffi::PyObject, + nargs: ffi::Py_ssize_t, + kwnames: *mut ffi::PyObject, +) -> PyResult> { + debug_assert_eq!(spec.params.len(), N); + let mut slots: ArgSlots = [None; N]; + + let nargs = nargs as usize; + if nargs > N { + return Err(raise_type_error( + ts, + format_args!( + "{}() takes at most {} argument{} ({} given)", + spec.fn_name, + N, + if N == 1 { "" } else { "s" }, + nargs + ), + )); + } + for (i, slot) in slots.iter_mut().enumerate().take(nargs) { + // SAFETY: the interpreter guarantees `nargs` valid entries. + *slot = NonNull::new(unsafe { *args.add(i) }); + debug_assert!(slot.is_some()); + } + + if !kwnames.is_null() { + let nkw = unsafe { sys_calls::tuple_size(ts, kwnames) }; + for k in 0..nkw { + let name = unsafe { sys_calls::tuple_get_item(ts, kwnames, k) }; + let value = NonNull::new(unsafe { *args.add(nargs + k as usize) }); + let mut matched = false; + for (i, param) in spec.params.iter().enumerate().skip(spec.pos_only) { + if unsafe { sys_calls::unicode_eq_ascii(name, param.name.as_ptr()) } != 0 { + if slots[i].is_some() { + return Err(raise_type_error( + ts, + format_args!( + "argument for {}() given by name ('{}') and position ({})", + spec.fn_name, + param.name.to_str().unwrap_or("?"), + i + 1, + ), + )); + } + slots[i] = value; + matched = true; + break; + } + } + if !matched { + return Err(raise_type_error( + ts, + format_args!("{}() got an unexpected keyword argument", spec.fn_name), + )); + } + } + } + + check_required(ts, spec, &slots)?; + Ok(slots) +} + +/// Bind a classic `(args_tuple, kwargs_dict)` pair (`tp_new`) to parameter +/// slots. +/// +/// The returned pointers are borrowed: positional values are borrowed from +/// the tuple (alive for the call), keyword values are borrowed from the +/// call-private kwargs dict. +/// +/// # Safety +/// +/// `args` must be the argument tuple and `kwargs` the (possibly null) keyword +/// dict the interpreter passed to a `tp_new` entry point. +pub unsafe fn parse_tuple_dict<'py, const N: usize>( + ts: &ThreadState<'py>, + spec: &ParamSpec, + args: *mut ffi::PyObject, + kwargs: *mut ffi::PyObject, +) -> PyResult> { + debug_assert_eq!(spec.params.len(), N); + let mut slots: ArgSlots = [None; N]; + + let nargs = unsafe { sys_calls::tuple_size(ts, args) } as usize; + if nargs > N { + return Err(raise_type_error( + ts, + format_args!( + "{}() takes at most {} argument{} ({} given)", + spec.fn_name, + N, + if N == 1 { "" } else { "s" }, + nargs + ), + )); + } + for (i, slot) in slots.iter_mut().enumerate().take(nargs) { + let item = unsafe { sys_calls::tuple_get_item(ts, args, i as ffi::Py_ssize_t) }; + if item.is_null() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + *slot = NonNull::new(item); + } + + if !kwargs.is_null() { + let total = unsafe { sys_calls::dict_size(ts, kwargs) }; + let mut matched: ffi::Py_ssize_t = 0; + for (i, param) in spec.params.iter().enumerate().skip(spec.pos_only) { + let mut value: *mut ffi::PyObject = std::ptr::null_mut(); + let found = unsafe { + sys_calls::dict_get_item_string_ref(ts, kwargs, param.name.as_ptr(), &mut value) + }; + match found { + -1 => return Err(unsafe { PyErrRaised::assume_set(ts) }), + 0 => {} + _ => { + // Downgrade the strong reference to a borrow: the kwargs + // dict is private to this call and keeps the value alive. + unsafe { sys_calls::decref(ts, value) }; + if slots[i].is_some() { + return Err(raise_type_error( + ts, + format_args!( + "argument for {}() given by name ('{}') and position ({})", + spec.fn_name, + param.name.to_str().unwrap_or("?"), + i + 1, + ), + )); + } + slots[i] = NonNull::new(value); + matched += 1; + } + } + } + if matched != total { + return Err(raise_type_error( + ts, + format_args!("{}() got an unexpected keyword argument", spec.fn_name), + )); + } + } + + check_required(ts, spec, &slots)?; + Ok(slots) +} + +fn check_required( + ts: &ThreadState<'_>, + spec: &ParamSpec, + slots: &ArgSlots, +) -> PyResult<()> { + for (i, param) in spec.params.iter().enumerate() { + if param.required && slots[i].is_none() { + return Err(raise_type_error( + ts, + format_args!( + "{}() missing required argument '{}' (pos {})", + spec.fn_name, + param.name.to_str().unwrap_or("?"), + i + 1, + ), + )); + } + } + Ok(()) +} diff --git a/Modules/cpython-api/src/buffer.rs b/Modules/cpython-api/src/buffer.rs new file mode 100644 index 000000000000000..7a5407f2bbc20c2 --- /dev/null +++ b/Modules/cpython-api/src/buffer.rs @@ -0,0 +1,106 @@ +//! Read-only buffer-protocol access (`PyBUF_SIMPLE`). + +use std::marker::PhantomData; +use std::mem::MaybeUninit; +use std::os::raw::c_int; + +use crate::conversion::FromPyObject; +use crate::err::{PyErrRaised, PyResult}; +use crate::ffi; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyAny; + +/// A read-only, C-contiguous byte view of a buffer-protocol object +/// (`bytes`, `bytearray`, `memoryview`, ...). +/// +/// Requested with `PyBUF_SIMPLE`, which by definition yields a C-contiguous +/// `u8` buffer — no separate contiguity check is needed. The view holds its +/// own reference to the exporting object and releases the buffer on drop. +/// +/// Note (free-threaded builds): a mutable exporter such as `bytearray` can be +/// mutated by other threads while the view is held. Reads may then see +/// inconsistent *data*, but never dangling memory — the same exposure the C +/// implementations accept. +pub struct PyBuffer<'py> { + view: ffi::Py_buffer, + /// Ties the view to the attached scope; `*mut ()` keeps it `!Send+!Sync`. + _marker: PhantomData<(&'py PyAny, *mut ())>, +} + +impl<'py> PyBuffer<'py> { + /// Acquire a simple buffer view of `obj`. + /// + /// Raises `TypeError`/`BufferError` (from `PyObject_GetBuffer`) if `obj` + /// does not support a simple contiguous view. + pub fn get(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult> { + let mut view = MaybeUninit::::uninit(); + let ret = unsafe { + sys_calls::get_buffer( + ts, + obj.as_ptr(), + view.as_mut_ptr(), + ffi::PyBUF_SIMPLE as c_int, + ) + }; + if ret != 0 { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(PyBuffer { + view: unsafe { view.assume_init() }, + _marker: PhantomData, + }) + } + + /// The buffer contents. + #[inline] + pub fn as_bytes(&self) -> &[u8] { + if self.view.len == 0 { + // `buf` may be NULL for an empty buffer. + &[] + } else { + // SAFETY: PyBUF_SIMPLE guarantees a C-contiguous byte buffer of + // `len` bytes, alive until PyBuffer_Release. + unsafe { + std::slice::from_raw_parts(self.view.buf as *const u8, self.view.len as usize) + } + } + } + + /// Length of the buffer in bytes. + #[inline] + pub fn len(&self) -> usize { + self.view.len as usize + } + + /// True if the buffer is empty. + #[inline] + pub fn is_empty(&self) -> bool { + self.view.len == 0 + } +} + +impl Drop for PyBuffer<'_> { + fn drop(&mut self) { + // Same attachment policy as Bound/Py drops: release when attached, + // leak (never UB) if someone smuggled the view past a detach. + unsafe { + if !ffi::PyThreadState_GetUnchecked().is_null() { + // tls-fallback: Drop has nowhere to store a token borrow. + ffi::PyBuffer_Release(&mut self.view); + } else { + debug_assert!( + false, + "PyBuffer dropped while thread state is detached; leaking" + ); + } + } + } +} + +impl<'py> FromPyObject<'py> for PyBuffer<'py> { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + PyBuffer::get(ts, obj) + } +} diff --git a/Modules/cpython-api/src/class.rs b/Modules/cpython-api/src/class.rs new file mode 100644 index 000000000000000..8441d9a5d0f7ade --- /dev/null +++ b/Modules/cpython-api/src/class.rs @@ -0,0 +1,295 @@ +//! Class definition via PEP 820 (`PySlot` arrays + `PyType_FromSlots`). +//! +//! A class is a plain Rust payload struct embedded after the `PyObject` +//! header, described by a `static` [`ClassDef`] built with `const fn`s: +//! +//! ```ignore +//! struct Compress { state: Mutex } +//! +//! static COMPRESS_CLASS: ClassDef = ClassDef::new(c"zlib._Compress") +//! .methods(&COMPRESS_METHODS) // emitted by #[pymethods] +//! .getsets(&COMPRESS_GETSETS); // emitted by #[pymethods] +//! ``` +//! +//! The type object itself is created at module-exec time with +//! [`ClassDef::create`], which associates it with the module (making +//! `PyType_GetModuleState(defining_class)` work from methods) and stores the +//! `ClassDef`'s address as the type's `Py_tp_token`. +//! +//! Types are final (`Py_TPFLAGS_IMMUTABLETYPE`, no `BASETYPE`) and, unless a +//! `tp_new` is provided, not instantiable from Python +//! (`Py_TPFLAGS_DISALLOW_INSTANTIATION`) — instances are made from Rust +//! factories via [`new_instance`]. Finality is what keeps the +//! defining-class-based module-state lookup safe. + +use std::ffi::CStr; +use std::marker::PhantomData; +use std::os::raw::c_void; +use std::ptr::NonNull; + +use crate::err::{PyErrRaised, PyResult, PySystemError}; +use crate::ffi; +use crate::instance::Bound; +use crate::module::{ + GetSetDefs, MethodDefs, ModuleState, SLOT_END, SlotArray, slot_data, slot_func, slot_size, + slot_static_data, slot_uint64, +}; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::{PyAny, PyModule, PyType}; + +/// `tp_new` slot function type (generated by `#[pymethods]` for `#[new]`). +pub type NewFunc = unsafe extern "C" fn( + subtype: *mut ffi::PyTypeObject, + args: *mut ffi::PyObject, + kwargs: *mut ffi::PyObject, +) -> *mut ffi::PyObject; + +/// Byte offset of the `T` payload behind the `PyObject` header. +pub const fn payload_offset() -> usize { + let header = std::mem::size_of::(); + let align = std::mem::align_of::(); + header.div_ceil(align) * align +} + +/// Raw pointer to the payload of an instance. +/// +/// # Safety +/// +/// `obj` must be an instance of a type defined by a `ClassDef`. +#[inline] +pub unsafe fn payload_ptr(obj: *mut ffi::PyObject) -> *mut T { + unsafe { obj.byte_add(payload_offset::()) as *mut T } +} + +/// Borrow the payload of an instance. +/// +/// # Safety +/// +/// `obj` must be a valid instance of a type defined by a `ClassDef`, alive +/// for `'a`. +#[doc(hidden)] +#[inline] +pub unsafe fn payload_ref<'a, T>(obj: *mut ffi::PyObject) -> &'a T { + unsafe { &*payload_ptr::(obj) } +} + +/// `tp_dealloc` for payload-carrying heap types: drop the payload, free the +/// instance, release the reference the instance holds on its heap type. +/// +/// Deallocation always runs with an attached thread state, so the payload's +/// `Drop` may (transitively) use TLS-checked reference drops. +/// +/// # Safety +/// +/// Interpreter-only entry point, installed by `ClassDef`. +unsafe extern "C" fn dealloc_impl(obj: *mut ffi::PyObject) { + unsafe { + let tp = (*(obj as *mut ffi::_object)).ob_type; + std::ptr::drop_in_place(payload_ptr::(obj)); + let free = (*tp).tp_free.unwrap_or(ffi::PyObject_Free); + free(obj as *mut c_void); + // Heap-type instances own a reference to their type. + ffi::Py_DecRef(tp as *mut ffi::PyObject); + } +} + +const MAX_CLASS_SLOTS: usize = 10; + +// Fixed indices for slots that builder methods rewrite. +const FLAGS_SLOT: usize = 2; + +/// A `const`-built PEP 820 class definition for payload type `T`. +/// +/// Must be stored in a `static` (its address becomes the type's +/// `Py_tp_token`). +pub struct ClassDef { + slots: SlotArray, + len: usize, + _marker: PhantomData T>, +} + +// SAFETY: logically immutable after const construction; every stored pointer +// targets 'static data (name string, method/getset tables, glue fns). +unsafe impl Sync for ClassDef {} + +impl ClassDef { + /// Start a class definition. `name` is the fully qualified name shown to + /// Python (e.g. `c"zlib._Compress"`). + pub const fn new(name: &'static CStr) -> Self { + // PyType_GenericAlloc guarantees at least pymalloc alignment (8); + // payloads needing more must not rely on the header offset. + assert!( + std::mem::align_of::() <= 8, + "class payload alignment above 8 is not supported" + ); + let mut slots = [SLOT_END; MAX_CLASS_SLOTS]; + slots[0] = slot_static_data(ffi::Py_tp_name, name.as_ptr() as *mut c_void); + slots[1] = slot_size( + ffi::Py_tp_basicsize, + (payload_offset::() + std::mem::size_of::()) as ffi::Py_ssize_t, + ); + slots[FLAGS_SLOT] = slot_uint64( + ffi::Py_tp_flags, + (ffi::Py_TPFLAGS_DEFAULT + | ffi::Py_TPFLAGS_IMMUTABLETYPE + | ffi::Py_TPFLAGS_DISALLOW_INSTANTIATION) as u64, + ); + slots[3] = slot_func( + ffi::Py_tp_dealloc, + Some(unsafe { + std::mem::transmute::< + unsafe extern "C" fn(*mut ffi::PyObject), + unsafe extern "C" fn(), + >(dealloc_impl::) + }), + ); + ClassDef { + slots: SlotArray(slots), + len: 4, + _marker: PhantomData, + } + } + + const fn push(mut self, slot: ffi::PySlot) -> Self { + assert!(self.len < MAX_CLASS_SLOTS - 1, "too many class slots"); + self.slots.0[self.len] = slot; + self.len += 1; + self + } + + /// Attach the method table (emitted by `#[pymethods]`). + pub const fn methods(self, defs: &'static MethodDefs) -> Self { + self.push(slot_static_data( + ffi::Py_tp_methods, + defs.0.as_ptr() as *mut c_void, + )) + } + + /// Attach the getter table (emitted by `#[pymethods]`). + pub const fn getsets(self, defs: &'static GetSetDefs) -> Self { + self.push(slot_static_data( + ffi::Py_tp_getset, + defs.0.as_ptr() as *mut c_void, + )) + } + + /// Make the type constructible from Python with the given `tp_new` + /// (emitted by `#[pymethods]` for a `#[new]` method). Clears + /// `Py_TPFLAGS_DISALLOW_INSTANTIATION`. + pub const fn tp_new(mut self, new: NewFunc) -> Self { + // SAFETY: reading back the value this builder wrote in `new()`. + let flags = unsafe { self.slots.0[FLAGS_SLOT].__bindgen_anon_2.sl_uint64 }; + self.slots.0[FLAGS_SLOT] = slot_uint64( + ffi::Py_tp_flags, + flags & !(ffi::Py_TPFLAGS_DISALLOW_INSTANTIATION as u64), + ); + self.push(slot_func( + ffi::Py_tp_new, + Some(unsafe { std::mem::transmute::(new) }), + )) + } +} + +impl ClassDef { + /// The type's token: the address of this definition. + pub fn token(&'static self) -> *mut c_void { + self as *const Self as *mut c_void + } + + /// Create the heap type, associated with `module`. + /// + /// Called from the module's exec function; the returned type object then + /// lives in the module state as a `Py`. The module association is + /// what makes `PyType_GetModuleState(defining_class)` (used by methods + /// with a state parameter) resolve to this module's state. + pub fn create<'py>( + &'static self, + ts: &ThreadState<'py>, + module: &Bound<'py, PyModule>, + ) -> PyResult> { + // The static half of the slots is nested via Py_slot_subslots; the + // runtime-only slots (module pointer, token) are built on the stack. + let mut dynamic = [ + slot_data(ffi::Py_tp_module, module.as_ptr() as *mut c_void), + slot_static_data(ffi::Py_tp_token, self.token()), + slot_static_data(ffi::Py_slot_subslots, self.slots.as_ptr() as *mut c_void), + SLOT_END, + ]; + let ptr = unsafe { sys_calls::type_from_slots(ts, dynamic.as_mut_ptr()) }; + match NonNull::new(ptr) { + Some(p) => { + let any = unsafe { Bound::<'_, PyAny>::from_owned_ptr(ts, p) }; + Ok(unsafe { any.cast_unchecked::() }) + } + None => Err(unsafe { PyErrRaised::assume_set(ts) }), + } + } +} + +/// Allocate an instance of `cls` and move `payload` into it. +/// +/// This is the Rust-side constructor used by factory functions (e.g. +/// `compressobj()` building a `_Compress`), and by generated `tp_new` glue. +/// `cls` must be a type created from a `ClassDef` (debug-checked in +/// generated callers via the token where possible). +pub fn new_instance<'py, T: Send + Sync + 'static>( + ts: &ThreadState<'py>, + cls: &Bound<'py, PyType>, + payload: T, +) -> PyResult> { + let obj = + unsafe { sys_calls::type_generic_alloc(ts, cls.as_ptr() as *mut ffi::PyTypeObject, 0) }; + let Some(ptr) = NonNull::new(obj) else { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + }; + unsafe { payload_ptr::(obj).write(payload) }; + Ok(unsafe { Bound::from_owned_ptr(ts, ptr) }) +} + +/// Raw-pointer variant of [`new_instance`] for generated `tp_new` glue. +/// +/// # Safety +/// +/// `cls` must be a valid type object created from a `ClassDef`. +#[doc(hidden)] +pub unsafe fn alloc_with( + ts: &ThreadState<'_>, + cls: *mut ffi::PyTypeObject, + payload: T, +) -> PyResult<*mut ffi::PyObject> { + let obj = unsafe { sys_calls::type_generic_alloc(ts, cls, 0) }; + if obj.is_null() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + unsafe { payload_ptr::(obj).write(payload) }; + Ok(obj) +} + +/// Borrow the typed module state via a method's defining class +/// (`METH_METHOD` calling convention; generated glue). +/// +/// # Safety +/// +/// `defining_class` must be the defining class the interpreter passed to a +/// `PyCMethod` trampoline, for a type created by [`ClassDef::create`] from a +/// module whose state type is `T`. Finality of our types guarantees the +/// defining class *is* the created type, making `PyType_GetModuleState` +/// safe (cf. the analogous invariant comment in `Modules/zlibmodule.c`). +#[doc(hidden)] +pub unsafe fn state_from_defining_class<'a, T: ModuleState>( + ts: &ThreadState<'_>, + defining_class: *mut ffi::PyTypeObject, +) -> PyResult<&'a T> { + let ptr = unsafe { sys_calls::type_get_module_state(defining_class) }; + if ptr.is_null() { + return Err(PySystemError::raise( + ts, + "defining class has no module state", + )); + } + // The module state block is a StateStorage; go through the module + // helper's layout by reinterpreting identically. + let storage = ptr as *mut crate::module::StateStorage; + unsafe { crate::module::storage_state_ref(ts, storage) } +} diff --git a/Modules/cpython-api/src/conversion.rs b/Modules/cpython-api/src/conversion.rs new file mode 100644 index 000000000000000..964af0e302c65cb --- /dev/null +++ b/Modules/cpython-api/src/conversion.rs @@ -0,0 +1,253 @@ +//! Conversions between Python objects and Rust values. + +use std::os::raw::c_char; +use std::ptr::NonNull; + +use crate::err::{PyErrRaised, PyOverflowError, PyResult}; +use crate::ffi; +use crate::instance::{Bound, Py}; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyAny; + +/// Extract a Rust value from a Python object (argument conversion). +pub trait FromPyObject<'py>: Sized { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult; +} + +/// Convert a Rust value into a Python object (return-value conversion). +pub trait IntoPyObject<'py> { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult>; +} + +#[inline] +fn none_ptr() -> *mut ffi::PyObject { + (&raw const ffi::_Py_NoneStruct).cast_mut() +} + +#[inline] +fn owned_or_raised<'py>( + ts: &ThreadState<'py>, + ptr: *mut ffi::PyObject, +) -> PyResult> { + match NonNull::new(ptr) { + Some(p) => Ok(unsafe { Bound::from_owned_ptr(ts, p) }), + None => Err(unsafe { PyErrRaised::assume_set(ts) }), + } +} + +// --- integers -------------------------------------------------------------- + +fn extract_i64(ts: &ThreadState<'_>, obj: &Bound<'_, PyAny>) -> PyResult { + let v = unsafe { sys_calls::long_as_longlong(ts, obj.as_ptr()) }; + if v == -1 && ts.exception_set() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(v) +} + +macro_rules! int_via_i64 { + ($($ty:ty => $cname:literal;)*) => { + $( + impl<'py> FromPyObject<'py> for $ty { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + let v = extract_i64(ts, obj)?; + <$ty>::try_from(v).map_err(|_| { + PyOverflowError::raise( + ts, + concat!("Python int out of range for C ", $cname), + ) + }) + } + } + )* + }; +} + +int_via_i64! { + i32 => "int"; + u32 => "unsigned int"; + i64 => "long long"; +} + +impl<'py> FromPyObject<'py> for u64 { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + let v = unsafe { sys_calls::long_as_unsigned_longlong(ts, obj.as_ptr()) }; + if v == u64::MAX && ts.exception_set() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(v) + } +} + +impl<'py> FromPyObject<'py> for isize { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + let v = unsafe { sys_calls::long_as_ssize_t(ts, obj.as_ptr()) }; + if v == -1 && ts.exception_set() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(v as isize) + } +} + +impl<'py> FromPyObject<'py> for usize { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + let v = unsafe { sys_calls::long_as_size_t(ts, obj.as_ptr()) }; + if v == usize::MAX && ts.exception_set() { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(v) + } +} + +impl<'py> FromPyObject<'py> for bool { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + let v = unsafe { sys_calls::object_is_true(ts, obj.as_ptr()) }; + if v == -1 { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + } + Ok(v != 0) + } +} + +impl<'py, T: FromPyObject<'py>> FromPyObject<'py> for Option { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + if obj.as_ptr() == none_ptr() { + Ok(None) + } else { + T::extract(ts, obj).map(Some) + } + } +} + +impl<'py> FromPyObject<'py> for Bound<'py, PyAny> { + fn extract(ts: &ThreadState<'py>, obj: &Bound<'py, PyAny>) -> PyResult { + Ok(obj.clone_ref(ts)) + } +} + +// --- IntoPyObject ---------------------------------------------------------- + +macro_rules! int_into_signed { + ($($ty:ty),*) => { + $( + impl<'py> IntoPyObject<'py> for $ty { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { sys_calls::long_from_longlong(ts, self as i64) }; + owned_or_raised(ts, p) + } + } + )* + }; +} + +int_into_signed!(i8, i16, i32, i64); + +macro_rules! int_into_unsigned { + ($($ty:ty),*) => { + $( + impl<'py> IntoPyObject<'py> for $ty { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { sys_calls::long_from_unsigned_longlong(ts, self as u64) }; + owned_or_raised(ts, p) + } + } + )* + }; +} + +int_into_unsigned!(u8, u16, u32, u64); + +impl<'py> IntoPyObject<'py> for isize { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { sys_calls::long_from_ssize_t(ts, self as ffi::Py_ssize_t) }; + owned_or_raised(ts, p) + } +} + +impl<'py> IntoPyObject<'py> for usize { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { sys_calls::long_from_unsigned_longlong(ts, self as u64) }; + owned_or_raised(ts, p) + } +} + +impl<'py> IntoPyObject<'py> for bool { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { sys_calls::bool_from_long(ts, self as std::os::raw::c_long) }; + owned_or_raised(ts, p) + } +} + +impl<'py> IntoPyObject<'py> for &str { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + let p = unsafe { + sys_calls::unicode_from_string_and_size( + ts, + self.as_ptr() as *const c_char, + self.len() as ffi::Py_ssize_t, + ) + }; + owned_or_raised(ts, p) + } +} + +impl<'py> IntoPyObject<'py> for String { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + self.as_str().into_pyobject(ts) + } +} + +impl<'py> IntoPyObject<'py> for () { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + // None is immortal; the incref is a no-op but keeps the ownership + // model uniform. + let none = none_ptr(); + unsafe { + sys_calls::incref(ts, none); + Ok(Bound::from_owned_ptr(ts, NonNull::new_unchecked(none))) + } + } +} + +impl<'py, T> IntoPyObject<'py> for Bound<'py, T> { + fn into_pyobject(self, _ts: &ThreadState<'py>) -> PyResult> { + Ok(self.into_any()) + } +} + +impl<'py, T> IntoPyObject<'py> for Py { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + Ok(self.into_bound(ts).into_any()) + } +} + +impl<'py, T: IntoPyObject<'py>> IntoPyObject<'py> for Option { + fn into_pyobject(self, ts: &ThreadState<'py>) -> PyResult> { + match self { + Some(v) => v.into_pyobject(ts), + None => ().into_pyobject(ts), + } + } +} + +// --- trampoline return conversion ------------------------------------------ + +/// Return-value conversion for generated trampolines: accepts both plain +/// values and `PyResult`s of them. +#[doc(hidden)] +pub trait IntoPyCallbackOutput<'py> { + fn convert(self, ts: &ThreadState<'py>) -> PyResult<*mut ffi::PyObject>; +} + +impl<'py, T: IntoPyObject<'py>> IntoPyCallbackOutput<'py> for T { + fn convert(self, ts: &ThreadState<'py>) -> PyResult<*mut ffi::PyObject> { + Ok(self.into_pyobject(ts)?.into_ptr()) + } +} + +impl<'py, T: IntoPyObject<'py>> IntoPyCallbackOutput<'py> for PyResult { + fn convert(self, ts: &ThreadState<'py>) -> PyResult<*mut ffi::PyObject> { + Ok(self?.into_pyobject(ts)?.into_ptr()) + } +} diff --git a/Modules/cpython-api/src/err.rs b/Modules/cpython-api/src/err.rs new file mode 100644 index 000000000000000..c0d5f60328a07d3 --- /dev/null +++ b/Modules/cpython-api/src/err.rs @@ -0,0 +1,137 @@ +//! Error handling: the zero-sized "exception is set" marker and raise helpers. +//! +//! The exception itself lives where C puts it — in the thread state. Rust +//! code only carries the [`PyErrRaised`] marker, so `Result` +//! is the same size as `T` and there is no duplicate error state to +//! reconcile. + +use std::ffi::CString; +use std::fmt; +use std::marker::PhantomData; + +use crate::Py; +use crate::ffi; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyType; + +/// Zero-sized marker: a Python exception has been set on the current thread. +/// +/// Produced by the `raise` methods on exception types; consumed by returning +/// it (usually with `?`) until a trampoline turns it into a `NULL` return. +#[must_use = "a raised exception must be propagated (usually with `?` or `return Err(..)`)"] +pub struct PyErrRaised { + /// `*mut ()` keeps the marker `!Send`: it refers to per-thread state. + _priv: PhantomData<*mut ()>, +} + +/// The result of any fallible operation against the interpreter. +pub type PyResult = Result; + +impl PyErrRaised { + /// Assert that an exception is already set (e.g. after an FFI call + /// returned `NULL`/`-1`). + /// + /// # Safety + /// + /// An exception must actually be set on this thread; the marker's whole + /// meaning depends on it. + #[inline] + pub unsafe fn assume_set(ts: &ThreadState<'_>) -> PyErrRaised { + debug_assert!( + ts.exception_set(), + "PyErrRaised::assume_set called with no exception set" + ); + PyErrRaised { _priv: PhantomData } + } +} + +impl fmt::Debug for PyErrRaised { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("PyErrRaised") + } +} + +/// Set `exc_type` with a formatted message and return the raised marker. +pub(crate) fn raise_with_type( + ts: &ThreadState<'_>, + exc_type: *mut ffi::PyObject, + msg: impl fmt::Display, +) -> PyErrRaised { + let text = msg.to_string(); + // A message containing an interior NUL can't round-trip through the C + // API; truncate at the first NUL rather than losing the exception. + let ctext = CString::new(text.clone()) + .unwrap_or_else(|e| CString::new(&text[..e.nul_position()]).expect("prefix has no NUL")); + unsafe { + sys_calls::err_set_string(ts, exc_type, ctext.as_ptr()); + PyErrRaised::assume_set(ts) + } +} + +/// Convert a C `-1`-on-error return into a `PyResult`. +#[inline] +pub(crate) fn error_on_minus_one(ts: &ThreadState<'_>, ret: std::os::raw::c_int) -> PyResult<()> { + if ret == -1 { + Err(unsafe { PyErrRaised::assume_set(ts) }) + } else { + Ok(()) + } +} + +macro_rules! builtin_exceptions { + ($($(#[$doc:meta])* $name:ident => $ffi_static:ident;)*) => { + $( + $(#[$doc])* + pub struct $name; + + impl $name { + /// Set this exception with the given message and return the + /// raised marker. + pub fn raise(ts: &ThreadState<'_>, msg: impl fmt::Display) -> PyErrRaised { + raise_with_type(ts, unsafe { ffi::$ffi_static }, msg) + } + } + )* + }; +} + +builtin_exceptions! { + /// `Exception` + PyException => PyExc_Exception; + /// `ValueError` + PyValueError => PyExc_ValueError; + /// `TypeError` + PyTypeError => PyExc_TypeError; + /// `OverflowError` + PyOverflowError => PyExc_OverflowError; + /// `BufferError` + PyBufferError => PyExc_BufferError; + /// `EOFError` + PyEOFError => PyExc_EOFError; + /// `NotImplementedError` + PyNotImplementedError => PyExc_NotImplementedError; + /// `MemoryError` + PyMemoryError => PyExc_MemoryError; + /// `RuntimeError` + PyRuntimeError => PyExc_RuntimeError; + /// `SystemError` + PySystemError => PyExc_SystemError; +} + +impl<'py> Bound<'py, PyType> { + /// Raise this exception type (e.g. a module-state-held `zlib.error`) with + /// a formatted message. + pub fn raise(&self, ts: &ThreadState<'py>, msg: impl fmt::Display) -> PyErrRaised { + raise_with_type(ts, self.as_ptr(), msg) + } +} + +impl Py { + /// Raise this exception type (e.g. a module-state-held `zlib.error`) with + /// a formatted message. + pub fn raise(&self, ts: &ThreadState<'_>, msg: impl fmt::Display) -> PyErrRaised { + raise_with_type(ts, self.as_ptr(), msg) + } +} diff --git a/Modules/cpython-api/src/instance.rs b/Modules/cpython-api/src/instance.rs new file mode 100644 index 000000000000000..4ffa6b48e3e987c --- /dev/null +++ b/Modules/cpython-api/src/instance.rs @@ -0,0 +1,238 @@ +//! Owned Python object references: [`Bound`] (attached-scope) and [`Py`] +//! (`'static`, for storage in module state). + +use std::marker::PhantomData; +use std::ptr::NonNull; + +use crate::ffi; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyAny; + +/// An owned reference to a Python object, valid within the attached scope +/// `'py`. +/// +/// `#[repr(transparent)]` over `NonNull`, so borrowed FFI pointers +/// (e.g. the vectorcall args array) can be lent as `&Bound` without any +/// refcount traffic. +/// +/// `!Send + !Sync`: the reference is tied to this thread's attached scope. In +/// particular it can never enter a [`ThreadState::detach`] closure. +#[repr(transparent)] +pub struct Bound<'py, T> { + ptr: NonNull, + /// `&'py ()` ties the reference to the attached scope; `*mut ()` makes it + /// `!Send + !Sync`; `fn() -> T` carries the type marker without affecting + /// auto traits or drop-check. + #[allow(clippy::type_complexity)] + _marker: PhantomData<(&'py (), *mut (), fn() -> T)>, +} + +/// An owned, `'static` reference to a Python object, for storage in module +/// state (and other places that outlive a single attached scope). +/// +/// `Send + Sync`: the *handle* may move between threads; every operation on +/// it still requires `&ThreadState`. There is deliberately no `Clone` — use +/// [`Py::clone_ref`], which requires the token. +#[repr(transparent)] +pub struct Py { + ptr: NonNull, + _marker: PhantomData T>, +} + +// SAFETY: the handle is just a pointer; all operations (including clone) +// require `&ThreadState`. Drop is attachment-checked (see below). +unsafe impl Send for Py {} +unsafe impl Sync for Py {} + +impl<'py, T> Bound<'py, T> { + /// Take ownership of a strong reference returned by an FFI call. + /// + /// # Safety + /// + /// `ptr` must be a valid strong reference (this `Bound` will decref it on + /// drop) to an object of type `T`. + #[inline] + pub unsafe fn from_owned_ptr(_ts: &ThreadState<'py>, ptr: NonNull) -> Self { + Bound { + ptr, + _marker: PhantomData, + } + } + + /// Lend a *borrowed* FFI pointer as a `&Bound` without touching the + /// refcount. + /// + /// # Safety + /// + /// `ptr` must point to a valid object of type `T` that stays alive (kept + /// by its real owner) for as long as the returned reference is used. + #[inline] + pub unsafe fn ref_from_ptr<'a>(ptr: &'a NonNull) -> &'a Bound<'py, T> { + // SAFETY: Bound is repr(transparent) over NonNull. + unsafe { &*(ptr as *const NonNull).cast::>() } + } + + /// The raw object pointer (borrowed; the `Bound` keeps its reference). + #[inline] + pub fn as_ptr(&self) -> *mut ffi::PyObject { + self.ptr.as_ptr() + } + + /// Create a new strong reference. + #[inline] + pub fn clone_ref(&self, ts: &ThreadState<'py>) -> Bound<'py, T> { + unsafe { + sys_calls::incref(ts, self.ptr.as_ptr()); + Bound::from_owned_ptr(ts, self.ptr) + } + } + + /// Detach from the `'py` scope, producing a `'static` handle. + #[inline] + pub fn unbind(self) -> Py { + let ptr = self.ptr; + std::mem::forget(self); + Py { + ptr, + _marker: PhantomData, + } + } + + /// View as a `Bound`. + #[inline] + pub fn as_any(&self) -> &Bound<'py, PyAny> { + // SAFETY: identical repr(transparent) layout; PyAny is the base view. + unsafe { &*(self as *const Bound<'py, T>).cast::>() } + } + + /// Convert into a `Bound`. + #[inline] + pub fn into_any(self) -> Bound<'py, PyAny> { + let ptr = self.ptr; + std::mem::forget(self); + Bound { + ptr, + _marker: PhantomData, + } + } + + /// Reinterpret as a different concrete type without checking. + /// + /// # Safety + /// + /// The object must actually be an instance of `U` (or a subtype). + #[inline] + pub unsafe fn cast_unchecked(self) -> Bound<'py, U> { + let ptr = self.ptr; + std::mem::forget(self); + Bound { + ptr, + _marker: PhantomData, + } + } + + /// Hand the strong reference to C (e.g. as a trampoline return value). + #[inline] + pub fn into_ptr(self) -> *mut ffi::PyObject { + let ptr = self.ptr; + std::mem::forget(self); + ptr.as_ptr() + } + + /// Explicitly decref with the token, skipping the TLS attachment check + /// that `Drop` performs. + #[inline] + pub fn drop_with(self, ts: &ThreadState<'py>) { + let ptr = self.ptr; + std::mem::forget(self); + unsafe { sys_calls::decref(ts, ptr.as_ptr()) }; + } +} + +impl Py { + /// The raw object pointer (borrowed; the `Py` keeps its reference). + #[inline] + pub fn as_ptr(&self) -> *mut ffi::PyObject { + self.ptr.as_ptr() + } + + /// Borrow as a `&Bound` for the current attached scope, without touching + /// the refcount. + #[inline] + pub fn bind<'a, 'py>(&'a self, _ts: &'a ThreadState<'py>) -> &'a Bound<'py, T> { + // SAFETY: identical repr(transparent) layout; the borrow of self keeps + // the reference alive, and the token borrow caps it to the attached + // scope. + unsafe { &*(self as *const Py).cast::>() } + } + + /// Convert into a `Bound`, re-entering the attached scope. + #[inline] + pub fn into_bound<'py>(self, _ts: &ThreadState<'py>) -> Bound<'py, T> { + let ptr = self.ptr; + std::mem::forget(self); + Bound { + ptr, + _marker: PhantomData, + } + } + + /// Create a new strong reference. + #[inline] + pub fn clone_ref(&self, ts: &ThreadState<'_>) -> Py { + unsafe { sys_calls::incref(ts, self.ptr.as_ptr()) }; + Py { + ptr: self.ptr, + _marker: PhantomData, + } + } + + /// Explicitly decref with the token, skipping the TLS attachment check + /// that `Drop` performs. + #[inline] + pub fn drop_with(self, ts: &ThreadState<'_>) { + let ptr = self.ptr; + std::mem::forget(self); + unsafe { sys_calls::decref(ts, ptr.as_ptr()) }; + } +} + +/// Shared drop policy for `Bound` and `Py`: decref if this thread is +/// attached, otherwise leak (and fail a debug assertion). +/// +/// Leaking is always sound; decref-ing while detached is not. In correct code +/// drops always happen attached — the detached case is only reachable by +/// deliberately smuggling a reference across a detach boundary or dropping a +/// `Py` on a non-Python thread, and turning those into leaks (never UB) is +/// the point of this check. See RUST_API.md §1.2. +#[inline] +fn drop_ref(ptr: NonNull) { + unsafe { + if !ffi::PyThreadState_GetUnchecked().is_null() { + // tls-fallback: Drop has nowhere to store a token borrow; this is + // the one deliberate TLS dependency (see RUST_API.md, explicit- + // tstate policy). Use `drop_with` on hot paths. + ffi::Py_DecRef(ptr.as_ptr()); + } else { + debug_assert!( + false, + "Python object reference dropped while thread state is detached; leaking" + ); + } + } +} + +impl Drop for Bound<'_, T> { + #[inline] + fn drop(&mut self) { + drop_ref(self.ptr); + } +} + +impl Drop for Py { + #[inline] + fn drop(&mut self) { + drop_ref(self.ptr); + } +} diff --git a/Modules/cpython-api/src/lib.rs b/Modules/cpython-api/src/lib.rs new file mode 100644 index 000000000000000..d94ffc2b546ef76 --- /dev/null +++ b/Modules/cpython-api/src/lib.rs @@ -0,0 +1,68 @@ +//! Safe Rust API for CPython. +//! +//! See `RUST_API.md` at the repository root for the design document. The short +//! version: +//! +//! - The thread state is passed explicitly everywhere as [`ThreadState`], +//! which wraps the actual `PyThreadState*` rather than being a zero-sized +//! token. Every operation that touches the interpreter requires +//! `&ThreadState`; [`ThreadState::detach`] takes `&mut self` so nothing can +//! touch the interpreter while detached. +//! - PyO3 vocabulary is used where the semantics match: [`Bound`], [`Py`], +//! [`PyResult`], [`types::PyBytes`], ... +//! - Errors are represented by the zero-sized [`PyErrRaised`] marker: the +//! exception itself lives in the thread state, exactly as in the C API. +//! - Modules are defined with PEP 793 (`PyModExport`) via [`export_module!`]; +//! classes with PEP 820 (`PyType_FromSlots`) via [`class::ClassDef`]. Both +//! are free-threading and subinterpreter compatible by default. + +pub use cpython_sys as ffi; + +pub mod args; +pub mod buffer; +pub mod class; +pub mod conversion; +pub mod err; +pub mod instance; +pub mod module; +pub(crate) mod sys_calls; +pub mod threadstate; +pub mod types; + +pub use buffer::PyBuffer; +pub use conversion::{FromPyObject, IntoPyObject}; +pub use err::{PyErrRaised, PyResult}; +pub use instance::{Bound, Py}; +pub use module::{ModuleState, TraverseResult, TraverseStop, Visit}; +pub use threadstate::ThreadState; +pub use types::{PyAny, PyBytes, PyModule, PyType}; + +pub use cpython_api_macros::{pyfunction, pymethods}; + +/// Commonly used items, for glob import in module implementations. +pub mod prelude { + pub use crate::buffer::PyBuffer; + pub use crate::conversion::{FromPyObject, IntoPyObject}; + pub use crate::err::{ + PyBufferError, PyEOFError, PyException, PyMemoryError, PyNotImplementedError, + PyOverflowError, PyRuntimeError, PySystemError, PyTypeError, PyValueError, + }; + pub use crate::err::{PyErrRaised, PyResult}; + pub use crate::instance::{Bound, Py}; + pub use crate::module::ModuleState; + pub use crate::threadstate::ThreadState; + pub use crate::types::{PyAny, PyBytes, PyModule, PyType}; + pub use crate::{export_module, pyfunction, pymethods}; +} + +/// Build a `&'static CStr` from a nul-terminated byte string at compile time. +/// +/// Used by macro-generated code; panics at compile time if `bytes` is not +/// nul-terminated or contains interior nul bytes. +#[doc(hidden)] +pub const fn const_cstr(bytes: &'static [u8]) -> &'static core::ffi::CStr { + match core::ffi::CStr::from_bytes_with_nul(bytes) { + Ok(c) => c, + Err(_) => panic!("invalid C string literal"), + } +} diff --git a/Modules/cpython-api/src/module.rs b/Modules/cpython-api/src/module.rs new file mode 100644 index 000000000000000..9efdb9a04d8358b --- /dev/null +++ b/Modules/cpython-api/src/module.rs @@ -0,0 +1,503 @@ +//! Module definition via PEP 793 (`PyModExport` + `PySlot` arrays). +//! +//! Modules are defined with the [`export_module!`] macro, which expands to a +//! static slot array and the `PyModExport_` entry point. Free-threading +//! (`Py_mod_gil = Py_MOD_GIL_NOT_USED`) and subinterpreter support +//! (`Py_mod_multiple_interpreters = PER_INTERPRETER_GIL_SUPPORTED`) are +//! emitted unconditionally — they are defaults of this API, not options. +//! +//! Per-module state (the [`ModuleState`] trait) is where exception types and +//! class type objects live, so modules never share objects across +//! interpreters through statics. + +use std::marker::PhantomData; +use std::mem::MaybeUninit; +use std::os::raw::{c_int, c_void}; + +use crate::err::{PyResult, PySystemError}; +use crate::ffi; +use crate::instance::{Bound, Py}; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::PyModule; + +// Slot IDs that are function-like compat macros in C (`_Py_SLOT_COMPAT_VALUE`) +// and therefore absent from the generated bindings. Values are the non- +// limited-API (3.15+) IDs from Include/slots_generated.h. +pub const PY_MOD_CREATE: u32 = 84; +pub const PY_MOD_EXEC: u32 = 85; +pub const PY_MOD_MULTIPLE_INTERPRETERS: u32 = 86; +pub const PY_MOD_GIL: u32 = 87; + +// Pointer-valued slot values (pointer-cast macros in C, absent from bindings). +pub const PY_MOD_MULTIPLE_INTERPRETERS_SUPPORTED: *mut c_void = std::ptr::without_provenance_mut(1); +pub const PY_MOD_PER_INTERPRETER_GIL_SUPPORTED: *mut c_void = std::ptr::without_provenance_mut(2); +pub const PY_MOD_GIL_NOT_USED: *mut c_void = std::ptr::without_provenance_mut(1); + +// --- PySlot construction --------------------------------------------------- + +/// `PySlot_END`. +pub const SLOT_END: ffi::PySlot = ffi::PySlot { + sl_id: 0, + sl_flags: 0, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { + sl_ptr: std::ptr::null_mut(), + }, +}; + +/// `PySlot_DATA`: a data-pointer slot. +pub const fn slot_data(id: u32, ptr: *mut c_void) -> ffi::PySlot { + ffi::PySlot { + sl_id: id as u16, + sl_flags: ffi::PySlot_INTPTR as u16, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { sl_ptr: ptr }, + } +} + +/// `PySlot_STATIC_DATA`: a data-pointer slot whose target is static. +pub const fn slot_static_data(id: u32, ptr: *mut c_void) -> ffi::PySlot { + ffi::PySlot { + sl_id: id as u16, + sl_flags: ffi::PySlot_STATIC as u16, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { sl_ptr: ptr }, + } +} + +/// `PySlot_FUNC`: a function-pointer slot. +pub const fn slot_func(id: u32, func: ffi::_Py_funcptr_t) -> ffi::PySlot { + ffi::PySlot { + sl_id: id as u16, + sl_flags: 0, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { sl_func: func }, + } +} + +/// `PySlot_SIZE`: a `Py_ssize_t` slot. +pub const fn slot_size(id: u32, size: ffi::Py_ssize_t) -> ffi::PySlot { + ffi::PySlot { + sl_id: id as u16, + sl_flags: 0, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { sl_size: size }, + } +} + +/// `PySlot_UINT64`: a `uint64_t` slot. +pub const fn slot_uint64(id: u32, value: u64) -> ffi::PySlot { + ffi::PySlot { + sl_id: id as u16, + sl_flags: 0, + __bindgen_anon_1: ffi::PySlot__bindgen_ty_1 { sl_reserved: 0 }, + __bindgen_anon_2: ffi::PySlot__bindgen_ty_2 { sl_uint64: value }, + } +} + +/// A static `PySlot` array. +/// +/// SAFETY of `Sync`: the array is logically immutable after const +/// construction, and every pointer stored in it targets `'static` data +/// (strings, method tables, glue functions). The interpreter only reads it. +#[repr(transparent)] +pub struct SlotArray(pub [ffi::PySlot; N]); + +unsafe impl Sync for SlotArray {} + +impl SlotArray { + /// The pointer handed to `PyModExport`/`PyType_FromSlots`. + /// + /// The cast to `*mut` matches the C signatures; the interpreter treats + /// the array as read-only. + pub fn as_ptr(&'static self) -> *mut ffi::PySlot { + self.0.as_ptr().cast_mut() + } +} + +/// A static, null-terminated `PyGetSetDef` array (emitted by `#[pymethods]`). +/// +/// SAFETY of `Sync`: logically immutable, pointers target `'static` data. +#[repr(transparent)] +pub struct GetSetDefs(pub [ffi::PyGetSetDef; N]); + +unsafe impl Sync for GetSetDefs {} + +/// A static, null-terminated `PyMethodDef` array (emitted by `#[pymethods]`; +/// module-level arrays are built by `export_module!`). +#[repr(transparent)] +pub struct MethodDefs(pub [ffi::PyMethodDef; N]); + +// PyMethodDef is Send + Sync in cpython-sys, so MethodDefs is automatically. + +/// `PY_VERSION_HEX`, reassembled from its parts (the C macro is a +/// function-like macro invocation, which bindgen cannot evaluate). +pub const PY_VERSION_HEX: u32 = (ffi::PY_MAJOR_VERSION << 24) + | (ffi::PY_MINOR_VERSION << 16) + | (ffi::PY_MICRO_VERSION << 8) + | (ffi::PY_RELEASE_LEVEL << 4) + | ffi::PY_RELEASE_SERIAL; + +/// The ABI declaration for a slots-only module built by this crate: +/// internal ABI (we link against `Py_BUILD_CORE` bindings), exact-version, +/// free-threaded or GIL to match the interpreter build. +pub const fn abi_info() -> ffi::PyABIInfo { + let ft_flag = if ffi::GIL_DISABLED { + ffi::PyABIInfo_FREETHREADED + } else { + ffi::PyABIInfo_GIL + }; + ffi::PyABIInfo { + abiinfo_major_version: 1, + abiinfo_minor_version: 0, + flags: (ffi::PyABIInfo_INTERNAL | ft_flag) as u16, + build_version: PY_VERSION_HEX, + abi_version: PY_VERSION_HEX, + } +} + +// --- typed per-module state ------------------------------------------------ + +/// GC visitor passed to [`ModuleState::traverse`]. +pub struct Visit<'a> { + visit: ffi::visitproc, + arg: *mut c_void, + _marker: PhantomData<&'a ()>, +} + +/// Nonzero return from a `visitproc`, propagated out of `traverse`. +pub struct TraverseStop(pub c_int); + +pub type TraverseResult = Result<(), TraverseStop>; + +impl Visit<'_> { + /// Report an owned object reference to the GC. + pub fn call(&self, obj: &Py) -> TraverseResult { + let Some(visit) = self.visit else { + return Ok(()); + }; + let ret = unsafe { visit(obj.as_ptr(), self.arg) }; + if ret == 0 { + Ok(()) + } else { + Err(TraverseStop(ret)) + } + } +} + +/// Typed per-module state. +/// +/// This is where a module keeps its exception types and class type objects +/// (as [`Py`] handles), mirroring the C convention (e.g. `zlibstate`). +/// Implementations holding `Py` references must implement `traverse` (and +/// usually `clear`, which requires those fields to be `Option`s so the +/// references can be dropped). +pub trait ModuleState: Sized + Send + Sync + 'static { + /// Build the state. Runs from the `Py_mod_exec` glue, before the user + /// exec function. + /// + /// Note the shared `'py`: `ThreadState` is invariant in its lifetime, so + /// the token and the module reference must name the same attached scope. + fn new<'py>(ts: &ThreadState<'py>, module: &Bound<'py, PyModule>) -> PyResult; + + /// Report owned object references to the GC. + fn traverse(&self, _visit: &Visit<'_>) -> TraverseResult { + Ok(()) + } + + /// Drop owned object references (GC cycle breaking). + fn clear(&mut self) {} +} + +/// The in-memory layout of the module state block. +/// +/// CPython zero-fills the state allocation, so `initialized == false` until +/// the exec glue writes the value; the traverse/clear/free glue must not +/// touch `value` before then (exec can fail, and `m_free` runs regardless). +#[repr(C)] +pub struct StateStorage { + initialized: bool, + value: MaybeUninit, +} + +/// `Py_mod_state_size` value for a state type. +pub const fn state_size() -> ffi::Py_ssize_t { + std::mem::size_of::>() as ffi::Py_ssize_t +} + +/// Borrow the initialized state out of a raw storage block (shared by the +/// module-pointer and defining-class access paths). +/// +/// # Safety +/// +/// `storage` must point to a live `StateStorage` block. +#[doc(hidden)] +pub unsafe fn storage_state_ref<'a, T: ModuleState>( + ts: &ThreadState<'_>, + storage: *mut StateStorage, +) -> PyResult<&'a T> { + match unsafe { storage.as_ref() } { + Some(storage) if storage.initialized => Ok(unsafe { storage.value.assume_init_ref() }), + _ => Err(PySystemError::raise(ts, "module state not initialized")), + } +} + +unsafe fn storage_of<'a, T: ModuleState>( + module: *mut ffi::PyObject, +) -> Option<&'a mut StateStorage> { + let ptr = unsafe { sys_calls::module_get_state(module) } as *mut StateStorage; + unsafe { ptr.as_mut() } +} + +/// Borrow the typed state of `module`. +/// +/// Raises `SystemError` if the module has no (initialized) state — which for +/// a module defined with [`export_module!`] means `T` doesn't match the +/// module's declared state type. +pub fn module_state<'a, T: ModuleState>( + ts: &ThreadState<'_>, + module: &'a Bound<'_, PyModule>, +) -> PyResult<&'a T> { + unsafe { state_from_module_ptr(ts, module.as_ptr()) } +} + +/// Borrow the typed state from a raw module pointer (trampoline glue). +/// +/// # Safety +/// +/// `module` must be a valid module object pointer that stays alive for `'a`, +/// and its state block must be a `StateStorage` (guaranteed when the +/// module was defined by `export_module!` with state type `T`). +#[doc(hidden)] +pub unsafe fn state_from_module_ptr<'a, T: ModuleState>( + ts: &ThreadState<'_>, + module: *mut ffi::PyObject, +) -> PyResult<&'a T> { + match unsafe { storage_of::(module) } { + Some(storage) if storage.initialized => Ok(unsafe { storage.value.assume_init_ref() }), + _ => Err(PySystemError::raise(ts, "module state not initialized")), + } +} + +/// The user exec function type: shared `'py` between token and module. +pub type ModuleExecFn = for<'py> fn(&ThreadState<'py>, &Bound<'py, PyModule>) -> PyResult<()>; + +/// `Py_mod_exec` glue: initialize the typed state, then run the user exec. +/// +/// # Safety +/// +/// Must only be called by the interpreter as the module's exec slot, with the +/// module created from an `export_module!` slot array declaring state type +/// `T`. +#[doc(hidden)] +pub unsafe fn exec_impl( + module: *mut ffi::PyObject, + user_exec: ModuleExecFn, +) -> c_int { + let ts = unsafe { ThreadState::current_unchecked() }; + let Some(ptr) = std::ptr::NonNull::new(module) else { + let _ = PySystemError::raise(&ts, "module exec called with NULL module"); + return -1; + }; + let bound: &Bound<'_, PyModule> = unsafe { Bound::ref_from_ptr(&ptr) }; + + let Some(storage) = (unsafe { storage_of::(module) }) else { + let _ = PySystemError::raise(&ts, "module has no state block"); + return -1; + }; + match T::new(&ts, bound) { + Ok(state) => { + storage.value.write(state); + storage.initialized = true; + } + Err(_raised) => return -1, + } + + match user_exec(&ts, bound) { + Ok(()) => 0, + Err(_raised) => -1, + } +} + +/// `Py_mod_state_traverse` glue. +/// +/// # Safety +/// +/// Interpreter-only entry point; see `exec_impl`. +#[doc(hidden)] +pub unsafe extern "C" fn traverse_impl( + module: *mut ffi::PyObject, + visit: ffi::visitproc, + arg: *mut c_void, +) -> c_int { + match unsafe { storage_of::(module) } { + Some(storage) if storage.initialized => { + let v = Visit { + visit, + arg, + _marker: PhantomData, + }; + match unsafe { storage.value.assume_init_ref() }.traverse(&v) { + Ok(()) => 0, + Err(TraverseStop(code)) => code, + } + } + _ => 0, + } +} + +/// `Py_mod_state_clear` glue. +/// +/// # Safety +/// +/// Interpreter-only entry point; see `exec_impl`. +#[doc(hidden)] +pub unsafe extern "C" fn clear_impl(module: *mut ffi::PyObject) -> c_int { + if let Some(storage) = unsafe { storage_of::(module) } + && storage.initialized + { + unsafe { storage.value.assume_init_mut() }.clear(); + } + 0 +} + +/// `Py_mod_state_free` glue: drop the state in place. +/// +/// Runs with an attached thread state (module deallocation), so `Py` +/// fields decref normally. +/// +/// # Safety +/// +/// Interpreter-only entry point; see `exec_impl`. +#[doc(hidden)] +pub unsafe extern "C" fn free_impl(module: *mut c_void) { + if let Some(storage) = unsafe { storage_of::(module as *mut ffi::PyObject) } + && storage.initialized + { + storage.initialized = false; + unsafe { storage.value.assume_init_drop() }; + } +} + +// --- the export macro ------------------------------------------------------ + +/// Define a PEP 793 module: static slot array + `PyModExport_` symbol. +/// +/// ```ignore +/// export_module! { +/// name: zlib, +/// doc: c"zlib compression / decompression module", +/// state: ZlibState, +/// methods: [adler32, crc32, compress, decompress], +/// exec: zlib_exec, +/// } +/// ``` +/// +/// - `name` sets both the module name and the export symbol +/// (`PyModExport_zlib`). A Rust item `PyModExport` is also emitted at the +/// call site so static builds can re-export it +/// (`pub use my_module::PyModExport;`). +/// - `state` is a type implementing [`ModuleState`]; its `new` runs before +/// `exec`. +/// - `methods` lists `#[pyfunction]` names. +/// - `exec` is `fn(&ThreadState<'_>, &Bound<'_, PyModule>) -> PyResult<()>`. +/// +/// The expansion always declares `Py_mod_gil = Py_MOD_GIL_NOT_USED` and +/// `Py_mod_multiple_interpreters = PER_INTERPRETER_GIL_SUPPORTED`: modules +/// built with this API must keep their state per-module and thread-safe. +#[macro_export] +macro_rules! export_module { + ( + name: $name:ident, + doc: $doc:literal, + state: $state:ty, + methods: [$($method:ident),* $(,)?], + exec: $exec:path $(,)? + ) => { + static __PY_MOD_METHODS: &[$crate::ffi::PyMethodDef] = &[ + $($method::DEF,)* + $crate::ffi::PyMethodDef::zeroed(), + ]; + + static __PY_MOD_ABI: $crate::ffi::PyABIInfo = $crate::module::abi_info(); + + static __PY_MOD_SLOTS: $crate::module::SlotArray<12> = $crate::module::SlotArray([ + $crate::module::slot_static_data( + $crate::ffi::Py_mod_name, + concat!(stringify!($name), "\0").as_ptr() as *mut ::std::os::raw::c_void, + ), + $crate::module::slot_static_data( + $crate::ffi::Py_mod_doc, + $doc.as_ptr() as *mut ::std::os::raw::c_void, + ), + $crate::module::slot_size( + $crate::ffi::Py_mod_state_size, + $crate::module::state_size::<$state>(), + ), + $crate::module::slot_static_data( + $crate::ffi::Py_mod_methods, + __PY_MOD_METHODS.as_ptr() as *mut ::std::os::raw::c_void, + ), + $crate::module::slot_func($crate::module::PY_MOD_EXEC, { + unsafe extern "C" fn __exec(m: *mut $crate::ffi::PyObject) -> ::std::os::raw::c_int { + unsafe { $crate::module::exec_impl::<$state>(m, $exec) } + } + // SAFETY: stored as the generic C slot function pointer type; + // the interpreter calls it with the correct signature for + // Py_mod_exec. + Some(unsafe { + ::std::mem::transmute::< + unsafe extern "C" fn(*mut $crate::ffi::PyObject) -> ::std::os::raw::c_int, + unsafe extern "C" fn(), + >(__exec) + }) + }), + $crate::module::slot_func($crate::ffi::Py_mod_state_traverse, Some(unsafe { + ::std::mem::transmute::< + unsafe extern "C" fn( + *mut $crate::ffi::PyObject, + $crate::ffi::visitproc, + *mut ::std::os::raw::c_void, + ) -> ::std::os::raw::c_int, + unsafe extern "C" fn(), + >($crate::module::traverse_impl::<$state>) + })), + $crate::module::slot_func($crate::ffi::Py_mod_state_clear, Some(unsafe { + ::std::mem::transmute::< + unsafe extern "C" fn(*mut $crate::ffi::PyObject) -> ::std::os::raw::c_int, + unsafe extern "C" fn(), + >($crate::module::clear_impl::<$state>) + })), + $crate::module::slot_func($crate::ffi::Py_mod_state_free, Some(unsafe { + ::std::mem::transmute::< + unsafe extern "C" fn(*mut ::std::os::raw::c_void), + unsafe extern "C" fn(), + >($crate::module::free_impl::<$state>) + })), + $crate::module::slot_data( + $crate::module::PY_MOD_MULTIPLE_INTERPRETERS, + $crate::module::PY_MOD_PER_INTERPRETER_GIL_SUPPORTED, + ), + $crate::module::slot_data( + $crate::module::PY_MOD_GIL, + $crate::module::PY_MOD_GIL_NOT_USED, + ), + $crate::module::slot_static_data( + $crate::ffi::Py_mod_abi, + &raw const __PY_MOD_ABI as *mut ::std::os::raw::c_void, + ), + $crate::module::SLOT_END, + ]); + + /// PEP 793 module export hook (exported as `PyModExport_`). + /// + /// # Safety + /// + /// Called by the import machinery only. + #[unsafe(export_name = concat!("PyModExport_", stringify!($name)))] + pub unsafe extern "C" fn PyModExport() -> *mut $crate::ffi::PySlot { + __PY_MOD_SLOTS.as_ptr() + } + }; +} diff --git a/Modules/cpython-api/src/sys_calls.rs b/Modules/cpython-api/src/sys_calls.rs new file mode 100644 index 000000000000000..a5f21d4195598c3 --- /dev/null +++ b/Modules/cpython-api/src/sys_calls.rs @@ -0,0 +1,273 @@ +//! The single raw-call layer. +//! +//! Every C API call made by this crate goes through one of these wrappers so +//! the explicit-tstate migration is mechanical and auditable. Each wrapper is +//! annotated: +//! +//! - `explicit-tstate`: the underlying C API takes the thread state +//! explicitly (or we read a `PyThreadState` field directly). +//! - `tls-fallback`: the underlying C API fetches the thread state from TLS. +//! These are temporary: per the design (RUST_API.md, "Explicit-tstate C API +//! policy"), each one is a candidate for a thin `_Py*` internal wrapper in +//! CPython that takes `tstate` explicitly. Holding `&ThreadState` while +//! calling them keeps them sound in the meantime (the token proves the TLS +//! state is ours). +//! +//! All functions here are `unsafe`: they trust raw pointers from the caller. +//! The `ts` parameter both proves attachment and (where possible) supplies +//! the tstate pointer. + +use std::os::raw::{c_char, c_int, c_void}; + +use crate::ffi; +use crate::threadstate::ThreadState; + +// --- refcounting ----------------------------------------------------------- + +/// explicit-attachment (tls-fallback internally on free-threaded builds, +/// where biased refcounting consults the thread id). +#[inline] +pub(crate) unsafe fn incref(_ts: &ThreadState<'_>, obj: *mut ffi::PyObject) { + unsafe { ffi::Py_IncRef(obj) } +} + +/// explicit-attachment (see `incref`). +#[inline] +pub(crate) unsafe fn decref(_ts: &ThreadState<'_>, obj: *mut ffi::PyObject) { + unsafe { ffi::Py_DecRef(obj) } +} + +// --- exceptions ------------------------------------------------------------ + +/// explicit-tstate: `_PyErr_SetString(tstate, ...)`. +#[inline] +pub(crate) unsafe fn err_set_string( + ts: &ThreadState<'_>, + exc_type: *mut ffi::PyObject, + msg: *const c_char, +) { + unsafe { ffi::_PyErr_SetString(ts.as_ptr(), exc_type, msg) } +} + +/// tls-fallback: `PyErr_NewException`. +#[inline] +pub(crate) unsafe fn err_new_exception( + _ts: &ThreadState<'_>, + qualname: *const c_char, + base: *mut ffi::PyObject, +) -> *mut ffi::PyObject { + unsafe { ffi::PyErr_NewException(qualname, base, std::ptr::null_mut()) } +} + +// --- bytes ----------------------------------------------------------------- + +/// tls-fallback: `PyBytes_FromStringAndSize` (candidate for an explicit- +/// tstate `_Py*` wrapper; allocation paths consult TLS). +#[inline] +pub(crate) unsafe fn bytes_from_string_and_size( + _ts: &ThreadState<'_>, + data: *const c_char, + len: ffi::Py_ssize_t, +) -> *mut ffi::PyObject { + unsafe { ffi::PyBytes_FromStringAndSize(data, len) } +} + +/// tls-fallback: `PyBytes_AsString`. +#[inline] +pub(crate) unsafe fn bytes_as_string( + _ts: &ThreadState<'_>, + obj: *mut ffi::PyObject, +) -> *mut c_char { + unsafe { ffi::PyBytes_AsString(obj) } +} + +// --- buffers --------------------------------------------------------------- + +/// tls-fallback: `PyObject_GetBuffer`. +#[inline] +pub(crate) unsafe fn get_buffer( + _ts: &ThreadState<'_>, + obj: *mut ffi::PyObject, + view: *mut ffi::Py_buffer, + flags: c_int, +) -> c_int { + unsafe { ffi::PyObject_GetBuffer(obj, view, flags) } +} + +// --- int / bool / str conversions ----------------------------------------- + +/// tls-fallback: `PyLong_AsLongLong`. +#[inline] +pub(crate) unsafe fn long_as_longlong(_ts: &ThreadState<'_>, obj: *mut ffi::PyObject) -> i64 { + unsafe { ffi::PyLong_AsLongLong(obj) } +} + +/// tls-fallback: `PyLong_AsUnsignedLongLong`. +#[inline] +pub(crate) unsafe fn long_as_unsigned_longlong( + _ts: &ThreadState<'_>, + obj: *mut ffi::PyObject, +) -> u64 { + unsafe { ffi::PyLong_AsUnsignedLongLong(obj) } +} + +/// tls-fallback: `PyLong_AsSsize_t`. +#[inline] +pub(crate) unsafe fn long_as_ssize_t( + _ts: &ThreadState<'_>, + obj: *mut ffi::PyObject, +) -> ffi::Py_ssize_t { + unsafe { ffi::PyLong_AsSsize_t(obj) } +} + +/// tls-fallback: `PyLong_AsSize_t`. +#[inline] +pub(crate) unsafe fn long_as_size_t(_ts: &ThreadState<'_>, obj: *mut ffi::PyObject) -> usize { + unsafe { ffi::PyLong_AsSize_t(obj) } +} + +/// tls-fallback: `PyLong_FromLongLong`. +#[inline] +pub(crate) unsafe fn long_from_longlong(_ts: &ThreadState<'_>, v: i64) -> *mut ffi::PyObject { + unsafe { ffi::PyLong_FromLongLong(v) } +} + +/// tls-fallback: `PyLong_FromUnsignedLongLong`. +#[inline] +pub(crate) unsafe fn long_from_unsigned_longlong( + _ts: &ThreadState<'_>, + v: u64, +) -> *mut ffi::PyObject { + unsafe { ffi::PyLong_FromUnsignedLongLong(v) } +} + +/// tls-fallback: `PyLong_FromSsize_t`. +#[inline] +pub(crate) unsafe fn long_from_ssize_t( + _ts: &ThreadState<'_>, + v: ffi::Py_ssize_t, +) -> *mut ffi::PyObject { + unsafe { ffi::PyLong_FromSsize_t(v) } +} + +/// tls-fallback: `PyObject_IsTrue`. +#[inline] +pub(crate) unsafe fn object_is_true(_ts: &ThreadState<'_>, obj: *mut ffi::PyObject) -> c_int { + unsafe { ffi::PyObject_IsTrue(obj) } +} + +/// tls-fallback: `PyBool_FromLong`. +#[inline] +pub(crate) unsafe fn bool_from_long( + _ts: &ThreadState<'_>, + v: std::os::raw::c_long, +) -> *mut ffi::PyObject { + unsafe { ffi::PyBool_FromLong(v) } +} + +/// tls-fallback: `PyUnicode_FromStringAndSize`. +#[inline] +pub(crate) unsafe fn unicode_from_string_and_size( + _ts: &ThreadState<'_>, + data: *const c_char, + len: ffi::Py_ssize_t, +) -> *mut ffi::PyObject { + unsafe { ffi::PyUnicode_FromStringAndSize(data, len) } +} + +/// explicit-tstate-free: pure comparison, no tstate involved. +#[inline] +pub(crate) unsafe fn unicode_eq_ascii(obj: *mut ffi::PyObject, s: *const c_char) -> c_int { + unsafe { ffi::_PyUnicode_EqualToASCIIString(obj, s) } +} + +// --- tuples / dicts (argument parsing) ------------------------------------- + +/// tls-fallback: `PyTuple_Size`. +#[inline] +pub(crate) unsafe fn tuple_size(_ts: &ThreadState<'_>, t: *mut ffi::PyObject) -> ffi::Py_ssize_t { + unsafe { ffi::PyTuple_Size(t) } +} + +/// tls-fallback: `PyTuple_GetItem` (borrowed reference). +#[inline] +pub(crate) unsafe fn tuple_get_item( + _ts: &ThreadState<'_>, + t: *mut ffi::PyObject, + i: ffi::Py_ssize_t, +) -> *mut ffi::PyObject { + unsafe { ffi::PyTuple_GetItem(t, i) } +} + +/// tls-fallback: `PyDict_Size`. +#[inline] +pub(crate) unsafe fn dict_size(_ts: &ThreadState<'_>, d: *mut ffi::PyObject) -> ffi::Py_ssize_t { + unsafe { ffi::PyDict_Size(d) } +} + +/// tls-fallback: `PyDict_GetItemStringRef` (strong reference out-param). +#[inline] +pub(crate) unsafe fn dict_get_item_string_ref( + _ts: &ThreadState<'_>, + d: *mut ffi::PyObject, + key: *const c_char, + out: *mut *mut ffi::PyObject, +) -> c_int { + unsafe { ffi::PyDict_GetItemStringRef(d, key, out) } +} + +// --- modules --------------------------------------------------------------- + +/// tls-fallback: `PyModule_AddObjectRef`. +#[inline] +pub(crate) unsafe fn module_add_object_ref( + _ts: &ThreadState<'_>, + m: *mut ffi::PyObject, + name: *const c_char, + value: *mut ffi::PyObject, +) -> c_int { + unsafe { ffi::PyModule_AddObjectRef(m, name, value) } +} + +/// tls-fallback: `PyModule_AddType`. +#[inline] +pub(crate) unsafe fn module_add_type( + _ts: &ThreadState<'_>, + m: *mut ffi::PyObject, + ty: *mut ffi::PyTypeObject, +) -> c_int { + unsafe { ffi::PyModule_AddType(m, ty) } +} + +/// tstate-free: reads the module object. +#[inline] +pub(crate) unsafe fn module_get_state(m: *mut ffi::PyObject) -> *mut c_void { + unsafe { ffi::PyModule_GetState(m) } +} + +// --- types ----------------------------------------------------------------- + +/// tls-fallback: `PyType_FromSlots`. +#[inline] +pub(crate) unsafe fn type_from_slots( + _ts: &ThreadState<'_>, + slots: *mut ffi::PySlot, +) -> *mut ffi::PyObject { + unsafe { ffi::PyType_FromSlots(slots) } +} + +/// tls-fallback: `PyType_GenericAlloc`. +#[inline] +pub(crate) unsafe fn type_generic_alloc( + _ts: &ThreadState<'_>, + tp: *mut ffi::PyTypeObject, + nitems: ffi::Py_ssize_t, +) -> *mut ffi::PyObject { + unsafe { ffi::PyType_GenericAlloc(tp, nitems) } +} + +/// tstate-free: reads the type object. +#[inline] +pub(crate) unsafe fn type_get_module_state(tp: *mut ffi::PyTypeObject) -> *mut c_void { + unsafe { ffi::PyType_GetModuleState(tp) } +} diff --git a/Modules/cpython-api/src/threadstate.rs b/Modules/cpython-api/src/threadstate.rs new file mode 100644 index 000000000000000..e2cecdcc9914931 --- /dev/null +++ b/Modules/cpython-api/src/threadstate.rs @@ -0,0 +1,118 @@ +//! The explicit thread-state token. + +use std::marker::PhantomData; +use std::ptr::NonNull; + +use crate::ffi; + +/// Proof of an attached thread state, wrapping the actual `PyThreadState*`. +/// +/// Every operation that touches the interpreter takes `&ThreadState<'py>`. +/// The wrapped pointer lets the crate call explicit-tstate C APIs directly +/// instead of re-fetching the thread state from TLS. +/// +/// Soundness properties: +/// +/// - Not `Copy`/`Clone`: [`ThreadState::detach`] takes `&mut self`, so the +/// exclusive borrow statically excludes every other use of the token while +/// the thread state is detached — including uses smuggled through `Send` +/// wrappers (any `&ThreadState`, however wrapped, is still a borrow). +/// - `!Send`/`!Sync` (via the `*mut ()` marker): the token can neither move to +/// nor be shared with another thread, and a detach closure (which must be +/// `Send`) can never capture one. +/// - The lifetime `'py` is invariant, so tokens with different attachment +/// scopes never unify. +#[repr(transparent)] +pub struct ThreadState<'py> { + ptr: NonNull, + /// `fn(&'py ()) -> &'py ()` makes `'py` invariant; `*mut ()` makes the + /// type `!Send + !Sync`. + #[allow(clippy::type_complexity)] + _marker: PhantomData<(fn(&'py ()) -> &'py (), *mut ())>, +} + +impl<'py> ThreadState<'py> { + /// Wrap a raw thread-state pointer. + /// + /// # Safety + /// + /// `ptr` must be the thread state attached to the *current* thread, and it + /// must remain attached for the whole lifetime `'py` (except inside + /// [`ThreadState::detach`], which re-establishes attachment before + /// returning). + #[inline] + pub unsafe fn from_raw(ptr: NonNull) -> ThreadState<'py> { + ThreadState { + ptr, + _marker: PhantomData, + } + } + + /// Fetch the current thread's attached thread state from TLS. + /// + /// This is the entry-point shim used by generated trampolines: extension + /// entry points are only ever invoked with an attached thread state. + /// + /// # Safety + /// + /// The current thread must have an attached thread state (true whenever + /// Python calls into extension code), and it must stay attached for `'py`. + #[doc(hidden)] + #[inline] + pub unsafe fn current_unchecked() -> ThreadState<'py> { + // tls-fallback by nature: this is the one place a trampoline has to + // consult TLS, converting the implicit attachment into the explicit + // token everything else uses. + let ptr = unsafe { ffi::PyThreadState_GetUnchecked() }; + debug_assert!(!ptr.is_null(), "no attached thread state at entry point"); + unsafe { ThreadState::from_raw(NonNull::new_unchecked(ptr)) } + } + + /// The raw `PyThreadState*`. + #[inline] + pub fn as_ptr(&self) -> *mut ffi::PyThreadState { + self.ptr.as_ptr() + } + + /// True if an exception is currently set on this thread. + /// + /// Reads the thread state's `current_exception` field directly + /// (explicit-tstate; no TLS fetch). + #[inline] + pub fn exception_set(&self) -> bool { + unsafe { !(*self.ptr.as_ptr()).current_exception.is_null() } + } + + /// Detach the thread state (releasing the GIL on GIL builds, and allowing + /// stop-the-world pauses on free-threaded builds), run `f`, then reattach. + /// + /// The closure runs without any access to the interpreter: + /// + /// - it has no token, and `F: Send` means it cannot capture + /// `&ThreadState` (`!Sync`) or any `Bound` reference (`!Send`); + /// - the `&mut self` borrow prevents any other use of this token for the + /// duration, even through `Send`-laundering wrappers. + pub fn detach(&mut self, f: F) -> R + where + F: FnOnce() -> R + Send, + R: Send, + { + struct ReattachGuard(*mut ffi::PyThreadState); + impl Drop for ReattachGuard { + fn drop(&mut self) { + // Reattach even if `f` panics, so unwinding back into + // interpreter-touching code is sound. + unsafe { ffi::PyEval_RestoreThread(self.0) }; + } + } + + let saved = unsafe { ffi::PyEval_SaveThread() }; + debug_assert_eq!( + saved, + self.ptr.as_ptr(), + "detached a different thread state than the token wraps" + ); + let _guard = ReattachGuard(saved); + f() + } +} diff --git a/Modules/cpython-api/src/types/any.rs b/Modules/cpython-api/src/types/any.rs new file mode 100644 index 000000000000000..c6933b31f32ba5b --- /dev/null +++ b/Modules/cpython-api/src/types/any.rs @@ -0,0 +1,22 @@ +//! The abstract object type. + +use crate::ffi; + +/// Any Python object. +/// +/// A Python object can be mutated by other code at any time, so there is +/// never a `&mut PyAny`; the wrapped `PyObject` already models interior +/// mutability (`cpython-sys` defines it as a transparent `UnsafeCell`). +/// +/// This type is only ever used behind [`crate::Bound`]/[`crate::Py`] (or +/// `&PyAny`); it is never instantiated by value. +#[repr(transparent)] +pub struct PyAny(ffi::PyObject); + +impl PyAny { + /// The raw object pointer. + #[inline] + pub fn as_ptr(&self) -> *mut ffi::PyObject { + self as *const PyAny as *mut ffi::PyObject + } +} diff --git a/Modules/cpython-api/src/types/bytes.rs b/Modules/cpython-api/src/types/bytes.rs new file mode 100644 index 000000000000000..33237867cb93c2e --- /dev/null +++ b/Modules/cpython-api/src/types/bytes.rs @@ -0,0 +1,61 @@ +//! The `bytes` type. + +use std::mem::MaybeUninit; +use std::os::raw::c_char; +use std::ptr::NonNull; + +use crate::err::{PyErrRaised, PyMemoryError, PyResult}; +use crate::ffi; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::any::PyAny; + +/// The Python `bytes` type marker. +#[repr(transparent)] +pub struct PyBytes(PyAny); + +impl PyBytes { + /// Create a `bytes` object as a copy of `data`. + pub fn new<'py>(ts: &ThreadState<'py>, data: &[u8]) -> PyResult> { + let ptr = unsafe { + sys_calls::bytes_from_string_and_size( + ts, + data.as_ptr() as *const c_char, + data.len() as ffi::Py_ssize_t, + ) + }; + match NonNull::new(ptr) { + Some(p) => Ok(unsafe { Bound::from_owned_ptr(ts, p) }), + None => Err(unsafe { PyErrRaised::assume_set(ts) }), + } + } + + /// Create a `bytes` object of length `len` and let `init` fill it, + /// avoiding the extra copy of building in a `Vec` first. + /// + /// `init` receives the uninitialized buffer and must fully initialize it + /// when returning `Ok(())`. + pub fn new_with<'py>( + ts: &ThreadState<'py>, + len: usize, + init: impl FnOnce(&mut [MaybeUninit]) -> PyResult<()>, + ) -> PyResult> { + if len > ffi::Py_ssize_t::MAX as usize { + return Err(PyMemoryError::raise(ts, "bytes object is too large")); + } + let ptr = unsafe { + sys_calls::bytes_from_string_and_size(ts, std::ptr::null(), len as ffi::Py_ssize_t) + }; + let Some(p) = NonNull::new(ptr) else { + return Err(unsafe { PyErrRaised::assume_set(ts) }); + }; + let bytes: Bound<'py, PyBytes> = unsafe { Bound::from_owned_ptr(ts, p) }; + let buf = unsafe { + let data = sys_calls::bytes_as_string(ts, bytes.as_ptr()) as *mut MaybeUninit; + std::slice::from_raw_parts_mut(data, len) + }; + init(buf)?; + Ok(bytes) + } +} diff --git a/Modules/cpython-api/src/types/mod.rs b/Modules/cpython-api/src/types/mod.rs new file mode 100644 index 000000000000000..0b96af6c8376603 --- /dev/null +++ b/Modules/cpython-api/src/types/mod.rs @@ -0,0 +1,11 @@ +//! Concrete Python type markers and their APIs. + +mod any; +mod bytes; +mod module; +mod type_object; + +pub use any::PyAny; +pub use bytes::PyBytes; +pub use module::PyModule; +pub use type_object::PyType; diff --git a/Modules/cpython-api/src/types/module.rs b/Modules/cpython-api/src/types/module.rs new file mode 100644 index 000000000000000..a7ac759d77fc999 --- /dev/null +++ b/Modules/cpython-api/src/types/module.rs @@ -0,0 +1,42 @@ +//! The module type. + +use std::ffi::CStr; + +use crate::conversion::IntoPyObject; +use crate::err::{PyResult, error_on_minus_one}; +use crate::ffi; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::any::PyAny; +use crate::types::type_object::PyType; + +/// The Python module type marker. +#[repr(transparent)] +pub struct PyModule(PyAny); + +impl<'py> Bound<'py, PyModule> { + /// Add an attribute to the module (constants, exception types, ...). + pub fn add( + &self, + ts: &ThreadState<'py>, + name: &CStr, + value: impl IntoPyObject<'py>, + ) -> PyResult<()> { + let value = value.into_pyobject(ts)?; + let ret = unsafe { + sys_calls::module_add_object_ref(ts, self.as_ptr(), name.as_ptr(), value.as_ptr()) + }; + // `value` drops here, releasing our reference; AddObjectRef took its + // own. + error_on_minus_one(ts, ret) + } + + /// Add a type object to the module under its (unqualified) name. + pub fn add_type(&self, ts: &ThreadState<'py>, ty: &Bound<'py, PyType>) -> PyResult<()> { + let ret = unsafe { + sys_calls::module_add_type(ts, self.as_ptr(), ty.as_ptr() as *mut ffi::PyTypeObject) + }; + error_on_minus_one(ts, ret) + } +} diff --git a/Modules/cpython-api/src/types/type_object.rs b/Modules/cpython-api/src/types/type_object.rs new file mode 100644 index 000000000000000..a29f586cc0f254d --- /dev/null +++ b/Modules/cpython-api/src/types/type_object.rs @@ -0,0 +1,37 @@ +//! The type type. + +use std::ffi::CStr; +use std::ptr::NonNull; + +use crate::err::{PyErrRaised, PyResult}; +use crate::instance::Bound; +use crate::sys_calls; +use crate::threadstate::ThreadState; +use crate::types::any::PyAny; + +/// The Python type-object marker. +#[repr(transparent)] +pub struct PyType(PyAny); + +impl PyType { + /// Create a new exception type (`PyErr_NewException`). + /// + /// `qualname` must be of the form `"module.name"` (e.g. `c"zlib.error"`). + /// With `base: None` the base class is `Exception`. + /// + /// Exception types belong in per-module state, created from the module's + /// exec function — never in statics — so that modules stay subinterpreter + /// compatible. + pub fn new_exception<'py>( + ts: &ThreadState<'py>, + qualname: &CStr, + base: Option<&Bound<'py, PyType>>, + ) -> PyResult> { + let base_ptr = base.map_or(std::ptr::null_mut(), |b| b.as_ptr()); + let ptr = unsafe { sys_calls::err_new_exception(ts, qualname.as_ptr(), base_ptr) }; + match NonNull::new(ptr) { + Some(p) => Ok(unsafe { Bound::from_owned_ptr(ts, p) }), + None => Err(unsafe { PyErrRaised::assume_set(ts) }), + } + } +} diff --git a/Modules/cpython-api/tests/define_module.rs b/Modules/cpython-api/tests/define_module.rs new file mode 100644 index 000000000000000..e22203c8dfc3430 --- /dev/null +++ b/Modules/cpython-api/tests/define_module.rs @@ -0,0 +1,304 @@ +//! Compile-and-shape test: defines a zlib-shaped module end-to-end. +//! +//! Nothing here starts an interpreter — the value of this test is that the +//! whole definition surface (proc macros, slot builders, module state, +//! class definition) compiles and const-evaluates, and that the resulting +//! static tables have the right shape. +//! +//! Gated on `has_libpython` (emitted by build.rs when the build tree +//! contains a static libpython): the test binary's static tables reference +//! interpreter symbols that the dynamic loader resolves at startup, so it +//! can only load when linked against a real libpython. +#![cfg(has_libpython)] + +use std::sync::Mutex; + +use cpython_api::class::{ClassDef, payload_offset}; +use cpython_api::prelude::*; +use cpython_api::{ffi, module}; + +// --- module state ----------------------------------------------------------- + +struct TestState { + error: Option>, + compress_type: Option>, +} + +impl ModuleState for TestState { + fn new<'py>(ts: &ThreadState<'py>, m: &Bound<'py, PyModule>) -> PyResult { + let error = PyType::new_exception(ts, c"testmod.error", None)?; + m.add(ts, c"error", error.clone_ref(ts))?; + let compress_type = COMPRESS_CLASS.create(ts, m)?; + m.add_type(ts, &compress_type)?; + Ok(TestState { + error: Some(error.unbind()), + compress_type: Some(compress_type.unbind()), + }) + } + + fn traverse(&self, visit: &cpython_api::Visit<'_>) -> cpython_api::TraverseResult { + if let Some(e) = &self.error { + visit.call(e)?; + } + if let Some(t) = &self.compress_type { + visit.call(t)?; + } + Ok(()) + } + + fn clear(&mut self) { + self.error = None; + self.compress_type = None; + } +} + +// --- module-level functions ------------------------------------------------- + +#[pyfunction(signature = (data, value = 1, /))] +fn adler32( + _ts: &ThreadState<'_>, + _state: &TestState, + data: PyBuffer<'_>, + value: u32, +) -> PyResult { + Ok(value.wrapping_add(data.len() as u32)) +} + +// `ts: &mut ThreadState` opts in to `detach` (the trampoline passes the +// token through with the declared mutability). +#[pyfunction(signature = (data, /, level = -1, wbits = 15))] +fn compress<'py>( + ts: &mut ThreadState<'py>, + state: &TestState, + data: PyBuffer<'py>, + level: i32, + wbits: i32, +) -> PyResult> { + if !(-1..=9).contains(&level) || wbits == 0 { + let error = state.error.as_ref().expect("state initialized"); + return Err(error.raise(ts, format_args!("Bad compression level {level}"))); + } + // Long-running work runs with the thread state detached; the closure can + // only capture Send data (the byte slice), never the token or a Bound. + let input = data.as_bytes(); + let compressed = ts.detach(|| input.to_vec()); + PyBytes::new(ts, &compressed) +} + +// A signature-less function: all parameters positional-or-keyword, required. +#[pyfunction] +fn crc32_combine( + _ts: &ThreadState<'_>, + _state: &TestState, + crc1: u32, + crc2: u32, + len2: i64, +) -> PyResult { + Ok(crc1 ^ crc2 ^ (len2 as u32)) +} + +// --- a class --------------------------------------------------------------- + +struct Compress { + inner: Mutex>, +} + +#[pymethods] +impl Compress { + #[pyfunction(signature = (data, /))] + fn compress<'py>( + &self, + ts: &ThreadState<'py>, + state: &TestState, + data: PyBuffer<'py>, + ) -> PyResult> { + let mut inner = self.inner.lock().unwrap(); + if inner.len() > 1 << 20 { + let error = state.error.as_ref().expect("state initialized"); + return Err(error.raise(ts, "buffer overflow")); + } + inner.extend_from_slice(data.as_bytes()); + PyBytes::new(ts, &inner) + } + + #[pyfunction(signature = (mode = 4, /))] + fn flush<'py>( + &self, + ts: &ThreadState<'py>, + _state: &TestState, + mode: i32, + ) -> PyResult> { + let _ = mode; + PyBytes::new(ts, &self.inner.lock().unwrap()) + } + + #[getter] + fn eof(&self, _ts: &ThreadState<'_>) -> bool { + false + } + + #[new] + #[pyfunction(signature = (wbits = 15, zdict = None))] + fn new(ts: &ThreadState<'_>, wbits: i32, zdict: Option>) -> PyResult { + if wbits == 0 { + return Err(PyValueError::raise(ts, "invalid wbits")); + } + Ok(Compress { + inner: Mutex::new(zdict.map(|z| z.as_bytes().to_vec()).unwrap_or_default()), + }) + } +} + +static COMPRESS_CLASS: ClassDef = ClassDef::new(c"testmod._Compress") + .methods(&COMPRESS_METHODS) + .getsets(&COMPRESS_GETSETS) + .tp_new(COMPRESS_TP_NEW); + +// --- factory function using new_instance ------------------------------------ + +#[pyfunction(signature = (level = -1))] +fn compressobj<'py>( + ts: &ThreadState<'py>, + state: &TestState, + level: i32, +) -> PyResult> { + let _ = level; + let cls = state.compress_type.as_ref().expect("state initialized"); + cpython_api::class::new_instance( + ts, + cls.bind(ts), + Compress { + inner: Mutex::new(Vec::new()), + }, + ) +} + +// --- the module ------------------------------------------------------------- + +fn testmod_exec<'py>(ts: &ThreadState<'py>, m: &Bound<'py, PyModule>) -> PyResult<()> { + m.add(ts, c"MAX_WBITS", 15)?; + m.add(ts, c"DEF_BUF_SIZE", 16384usize)?; + m.add(ts, c"ZLIB_VERSION", "1.3.1")?; + Ok(()) +} + +export_module! { + name: testmod, + doc: c"A zlib-shaped test module", + state: TestState, + methods: [adler32, compress, crc32_combine, compressobj], + exec: testmod_exec, +} + +// --- shape checks ----------------------------------------------------------- + +#[test] +fn method_table_shape() { + // 4 methods + terminator, built by export_module!. + let def = &adler32::DEF; + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(def.ml_name) } + .to_str() + .unwrap(), + "adler32" + ); + assert_eq!(def.ml_flags, ffi::METH_FASTCALL | ffi::METH_KEYWORDS); + + // Module functions stay FASTCALL (state comes from the module object); + // methods are uniformly METH_METHOD (state via defining class). + assert_eq!( + compress::DEF.ml_flags, + ffi::METH_FASTCALL | ffi::METH_KEYWORDS + ); + for def in &COMPRESS_METHODS.0[..2] { + assert_eq!( + def.ml_flags, + ffi::METH_FASTCALL | ffi::METH_KEYWORDS | ffi::METH_METHOD + ); + } + // Terminator. + assert!(COMPRESS_METHODS.0[2].ml_name.is_null()); +} + +#[test] +fn spec_shape() { + assert_eq!(adler32::SPEC.fn_name, "adler32"); + assert_eq!(adler32::SPEC.params.len(), 2); + assert_eq!(adler32::SPEC.pos_only, 2); + assert!(adler32::SPEC.params[0].required); + assert!(!adler32::SPEC.params[1].required); + + assert_eq!(compress::SPEC.pos_only, 1); + assert_eq!(compress::SPEC.params.len(), 3); + + // Signature-less: all positional-or-keyword, required. + assert_eq!(crc32_combine::SPEC.pos_only, 0); + assert_eq!(crc32_combine::SPEC.params.len(), 3); + assert!(crc32_combine::SPEC.params.iter().all(|p| p.required)); +} + +#[test] +fn getset_table_shape() { + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(COMPRESS_GETSETS.0[0].name) } + .to_str() + .unwrap(), + "eof" + ); + assert!(COMPRESS_GETSETS.0[0].get.is_some()); + assert!(COMPRESS_GETSETS.0[0].set.is_none()); + assert!(COMPRESS_GETSETS.0[1].name.is_null()); +} + +#[test] +fn payload_layout() { + // Payload starts after the header, aligned for the payload type. + let off = payload_offset::(); + assert!(off >= std::mem::size_of::()); + assert_eq!(off % std::mem::align_of::(), 0); +} + +#[test] +fn abi_info_shape() { + let abi = module::abi_info(); + assert_eq!(abi.abiinfo_major_version, 1); + assert_ne!(abi.flags & ffi::PyABIInfo_INTERNAL as u16, 0); + // Exactly one of GIL / FREETHREADED, matching the interpreter build. + let ft = abi.flags & (ffi::PyABIInfo_GIL | ffi::PyABIInfo_FREETHREADED) as u16; + assert!(ft == ffi::PyABIInfo_GIL as u16 || ft == ffi::PyABIInfo_FREETHREADED as u16); + assert_eq!(abi.build_version, module::PY_VERSION_HEX); +} + +#[test] +fn module_slots_shape() { + // The export hook returns the static slot array; walk it and check the + // required PEP 793 slots are present, terminated, and defaulted to + // free-threading + per-interpreter-GIL support. + let slots = unsafe { PyModExport() }; + let mut ids = Vec::new(); + let mut i = 0; + loop { + let slot = unsafe { *slots.add(i) }; + if slot.sl_id == 0 { + break; + } + ids.push(slot.sl_id as u32); + i += 1; + assert!(i < 32, "unterminated slot array"); + } + for required in [ + ffi::Py_mod_name, + ffi::Py_mod_doc, + ffi::Py_mod_state_size, + ffi::Py_mod_methods, + module::PY_MOD_EXEC, + ffi::Py_mod_state_traverse, + ffi::Py_mod_state_clear, + ffi::Py_mod_state_free, + module::PY_MOD_MULTIPLE_INTERPRETERS, + module::PY_MOD_GIL, + ffi::Py_mod_abi, + ] { + assert!(ids.contains(&required), "missing slot id {required}"); + } +} diff --git a/Modules/cpython-rust-staticlib/src/lib.rs b/Modules/cpython-rust-staticlib/src/lib.rs index a440707833f65d6..35de053efc72711 100644 --- a/Modules/cpython-rust-staticlib/src/lib.rs +++ b/Modules/cpython-rust-staticlib/src/lib.rs @@ -1 +1,9 @@ -pub use _base64::PyInit__base64; +// Re-export each Rust module's PEP 793 export hook so its object code (and +// the `PyModExport_` symbol) is retained in the static archive. +// +// NOTE: builtin/static interpreter builds currently register modules through +// `struct _inittab`, which only carries `PyInit_*`-style functions — slots- +// only (PyModExport) modules can be imported as shared extensions today, but +// wiring them into a static interpreter needs PEP 793 inittab support in the +// C import machinery first. +pub use _base64::PyModExport; diff --git a/Modules/cpython-sys/build.rs b/Modules/cpython-sys/build.rs index 03335128e9fbe55..f2e19e64e201cc9 100644 --- a/Modules/cpython-sys/build.rs +++ b/Modules/cpython-sys/build.rs @@ -265,6 +265,9 @@ fn generate_c_api_bindings( .allowlist_function("_?Py.*") .allowlist_type("_?Py.*") .allowlist_var("_?Py.*") + // Version macros (PY_VERSION_HEX etc.) don't match the case-sensitive + // Py.* patterns above but are needed for PyABIInfo construction. + .allowlist_var("PY_(MAJOR|MINOR|MICRO)_VERSION|PY_VERSION_HEX|PY_RELEASE_(LEVEL|SERIAL)") .blocklist_type("^PyMethodDef$") .blocklist_type("PyObject") .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) diff --git a/Modules/cpython-sys/src/lib.rs b/Modules/cpython-sys/src/lib.rs index 17924f0e5fb00a9..1025a35e60e7801 100644 --- a/Modules/cpython-sys/src/lib.rs +++ b/Modules/cpython-sys/src/lib.rs @@ -57,6 +57,15 @@ pub const _Py_STATIC_IMMORTAL_INITIAL_REFCNT: Py_ssize_t = #[cfg(not(target_pointer_width = "64"))] pub const _Py_STATIC_IMMORTAL_INITIAL_REFCNT: Py_ssize_t = (7u32 << 28) as Py_ssize_t; +/// Whether these bindings were generated for a free-threaded (`Py_GIL_DISABLED`) +/// build. Exposed as a const so dependent crates can branch on it without +/// re-detecting the build configuration (the `py_gil_disabled` cfg is local to +/// this crate). +#[cfg(py_gil_disabled)] +pub const GIL_DISABLED: bool = true; +#[cfg(not(py_gil_disabled))] +pub const GIL_DISABLED: bool = false; + #[repr(transparent)] pub struct PyObject(std::cell::UnsafeCell<_object>);