Skip to content

experimental-inspect: no-parameter pymodule_init keeps module complete - #6271

Open
jonasdedden wants to merge 6 commits into
PyO3:mainfrom
jonasdedden:experimental-inspect-pymodule-init-keeps-module-complete
Open

experimental-inspect: no-parameter pymodule_init keeps module complete#6271
jonasdedden wants to merge 6 commits into
PyO3:mainfrom
jonasdedden:experimental-inspect-pymodule-init-keeps-module-complete

Conversation

@jonasdedden

@jonasdedden jonasdedden commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

incomplete was set to pymodule_init.is_some(), so any declarative module with an initialiser got def __getattr__(name: str) -> Incomplete: ... in its stubs. This is also relevant for #6242 where it would lead to no emission of __all__ either, breaking stubtest compliance. 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, so it would be silly to have such a strong negative impact on typestubs:

    #[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.

@jonasdedden jonasdedden changed the title experimental-inspect no-parameter pymodule init keeps module complete experimental-inspect: no-parameter pymodule init keeps module complete Jul 31, 2026
@jonasdedden jonasdedden changed the title experimental-inspect: no-parameter pymodule init keeps module complete experimental-inspect: no-parameter pymodule_init keeps module complete Jul 31, 2026
@davidhewitt davidhewitt mentioned this pull request Aug 3, 2026
8 tasks
…plete

`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 PyO3#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.
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.
@jonasdedden
jonasdedden force-pushed the experimental-inspect-pymodule-init-keeps-module-complete branch from 3da1d09 to f2b01cd Compare August 14, 2026 11:48
@jonasdedden

Copy link
Copy Markdown
Contributor Author

@davidhewitt I ran some additional cleanups over this PR and also introduced one additional change: pymodule_init now also is allowed to return -> (), which simplifies the examples around logging init for example. The latest change is in commit f2b01cd, meaning it also could be easily reverted if you wish so.

@Tpt Tpt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Makes perfect sense to allow () as a return type

Comment thread pyo3-macros-backend/src/module.rs Outdated
pymodule_init = Some(quote! { #ident(module)?; });
ensure_spanned!(
item_fn.sig.inputs.len() <= 1,
item_fn.sig.inputs[1].span() => "`#[pymodule_init]` takes either no argument or the module"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hyper-nit: it's a bit weird to anchor the error on the first argument and not on the full function signature

Suggested change
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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented the suggestion

Comment thread src/impl_/pymodule.rs Outdated
Comment on lines +499 to +506
impl<T, E> PyModuleInitResult for Result<T, E>
where
PyErr: From<E>,
{
fn into_result(self) -> PyResult<()> {
self.map(|_| ()).map_err(PyErr::from)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ignores the return type. Is it something we want to do? I would tend to think it might be cleaner to fail on everything that is not ():

Suggested change
impl<T, E> PyModuleInitResult for Result<T, E>
where
PyErr: From<E>,
{
fn into_result(self) -> PyResult<()> {
self.map(|_| ()).map_err(PyErr::from)
}
}
impl<E> PyModuleInitResult for Result<(), E>
where
PyErr: From<E>,
{
fn into_result(self) -> PyResult<()> {
self.map_err(PyErr::from)
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now, only () and Result<(), E> are allowed, as suggested

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants