From eb53c2d8d04fcb2cee44dcc2deb243d3de243e85 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 26 Jan 2026 14:15:03 -0700 Subject: [PATCH 01/25] Use PyModExport and PyABIInfo APIs in pymodule implementation --- pyo3-macros-backend/src/module.rs | 2 +- src/impl_/pymodule.rs | 198 ++++++++++++++++++++++-------- 2 files changed, 146 insertions(+), 54 deletions(-) diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 001c36d1eed..23faf6ab1c5 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -544,7 +544,7 @@ fn module_initialization( #pyo3_path::impl_::trampoline::module_exec(module, #module_exec) } - static SLOTS: impl_::PyModuleSlots<4> = impl_::PyModuleSlotsBuilder::new() + static SLOTS: impl_::PyModuleSlots = impl_::PyModuleSlotsBuilder::new() .with_mod_exec(__pyo3_module_exec) .with_gil_used(#gil_used) .build(); diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 66749e29d1b..daa7ab5e972 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -44,7 +44,14 @@ use crate::{ffi_ptr_ext::FfiPtrExt, PyErr}; /// `Sync` wrapper of `ffi::PyModuleDef`. pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability + #[cfg(not(Py_3_15))] ffi_def: UnsafeCell, + #[cfg(Py_3_15)] + name: &'static CStr, + #[cfg(Py_3_15)] + doc: &'static CStr, + #[cfg(Py_3_15)] + slots: &'static PyModuleSlots, /// Interpreter ID where module was initialized (not applicable on PyPy). #[cfg(all( not(any(PyPy, GraalPy)), @@ -60,14 +67,12 @@ unsafe impl Sync for ModuleDef {} impl ModuleDef { /// Make new module definition with given module name. - pub const fn new( + pub const fn new( name: &'static CStr, doc: &'static CStr, - // TODO: it might be nice to make this unsized and not need the - // const N generic parameter, however that might need unsized return values - // or other messy hacks. - slots: &'static PyModuleSlots, + slots: &'static PyModuleSlots, ) -> Self { + #[cfg(not(Py_3_15))] #[allow(clippy::declare_interior_mutable_const)] const INIT: ffi::PyModuleDef = ffi::PyModuleDef { m_base: ffi::PyModuleDef_HEAD_INIT, @@ -81,6 +86,7 @@ impl ModuleDef { m_free: None, }; + #[cfg(not(Py_3_15))] let ffi_def = UnsafeCell::new(ffi::PyModuleDef { m_name: name.as_ptr(), m_doc: doc.as_ptr(), @@ -91,7 +97,14 @@ impl ModuleDef { }); ModuleDef { + #[cfg(not(Py_3_15))] ffi_def, + #[cfg(Py_3_15)] + name, + #[cfg(Py_3_15)] + doc, + #[cfg(Py_3_15)] + slots, // -1 is never expected to be a valid interpreter ID #[cfg(all( not(any(PyPy, GraalPy)), @@ -105,7 +118,14 @@ impl ModuleDef { pub fn init_multi_phase(&'static self) -> *mut ffi::PyObject { // SAFETY: `ffi_def` is correctly initialized in `new()` - unsafe { ffi::PyModuleDef_Init(self.ffi_def.get()) } + #[cfg(not(Py_3_15))] + unsafe { + ffi::PyModuleDef_Init(self.ffi_def.get()) + } + #[cfg(Py_3_15)] + unreachable!( + "Python shouldn't be calling an intialization function in Python 3.15 or newer" + ) } /// Builds a module object directly. Used for [`#[pymodule]`][crate::pymodule] submodules. @@ -157,47 +177,88 @@ impl ModuleDef { static SIMPLE_NAMESPACE: PyOnceLock> = PyOnceLock::new(); let simple_ns = SIMPLE_NAMESPACE.import(py, "types", "SimpleNamespace")?; - let ffi_def = self.ffi_def.get(); - - let name = unsafe { CStr::from_ptr((*ffi_def).m_name).to_str()? }.to_string(); - let kwargs = PyDict::new(py); - kwargs.set_item("name", name)?; - let spec = simple_ns.call((), Some(&kwargs))?; + #[cfg(not(Py_3_15))] + { + let ffi_def = self.ffi_def.get(); + + let name = unsafe { CStr::from_ptr((*ffi_def).m_name).to_str()? }.to_string(); + let kwargs = PyDict::new(py); + kwargs.set_item("name", name)?; + let spec = simple_ns.call((), Some(&kwargs))?; + + self.module + .get_or_try_init(py, || { + let def = self.ffi_def.get(); + let module = unsafe { + ffi::PyModule_FromDefAndSpec(def, spec.as_ptr()).assume_owned_or_err(py)? + } + .cast_into()?; + if unsafe { ffi::PyModule_ExecDef(module.as_ptr(), def) } != 0 { + return Err(PyErr::fetch(py)); + } + Ok(module.unbind()) + }) + .map(|py_module| py_module.clone_ref(py)) + } - self.module - .get_or_try_init(py, || { - let def = self.ffi_def.get(); - let module = unsafe { - ffi::PyModule_FromDefAndSpec(def, spec.as_ptr()).assume_owned_or_err(py)? - } - .cast_into()?; - if unsafe { ffi::PyModule_ExecDef(module.as_ptr(), def) } != 0 { - return Err(PyErr::fetch(py)); - } - Ok(module.unbind()) - }) - .map(|py_module| py_module.clone_ref(py)) + #[cfg(Py_3_15)] + { + let name = self.name; + let doc = self.doc; + let kwargs = PyDict::new(py); + kwargs.set_item("name", name)?; + let spec = simple_ns.call((), Some(&kwargs))?; + + self.module + .get_or_try_init(py, || { + let slots = self.slots.0.get() as *const ffi::PyModuleDef_Slot; + let module = unsafe { ffi::PyModule_FromSlotsAndSpec(slots, spec.as_ptr()) }; + if unsafe { ffi::PyModule_SetDocString(module, doc.as_ptr()) } != 0 { + return Err(PyErr::fetch(py)); + } + let module = unsafe { module.assume_owned_or_err(py)? }.cast_into()?; + if unsafe { ffi::PyModule_Exec(module.as_ptr()) } != 0 { + return Err(PyErr::fetch(py)); + } + Ok(module.unbind()) + }) + .map(|py_module| py_module.clone_ref(py)) + } } } /// Type of the exec slot used to initialise module contents pub type ModuleExecSlot = unsafe extern "C" fn(*mut ffi::PyObject) -> c_int; +const MAX_SLOTS: usize = + // Py_mod_exec and a trailing null entry + 2 + + // Py_mod_gil + cfg!(Py_3_13) as usize + + // Py_mod_name and Py_mod_abi + 2 * (cfg!(Py_3_15) as usize); + /// Builder to create `PyModuleSlots`. The size of the number of slots desired must /// be known up front, and N needs to be at least one greater than the number of /// actual slots pushed due to the need to have a zeroed element on the end. -pub struct PyModuleSlotsBuilder { +pub struct PyModuleSlotsBuilder { // values (initially all zeroed) - values: [ffi::PyModuleDef_Slot; N], + values: [ffi::PyModuleDef_Slot; MAX_SLOTS], // current length len: usize, } -impl PyModuleSlotsBuilder { +// note that macros cannot use conditional compilation, +// so all implementations below must be available in all +// Python versions +// By handling it here we can avoid conditional +// compilation within the macros; they can always emit +// e.g. a `.with_gil_used()` call. +impl PyModuleSlotsBuilder { #[allow(clippy::new_without_default)] pub const fn new() -> Self { Self { - values: [unsafe { std::mem::zeroed() }; N], + values: [unsafe { std::mem::zeroed() }; MAX_SLOTS], len: 0, } } @@ -223,22 +284,44 @@ impl PyModuleSlotsBuilder { { // Silence unused variable warning let _ = gil_used; + self + } + } + + pub const fn with_name(self, name: &'static CStr) -> Self { + #[cfg(Py_3_15)] + { + self.push(ffi::Py_mod_name, name.as_ptr() as *mut c_void) + } + + #[cfg(not(Py_3_15))] + { + // Silence unused variable warning + let _ = name; + self + } + } - // Py_mod_gil didn't exist before 3.13, can just make - // this function a noop. - // - // By handling it here we can avoid conditional - // compilation within the macros; they can always emit - // a `.with_gil_used()` call. + pub const fn with_abi_info(self) -> Self { + #[cfg(Py_3_15)] + { + ffi::PyABIInfo_VAR!(ABI_INFO); + self.push(ffi::Py_mod_abi, std::ptr::addr_of_mut!(ABI_INFO).cast()) + } + + #[cfg(not(Py_3_15))] + { + // Silence unused variable warning + let _ = abi_info; self } } - pub const fn build(self) -> PyModuleSlots { + pub const fn build(self) -> PyModuleSlots { // Required to guarantee there's still a zeroed element // at the end assert!( - self.len < N, + self.len < MAX_SLOTS, "N must be greater than the number of slots pushed" ); PyModuleSlots(UnsafeCell::new(self.values)) @@ -252,13 +335,13 @@ impl PyModuleSlotsBuilder { } /// Wrapper to safely store module slots, to be used in a `ModuleDef`. -pub struct PyModuleSlots(UnsafeCell<[ffi::PyModuleDef_Slot; N]>); +pub struct PyModuleSlots(UnsafeCell<[ffi::PyModuleDef_Slot; MAX_SLOTS]>); // It might be possible to avoid this with SyncUnsafeCell in the future // // SAFETY: the inner values are only accessed within a `ModuleDef`, // which only uses them to build the `ffi::ModuleDef`. -unsafe impl Sync for PyModuleSlots {} +unsafe impl Sync for PyModuleSlots {} /// Trait to add an element (class, function...) to a module. /// @@ -342,10 +425,17 @@ mod tests { } } - static SLOTS: PyModuleSlots<2> = PyModuleSlotsBuilder::new() + static NAME: &CStr = c"test_module"; + static DOC: &CStr = c"some doc"; + + static SLOTS: PyModuleSlots = PyModuleSlotsBuilder::new() .with_mod_exec(module_exec) + .with_gil_used(false) + .with_abi_info() + .with_name(NAME) .build(); - static MODULE_DEF: ModuleDef = ModuleDef::new(c"test_module", c"some doc", &SLOTS); + + static MODULE_DEF: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); Python::attach(|py| { let module = MODULE_DEF.make_module(py).unwrap().into_bound(py); @@ -383,24 +473,22 @@ mod tests { static NAME: &CStr = c"test_module"; static DOC: &CStr = c"some doc"; - static SLOTS: PyModuleSlots<2> = PyModuleSlotsBuilder::new().build(); + static SLOTS: PyModuleSlots = PyModuleSlotsBuilder::new().build(); + + let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); + #[cfg(not(Py_3_15))] unsafe { - let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); assert_eq!((*module_def.ffi_def.get()).m_name, NAME.as_ptr() as _); assert_eq!((*module_def.ffi_def.get()).m_doc, DOC.as_ptr() as _); assert_eq!((*module_def.ffi_def.get()).m_slots, SLOTS.0.get().cast()); } - } - - #[test] - #[should_panic] - fn test_module_slots_builder_overflow() { - unsafe extern "C" fn module_exec(_module: *mut ffi::PyObject) -> c_int { - 0 + #[cfg(Py_3_15)] + { + assert_eq!(module_def.name, NAME); + assert_eq!(module_def.doc, DOC); + assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); } - - PyModuleSlotsBuilder::<0>::new().with_mod_exec(module_exec); } #[test] @@ -410,7 +498,11 @@ mod tests { 0 } - PyModuleSlotsBuilder::<2>::new() + PyModuleSlotsBuilder::new() + .with_mod_exec(module_exec) + .with_mod_exec(module_exec) + .with_mod_exec(module_exec) + .with_mod_exec(module_exec) .with_mod_exec(module_exec) .with_mod_exec(module_exec) .build(); From e41b509428ad4ec892196eb30c7de8114d9278b7 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 26 Jan 2026 14:26:35 -0700 Subject: [PATCH 02/25] Add PyModExport function --- pyo3-macros-backend/src/module.rs | 12 +++++++++++- src/impl_/pymodule.rs | 21 ++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 23faf6ab1c5..27d55262bbb 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -519,6 +519,7 @@ fn module_initialization( ) -> Result { let Ctx { pyo3_path, .. } = ctx; let pyinit_symbol = format!("PyInit_{name}"); + let pymodexport_symbol = format!("PyModExport_{name}"); let pyo3_name = LitCStr::new(&CString::new(full_name).unwrap(), Span::call_site()); let doc = if let Some(doc) = doc { doc.to_cstr_stream(ctx)? @@ -555,13 +556,22 @@ fn module_initialization( if !is_submodule { result.extend(quote! { /// This autogenerated function is called by the python interpreter when importing - /// the module. + /// the module on Python 3.14 and older. #[doc(hidden)] #[export_name = #pyinit_symbol] pub unsafe extern "C" fn __pyo3_init() -> *mut #pyo3_path::ffi::PyObject { _PYO3_DEF.init_multi_phase() } }); + result.extend(quote! { + /// This autogenerated function is called by the python interpreter when importing + /// the module on Python 3.15 and newer. + #[doc(hidden)] + #[export_name = #pymodexport_symbol] + pub unsafe extern "C" fn __pyo3_export() -> *mut #pyo3_path::ffi::PyModuleDef_Slot { + _PYO3_DEF.get_slots() + } + }); } Ok(result) } diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index daa7ab5e972..0f2b18614bd 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -50,8 +50,7 @@ pub struct ModuleDef { name: &'static CStr, #[cfg(Py_3_15)] doc: &'static CStr, - #[cfg(Py_3_15)] - slots: &'static PyModuleSlots, + slots: Option<&'static PyModuleSlots>, /// Interpreter ID where module was initialized (not applicable on PyPy). #[cfg(all( not(any(PyPy, GraalPy)), @@ -104,7 +103,9 @@ impl ModuleDef { #[cfg(Py_3_15)] doc, #[cfg(Py_3_15)] - slots, + slots: Some(slots), + #[cfg(not(Py_3_15))] + slots: None, // -1 is never expected to be a valid interpreter ID #[cfg(all( not(any(PyPy, GraalPy)), @@ -211,7 +212,7 @@ impl ModuleDef { self.module .get_or_try_init(py, || { - let slots = self.slots.0.get() as *const ffi::PyModuleDef_Slot; + let slots = self.get_slots(); let module = unsafe { ffi::PyModule_FromSlotsAndSpec(slots, spec.as_ptr()) }; if unsafe { ffi::PyModule_SetDocString(module, doc.as_ptr()) } != 0 { return Err(PyErr::fetch(py)); @@ -225,6 +226,16 @@ impl ModuleDef { .map(|py_module| py_module.clone_ref(py)) } } + pub fn get_slots(&'static self) -> *mut ffi::PyModuleDef_Slot { + #[cfg(Py_3_15)] + { + self.slots.unwrap().0.get() as *mut ffi::PyModuleDef_Slot + } + #[cfg(not(Py_3_15))] + { + unsafe { *self.ffi_def.get() }.m_slots + } + } } /// Type of the exec slot used to initialise module contents @@ -487,7 +498,7 @@ mod tests { { assert_eq!(module_def.name, NAME); assert_eq!(module_def.doc, DOC); - assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); + assert_eq!(module_def.slots.unwrap().0.get(), SLOTS.0.get()); } } From 91becaf7b7a67d3173db6f071a45df29a38d4d64 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 26 Jan 2026 14:27:52 -0700 Subject: [PATCH 03/25] DNM: temporarily disable append_to_inittab doctest --- guide/src/python-from-rust/calling-existing-code.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guide/src/python-from-rust/calling-existing-code.md b/guide/src/python-from-rust/calling-existing-code.md index 09001929703..eb1cfc46bfb 100644 --- a/guide/src/python-from-rust/calling-existing-code.md +++ b/guide/src/python-from-rust/calling-existing-code.md @@ -141,7 +141,7 @@ The macro **must** be invoked _before_ initializing Python. As an example, the below adds the module `foo` to the embedded interpreter: -```rust +```rust,no_run use pyo3::prelude::*; #[pymodule] From 1020688e8efb805c3d57b1852b14a7c03de0e977 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 26 Jan 2026 15:04:18 -0700 Subject: [PATCH 04/25] fix issues seen on older pythons in CI --- src/impl_/pymodule.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 0f2b18614bd..2cbe882aaa8 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -50,7 +50,8 @@ pub struct ModuleDef { name: &'static CStr, #[cfg(Py_3_15)] doc: &'static CStr, - slots: Option<&'static PyModuleSlots>, + #[cfg(Py_3_15)] + slots: &'static PyModuleSlots, /// Interpreter ID where module was initialized (not applicable on PyPy). #[cfg(all( not(any(PyPy, GraalPy)), @@ -104,8 +105,6 @@ impl ModuleDef { doc, #[cfg(Py_3_15)] slots: Some(slots), - #[cfg(not(Py_3_15))] - slots: None, // -1 is never expected to be a valid interpreter ID #[cfg(all( not(any(PyPy, GraalPy)), @@ -229,11 +228,11 @@ impl ModuleDef { pub fn get_slots(&'static self) -> *mut ffi::PyModuleDef_Slot { #[cfg(Py_3_15)] { - self.slots.unwrap().0.get() as *mut ffi::PyModuleDef_Slot + self.slots.0.get() as *mut ffi::PyModuleDef_Slot } #[cfg(not(Py_3_15))] { - unsafe { *self.ffi_def.get() }.m_slots + unsafe { (*self.ffi_def.get()).m_slots } } } } @@ -322,8 +321,6 @@ impl PyModuleSlotsBuilder { #[cfg(not(Py_3_15))] { - // Silence unused variable warning - let _ = abi_info; self } } @@ -498,7 +495,7 @@ mod tests { { assert_eq!(module_def.name, NAME); assert_eq!(module_def.doc, DOC); - assert_eq!(module_def.slots.unwrap().0.get(), SLOTS.0.get()); + assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); } } From 3afa9ae44078aa84faf3b3fdca2d2d37d516bc4d Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 26 Jan 2026 15:35:19 -0700 Subject: [PATCH 05/25] fix incorrect ModuleDef setup on 3.15 --- src/impl_/pymodule.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 2cbe882aaa8..71b03c00a35 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -104,7 +104,7 @@ impl ModuleDef { #[cfg(Py_3_15)] doc, #[cfg(Py_3_15)] - slots: Some(slots), + slots, // -1 is never expected to be a valid interpreter ID #[cfg(all( not(any(PyPy, GraalPy)), From f8d6caec00eff714b9d84cfa53cccdf964dcc0b5 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 27 Jan 2026 09:39:11 -0700 Subject: [PATCH 06/25] Expose both the PyInit and PyModExport initialization hooks --- .../python-from-rust/calling-existing-code.md | 2 +- pyo3-macros-backend/src/module.rs | 16 ++++++- src/impl_/pymodule.rs | 47 ++++++++++++------- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/guide/src/python-from-rust/calling-existing-code.md b/guide/src/python-from-rust/calling-existing-code.md index eb1cfc46bfb..09001929703 100644 --- a/guide/src/python-from-rust/calling-existing-code.md +++ b/guide/src/python-from-rust/calling-existing-code.md @@ -141,7 +141,7 @@ The macro **must** be invoked _before_ initializing Python. As an example, the below adds the module `foo` to the embedded interpreter: -```rust,no_run +```rust use pyo3::prelude::*; #[pymodule] diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 27d55262bbb..89bc4b035c6 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -545,12 +545,26 @@ fn module_initialization( #pyo3_path::impl_::trampoline::module_exec(module, #module_exec) } + // The full slots, used for the PyModExport initializaiton static SLOTS: impl_::PyModuleSlots = impl_::PyModuleSlotsBuilder::new() .with_mod_exec(__pyo3_module_exec) .with_gil_used(#gil_used) + .with_name(__PYO3_NAME) + .with_doc(#doc) .build(); - impl_::ModuleDef::new(__PYO3_NAME, #doc, &SLOTS) + // Used for old-style PyModuleDef initialization + // CPython doesn't allow specifying slots like the name and docstring that + // can be defined in PyModuleDef, so we skip those slots + static SLOTS_MINIMAL: impl_::PyModuleSlots = impl_::PyModuleSlotsBuilder::new() + .with_mod_exec(__pyo3_module_exec) + .with_gil_used(#gil_used) + .build(); + + // Since the macros need to be written agnostic to the Python version + // we need to explicitly pass the name and docstring for PyModuleDef + // initializaiton. + impl_::ModuleDef::new(__PYO3_NAME, #doc, &SLOTS, &SLOTS_MINIMAL) }; }; if !is_submodule { diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 71b03c00a35..d1ace58814f 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -44,7 +44,6 @@ use crate::{ffi_ptr_ext::FfiPtrExt, PyErr}; /// `Sync` wrapper of `ffi::PyModuleDef`. pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability - #[cfg(not(Py_3_15))] ffi_def: UnsafeCell, #[cfg(Py_3_15)] name: &'static CStr, @@ -71,8 +70,11 @@ impl ModuleDef { name: &'static CStr, doc: &'static CStr, slots: &'static PyModuleSlots, + slots_with_no_name_or_doc: &'static PyModuleSlots, ) -> Self { - #[cfg(not(Py_3_15))] + // This is only used in PyO3 for append_to_inittab on Python 3.15 and newer. + // There could also be other tools that need the legacy init hook. + // Opaque PyObject builds won't be able to use this. #[allow(clippy::declare_interior_mutable_const)] const INIT: ffi::PyModuleDef = ffi::PyModuleDef { m_base: ffi::PyModuleDef_HEAD_INIT, @@ -86,18 +88,16 @@ impl ModuleDef { m_free: None, }; - #[cfg(not(Py_3_15))] let ffi_def = UnsafeCell::new(ffi::PyModuleDef { m_name: name.as_ptr(), m_doc: doc.as_ptr(), // TODO: would be slightly nicer to use `[T]::as_mut_ptr()` here, // but that requires mut ptr deref on MSRV. - m_slots: slots.0.get() as _, + m_slots: slots_with_no_name_or_doc.0.get() as _, ..INIT }); ModuleDef { - #[cfg(not(Py_3_15))] ffi_def, #[cfg(Py_3_15)] name, @@ -117,15 +117,7 @@ impl ModuleDef { } pub fn init_multi_phase(&'static self) -> *mut ffi::PyObject { - // SAFETY: `ffi_def` is correctly initialized in `new()` - #[cfg(not(Py_3_15))] - unsafe { - ffi::PyModuleDef_Init(self.ffi_def.get()) - } - #[cfg(Py_3_15)] - unreachable!( - "Python shouldn't be calling an intialization function in Python 3.15 or newer" - ) + unsafe { ffi::PyModuleDef_Init(self.ffi_def.get()) } } /// Builds a module object directly. Used for [`#[pymodule]`][crate::pymodule] submodules. @@ -245,8 +237,8 @@ const MAX_SLOTS: usize = 2 + // Py_mod_gil cfg!(Py_3_13) as usize + - // Py_mod_name and Py_mod_abi - 2 * (cfg!(Py_3_15) as usize); + // Py_mod_name, Py_mod_doc, and Py_mod_abi + 3 * (cfg!(Py_3_15) as usize); /// Builder to create `PyModuleSlots`. The size of the number of slots desired must /// be known up front, and N needs to be at least one greater than the number of @@ -325,6 +317,18 @@ impl PyModuleSlotsBuilder { } } + pub const fn with_doc(self, doc: &'static CStr) -> Self { + #[cfg(Py_3_15)] + { + self.push(ffi::Py_mod_doc, doc.as_ptr() as *mut c_void) + } + + #[cfg(not(Py_3_15))] + { + self + } + } + pub const fn build(self) -> PyModuleSlots { // Required to guarantee there's still a zeroed element // at the end @@ -441,9 +445,16 @@ mod tests { .with_gil_used(false) .with_abi_info() .with_name(NAME) + .with_doc(DOC) + .build(); + + static SLOTS_MINIMAL: PyModuleSlots = PyModuleSlotsBuilder::new() + .with_mod_exec(module_exec) + .with_gil_used(false) + .with_abi_info() .build(); - static MODULE_DEF: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); + static MODULE_DEF: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS, &SLOTS_MINIMAL); Python::attach(|py| { let module = MODULE_DEF.make_module(py).unwrap().into_bound(py); @@ -483,7 +494,7 @@ mod tests { static SLOTS: PyModuleSlots = PyModuleSlotsBuilder::new().build(); - let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); + let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS, &SLOTS); #[cfg(not(Py_3_15))] unsafe { From a59087431c97a37f06499141c72efd746457e00c Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 27 Jan 2026 09:49:29 -0700 Subject: [PATCH 07/25] fix clippy --- src/impl_/pymodule.rs | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index d1ace58814f..825815f92f5 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -45,11 +45,10 @@ use crate::{ffi_ptr_ext::FfiPtrExt, PyErr}; pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability ffi_def: UnsafeCell, - #[cfg(Py_3_15)] + #[cfg_attr(not(Py_3_15), allow(dead_code))] name: &'static CStr, - #[cfg(Py_3_15)] + #[cfg_attr(not(Py_3_15), allow(dead_code))] doc: &'static CStr, - #[cfg(Py_3_15)] slots: &'static PyModuleSlots, /// Interpreter ID where module was initialized (not applicable on PyPy). #[cfg(all( @@ -99,11 +98,8 @@ impl ModuleDef { ModuleDef { ffi_def, - #[cfg(Py_3_15)] name, - #[cfg(Py_3_15)] doc, - #[cfg(Py_3_15)] slots, // -1 is never expected to be a valid interpreter ID #[cfg(all( @@ -218,14 +214,7 @@ impl ModuleDef { } } pub fn get_slots(&'static self) -> *mut ffi::PyModuleDef_Slot { - #[cfg(Py_3_15)] - { - self.slots.0.get() as *mut ffi::PyModuleDef_Slot - } - #[cfg(not(Py_3_15))] - { - unsafe { (*self.ffi_def.get()).m_slots } - } + self.slots.0.get() as *mut ffi::PyModuleDef_Slot } } @@ -325,6 +314,8 @@ impl PyModuleSlotsBuilder { #[cfg(not(Py_3_15))] { + // Silence unused variable warning + let _ = doc; self } } @@ -496,18 +487,14 @@ mod tests { let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS, &SLOTS); - #[cfg(not(Py_3_15))] unsafe { assert_eq!((*module_def.ffi_def.get()).m_name, NAME.as_ptr() as _); assert_eq!((*module_def.ffi_def.get()).m_doc, DOC.as_ptr() as _); assert_eq!((*module_def.ffi_def.get()).m_slots, SLOTS.0.get().cast()); } - #[cfg(Py_3_15)] - { - assert_eq!(module_def.name, NAME); - assert_eq!(module_def.doc, DOC); - assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); - } + assert_eq!(module_def.name, NAME); + assert_eq!(module_def.doc, DOC); + assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); } #[test] From a96219f707f6efbb400b40cf877c9ff22fb814ab Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 27 Jan 2026 10:31:03 -0700 Subject: [PATCH 08/25] add changelog entry --- newsfragments/5753.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 newsfragments/5753.changed.md diff --git a/newsfragments/5753.changed.md b/newsfragments/5753.changed.md new file mode 100644 index 00000000000..5f22cf42516 --- /dev/null +++ b/newsfragments/5753.changed.md @@ -0,0 +1 @@ +Module initialization uses the PyModExport and PyABIInfo APIs on python 3.15 and newer. \ No newline at end of file From e7ac9c08bafa587ee98cecd217db246172425f29 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Thu, 29 Jan 2026 08:22:30 -0700 Subject: [PATCH 09/25] try use only slots for both init hooks on 3.15 --- pyo3-macros-backend/src/module.rs | 10 +--------- src/impl_/pymodule.rs | 17 +++++------------ 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 89bc4b035c6..6dec3f05a56 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -553,18 +553,10 @@ fn module_initialization( .with_doc(#doc) .build(); - // Used for old-style PyModuleDef initialization - // CPython doesn't allow specifying slots like the name and docstring that - // can be defined in PyModuleDef, so we skip those slots - static SLOTS_MINIMAL: impl_::PyModuleSlots = impl_::PyModuleSlotsBuilder::new() - .with_mod_exec(__pyo3_module_exec) - .with_gil_used(#gil_used) - .build(); - // Since the macros need to be written agnostic to the Python version // we need to explicitly pass the name and docstring for PyModuleDef // initializaiton. - impl_::ModuleDef::new(__PYO3_NAME, #doc, &SLOTS, &SLOTS_MINIMAL) + impl_::ModuleDef::new(__PYO3_NAME, #doc, &SLOTS) }; }; if !is_submodule { diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 825815f92f5..585c889bd8f 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -69,7 +69,6 @@ impl ModuleDef { name: &'static CStr, doc: &'static CStr, slots: &'static PyModuleSlots, - slots_with_no_name_or_doc: &'static PyModuleSlots, ) -> Self { // This is only used in PyO3 for append_to_inittab on Python 3.15 and newer. // There could also be other tools that need the legacy init hook. @@ -88,11 +87,13 @@ impl ModuleDef { }; let ffi_def = UnsafeCell::new(ffi::PyModuleDef { + #[cfg(not(Py_3_15))] m_name: name.as_ptr(), + #[cfg(not(Py_3_15))] m_doc: doc.as_ptr(), // TODO: would be slightly nicer to use `[T]::as_mut_ptr()` here, // but that requires mut ptr deref on MSRV. - m_slots: slots_with_no_name_or_doc.0.get() as _, + m_slots: slots.0.get() as _, ..INIT }); @@ -439,13 +440,7 @@ mod tests { .with_doc(DOC) .build(); - static SLOTS_MINIMAL: PyModuleSlots = PyModuleSlotsBuilder::new() - .with_mod_exec(module_exec) - .with_gil_used(false) - .with_abi_info() - .build(); - - static MODULE_DEF: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS, &SLOTS_MINIMAL); + static MODULE_DEF: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); Python::attach(|py| { let module = MODULE_DEF.make_module(py).unwrap().into_bound(py); @@ -485,11 +480,9 @@ mod tests { static SLOTS: PyModuleSlots = PyModuleSlotsBuilder::new().build(); - let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS, &SLOTS); + let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); unsafe { - assert_eq!((*module_def.ffi_def.get()).m_name, NAME.as_ptr() as _); - assert_eq!((*module_def.ffi_def.get()).m_doc, DOC.as_ptr() as _); assert_eq!((*module_def.ffi_def.get()).m_slots, SLOTS.0.get().cast()); } assert_eq!(module_def.name, NAME); From d981be7e3846b2b561f5a82690b37fc4056e21ba Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 30 Jan 2026 08:07:49 -0700 Subject: [PATCH 10/25] Always pass m_name and m_doc, following cpython-gh-144340 --- src/impl_/pymodule.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 585c889bd8f..5e89f56f6f3 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -45,9 +45,7 @@ use crate::{ffi_ptr_ext::FfiPtrExt, PyErr}; pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability ffi_def: UnsafeCell, - #[cfg_attr(not(Py_3_15), allow(dead_code))] name: &'static CStr, - #[cfg_attr(not(Py_3_15), allow(dead_code))] doc: &'static CStr, slots: &'static PyModuleSlots, /// Interpreter ID where module was initialized (not applicable on PyPy). @@ -87,9 +85,7 @@ impl ModuleDef { }; let ffi_def = UnsafeCell::new(ffi::PyModuleDef { - #[cfg(not(Py_3_15))] m_name: name.as_ptr(), - #[cfg(not(Py_3_15))] m_doc: doc.as_ptr(), // TODO: would be slightly nicer to use `[T]::as_mut_ptr()` here, // but that requires mut ptr deref on MSRV. From 55b6acdf26e2bb5f13d98190d641355a87d4b49b Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 07:52:35 -0700 Subject: [PATCH 11/25] WIP: opaque pyobject support (without Py_GIL_DISABLED) --- noxfile.py | 5 +---- pyo3-build-config/src/impl_.rs | 32 ++++++++++++++++++++++++++++++-- pyo3-build-config/src/lib.rs | 1 + pyo3-ffi/src/moduleobject.rs | 1 + pyo3-ffi/src/object.rs | 27 +++++++++++++++++++++++++++ pyo3-ffi/src/refcount.rs | 3 ++- src/impl_/pymodule.rs | 22 ++++++++++++++++++---- 7 files changed, 80 insertions(+), 11 deletions(-) diff --git a/noxfile.py b/noxfile.py index 5004b75c2c4..2e1b57b7dcf 100644 --- a/noxfile.py +++ b/noxfile.py @@ -82,10 +82,7 @@ def _supported_interpreter_versions( PY_VERSIONS = _supported_interpreter_versions("cpython") -# We don't yet support abi3-py315 but do support cp315 and cp315t -# version-specific builds ABI3_PY_VERSIONS = [p for p in PY_VERSIONS if not p.endswith("t")] -ABI3_PY_VERSIONS.remove("3.15") PYPY_VERSIONS = _supported_interpreter_versions("pypy") @@ -124,7 +121,7 @@ def test_rust(session: nox.Session): # We need to pass the feature set to the test command # so that it can be used in the test code # (e.g. for `#[cfg(feature = "abi3-py37")]`) - if feature_set and "abi3" in feature_set and FREE_THREADED_BUILD: + if feature_set and "abi3" in feature_set and FREE_THREADED_BUILD and sys.version_info < (3, 15): # free-threaded builds don't support abi3 yet continue diff --git a/pyo3-build-config/src/impl_.rs b/pyo3-build-config/src/impl_.rs index 167013f5c42..cf724d41f14 100644 --- a/pyo3-build-config/src/impl_.rs +++ b/pyo3-build-config/src/impl_.rs @@ -40,7 +40,7 @@ const MINIMUM_SUPPORTED_VERSION_GRAALPY: PythonVersion = PythonVersion { }; /// Maximum Python version that can be used as minimum required Python version with abi3. -pub(crate) const ABI3_MAX_MINOR: u8 = 14; +pub(crate) const ABI3_MAX_MINOR: u8 = 15; #[cfg(test)] thread_local! { @@ -190,8 +190,11 @@ impl InterpreterConfig { } // If Py_GIL_DISABLED is set, do not build with limited API support - if self.abi3 && !self.is_free_threaded() { + if self.abi3 && !(self.is_free_threaded()) { out.push("cargo:rustc-cfg=Py_LIMITED_API".to_owned()); + if self.version.minor >= 15 { + out.push("cargo:rustc-cfg=_Py_OPAQUE_PYOBJECT".to_owned()); + } } for flag in &self.build_flags.0 { @@ -3203,6 +3206,31 @@ mod tests { "cargo:rustc-cfg=Py_LIMITED_API".to_owned(), ] ); + + let interpreter_config = InterpreterConfig { + implementation: PythonImplementation::CPython, + version: PythonVersion { + major: 3, + minor: 15, + }, + ..interpreter_config + }; + assert_eq!( + interpreter_config.build_script_outputs(), + [ + "cargo:rustc-cfg=Py_3_7".to_owned(), + "cargo:rustc-cfg=Py_3_8".to_owned(), + "cargo:rustc-cfg=Py_3_9".to_owned(), + "cargo:rustc-cfg=Py_3_10".to_owned(), + "cargo:rustc-cfg=Py_3_11".to_owned(), + "cargo:rustc-cfg=Py_3_12".to_owned(), + "cargo:rustc-cfg=Py_3_13".to_owned(), + "cargo:rustc-cfg=Py_3_14".to_owned(), + "cargo:rustc-cfg=Py_3_15".to_owned(), + "cargo:rustc-cfg=Py_LIMITED_API".to_owned(), + "cargo:rustc-cfg=_Py_OPAQUE_PYOBJECT".to_owned(), + ] + ); } #[test] diff --git a/pyo3-build-config/src/lib.rs b/pyo3-build-config/src/lib.rs index 156ecd309f6..ff2e77d374b 100644 --- a/pyo3-build-config/src/lib.rs +++ b/pyo3-build-config/src/lib.rs @@ -254,6 +254,7 @@ pub fn print_expected_cfgs() { println!("cargo:rustc-check-cfg=cfg(Py_LIMITED_API)"); println!("cargo:rustc-check-cfg=cfg(Py_GIL_DISABLED)"); + println!("cargo:rustc-check-cfg=cfg(_Py_OPAQUE_PYOBJECT)"); println!("cargo:rustc-check-cfg=cfg(PyPy)"); println!("cargo:rustc-check-cfg=cfg(GraalPy)"); println!("cargo:rustc-check-cfg=cfg(py_sys_config, values(\"Py_DEBUG\", \"Py_REF_DEBUG\", \"Py_TRACE_REFS\", \"COUNT_ALLOCS\"))"); diff --git a/pyo3-ffi/src/moduleobject.rs b/pyo3-ffi/src/moduleobject.rs index ace202d969e..6c8d8272e6d 100644 --- a/pyo3-ffi/src/moduleobject.rs +++ b/pyo3-ffi/src/moduleobject.rs @@ -61,6 +61,7 @@ pub struct PyModuleDef_Base { pub m_copy: *mut PyObject, } +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[allow( clippy::declare_interior_mutable_const, reason = "contains atomic refcount on free-threaded builds" diff --git a/pyo3-ffi/src/object.rs b/pyo3-ffi/src/object.rs index ddeabb9be3f..3b060fb0be8 100644 --- a/pyo3-ffi/src/object.rs +++ b/pyo3-ffi/src/object.rs @@ -1,4 +1,5 @@ use crate::pyport::{Py_hash_t, Py_ssize_t}; +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[cfg(Py_GIL_DISABLED)] use crate::refcount; #[cfg(Py_GIL_DISABLED)] @@ -6,6 +7,7 @@ use crate::PyMutex; use std::ffi::{c_char, c_int, c_uint, c_ulong, c_void}; use std::mem; use std::ptr; +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[cfg(Py_GIL_DISABLED)] use std::sync::atomic::{AtomicIsize, AtomicU32}; @@ -92,6 +94,7 @@ const _PyObject_MIN_ALIGNMENT: usize = 4; // not currently possible to use constant variables with repr(align()), see // https://github.com/rust-lang/rust/issues/52840 +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[cfg_attr(not(all(Py_3_15, Py_GIL_DISABLED)), repr(C))] #[cfg_attr(all(Py_3_15, Py_GIL_DISABLED), repr(C, align(4)))] #[derive(Debug)] @@ -121,8 +124,10 @@ pub struct PyObject { pub ob_type: *mut PyTypeObject, } +#[cfg(not(_Py_OPAQUE_PYOBJECT))] const _: () = assert!(std::mem::align_of::() >= _PyObject_MIN_ALIGNMENT); +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[allow( clippy::declare_interior_mutable_const, reason = "contains atomic refcount on free-threaded builds" @@ -157,10 +162,14 @@ pub const PyObject_HEAD_INIT: PyObject = PyObject { ob_type: std::ptr::null_mut(), }; +#[cfg(_Py_OPAQUE_PYOBJECT)] +opaque_struct!(pub PyObject); + // skipped _Py_UNOWNED_TID // skipped _PyObject_CAST +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[repr(C)] #[derive(Debug)] pub struct PyVarObject { @@ -172,6 +181,9 @@ pub struct PyVarObject { pub _ob_size_graalpy: Py_ssize_t, } +#[cfg(_Py_OPAQUE_PYOBJECT)] +opaque_struct!(pub PyVarObject); + // skipped private _PyVarObject_CAST #[inline] @@ -219,6 +231,16 @@ extern "C" { pub fn Py_TYPE(ob: *mut PyObject) -> *mut PyTypeObject; } +#[cfg_attr(windows, link(name = "pythonXY"))] +#[cfg(all(Py_LIMITED_API, Py_3_15))] +extern "C" { + #[cfg_attr(PyPy, link_name = "PyPy_SIZE")] + pub fn Py_SIZE(ob: *mut PyObject) -> Py_ssize_t; + #[cfg_attr(PyPy, link_name = "PyPy_IS_TYPE")] + pub fn Py_IS_TYPE(ob: *mut PyObject, tp: *mut PyTypeObject) -> c_int; + // skipped Py_SET_SIZE +} + // skip _Py_TYPE compat shim #[cfg_attr(windows, link(name = "pythonXY"))] @@ -229,6 +251,7 @@ extern "C" { pub static mut PyBool_Type: PyTypeObject; } +#[cfg(not(all(Py_LIMITED_API, Py_3_15)))] #[inline] pub unsafe fn Py_SIZE(ob: *mut PyObject) -> Py_ssize_t { #[cfg(not(GraalPy))] @@ -241,6 +264,7 @@ pub unsafe fn Py_SIZE(ob: *mut PyObject) -> Py_ssize_t { _Py_SIZE(ob) } +#[cfg(not(all(Py_LIMITED_API, Py_3_15)))] #[inline] pub unsafe fn Py_IS_TYPE(ob: *mut PyObject, tp: *mut PyTypeObject) -> c_int { (Py_TYPE(ob) == tp) as c_int @@ -390,6 +414,9 @@ extern "C" { #[inline] pub unsafe fn PyObject_TypeCheck(ob: *mut PyObject, tp: *mut PyTypeObject) -> c_int { + dbg!(ob); + dbg!(Py_TYPE(ob)); + dbg!(tp); (Py_IS_TYPE(ob, tp) != 0 || PyType_IsSubtype(Py_TYPE(ob), tp) != 0) as c_int } diff --git a/pyo3-ffi/src/refcount.rs b/pyo3-ffi/src/refcount.rs index 745eaa69a97..f155e6c4d23 100644 --- a/pyo3-ffi/src/refcount.rs +++ b/pyo3-ffi/src/refcount.rs @@ -11,7 +11,7 @@ use std::ffi::c_uint; #[cfg(all(Py_3_14, not(Py_GIL_DISABLED)))] use std::ffi::c_ulong; use std::ptr; -#[cfg(Py_GIL_DISABLED)] +#[cfg(all(Py_GIL_DISABLED, not(Py_LIMITED_API)))] use std::sync::atomic::Ordering::Relaxed; #[cfg(all(Py_3_14, not(Py_3_15)))] @@ -116,6 +116,7 @@ pub unsafe fn Py_REFCNT(ob: *mut PyObject) -> Py_ssize_t { } } +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[cfg(Py_3_12)] #[inline(always)] unsafe fn _Py_IsImmortal(op: *mut PyObject) -> c_int { diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 5e89f56f6f3..32cc6d76606 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -35,15 +35,20 @@ use crate::prelude::PyTypeMethods; use crate::{ ffi, impl_::pyfunction::PyFunctionDef, - sync::PyOnceLock, - types::{any::PyAnyMethods, dict::PyDictMethods, PyDict, PyModule, PyModuleMethods}, - Bound, Py, PyAny, PyClass, PyResult, PyTypeInfo, Python, + types::{PyModule, PyModuleMethods}, + Bound, PyClass, PyResult, PyTypeInfo, }; use crate::{ffi_ptr_ext::FfiPtrExt, PyErr}; +use crate::{ + sync::PyOnceLock, + types::{any::PyAnyMethods, dict::PyDictMethods, PyDict}, + Py, PyAny, Python, +}; /// `Sync` wrapper of `ffi::PyModuleDef`. pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability + #[cfg(not(_Py_OPAQUE_PYOBJECT))] ffi_def: UnsafeCell, name: &'static CStr, doc: &'static CStr, @@ -71,6 +76,7 @@ impl ModuleDef { // This is only used in PyO3 for append_to_inittab on Python 3.15 and newer. // There could also be other tools that need the legacy init hook. // Opaque PyObject builds won't be able to use this. + #[cfg(not(_Py_OPAQUE_PYOBJECT))] #[allow(clippy::declare_interior_mutable_const)] const INIT: ffi::PyModuleDef = ffi::PyModuleDef { m_base: ffi::PyModuleDef_HEAD_INIT, @@ -84,6 +90,7 @@ impl ModuleDef { m_free: None, }; + #[cfg(not(_Py_OPAQUE_PYOBJECT))] let ffi_def = UnsafeCell::new(ffi::PyModuleDef { m_name: name.as_ptr(), m_doc: doc.as_ptr(), @@ -94,6 +101,7 @@ impl ModuleDef { }); ModuleDef { + #[cfg(not(_Py_OPAQUE_PYOBJECT))] ffi_def, name, doc, @@ -110,7 +118,12 @@ impl ModuleDef { } pub fn init_multi_phase(&'static self) -> *mut ffi::PyObject { - unsafe { ffi::PyModuleDef_Init(self.ffi_def.get()) } + #[cfg(not(_Py_OPAQUE_PYOBJECT))] + unsafe { + ffi::PyModuleDef_Init(self.ffi_def.get()) + } + #[cfg(_Py_OPAQUE_PYOBJECT)] + panic!("TODO: fix this panic"); } /// Builds a module object directly. Used for [`#[pymodule]`][crate::pymodule] submodules. @@ -478,6 +491,7 @@ mod tests { let module_def: ModuleDef = ModuleDef::new(NAME, DOC, &SLOTS); + #[cfg(not(_Py_OPAQUE_PYOBJECT))] unsafe { assert_eq!((*module_def.ffi_def.get()).m_slots, SLOTS.0.get().cast()); } From 733aa824fe1b6ce5fdc58981b5decab0d6f22004 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 10:23:04 -0700 Subject: [PATCH 12/25] delete debug prints --- pyo3-ffi/src/object.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyo3-ffi/src/object.rs b/pyo3-ffi/src/object.rs index 3b060fb0be8..2c8c23d9f9e 100644 --- a/pyo3-ffi/src/object.rs +++ b/pyo3-ffi/src/object.rs @@ -414,9 +414,6 @@ extern "C" { #[inline] pub unsafe fn PyObject_TypeCheck(ob: *mut PyObject, tp: *mut PyTypeObject) -> c_int { - dbg!(ob); - dbg!(Py_TYPE(ob)); - dbg!(tp); (Py_IS_TYPE(ob, tp) != 0 || PyType_IsSubtype(Py_TYPE(ob), tp) != 0) as c_int } From c43061e290b9bd140dd32f9b589adbf88fd5a689 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 11:02:36 -0700 Subject: [PATCH 13/25] WIP: fix segfault --- src/impl_/pyclass.rs | 27 +++++++++++++++------------ src/pycell.rs | 14 ++++++++++---- src/pycell/impl_.rs | 20 ++++++++++---------- src/types/any.rs | 13 +++++++++++-- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/impl_/pyclass.rs b/src/impl_/pyclass.rs index 396c079ac0f..d949d4b0a6d 100644 --- a/src/impl_/pyclass.rs +++ b/src/impl_/pyclass.rs @@ -1496,7 +1496,7 @@ mod tests { (offset_of!(ExpectedLayout, contents) + offset_of!(FrozenClass, value)) as ffi::Py_ssize_t ); - assert_eq!(member.flags, ffi::Py_READONLY); + assert_eq!(member.flags & ffi::Py_READONLY, ffi::Py_READONLY); } _ => panic!("Expected a StructMember"), } @@ -1608,17 +1608,20 @@ mod tests { // SAFETY: def.doc originated from a CStr assert_eq!(unsafe { CStr::from_ptr(def.doc) }, c"My field doc"); assert_eq!(def.type_code, ffi::Py_T_OBJECT_EX); - #[allow(irrefutable_let_patterns)] - let PyObjectOffset::Absolute(contents_offset) = - ::Layout::CONTENTS_OFFSET - else { - panic!() - }; - assert_eq!( - def.offset, - contents_offset + FIELD_OFFSET as ffi::Py_ssize_t - ); - assert_eq!(def.flags, ffi::Py_READONLY); + #[cfg(not(_Py_OPAQUE_PYOBJECT))] + { + #[allow(irrefutable_let_patterns)] + let PyObjectOffset::Absolute(contents_offset) = + ::Layout::CONTENTS_OFFSET + else { + panic!() + }; + assert_eq!( + def.offset, + contents_offset + FIELD_OFFSET as ffi::Py_ssize_t + ); + } + assert_eq!(def.flags & ffi::Py_READONLY, ffi::Py_READONLY); } #[test] diff --git a/src/pycell.rs b/src/pycell.rs index 80c922114b4..f674dd9a425 100644 --- a/src/pycell.rs +++ b/src/pycell.rs @@ -830,10 +830,16 @@ mod tests { Python::attach(|py| { let obj = SubSubClass::new(py).into_bound(py); let pyref = obj.borrow(); - assert_eq!(pyref.as_super().as_super().val1, 10); - assert_eq!(pyref.as_super().val2, 15); - assert_eq!(pyref.as_ref().val2, 15); // `as_ref` also works - assert_eq!(pyref.val3, 20); + dbg!(&pyref.inner); + dbg!(&pyref.as_super().inner); + dbg!(std::any::type_name_of_val(&pyref.inner.get_class_object())); + dbg!(std::any::type_name_of_val( + &pyref.as_super().inner.get_class_object() + )); + // assert_eq!(pyref.as_super().as_super().val1, 10); + // assert_eq!(pyref.as_super().val2, 15); + // assert_eq!(pyref.as_ref().val2, 15); // `as_ref` also works + // assert_eq!(pyref.val3, 20); assert_eq!(SubSubClass::get_values(pyref), (10, 15, 20)); }); } diff --git a/src/pycell/impl_.rs b/src/pycell/impl_.rs index 276eaafc600..727499e6004 100644 --- a/src/pycell/impl_.rs +++ b/src/pycell/impl_.rs @@ -634,16 +634,16 @@ mod tests { #[pyclass(crate = "crate", extends = BaseWithData)] struct ChildWithoutData; - #[test] - fn test_inherited_size() { - let base_size = PyStaticClassObject::::BASIC_SIZE; - assert!(base_size > 0); // negative indicates variable sized - assert_eq!( - base_size, - PyStaticClassObject::::BASIC_SIZE - ); - assert!(base_size < PyStaticClassObject::::BASIC_SIZE); - } + // #[test] + // fn test_inherited_size() { + // let base_size = PyStaticClassObject::::BASIC_SIZE; + // assert!(base_size > 0); // negative indicates variable sized + // assert_eq!( + // base_size, + // PyStaticClassObject::::BASIC_SIZE + // ); + // assert!(base_size < PyStaticClassObject::::BASIC_SIZE); + // } fn assert_mutable>() {} fn assert_immutable>() {} diff --git a/src/types/any.rs b/src/types/any.rs index b1691960a78..2302ce19c75 100644 --- a/src/types/any.rs +++ b/src/types/any.rs @@ -4,7 +4,10 @@ use crate::conversion::{FromPyObject, IntoPyObject}; use crate::err::{PyErr, PyResult}; use crate::exceptions::{PyAttributeError, PyTypeError}; use crate::ffi_ptr_ext::FfiPtrExt; -use crate::impl_::pycell::PyStaticClassObject; +#[cfg(not(_Py_OPAQUE_PYOBJECT))] +use crate::impl_::pycell::{PyClassObjectBase, PyStaticClassObject}; +#[cfg(_Py_OPAQUE_PYOBJECT)] +use crate::impl_::pycell::{PyVariableClassObject, PyVariableClassObjectBase}; use crate::instance::Bound; use crate::internal::get_slot::TP_DESCR_GET; use crate::py_result_ext::PyResultExt; @@ -53,10 +56,16 @@ pyobject_native_type_info!( pyobject_native_type_sized!(PyAny, ffi::PyObject); // We cannot use `pyobject_subclassable_native_type!()` because it cfgs out on `Py_LIMITED_API`. impl crate::impl_::pyclass::PyClassBaseType for PyAny { - type LayoutAsBase = crate::impl_::pycell::PyClassObjectBase; + #[cfg(_Py_OPAQUE_PYOBJECT)] + type LayoutAsBase = PyVariableClassObjectBase; + #[cfg(not(_Py_OPAQUE_PYOBJECT))] + type LayoutAsBase = PyClassObjectBase; type BaseNativeType = PyAny; type Initializer = crate::impl_::pyclass_init::PyNativeTypeInitializer; type PyClassMutability = crate::pycell::impl_::ImmutableClass; + #[cfg(_Py_OPAQUE_PYOBJECT)] + type Layout = PyVariableClassObject; + #[cfg(not(_Py_OPAQUE_PYOBJECT))] type Layout = PyStaticClassObject; } From 3812a644048c3a2f49e0e1853c68bb316832b77a Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 12:00:23 -0700 Subject: [PATCH 14/25] disable append_to_inittab tests --- guide/src/python-from-rust/calling-existing-code.md | 1 + tests/test_append_to_inittab.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/guide/src/python-from-rust/calling-existing-code.md b/guide/src/python-from-rust/calling-existing-code.md index 09001929703..efbcabc0198 100644 --- a/guide/src/python-from-rust/calling-existing-code.md +++ b/guide/src/python-from-rust/calling-existing-code.md @@ -154,6 +154,7 @@ mod foo { } } +# #[cfg(not(_Py_OPAQUE_PYOBJECT))] fn main() -> PyResult<()> { pyo3::append_to_inittab!(foo); Python::attach(|py| Python::run(py, c"import foo; foo.add_one(6)", None, None)) diff --git a/tests/test_append_to_inittab.rs b/tests/test_append_to_inittab.rs index e147967a0c7..ba28a6fde68 100644 --- a/tests/test_append_to_inittab.rs +++ b/tests/test_append_to_inittab.rs @@ -19,7 +19,7 @@ mod module_mod_with_functions { use super::foo; } -#[cfg(not(any(PyPy, GraalPy)))] +#[cfg(not(any(PyPy, GraalPy, _Py_OPAQUE_PYOBJECT)))] #[test] fn test_module_append_to_inittab() { use pyo3::append_to_inittab; From 25a65a6e5ddc735c78f7c000d58fd51c841a8f61 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 13:10:42 -0700 Subject: [PATCH 15/25] fix clippy --- src/pycell.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/pycell.rs b/src/pycell.rs index f674dd9a425..80c922114b4 100644 --- a/src/pycell.rs +++ b/src/pycell.rs @@ -830,16 +830,10 @@ mod tests { Python::attach(|py| { let obj = SubSubClass::new(py).into_bound(py); let pyref = obj.borrow(); - dbg!(&pyref.inner); - dbg!(&pyref.as_super().inner); - dbg!(std::any::type_name_of_val(&pyref.inner.get_class_object())); - dbg!(std::any::type_name_of_val( - &pyref.as_super().inner.get_class_object() - )); - // assert_eq!(pyref.as_super().as_super().val1, 10); - // assert_eq!(pyref.as_super().val2, 15); - // assert_eq!(pyref.as_ref().val2, 15); // `as_ref` also works - // assert_eq!(pyref.val3, 20); + assert_eq!(pyref.as_super().as_super().val1, 10); + assert_eq!(pyref.as_super().val2, 15); + assert_eq!(pyref.as_ref().val2, 15); // `as_ref` also works + assert_eq!(pyref.val3, 20); assert_eq!(SubSubClass::get_values(pyref), (10, 15, 20)); }); } From 4a83024bae4dc5531545476afd19bcbb4c085ef4 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 13:11:54 -0700 Subject: [PATCH 16/25] fix ruff --- noxfile.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 2e1b57b7dcf..a75ee7d70bb 100644 --- a/noxfile.py +++ b/noxfile.py @@ -121,7 +121,12 @@ def test_rust(session: nox.Session): # We need to pass the feature set to the test command # so that it can be used in the test code # (e.g. for `#[cfg(feature = "abi3-py37")]`) - if feature_set and "abi3" in feature_set and FREE_THREADED_BUILD and sys.version_info < (3, 15): + if ( + feature_set + and "abi3" in feature_set + and FREE_THREADED_BUILD + and sys.version_info < (3, 15) + ): # free-threaded builds don't support abi3 yet continue From 9d0e2edf7bdc28ce2739e5d8bd867e21f925df2b Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 13:19:47 -0700 Subject: [PATCH 17/25] implement David's suggestion for pyobject_subclassable_native_type --- src/types/any.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/types/any.rs b/src/types/any.rs index 2302ce19c75..84ac173595a 100644 --- a/src/types/any.rs +++ b/src/types/any.rs @@ -4,10 +4,8 @@ use crate::conversion::{FromPyObject, IntoPyObject}; use crate::err::{PyErr, PyResult}; use crate::exceptions::{PyAttributeError, PyTypeError}; use crate::ffi_ptr_ext::FfiPtrExt; -#[cfg(not(_Py_OPAQUE_PYOBJECT))] +#[cfg(not(all(Py_3_12, Py_LIMITED_API)))] use crate::impl_::pycell::{PyClassObjectBase, PyStaticClassObject}; -#[cfg(_Py_OPAQUE_PYOBJECT)] -use crate::impl_::pycell::{PyVariableClassObject, PyVariableClassObjectBase}; use crate::instance::Bound; use crate::internal::get_slot::TP_DESCR_GET; use crate::py_result_ext::PyResultExt; @@ -54,21 +52,19 @@ pyobject_native_type_info!( ); pyobject_native_type_sized!(PyAny, ffi::PyObject); -// We cannot use `pyobject_subclassable_native_type!()` because it cfgs out on `Py_LIMITED_API`. +// We cannot use `pyobject_subclassable_native_type!()` because it cfgs out on `Py_LIMITED_API` for Python < 3.12. +#[cfg(not(all(Py_3_12, Py_LIMITED_API)))] impl crate::impl_::pyclass::PyClassBaseType for PyAny { - #[cfg(_Py_OPAQUE_PYOBJECT)] - type LayoutAsBase = PyVariableClassObjectBase; - #[cfg(not(_Py_OPAQUE_PYOBJECT))] type LayoutAsBase = PyClassObjectBase; type BaseNativeType = PyAny; type Initializer = crate::impl_::pyclass_init::PyNativeTypeInitializer; type PyClassMutability = crate::pycell::impl_::ImmutableClass; - #[cfg(_Py_OPAQUE_PYOBJECT)] - type Layout = PyVariableClassObject; - #[cfg(not(_Py_OPAQUE_PYOBJECT))] type Layout = PyStaticClassObject; } +#[cfg(all(Py_3_12, Py_LIMITED_API))] +pyobject_subclassable_native_type!(PyAny, ffi::PyObject); + /// This trait represents the Python APIs which are usable on all Python objects. /// /// It is recommended you import this trait via `use pyo3::prelude::*` rather than From a78b5dfc2164452fcbdbef1532b16f663c88a0a1 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 13:45:58 -0700 Subject: [PATCH 18/25] replace skipped test with real test --- src/impl_/pyclass.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/impl_/pyclass.rs b/src/impl_/pyclass.rs index d949d4b0a6d..1587617f16c 100644 --- a/src/impl_/pyclass.rs +++ b/src/impl_/pyclass.rs @@ -1608,19 +1608,15 @@ mod tests { // SAFETY: def.doc originated from a CStr assert_eq!(unsafe { CStr::from_ptr(def.doc) }, c"My field doc"); assert_eq!(def.type_code, ffi::Py_T_OBJECT_EX); - #[cfg(not(_Py_OPAQUE_PYOBJECT))] - { - #[allow(irrefutable_let_patterns)] - let PyObjectOffset::Absolute(contents_offset) = - ::Layout::CONTENTS_OFFSET - else { - panic!() - }; - assert_eq!( - def.offset, - contents_offset + FIELD_OFFSET as ffi::Py_ssize_t - ); - } + #[allow(irrefutable_let_patterns)] + let contents_offset = match ::Layout::CONTENTS_OFFSET { + PyObjectOffset::Absolute(contents_offset) => contents_offset, + PyObjectOffset::Relative(contents_offset) => contents_offset, + }; + assert_eq!( + def.offset, + contents_offset + FIELD_OFFSET as ffi::Py_ssize_t + ); assert_eq!(def.flags & ffi::Py_READONLY, ffi::Py_READONLY); } From 42a73e1aed4ff4065e81351f15c661a2e878bd91 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 13:55:53 -0700 Subject: [PATCH 19/25] fix check-feature-powerset --- Cargo.toml | 3 ++- pyo3-build-config/Cargo.toml | 3 ++- pyo3-ffi/Cargo.toml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7cffab03b9b..7c23b80387b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -116,7 +116,8 @@ abi3-py310 = ["abi3-py311", "pyo3-build-config/abi3-py310", "pyo3-ffi/abi3-py310 abi3-py311 = ["abi3-py312", "pyo3-build-config/abi3-py311", "pyo3-ffi/abi3-py311"] abi3-py312 = ["abi3-py313", "pyo3-build-config/abi3-py312", "pyo3-ffi/abi3-py312"] abi3-py313 = ["abi3-py314", "pyo3-build-config/abi3-py313", "pyo3-ffi/abi3-py313"] -abi3-py314 = ["abi3", "pyo3-build-config/abi3-py314", "pyo3-ffi/abi3-py314"] +abi3-py314 = ["abi3-py315", "pyo3-build-config/abi3-py314", "pyo3-ffi/abi3-py314"] +abi3-py315 = ["abi3", "pyo3-build-config/abi3-py315", "pyo3-ffi/abi3-py315"] # Automatically generates `python3.dll` import libraries for Windows targets. generate-import-lib = ["pyo3-ffi/generate-import-lib"] diff --git a/pyo3-build-config/Cargo.toml b/pyo3-build-config/Cargo.toml index 82a70b008b9..5da31a0b93f 100644 --- a/pyo3-build-config/Cargo.toml +++ b/pyo3-build-config/Cargo.toml @@ -41,7 +41,8 @@ abi3-py310 = ["abi3-py311"] abi3-py311 = ["abi3-py312"] abi3-py312 = ["abi3-py313"] abi3-py313 = ["abi3-py314"] -abi3-py314 = ["abi3"] +abi3-py314 = ["abi3-py315"] +abi3-py315 = ["abi3"] [package.metadata.docs.rs] features = ["resolve-config"] diff --git a/pyo3-ffi/Cargo.toml b/pyo3-ffi/Cargo.toml index d64a7dadda0..a4989095daa 100644 --- a/pyo3-ffi/Cargo.toml +++ b/pyo3-ffi/Cargo.toml @@ -33,7 +33,8 @@ abi3-py310 = ["abi3-py311", "pyo3-build-config/abi3-py310"] abi3-py311 = ["abi3-py312", "pyo3-build-config/abi3-py311"] abi3-py312 = ["abi3-py313", "pyo3-build-config/abi3-py312"] abi3-py313 = ["abi3-py314", "pyo3-build-config/abi3-py313"] -abi3-py314 = ["abi3", "pyo3-build-config/abi3-py314"] +abi3-py314 = ["abi3-py315", "pyo3-build-config/abi3-py314"] +abi3-py315 = ["abi3", "pyo3-build-config/abi3-py315"] # Automatically generates `python3.dll` import libraries for Windows targets. generate-import-lib = ["pyo3-build-config/generate-import-lib"] From 060c3ca275a5ea36c81673b526efd3970b0a46b6 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 14:22:38 -0700 Subject: [PATCH 20/25] fix clippy-all --- src/impl_/pyclass.rs | 3 ++- src/impl_/pymodule.rs | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/impl_/pyclass.rs b/src/impl_/pyclass.rs index 1587617f16c..ef5f62ec19d 100644 --- a/src/impl_/pyclass.rs +++ b/src/impl_/pyclass.rs @@ -1608,9 +1608,10 @@ mod tests { // SAFETY: def.doc originated from a CStr assert_eq!(unsafe { CStr::from_ptr(def.doc) }, c"My field doc"); assert_eq!(def.type_code, ffi::Py_T_OBJECT_EX); - #[allow(irrefutable_let_patterns)] + #[allow(clippy::infallible_destructuring_match)] let contents_offset = match ::Layout::CONTENTS_OFFSET { PyObjectOffset::Absolute(contents_offset) => contents_offset, + #[cfg(Py_3_12)] PyObjectOffset::Relative(contents_offset) => contents_offset, }; assert_eq!( diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 32cc6d76606..87c196d5a80 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -50,7 +50,9 @@ pub struct ModuleDef { // wrapped in UnsafeCell so that Rust compiler treats this as interior mutability #[cfg(not(_Py_OPAQUE_PYOBJECT))] ffi_def: UnsafeCell, + #[cfg(Py_3_15)] name: &'static CStr, + #[cfg(Py_3_15)] doc: &'static CStr, slots: &'static PyModuleSlots, /// Interpreter ID where module was initialized (not applicable on PyPy). @@ -103,7 +105,9 @@ impl ModuleDef { ModuleDef { #[cfg(not(_Py_OPAQUE_PYOBJECT))] ffi_def, + #[cfg(Py_3_15)] name, + #[cfg(Py_3_15)] doc, slots, // -1 is never expected to be a valid interpreter ID @@ -495,7 +499,9 @@ mod tests { unsafe { assert_eq!((*module_def.ffi_def.get()).m_slots, SLOTS.0.get().cast()); } + #[cfg(Py_3_15)] assert_eq!(module_def.name, NAME); + #[cfg(Py_3_15)] assert_eq!(module_def.doc, DOC); assert_eq!(module_def.slots.0.get(), SLOTS.0.get()); } From c1bd2c781ab3327ca67a485bf67fdab6005eef30 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Fri, 13 Feb 2026 14:58:20 -0700 Subject: [PATCH 21/25] skip test that depend on struct layout on opaque pyobject builds --- src/impl_/pyclass.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/impl_/pyclass.rs b/src/impl_/pyclass.rs index ef5f62ec19d..fd19c1c453b 100644 --- a/src/impl_/pyclass.rs +++ b/src/impl_/pyclass.rs @@ -1458,6 +1458,7 @@ pub trait ExtractPyClassWithClone {} #[cfg(test)] #[cfg(feature = "macros")] mod tests { + #[cfg(not(_Py_OPAQUE_PYOBJECT))] use crate::pycell::impl_::PyClassObjectContents; use super::*; @@ -1486,11 +1487,13 @@ mod tests { Some(PyMethodDefType::StructMember(member)) => { assert_eq!(unsafe { CStr::from_ptr(member.name) }, c"value"); assert_eq!(member.type_code, ffi::Py_T_OBJECT_EX); + #[cfg(not(_Py_OPAQUE_PYOBJECT))] #[repr(C)] struct ExpectedLayout { ob_base: ffi::PyObject, contents: PyClassObjectContents, } + #[cfg(not(_Py_OPAQUE_PYOBJECT))] assert_eq!( member.offset, (offset_of!(ExpectedLayout, contents) + offset_of!(FrozenClass, value)) From ba8b09a2eab4de8aa166e8a24f9558b0165ead47 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 16 Feb 2026 11:49:05 -0700 Subject: [PATCH 22/25] Expose PyModuleDef as an opaque pointer on opaque PyObject builds --- pyo3-ffi/src/moduleobject.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyo3-ffi/src/moduleobject.rs b/pyo3-ffi/src/moduleobject.rs index 6c8d8272e6d..88b2d3b5f29 100644 --- a/pyo3-ffi/src/moduleobject.rs +++ b/pyo3-ffi/src/moduleobject.rs @@ -1,3 +1,4 @@ +#[cfg(not(_Py_OPAQUE_PYOBJECT))] use crate::methodobject::PyMethodDef; use crate::object::*; use crate::pyport::Py_ssize_t; @@ -52,6 +53,7 @@ extern "C" { pub static mut PyModuleDef_Type: PyTypeObject; } +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[repr(C)] pub struct PyModuleDef_Base { pub ob_base: PyObject, @@ -152,6 +154,7 @@ extern "C" { pub fn PyModule_GetToken(module: *mut PyObject, result: *mut *mut c_void) -> c_int; } +#[cfg(not(_Py_OPAQUE_PYOBJECT))] #[repr(C)] pub struct PyModuleDef { pub m_base: PyModuleDef_Base, @@ -165,3 +168,7 @@ pub struct PyModuleDef { pub m_clear: Option, pub m_free: Option, } + +// from pytypedefs.h +#[cfg(_Py_OPAQUE_PYOBJECT)] +opaque_struct!(pub PyModuleDef); From f15a7fc41f2d85eff9adddd58968656e1caf3657 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 16 Feb 2026 11:49:24 -0700 Subject: [PATCH 23/25] add comments about location of opaque pointers in CPython headers --- pyo3-ffi/src/object.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyo3-ffi/src/object.rs b/pyo3-ffi/src/object.rs index 2c8c23d9f9e..613cc9efad1 100644 --- a/pyo3-ffi/src/object.rs +++ b/pyo3-ffi/src/object.rs @@ -11,6 +11,7 @@ use std::ptr; #[cfg(Py_GIL_DISABLED)] use std::sync::atomic::{AtomicIsize, AtomicU32}; +// from pytypedefs.h #[cfg(Py_LIMITED_API)] opaque_struct!(pub PyTypeObject); @@ -162,6 +163,7 @@ pub const PyObject_HEAD_INIT: PyObject = PyObject { ob_type: std::ptr::null_mut(), }; +// from pytypedefs.h #[cfg(_Py_OPAQUE_PYOBJECT)] opaque_struct!(pub PyObject); @@ -181,6 +183,7 @@ pub struct PyVarObject { pub _ob_size_graalpy: Py_ssize_t, } +// from pytypedefs.h #[cfg(_Py_OPAQUE_PYOBJECT)] opaque_struct!(pub PyVarObject); From 3fa17d00c27369d35a55ae4704f263c018c3ddf4 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Mon, 16 Feb 2026 12:10:48 -0700 Subject: [PATCH 24/25] fix test_inherited_size --- src/pycell/impl_.rs | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/pycell/impl_.rs b/src/pycell/impl_.rs index 727499e6004..36080aafcb8 100644 --- a/src/pycell/impl_.rs +++ b/src/pycell/impl_.rs @@ -625,6 +625,9 @@ mod tests { #[pyclass(crate = "crate", extends = ImmutableChildOfImmutableBase, frozen)] struct ImmutableChildOfImmutableChildOfImmutableBase; + #[pyclass(crate = "crate")] + struct BaseWithoutData; + #[pyclass(crate = "crate", subclass)] struct BaseWithData(#[allow(unused)] u64); @@ -634,16 +637,35 @@ mod tests { #[pyclass(crate = "crate", extends = BaseWithData)] struct ChildWithoutData; - // #[test] - // fn test_inherited_size() { - // let base_size = PyStaticClassObject::::BASIC_SIZE; - // assert!(base_size > 0); // negative indicates variable sized - // assert_eq!( - // base_size, - // PyStaticClassObject::::BASIC_SIZE - // ); - // assert!(base_size < PyStaticClassObject::::BASIC_SIZE); - // } + #[test] + fn test_inherited_size() { + #[cfg(_Py_OPAQUE_PYOBJECT)] + type ClassObject = PyVariableClassObject; + #[cfg(not(_Py_OPAQUE_PYOBJECT))] + type ClassObject = PyStaticClassObject; + + let base_without_data_size = ClassObject::::BASIC_SIZE; + let base_with_data_size = ClassObject::::BASIC_SIZE; + let child_without_data_size = ClassObject::::BASIC_SIZE; + let child_with_data_size = ClassObject::::BASIC_SIZE; + #[cfg(_Py_OPAQUE_PYOBJECT)] + { + assert!(base_without_data_size < 0); // negative indicates variable sized + assert!(base_with_data_size < base_without_data_size); + assert_eq!(child_without_data_size, 0); + assert_eq!( + base_with_data_size - base_without_data_size, + child_with_data_size + ); + } + #[cfg(not(_Py_OPAQUE_PYOBJECT))] + { + assert!(base_without_data_size > 0); + assert!(base_with_data_size > base_without_data_size); + assert_eq!(base_with_data_size, child_without_data_size); + assert!(base_with_data_size < child_with_data_size); + } + } fn assert_mutable>() {} fn assert_immutable>() {} From 1970421e7a3449a841bc1f45358871cb322bdc60 Mon Sep 17 00:00:00 2001 From: Nathan Goldbaum Date: Tue, 17 Feb 2026 11:22:40 -0700 Subject: [PATCH 25/25] Fix doctest on _Py_OPAQUE_PYOBJECT builds --- guide/src/python-from-rust/calling-existing-code.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guide/src/python-from-rust/calling-existing-code.md b/guide/src/python-from-rust/calling-existing-code.md index efbcabc0198..0ee9672306e 100644 --- a/guide/src/python-from-rust/calling-existing-code.md +++ b/guide/src/python-from-rust/calling-existing-code.md @@ -159,6 +159,8 @@ fn main() -> PyResult<()> { pyo3::append_to_inittab!(foo); Python::attach(|py| Python::run(py, c"import foo; foo.add_one(6)", None, None)) } +# #[cfg(_Py_OPAQUE_PYOBJECT)] +# fn main() -> () {} ``` If `append_to_inittab` cannot be used due to constraints in the program, an alternative is to create a module using [`PyModule::new`] and insert it manually into `sys.modules`: