From 81e64e22fc8f6636ff92fb8cc1921818a1e49795 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Thu, 30 Jul 2026 21:59:03 +0200 Subject: [PATCH 1/9] feat(inspect): let a no-argument #[pymodule_init] keep the module complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `incomplete` was set to `pymodule_init.is_some()`, so any declarative module with an initialiser got `def __getattr__(name: str) -> Incomplete: ...` in its stubs and (since #6242) no `__all__` either. That flag is what makes every unknown attribute on the module resolve to `Any`, which is most of the value of having a stub at all. The flag is conservative for a good reason: `#[pymodule_init]` receives `&Bound<'_, PyModule>` and can add arbitrary attributes the macro cannot see. But the common case does not want the module. `pyo3_log::init()` is the motivating example — it installs a global `log` logger and takes nothing: #[pymodule_init] fn init() -> PyResult<()> { pyo3_log::init(); Ok(()) } An initialiser with no parameters cannot reach the module, so it cannot add members to it, so the module is still fully described. This is inferred from the signature rather than asserted by a new attribute: it is checked by the compiler instead of trusted. One-argument initialisers keep today's behaviour exactly. Two or more is now a clear error instead of a confusing one from the generated call site. --- newsfragments/6268.added.md | 1 + pyo3-macros-backend/src/module.rs | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 newsfragments/6268.added.md diff --git a/newsfragments/6268.added.md b/newsfragments/6268.added.md new file mode 100644 index 00000000000..26c4e39dd74 --- /dev/null +++ b/newsfragments/6268.added.md @@ -0,0 +1 @@ +`#[pymodule_init]` may now be written with no arguments. Such an initialiser provably cannot add attributes to the module, so the module is no longer tagged incomplete for `experimental-inspect`: its stubs get an `__all__` and no `__getattr__` catch-all. diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 9e3f7951e1e..4c8aa9eca1e 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -164,6 +164,9 @@ pub fn pymodule_module_impl( } let mut pymodule_init = None; + // Whether the `#[pymodule_init]`, if there is one, receives the module. An initialiser that + // does not cannot add attributes to it, which is what lets the module stay introspectable. + let mut pymodule_init_takes_module = false; let mut module_consts = Vec::new(); let mut module_consts_cfg_attrs = Vec::new(); @@ -196,7 +199,20 @@ pub fn pymodule_module_impl( item_fn.span() => "`#[pyfunction]` cannot be used alongside `#[pymodule_init]`" ); ensure_spanned!(pymodule_init.is_none(), item_fn.span() => "only one `#[pymodule_init]` may be specified"); - pymodule_init = Some(quote! { #ident(module)?; }); + ensure_spanned!( + item_fn.sig.inputs.len() <= 1, + item_fn.sig.inputs.span() => "`#[pymodule_init]` takes either no argument or the module" + ); + // An initialiser that asks for the module can add anything to it, and the macro + // cannot see what; one that does not is provably side-effect-only as far as the + // module's attributes are concerned. Only the first makes the module + // incomplete for introspection. + pymodule_init_takes_module = !item_fn.sig.inputs.is_empty(); + pymodule_init = Some(if pymodule_init_takes_module { + quote! { #ident(module)?; } + } else { + quote! { #ident()?; } + }); } else if has_attribute(&item_fn.attrs, "pyfunction") || has_attribute_with_namespace( &item_fn.attrs, @@ -382,7 +398,7 @@ pub fn pymodule_module_impl( &module_items, &module_items_cfg_attrs, doc.as_ref(), - pymodule_init.is_some(), + pymodule_init_takes_module, ); #[cfg(not(feature = "experimental-inspect"))] let introspection = quote! {}; From f921e375c9dcb2aec70b70fa93aa0bbb8070381f Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Fri, 31 Jul 2026 14:54:03 +0200 Subject: [PATCH 2/9] Rename --- newsfragments/{6268.added.md => 6271.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename newsfragments/{6268.added.md => 6271.added.md} (100%) diff --git a/newsfragments/6268.added.md b/newsfragments/6271.added.md similarity index 100% rename from newsfragments/6268.added.md rename to newsfragments/6271.added.md From 5679497f9ab4243bb4e3df2e852df88a003032f8 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Fri, 31 Jul 2026 16:47:29 +0200 Subject: [PATCH 3/9] test: cover the no-argument `#[pymodule_init]` paths Codecov flagged three lines in `pymodule_module_impl` as uncovered: the two `ensure_spanned!` error arms and the no-argument codegen branch. - `tests/ui/invalid_pymodule_init_args.rs` covers the new arity check. - `tests/ui/invalid_pymodule_init_pyfunction.rs` covers the pre-existing `#[pyfunction]`-alongside-`#[pymodule_init]` check, which had no test. - `test_pymodule_init_without_module` compiles a module whose `#[pymodule_init]` takes no argument and asserts it still runs, covering the `#ident()?` branch. --- tests/test_declarative_module.rs | 37 +++++++++++++++++++ tests/ui/invalid_pymodule_init_args.rs | 14 +++++++ tests/ui/invalid_pymodule_init_args.stderr | 7 ++++ tests/ui/invalid_pymodule_init_pyfunction.rs | 15 ++++++++ .../invalid_pymodule_init_pyfunction.stderr | 7 ++++ 5 files changed, 80 insertions(+) create mode 100644 tests/ui/invalid_pymodule_init_args.rs create mode 100644 tests/ui/invalid_pymodule_init_args.stderr create mode 100644 tests/ui/invalid_pymodule_init_pyfunction.rs create mode 100644 tests/ui/invalid_pymodule_init_pyfunction.stderr diff --git a/tests/test_declarative_module.rs b/tests/test_declarative_module.rs index e0d77f69e97..6a5976c3381 100644 --- a/tests/test_declarative_module.rs +++ b/tests/test_declarative_module.rs @@ -1,5 +1,6 @@ #![cfg(feature = "macros")] +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::OnceLock; use pyo3::create_exception; @@ -271,3 +272,39 @@ fn test_inner_module_full_path() { py_assert!(py, m, "m.full_path_inner"); }) } + +static NO_ARG_INIT_RAN: AtomicBool = AtomicBool::new(false); + +/// A `#[pymodule_init]` that does not take the module cannot add attributes to it, which is what +/// lets the module stay complete for introspection. It is still called. +#[pymodule] +mod module_with_no_arg_init { + use super::NO_ARG_INIT_RAN; + use pyo3::prelude::*; + use std::sync::atomic::Ordering; + + #[pyfunction] + pub fn triple(x: usize) -> usize { + x * 3 + } + + #[pymodule_init] + #[expect(clippy::unnecessary_wraps)] + fn init() -> PyResult<()> { + NO_ARG_INIT_RAN.store(true, Ordering::SeqCst); + Ok(()) + } +} + +#[test] +fn test_pymodule_init_without_module() { + Python::attach(|py| { + let m = pyo3::wrap_pymodule!(module_with_no_arg_init)(py); + let m = m.bind(py); + py_assert!(py, m, "m.triple(3) == 9"); + assert!( + NO_ARG_INIT_RAN.load(Ordering::SeqCst), + "a `#[pymodule_init]` taking no argument should still be called" + ); + }) +} diff --git a/tests/ui/invalid_pymodule_init_args.rs b/tests/ui/invalid_pymodule_init_args.rs new file mode 100644 index 00000000000..f97fd1c7c75 --- /dev/null +++ b/tests/ui/invalid_pymodule_init_args.rs @@ -0,0 +1,14 @@ +use pyo3::prelude::*; + +#[pymodule] +mod module { + use pyo3::prelude::*; + + #[pymodule_init] + fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { + //~^ ERROR: `#[pymodule_init]` takes either no argument or the module + Ok(()) + } +} + +fn main() {} diff --git a/tests/ui/invalid_pymodule_init_args.stderr b/tests/ui/invalid_pymodule_init_args.stderr new file mode 100644 index 00000000000..582aa234426 --- /dev/null +++ b/tests/ui/invalid_pymodule_init_args.stderr @@ -0,0 +1,7 @@ +error: `#[pymodule_init]` takes either no argument or the module + --> tests/ui/invalid_pymodule_init_args.rs:8:13 + | +8 | fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { + | ^^ + +error: aborting due to 1 previous error diff --git a/tests/ui/invalid_pymodule_init_pyfunction.rs b/tests/ui/invalid_pymodule_init_pyfunction.rs new file mode 100644 index 00000000000..bfc54069e17 --- /dev/null +++ b/tests/ui/invalid_pymodule_init_pyfunction.rs @@ -0,0 +1,15 @@ +use pyo3::prelude::*; + +#[pymodule] +mod module { + use pyo3::prelude::*; + + #[pymodule_init] + #[pyfunction] +//~^ ERROR: `#[pyfunction]` cannot be used alongside `#[pymodule_init]` + fn init(_m: &Bound<'_, PyModule>) -> PyResult<()> { + Ok(()) + } +} + +fn main() {} diff --git a/tests/ui/invalid_pymodule_init_pyfunction.stderr b/tests/ui/invalid_pymodule_init_pyfunction.stderr new file mode 100644 index 00000000000..fe5d4657972 --- /dev/null +++ b/tests/ui/invalid_pymodule_init_pyfunction.stderr @@ -0,0 +1,7 @@ +error: `#[pyfunction]` cannot be used alongside `#[pymodule_init]` + --> tests/ui/invalid_pymodule_init_pyfunction.rs:8:5 + | +8 | #[pyfunction] + | ^ + +error: aborting due to 1 previous error From 90015641c03695285c914bd66fda8dfa98e74a81 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Fri, 14 Aug 2026 13:36:27 +0200 Subject: [PATCH 4/9] Review --- guide/src/ecosystem/logging.md | 3 ++- guide/src/module.md | 19 +++++++++++++++++++ guide/src/type-stub.md | 1 + newsfragments/6271.added.md | 2 +- pyo3-macros-backend/src/module.rs | 10 +++------- pytests/src/othermod.rs | 8 ++++++++ tests/test_declarative_module.rs | 9 ++------- tests/ui/invalid_pymodule_init_args.rs | 2 +- tests/ui/invalid_pymodule_init_args.stderr | 4 ++-- 9 files changed, 39 insertions(+), 19 deletions(-) diff --git a/guide/src/ecosystem/logging.md b/guide/src/ecosystem/logging.md index ae5676c5554..d0b8a473175 100644 --- a/guide/src/ecosystem/logging.md +++ b/guide/src/ecosystem/logging.md @@ -28,9 +28,10 @@ mod my_module { } #[pymodule_init] - fn init(m: &Bound<'_, PyModule>) -> PyResult<()> { + fn init() -> PyResult<()> { // A good place to install the Rust -> Python logger. pyo3_log::init(); + Ok(()) } } ``` diff --git a/guide/src/module.md b/guide/src/module.md index eab280f2536..05a11d36d1c 100644 --- a/guide/src/module.md +++ b/guide/src/module.md @@ -174,3 +174,22 @@ mod my_extension { } # } ``` + +The module argument may be omitted if the initialization does not need it, for example when it only installs some global state: + +```rust,no_run +# mod procedural_module_no_arg_test { +#[pyo3::pymodule] +mod my_extension { + use pyo3::prelude::*; + + #[pymodule_init] + fn init() -> PyResult<()> { + // Arbitrary code which does not touch the module + Ok(()) + } +} +# } +``` + +Prefer this form where possible: an initializer which is not handed the module does not add attributes to it, so [type stub generation](type-stub.md) can keep describing the module in full. diff --git a/guide/src/type-stub.md b/guide/src/type-stub.md index 2a4eb8d266d..50cb069d393 100644 --- a/guide/src/type-stub.md +++ b/guide/src/type-stub.md @@ -89,3 +89,4 @@ PyO3 also provides the smaller `pyo3-introspection` binary that allows to genera - `FromPyObject::INPUT_TYPE` and `IntoPyObject::OUTPUT_TYPE` must be implemented for PyO3 to get the proper input/output type annotations to use. - PyO3 is not able to introspect the content of `#[pymodule]` and `#[pymodule_init]` functions. If they are present, the module is tagged as incomplete using a fake `def __getattr__(name: str) -> Incomplete: ...` function [following best practices](https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs). + A `#[pymodule_init]` function [declared without the module argument](module.md#procedural-initialization) is exempt: it is not handed the module, so the module is taken to be complete. diff --git a/newsfragments/6271.added.md b/newsfragments/6271.added.md index 26c4e39dd74..08862665f6b 100644 --- a/newsfragments/6271.added.md +++ b/newsfragments/6271.added.md @@ -1 +1 @@ -`#[pymodule_init]` may now be written with no arguments. Such an initialiser provably cannot add attributes to the module, so the module is no longer tagged incomplete for `experimental-inspect`: its stubs get an `__all__` and no `__getattr__` catch-all. +`#[pymodule_init]` may now be written without arguments. Such an initialiser is not handed the module, so with `experimental-inspect` the module is no longer tagged incomplete and its stubs no longer get a `def __getattr__(name: str) -> Incomplete: ...` catch-all. diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 4c8aa9eca1e..3019a2bd394 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -164,8 +164,8 @@ pub fn pymodule_module_impl( } let mut pymodule_init = None; - // Whether the `#[pymodule_init]`, if there is one, receives the module. An initialiser that - // does not cannot add attributes to it, which is what lets the module stay introspectable. + // An initialiser which receives the module can add attributes the macro cannot see; one which + // does not is what lets the module stay complete for introspection. let mut pymodule_init_takes_module = false; let mut module_consts = Vec::new(); let mut module_consts_cfg_attrs = Vec::new(); @@ -201,12 +201,8 @@ pub fn pymodule_module_impl( ensure_spanned!(pymodule_init.is_none(), item_fn.span() => "only one `#[pymodule_init]` may be specified"); ensure_spanned!( item_fn.sig.inputs.len() <= 1, - item_fn.sig.inputs.span() => "`#[pymodule_init]` takes either no argument or the module" + item_fn.sig.inputs[1].span() => "`#[pymodule_init]` takes either no argument or the module" ); - // An initialiser that asks for the module can add anything to it, and the macro - // cannot see what; one that does not is provably side-effect-only as far as the - // module's attributes are concerned. Only the first makes the module - // incomplete for introspection. pymodule_init_takes_module = !item_fn.sig.inputs.is_empty(); pymodule_init = Some(if pymodule_init_takes_module { quote! { #ident(module)?; } diff --git a/pytests/src/othermod.rs b/pytests/src/othermod.rs index 1c3c768e342..7e57c4dea88 100644 --- a/pytests/src/othermod.rs +++ b/pytests/src/othermod.rs @@ -36,4 +36,12 @@ pub mod othermod { pub const USIZE_MIN: usize = usize::MIN; #[pymodule_export] pub const USIZE_MAX: usize = usize::MAX; + + // An initialiser without the module argument leaves the module complete for introspection: + // `stubs/othermod.pyi` has no `__getattr__` catch-all. + #[pymodule_init] + #[expect(clippy::unnecessary_wraps)] + fn init() -> PyResult<()> { + Ok(()) + } } diff --git a/tests/test_declarative_module.rs b/tests/test_declarative_module.rs index 6a5976c3381..3b23cb5bdfd 100644 --- a/tests/test_declarative_module.rs +++ b/tests/test_declarative_module.rs @@ -275,8 +275,6 @@ fn test_inner_module_full_path() { static NO_ARG_INIT_RAN: AtomicBool = AtomicBool::new(false); -/// A `#[pymodule_init]` that does not take the module cannot add attributes to it, which is what -/// lets the module stay complete for introspection. It is still called. #[pymodule] mod module_with_no_arg_init { use super::NO_ARG_INIT_RAN; @@ -284,7 +282,7 @@ mod module_with_no_arg_init { use std::sync::atomic::Ordering; #[pyfunction] - pub fn triple(x: usize) -> usize { + fn triple(x: usize) -> usize { x * 3 } @@ -302,9 +300,6 @@ fn test_pymodule_init_without_module() { let m = pyo3::wrap_pymodule!(module_with_no_arg_init)(py); let m = m.bind(py); py_assert!(py, m, "m.triple(3) == 9"); - assert!( - NO_ARG_INIT_RAN.load(Ordering::SeqCst), - "a `#[pymodule_init]` taking no argument should still be called" - ); + assert!(NO_ARG_INIT_RAN.load(Ordering::SeqCst)); }) } diff --git a/tests/ui/invalid_pymodule_init_args.rs b/tests/ui/invalid_pymodule_init_args.rs index f97fd1c7c75..5b3605d8f7a 100644 --- a/tests/ui/invalid_pymodule_init_args.rs +++ b/tests/ui/invalid_pymodule_init_args.rs @@ -6,7 +6,7 @@ mod module { #[pymodule_init] fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { - //~^ ERROR: `#[pymodule_init]` takes either no argument or the module +//~^ ERROR: `#[pymodule_init]` takes either no argument or the module Ok(()) } } diff --git a/tests/ui/invalid_pymodule_init_args.stderr b/tests/ui/invalid_pymodule_init_args.stderr index 582aa234426..30d28eae6e6 100644 --- a/tests/ui/invalid_pymodule_init_args.stderr +++ b/tests/ui/invalid_pymodule_init_args.stderr @@ -1,7 +1,7 @@ error: `#[pymodule_init]` takes either no argument or the module - --> tests/ui/invalid_pymodule_init_args.rs:8:13 + --> tests/ui/invalid_pymodule_init_args.rs:8:39 | 8 | fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { - | ^^ + | ^^^^^^ error: aborting due to 1 previous error From f2b01cd647e119ff5ff9fd9579cdc680db5bcb44 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Fri, 14 Aug 2026 13:46:59 +0200 Subject: [PATCH 5/9] Allow `pymodule_init` also to be unfallible --- guide/src/ecosystem/logging.md | 3 +- guide/src/module.md | 8 ++---- newsfragments/6271.added.1.md | 1 + pyo3-macros-backend/src/module.rs | 9 ++++-- pytests/src/othermod.rs | 5 +--- src/impl_/pymodule.rs | 22 ++++++++++++++- tests/test_declarative_module.rs | 29 ++++++++++++++++++++ tests/ui/invalid_pymodule_init_return.rs | 12 ++++++++ tests/ui/invalid_pymodule_init_return.stderr | 21 ++++++++++++++ 9 files changed, 95 insertions(+), 15 deletions(-) create mode 100644 newsfragments/6271.added.1.md create mode 100644 tests/ui/invalid_pymodule_init_return.rs create mode 100644 tests/ui/invalid_pymodule_init_return.stderr diff --git a/guide/src/ecosystem/logging.md b/guide/src/ecosystem/logging.md index d0b8a473175..c3e566b937e 100644 --- a/guide/src/ecosystem/logging.md +++ b/guide/src/ecosystem/logging.md @@ -28,10 +28,9 @@ mod my_module { } #[pymodule_init] - fn init() -> PyResult<()> { + fn init() { // A good place to install the Rust -> Python logger. pyo3_log::init(); - Ok(()) } } ``` diff --git a/guide/src/module.md b/guide/src/module.md index 05a11d36d1c..1a23379e277 100644 --- a/guide/src/module.md +++ b/guide/src/module.md @@ -175,18 +175,16 @@ mod my_extension { # } ``` -The module argument may be omitted if the initialization does not need it, for example when it only installs some global state: +The module argument may be omitted if the initialization does not need it, for example when it only installs some global state. +The return type may then be omitted too, since there is nothing left which can fail: ```rust,no_run # mod procedural_module_no_arg_test { #[pyo3::pymodule] mod my_extension { - use pyo3::prelude::*; - #[pymodule_init] - fn init() -> PyResult<()> { + fn init() { // Arbitrary code which does not touch the module - Ok(()) } } # } diff --git a/newsfragments/6271.added.1.md b/newsfragments/6271.added.1.md new file mode 100644 index 00000000000..61f947c878a --- /dev/null +++ b/newsfragments/6271.added.1.md @@ -0,0 +1 @@ +A `#[pymodule_init]` function may now return `()` instead of `PyResult<()>`, which is useful for initialisers which cannot fail. diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 3019a2bd394..41813cf44c3 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -204,10 +204,13 @@ pub fn pymodule_module_impl( item_fn.sig.inputs[1].span() => "`#[pymodule_init]` takes either no argument or the module" ); pymodule_init_takes_module = !item_fn.sig.inputs.is_empty(); - pymodule_init = Some(if pymodule_init_takes_module { - quote! { #ident(module)?; } + let call = if pymodule_init_takes_module { + quote! { #ident(module) } } else { - quote! { #ident()?; } + quote! { #ident() } + }; + pymodule_init = Some(quote! { + #pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#call)?; }); } else if has_attribute(&item_fn.attrs, "pyfunction") || has_attribute_with_namespace( diff --git a/pytests/src/othermod.rs b/pytests/src/othermod.rs index 7e57c4dea88..797afa24cb0 100644 --- a/pytests/src/othermod.rs +++ b/pytests/src/othermod.rs @@ -40,8 +40,5 @@ pub mod othermod { // An initialiser without the module argument leaves the module complete for introspection: // `stubs/othermod.pyi` has no `__getattr__` catch-all. #[pymodule_init] - #[expect(clippy::unnecessary_wraps)] - fn init() -> PyResult<()> { - Ok(()) - } + fn init() {} } diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 8c20828b632..607f179425b 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -41,7 +41,7 @@ use crate::{ ffi, impl_::pyfunction::PyFunctionDef, types::{PyModule, PyModuleMethods}, - Bound, PyClass, PyResult, PyTypeInfo, + Bound, PyClass, PyErr, PyResult, PyTypeInfo, }; use crate::{ sync::PyOnceLock, @@ -485,6 +485,26 @@ unsafe impl Sync for PyModuleSlots {} #[cfg(not(all(Py_LIMITED_API, Py_GIL_DISABLED)))] unsafe impl Sync for PyModuleDefSlots {} +/// Used to accept either `()` or a `Result` from a `#[pymodule_init]` function. +pub trait PyModuleInitResult { + fn into_result(self) -> PyResult<()>; +} + +impl PyModuleInitResult for () { + fn into_result(self) -> PyResult<()> { + Ok(()) + } +} + +impl PyModuleInitResult for Result +where + PyErr: From, +{ + fn into_result(self) -> PyResult<()> { + self.map(|_| ()).map_err(PyErr::from) + } +} + /// Trait to add an element (class, function...) to a module. /// /// Currently only implemented for classes. diff --git a/tests/test_declarative_module.rs b/tests/test_declarative_module.rs index 3b23cb5bdfd..c7de19a1742 100644 --- a/tests/test_declarative_module.rs +++ b/tests/test_declarative_module.rs @@ -303,3 +303,32 @@ fn test_pymodule_init_without_module() { assert!(NO_ARG_INIT_RAN.load(Ordering::SeqCst)); }) } + +static UNIT_INIT_RAN: AtomicBool = AtomicBool::new(false); + +#[pymodule] +mod module_with_unit_init { + use super::UNIT_INIT_RAN; + use pyo3::prelude::*; + use std::sync::atomic::Ordering; + + #[pyfunction] + fn quadruple(x: usize) -> usize { + x * 4 + } + + #[pymodule_init] + fn init() { + UNIT_INIT_RAN.store(true, Ordering::SeqCst); + } +} + +#[test] +fn test_pymodule_init_returning_unit() { + Python::attach(|py| { + let m = pyo3::wrap_pymodule!(module_with_unit_init)(py); + let m = m.bind(py); + py_assert!(py, m, "m.quadruple(3) == 12"); + assert!(UNIT_INIT_RAN.load(Ordering::SeqCst)); + }) +} diff --git a/tests/ui/invalid_pymodule_init_return.rs b/tests/ui/invalid_pymodule_init_return.rs new file mode 100644 index 00000000000..875ae2a9daf --- /dev/null +++ b/tests/ui/invalid_pymodule_init_return.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +#[pymodule] +//~^ ERROR: the trait bound `usize: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +mod module { + #[pymodule_init] + fn init() -> usize { + 0 + } +} + +fn main() {} diff --git a/tests/ui/invalid_pymodule_init_return.stderr b/tests/ui/invalid_pymodule_init_return.stderr new file mode 100644 index 00000000000..56f94d80651 --- /dev/null +++ b/tests/ui/invalid_pymodule_init_return.stderr @@ -0,0 +1,21 @@ +error[E0277]: the trait bound `usize: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied + --> tests/ui/invalid_pymodule_init_return.rs:3:1 + | + 3 | #[pymodule] + | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `usize` + | +help: the following other types implement trait `pyo3::impl_::pymodule::PyModuleInitResult` + --> src/impl_/pymodule.rs + | + | impl PyModuleInitResult for () { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `()` +... + | / impl PyModuleInitResult for Result + | | where + | | PyErr: From, + | |___________________^ `Result` + = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: aborting due to 1 previous error + +For more information about this error, try `rustc --explain E0277`. From 36ee47d3c91aa197c3abdb89868f968708b93751 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Fri, 14 Aug 2026 15:41:34 +0200 Subject: [PATCH 6/9] Address review --- newsfragments/6271.added.1.md | 1 + pyo3-macros-backend/src/module.rs | 2 +- src/impl_/pymodule.rs | 6 +++--- tests/ui/invalid_pymodule_init_args.stderr | 4 ++-- tests/ui/invalid_pymodule_init_return.rs | 11 ++++++++++ tests/ui/invalid_pymodule_init_return.stderr | 21 +++++++++++++++++--- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/newsfragments/6271.added.1.md b/newsfragments/6271.added.1.md index 61f947c878a..84d287c68c2 100644 --- a/newsfragments/6271.added.1.md +++ b/newsfragments/6271.added.1.md @@ -1 +1,2 @@ A `#[pymodule_init]` function may now return `()` instead of `PyResult<()>`, which is useful for initialisers which cannot fail. +The return type is now checked: an initialiser returning a `Result` with a non-unit `Ok` value used to compile, with that value silently discarded, and is now rejected. diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 41813cf44c3..da657db5337 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -201,7 +201,7 @@ pub fn pymodule_module_impl( ensure_spanned!(pymodule_init.is_none(), item_fn.span() => "only one `#[pymodule_init]` may be specified"); ensure_spanned!( item_fn.sig.inputs.len() <= 1, - item_fn.sig.inputs[1].span() => "`#[pymodule_init]` takes either no argument or the module" + item_fn.sig.span() => "`#[pymodule_init]` takes either no argument or the module" ); pymodule_init_takes_module = !item_fn.sig.inputs.is_empty(); let call = if pymodule_init_takes_module { diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index 607f179425b..e95f84d4484 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -485,7 +485,7 @@ unsafe impl Sync for PyModuleSlots {} #[cfg(not(all(Py_LIMITED_API, Py_GIL_DISABLED)))] unsafe impl Sync for PyModuleDefSlots {} -/// Used to accept either `()` or a `Result` from a `#[pymodule_init]` function. +/// Used to accept either `()` or `Result<(), E>` from a `#[pymodule_init]` function. pub trait PyModuleInitResult { fn into_result(self) -> PyResult<()>; } @@ -496,12 +496,12 @@ impl PyModuleInitResult for () { } } -impl PyModuleInitResult for Result +impl PyModuleInitResult for Result<(), E> where PyErr: From, { fn into_result(self) -> PyResult<()> { - self.map(|_| ()).map_err(PyErr::from) + self.map_err(PyErr::from) } } diff --git a/tests/ui/invalid_pymodule_init_args.stderr b/tests/ui/invalid_pymodule_init_args.stderr index 30d28eae6e6..4e18dfabeca 100644 --- a/tests/ui/invalid_pymodule_init_args.stderr +++ b/tests/ui/invalid_pymodule_init_args.stderr @@ -1,7 +1,7 @@ error: `#[pymodule_init]` takes either no argument or the module - --> tests/ui/invalid_pymodule_init_args.rs:8:39 + --> tests/ui/invalid_pymodule_init_args.rs:8:5 | 8 | fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { - | ^^^^^^ + | ^^ error: aborting due to 1 previous error diff --git a/tests/ui/invalid_pymodule_init_return.rs b/tests/ui/invalid_pymodule_init_return.rs index 875ae2a9daf..203c0b4745d 100644 --- a/tests/ui/invalid_pymodule_init_return.rs +++ b/tests/ui/invalid_pymodule_init_return.rs @@ -9,4 +9,15 @@ mod module { } } +#[pymodule] +//~^ ERROR: the trait bound `Result: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +mod module_result { + use pyo3::prelude::*; + + #[pymodule_init] + fn init() -> PyResult { + Ok(0) + } +} + fn main() {} diff --git a/tests/ui/invalid_pymodule_init_return.stderr b/tests/ui/invalid_pymodule_init_return.stderr index 56f94d80651..06b30bcd099 100644 --- a/tests/ui/invalid_pymodule_init_return.stderr +++ b/tests/ui/invalid_pymodule_init_return.stderr @@ -10,12 +10,27 @@ help: the following other types implement trait `pyo3::impl_::pymodule::PyModule | impl PyModuleInitResult for () { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `()` ... - | / impl PyModuleInitResult for Result + | / impl PyModuleInitResult for Result<(), E> | | where | | PyErr: From, - | |___________________^ `Result` + | |___________________^ `Result<(), E>` = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) -error: aborting due to 1 previous error +error[E0277]: the trait bound `Result: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied + --> tests/ui/invalid_pymodule_init_return.rs:12:1 + | + 12 | #[pymodule] + | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `Result` + | +help: the trait `pyo3::impl_::pymodule::PyModuleInitResult` is implemented for `Result<(), E>` + --> src/impl_/pymodule.rs + | + | / impl PyModuleInitResult for Result<(), E> + | | where + | | PyErr: From, + | |___________________^ + = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: aborting due to 2 previous errors For more information about this error, try `rustc --explain E0277`. From dd18e953864eb629171b93657a985132734bf4c2 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Wed, 26 Aug 2026 13:01:12 +0200 Subject: [PATCH 7/9] Update guide/src/module.md Co-authored-by: David Hewitt --- guide/src/module.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guide/src/module.md b/guide/src/module.md index 1a23379e277..318008be732 100644 --- a/guide/src/module.md +++ b/guide/src/module.md @@ -176,7 +176,7 @@ mod my_extension { ``` The module argument may be omitted if the initialization does not need it, for example when it only installs some global state. -The return type may then be omitted too, since there is nothing left which can fail: +The return type may then be omitted too: ```rust,no_run # mod procedural_module_no_arg_test { From db5d07b6e34db71f434b98622a4587a05686387d Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Wed, 26 Aug 2026 13:20:49 +0200 Subject: [PATCH 8/9] Address review: optional `Python` argument and better diagnostics - `#[pymodule_init]` may take a `Python<'_>` marker in front of the module argument or instead of it, via `split_off_python_arg`, which moves from `pymethod` to `method` next to `FnArg`. A `Python`-only initialiser is still not handed the module, so the module stays complete for introspection. - `PyModuleInitResult` gets a `#[diagnostic::on_unimplemented]` message naming `#[pymodule_init]` instead of reporting a raw trait bound. --- guide/src/module.md | 21 ++++++- guide/src/type-stub.md | 1 + newsfragments/6271.added.md | 2 +- pyo3-macros-backend/src/method.rs | 10 ++++ pyo3-macros-backend/src/module.rs | 27 ++++++--- pyo3-macros-backend/src/pymethod.rs | 11 +--- src/impl_/pymodule.rs | 4 ++ tests/test_declarative_module.rs | 59 +++++++++++++++++++- tests/ui/invalid_pymodule_init_args.rs | 13 ++++- tests/ui/invalid_pymodule_init_args.stderr | 10 +++- tests/ui/invalid_pymodule_init_return.rs | 4 +- tests/ui/invalid_pymodule_init_return.stderr | 6 +- 12 files changed, 140 insertions(+), 28 deletions(-) diff --git a/guide/src/module.md b/guide/src/module.md index 318008be732..476cec0196a 100644 --- a/guide/src/module.md +++ b/guide/src/module.md @@ -190,4 +190,23 @@ mod my_extension { # } ``` -Prefer this form where possible: an initializer which is not handed the module does not add attributes to it, so [type stub generation](type-stub.md) can keep describing the module in full. +A `Python<'_>` marker may be taken as the first argument, either on its own or followed by the module. +This suits an initializer which needs the interpreter but not the module itself: + +```rust,no_run +# mod procedural_module_py_arg_test { +#[pyo3::pymodule] +mod my_extension { + use pyo3::prelude::*; + + #[pymodule_init] + fn init(py: Python<'_>) -> PyResult<()> { + // Arbitrary code which needs the interpreter but not the module + py.import("decimal")?; + Ok(()) + } +} +# } +``` + +Prefer a form which does not take the module where possible: an initializer which is not handed the module does not add attributes to it, so [type stub generation](type-stub.md) can keep describing the module in full. diff --git a/guide/src/type-stub.md b/guide/src/type-stub.md index 50cb069d393..cee684446aa 100644 --- a/guide/src/type-stub.md +++ b/guide/src/type-stub.md @@ -90,3 +90,4 @@ PyO3 also provides the smaller `pyo3-introspection` binary that allows to genera - PyO3 is not able to introspect the content of `#[pymodule]` and `#[pymodule_init]` functions. If they are present, the module is tagged as incomplete using a fake `def __getattr__(name: str) -> Incomplete: ...` function [following best practices](https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs). A `#[pymodule_init]` function [declared without the module argument](module.md#procedural-initialization) is exempt: it is not handed the module, so the module is taken to be complete. + A `Python<'_>` marker on its own does not count as the module argument. diff --git a/newsfragments/6271.added.md b/newsfragments/6271.added.md index 08862665f6b..c36bef2287d 100644 --- a/newsfragments/6271.added.md +++ b/newsfragments/6271.added.md @@ -1 +1 @@ -`#[pymodule_init]` may now be written without arguments. Such an initialiser is not handed the module, so with `experimental-inspect` the module is no longer tagged incomplete and its stubs no longer get a `def __getattr__(name: str) -> Incomplete: ...` catch-all. +`#[pymodule_init]` may now be written without the module argument, and may take a `Python<'_>` marker in front of it or instead of it. An initialiser which is not handed the module leaves it complete, so with `experimental-inspect` the module is no longer tagged incomplete and its stubs no longer get a `def __getattr__(name: str) -> Incomplete: ...` catch-all. diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index d6f811d4c72..08f9925e13f 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -201,6 +201,16 @@ impl<'a> FnArg<'a> { } } +/// Split an argument of pyo3::Python from the front of the arg list, if present +pub fn split_off_python_arg<'a, 'b>( + args: &'a [FnArg<'b>], +) -> (Option<&'a PyArg<'b>>, &'a [FnArg<'b>]) { + match args { + [FnArg::Py(py), args @ ..] => (Some(py), args), + args => (None, args), + } +} + fn handle_argument_error(pat: &syn::Pat) -> syn::Error { let span = pat.span(); let msg = match pat { diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 7ca0398bca7..4fc1548da82 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -13,6 +13,7 @@ use crate::{ }, combine_errors::CombineErrors, get_doc, + method::{split_off_python_arg, FnArg}, pyclass::PyClassPyO3Option, pyfunction::{impl_wrap_pyfunction, PyFunctionOptions}, utils::{has_attribute, has_attribute_with_namespace, Ctx, IdentOrStr, PythonDoc}, @@ -199,18 +200,26 @@ pub fn pymodule_module_impl( item_fn.span() => "`#[pyfunction]` cannot be used alongside `#[pymodule_init]`" ); ensure_spanned!(pymodule_init.is_none(), item_fn.span() => "only one `#[pymodule_init]` may be specified"); + let ident = ident.clone(); + let sig_span = item_fn.sig.span(); + let args: Vec<_> = item_fn + .sig + .inputs + .iter_mut() + .map(FnArg::parse) + .try_combine_syn_errors()?; + let (py_arg, args) = split_off_python_arg(&args); ensure_spanned!( - item_fn.sig.inputs.len() <= 1, - item_fn.sig.span() => "`#[pymodule_init]` takes either no argument or the module" + args.len() <= 1, + sig_span => "`#[pymodule_init]` takes an optional `Python` argument followed by an optional module argument" ); - pymodule_init_takes_module = !item_fn.sig.inputs.is_empty(); - let call = if pymodule_init_takes_module { - quote! { #ident(module) } - } else { - quote! { #ident() } - }; + pymodule_init_takes_module = !args.is_empty(); + let call_args = py_arg + .map(|_| quote! { module.py() }) + .into_iter() + .chain(pymodule_init_takes_module.then(|| quote! { module })); pymodule_init = Some(quote! { - #pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#call)?; + #pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#ident(#(#call_args),*))?; }); } else if has_attribute(&item_fn.attrs, "pyfunction") || has_attribute_with_namespace( diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs index 683e03ad898..cebcc078ed9 100644 --- a/pyo3-macros-backend/src/pymethod.rs +++ b/pyo3-macros-backend/src/pymethod.rs @@ -5,7 +5,8 @@ use crate::attributes::{FromPyWithAttribute, NameAttribute, RenamingRule}; #[cfg(feature = "experimental-inspect")] use crate::introspection::unique_element_id; use crate::method::{ - CallingConvention, ClassMethodReceiver, ExtractErrorMode, PyArg, SelfConversionPolicy, + split_off_python_arg, CallingConvention, ClassMethodReceiver, ExtractErrorMode, + SelfConversionPolicy, }; use crate::params::{impl_arg_params, impl_regular_arg_param, Holders}; use crate::pyfunction::WarningFactory; @@ -1035,14 +1036,6 @@ fn impl_call_deleter( Ok(fncall) } -/// Split an argument of pyo3::Python from the front of the arg list, if present -fn split_off_python_arg<'a, 'b>(args: &'a [FnArg<'b>]) -> (Option<&'a PyArg<'b>>, &'a [FnArg<'b>]) { - match args { - [FnArg::Py(py), args @ ..] => (Some(py), args), - args => (None, args), - } -} - pub enum PropertyType<'a> { Descriptor { field_index: usize, diff --git a/src/impl_/pymodule.rs b/src/impl_/pymodule.rs index e95f84d4484..ac101c850b9 100644 --- a/src/impl_/pymodule.rs +++ b/src/impl_/pymodule.rs @@ -486,6 +486,10 @@ unsafe impl Sync for PyModuleSlots {} unsafe impl Sync for PyModuleDefSlots {} /// Used to accept either `()` or `Result<(), E>` from a `#[pymodule_init]` function. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a suitable return value for `#[pymodule_init]` functions", + note = "`#[pymodule_init]` functions may return `()` or `Result<(), E>` where `PyErr: From`" +)] pub trait PyModuleInitResult { fn into_result(self) -> PyResult<()>; } diff --git a/tests/test_declarative_module.rs b/tests/test_declarative_module.rs index c7de19a1742..638d81cab7c 100644 --- a/tests/test_declarative_module.rs +++ b/tests/test_declarative_module.rs @@ -1,6 +1,6 @@ #![cfg(feature = "macros")] -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::OnceLock; use pyo3::create_exception; @@ -332,3 +332,60 @@ fn test_pymodule_init_returning_unit() { assert!(UNIT_INIT_RAN.load(Ordering::SeqCst)); }) } + +static PY_INIT_VERSION_MAJOR: AtomicU8 = AtomicU8::new(0); + +#[pymodule] +mod module_with_py_init { + use super::PY_INIT_VERSION_MAJOR; + use pyo3::prelude::*; + use std::sync::atomic::Ordering; + + #[pyfunction] + fn quintuple(x: usize) -> usize { + x * 5 + } + + #[pymodule_init] + fn init(py: Python<'_>) { + PY_INIT_VERSION_MAJOR.store(py.version_info().major, Ordering::SeqCst); + } +} + +#[test] +fn test_pymodule_init_with_only_python() { + Python::attach(|py| { + let m = pyo3::wrap_pymodule!(module_with_py_init)(py); + let m = m.bind(py); + py_assert!(py, m, "m.quintuple(3) == 15"); + assert_eq!( + PY_INIT_VERSION_MAJOR.load(Ordering::SeqCst), + py.version_info().major + ); + }) +} + +#[pymodule] +mod module_with_py_and_module_init { + use pyo3::prelude::*; + + #[pyfunction] + fn sextuple(x: usize) -> usize { + x * 6 + } + + #[pymodule_init] + fn init(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("added_by_init", pyo3::types::PyString::new(py, "hello")) + } +} + +#[test] +fn test_pymodule_init_with_python_and_module() { + Python::attach(|py| { + let m = pyo3::wrap_pymodule!(module_with_py_and_module_init)(py); + let m = m.bind(py); + py_assert!(py, m, "m.sextuple(3) == 18"); + py_assert!(py, m, "m.added_by_init == 'hello'"); + }) +} diff --git a/tests/ui/invalid_pymodule_init_args.rs b/tests/ui/invalid_pymodule_init_args.rs index 5b3605d8f7a..4fcb2238547 100644 --- a/tests/ui/invalid_pymodule_init_args.rs +++ b/tests/ui/invalid_pymodule_init_args.rs @@ -6,7 +6,18 @@ mod module { #[pymodule_init] fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { -//~^ ERROR: `#[pymodule_init]` takes either no argument or the module +//~^ ERROR: `#[pymodule_init]` takes an optional `Python` argument followed by an optional module argument + Ok(()) + } +} + +#[pymodule] +mod module_with_python_last { + use pyo3::prelude::*; + + #[pymodule_init] + fn init(_m: &Bound<'_, PyModule>, _py: Python<'_>) -> PyResult<()> { +//~^ ERROR: `#[pymodule_init]` takes an optional `Python` argument followed by an optional module argument Ok(()) } } diff --git a/tests/ui/invalid_pymodule_init_args.stderr b/tests/ui/invalid_pymodule_init_args.stderr index 4e18dfabeca..bcb3a7a250f 100644 --- a/tests/ui/invalid_pymodule_init_args.stderr +++ b/tests/ui/invalid_pymodule_init_args.stderr @@ -1,7 +1,13 @@ -error: `#[pymodule_init]` takes either no argument or the module +error: `#[pymodule_init]` takes an optional `Python` argument followed by an optional module argument --> tests/ui/invalid_pymodule_init_args.rs:8:5 | 8 | fn init(_m: &Bound<'_, PyModule>, _extra: usize) -> PyResult<()> { | ^^ -error: aborting due to 1 previous error +error: `#[pymodule_init]` takes an optional `Python` argument followed by an optional module argument + --> tests/ui/invalid_pymodule_init_args.rs:19:5 + | +19 | fn init(_m: &Bound<'_, PyModule>, _py: Python<'_>) -> PyResult<()> { + | ^^ + +error: aborting due to 2 previous errors diff --git a/tests/ui/invalid_pymodule_init_return.rs b/tests/ui/invalid_pymodule_init_return.rs index 203c0b4745d..32f9d3796a4 100644 --- a/tests/ui/invalid_pymodule_init_return.rs +++ b/tests/ui/invalid_pymodule_init_return.rs @@ -1,7 +1,7 @@ use pyo3::prelude::*; #[pymodule] -//~^ ERROR: the trait bound `usize: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +//~^ ERROR: `usize` is not a suitable return value for `#[pymodule_init]` functions mod module { #[pymodule_init] fn init() -> usize { @@ -10,7 +10,7 @@ mod module { } #[pymodule] -//~^ ERROR: the trait bound `Result: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +//~^ ERROR: `Result` is not a suitable return value for `#[pymodule_init]` functions mod module_result { use pyo3::prelude::*; diff --git a/tests/ui/invalid_pymodule_init_return.stderr b/tests/ui/invalid_pymodule_init_return.stderr index 06b30bcd099..740969374f1 100644 --- a/tests/ui/invalid_pymodule_init_return.stderr +++ b/tests/ui/invalid_pymodule_init_return.stderr @@ -1,9 +1,10 @@ -error[E0277]: the trait bound `usize: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +error[E0277]: `usize` is not a suitable return value for `#[pymodule_init]` functions --> tests/ui/invalid_pymodule_init_return.rs:3:1 | 3 | #[pymodule] | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `usize` | + = note: `#[pymodule_init]` functions may return `()` or `Result<(), E>` where `PyErr: From` help: the following other types implement trait `pyo3::impl_::pymodule::PyModuleInitResult` --> src/impl_/pymodule.rs | @@ -16,12 +17,13 @@ help: the following other types implement trait `pyo3::impl_::pymodule::PyModule | |___________________^ `Result<(), E>` = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: the trait bound `Result: pyo3::impl_::pymodule::PyModuleInitResult` is not satisfied +error[E0277]: `Result` is not a suitable return value for `#[pymodule_init]` functions --> tests/ui/invalid_pymodule_init_return.rs:12:1 | 12 | #[pymodule] | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `Result` | + = note: `#[pymodule_init]` functions may return `()` or `Result<(), E>` where `PyErr: From` help: the trait `pyo3::impl_::pymodule::PyModuleInitResult` is implemented for `Result<(), E>` --> src/impl_/pymodule.rs | From 39f99f54e4db59bf7f5b82a3303545a503f1282a Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Thu, 27 Aug 2026 13:21:21 +0200 Subject: [PATCH 9/9] Address review: keep `split_off_python_arg` in `pymethod`, span init on return type --- pyo3-macros-backend/src/method.rs | 10 ---------- pyo3-macros-backend/src/module.rs | 9 ++++++--- pyo3-macros-backend/src/pymethod.rs | 13 +++++++++++-- tests/ui/invalid_pymodule_init_return.rs | 4 ++-- tests/ui/invalid_pymodule_init_return.stderr | 20 ++++++++++++-------- 5 files changed, 31 insertions(+), 25 deletions(-) diff --git a/pyo3-macros-backend/src/method.rs b/pyo3-macros-backend/src/method.rs index 08f9925e13f..d6f811d4c72 100644 --- a/pyo3-macros-backend/src/method.rs +++ b/pyo3-macros-backend/src/method.rs @@ -201,16 +201,6 @@ impl<'a> FnArg<'a> { } } -/// Split an argument of pyo3::Python from the front of the arg list, if present -pub fn split_off_python_arg<'a, 'b>( - args: &'a [FnArg<'b>], -) -> (Option<&'a PyArg<'b>>, &'a [FnArg<'b>]) { - match args { - [FnArg::Py(py), args @ ..] => (Some(py), args), - args => (None, args), - } -} - fn handle_argument_error(pat: &syn::Pat) -> syn::Error { let span = pat.span(); let msg = match pat { diff --git a/pyo3-macros-backend/src/module.rs b/pyo3-macros-backend/src/module.rs index 4fc1548da82..682d692516a 100644 --- a/pyo3-macros-backend/src/module.rs +++ b/pyo3-macros-backend/src/module.rs @@ -13,13 +13,14 @@ use crate::{ }, combine_errors::CombineErrors, get_doc, - method::{split_off_python_arg, FnArg}, + method::FnArg, pyclass::PyClassPyO3Option, pyfunction::{impl_wrap_pyfunction, PyFunctionOptions}, + pymethod::split_off_python_arg, utils::{has_attribute, has_attribute_with_namespace, Ctx, IdentOrStr, PythonDoc}, }; use proc_macro2::{Span, TokenStream}; -use quote::{quote, ToTokens}; +use quote::{quote, quote_spanned, ToTokens}; use std::ffi::CString; use syn::LitCStr; use syn::{ @@ -202,6 +203,7 @@ pub fn pymodule_module_impl( ensure_spanned!(pymodule_init.is_none(), item_fn.span() => "only one `#[pymodule_init]` may be specified"); let ident = ident.clone(); let sig_span = item_fn.sig.span(); + let return_span = item_fn.sig.output.span(); let args: Vec<_> = item_fn .sig .inputs @@ -218,7 +220,8 @@ pub fn pymodule_module_impl( .map(|_| quote! { module.py() }) .into_iter() .chain(pymodule_init_takes_module.then(|| quote! { module })); - pymodule_init = Some(quote! { + let pyo3_path = pyo3_path.to_tokens_spanned(return_span); + pymodule_init = Some(quote_spanned! { return_span => #pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#ident(#(#call_args),*))?; }); } else if has_attribute(&item_fn.attrs, "pyfunction") diff --git a/pyo3-macros-backend/src/pymethod.rs b/pyo3-macros-backend/src/pymethod.rs index cebcc078ed9..23ab7df5efb 100644 --- a/pyo3-macros-backend/src/pymethod.rs +++ b/pyo3-macros-backend/src/pymethod.rs @@ -5,8 +5,7 @@ use crate::attributes::{FromPyWithAttribute, NameAttribute, RenamingRule}; #[cfg(feature = "experimental-inspect")] use crate::introspection::unique_element_id; use crate::method::{ - split_off_python_arg, CallingConvention, ClassMethodReceiver, ExtractErrorMode, - SelfConversionPolicy, + CallingConvention, ClassMethodReceiver, ExtractErrorMode, PyArg, SelfConversionPolicy, }; use crate::params::{impl_arg_params, impl_regular_arg_param, Holders}; use crate::pyfunction::WarningFactory; @@ -1036,6 +1035,16 @@ fn impl_call_deleter( Ok(fncall) } +/// Split an argument of pyo3::Python from the front of the arg list, if present +pub(crate) fn split_off_python_arg<'a, 'b>( + args: &'a [FnArg<'b>], +) -> (Option<&'a PyArg<'b>>, &'a [FnArg<'b>]) { + match args { + [FnArg::Py(py), args @ ..] => (Some(py), args), + args => (None, args), + } +} + pub enum PropertyType<'a> { Descriptor { field_index: usize, diff --git a/tests/ui/invalid_pymodule_init_return.rs b/tests/ui/invalid_pymodule_init_return.rs index 32f9d3796a4..db9bf25921f 100644 --- a/tests/ui/invalid_pymodule_init_return.rs +++ b/tests/ui/invalid_pymodule_init_return.rs @@ -1,21 +1,21 @@ use pyo3::prelude::*; #[pymodule] -//~^ ERROR: `usize` is not a suitable return value for `#[pymodule_init]` functions mod module { #[pymodule_init] fn init() -> usize { + //~^ ERROR: `usize` is not a suitable return value for `#[pymodule_init]` functions 0 } } #[pymodule] -//~^ ERROR: `Result` is not a suitable return value for `#[pymodule_init]` functions mod module_result { use pyo3::prelude::*; #[pymodule_init] fn init() -> PyResult { + //~^ ERROR: `Result` is not a suitable return value for `#[pymodule_init]` functions Ok(0) } } diff --git a/tests/ui/invalid_pymodule_init_return.stderr b/tests/ui/invalid_pymodule_init_return.stderr index 740969374f1..6ab07e639c5 100644 --- a/tests/ui/invalid_pymodule_init_return.stderr +++ b/tests/ui/invalid_pymodule_init_return.stderr @@ -1,8 +1,11 @@ error[E0277]: `usize` is not a suitable return value for `#[pymodule_init]` functions - --> tests/ui/invalid_pymodule_init_return.rs:3:1 + --> tests/ui/invalid_pymodule_init_return.rs:6:8 | - 3 | #[pymodule] - | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `usize` + 6 | fn init() -> usize { + | ^^^^^^^- + | | | + | | required by a bound introduced by this call + | the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `usize` | = note: `#[pymodule_init]` functions may return `()` or `Result<(), E>` where `PyErr: From` help: the following other types implement trait `pyo3::impl_::pymodule::PyModuleInitResult` @@ -15,13 +18,15 @@ help: the following other types implement trait `pyo3::impl_::pymodule::PyModule | | where | | PyErr: From, | |___________________^ `Result<(), E>` - = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0277]: `Result` is not a suitable return value for `#[pymodule_init]` functions - --> tests/ui/invalid_pymodule_init_return.rs:12:1 + --> tests/ui/invalid_pymodule_init_return.rs:17:8 | - 12 | #[pymodule] - | ^^^^^^^^^^^ the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `Result` + 17 | fn init() -> PyResult { + | ^^^^^^^- + | | | + | | required by a bound introduced by this call + | the trait `pyo3::impl_::pymodule::PyModuleInitResult` is not implemented for `Result` | = note: `#[pymodule_init]` functions may return `()` or `Result<(), E>` where `PyErr: From` help: the trait `pyo3::impl_::pymodule::PyModuleInitResult` is implemented for `Result<(), E>` @@ -31,7 +36,6 @@ help: the trait `pyo3::impl_::pymodule::PyModuleInitResult` is implemented for ` | | where | | PyErr: From, | |___________________^ - = note: this error originates in the attribute macro `pymodule` (in Nightly builds, run with -Z macro-backtrace for more info) error: aborting due to 2 previous errors