Skip to content

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

Merged
davidhewitt merged 10 commits into
PyO3:mainfrom
jonasdedden:experimental-inspect-pymodule-init-keeps-module-complete
Aug 27, 2026
Merged

experimental-inspect: no-parameter pymodule_init keeps module complete#6271
davidhewitt merged 10 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
Comment thread src/impl_/pymodule.rs Outdated
@jonasdedden

Copy link
Copy Markdown
Contributor Author

@Tpt cautious ping

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, a few small refinements suggested, overall I think this makes sense.

Comment thread guide/src/module.md Outdated
Comment thread src/impl_/pymodule.rs
Comment thread pyo3-macros-backend/src/module.rs Outdated
Comment on lines +207 to +211
let call = if pymodule_init_takes_module {
quote! { #ident(module) }
} else {
quote! { #ident() }
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It might be nice to use split_off_python_arg to optionally allow py: Python<'_> for initialization even if the module is not passed.

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.

py is now also allowed, plus a handful of new tests that show that this feature works

jonasdedden and others added 3 commits August 26, 2026 13:01
Co-authored-by: David Hewitt <mail@davidhewitt.dev>
- `#[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.
@jonasdedden

Copy link
Copy Markdown
Contributor Author

Raised some issue for the CI failure for completeness here: #6352

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, some smaller suggestions and then I think will be ready to merge.

Nightly rust failure should hopefully not block CI.

Comment thread pyo3-macros-backend/src/method.rs Outdated
Comment on lines +204 to +212
/// 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),
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why did this move? Seemed fine where it was.

Comment thread pyo3-macros-backend/src/module.rs Outdated
Comment on lines +221 to +223
pymodule_init = Some(quote! {
#pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#ident(#(#call_args),*))?;
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As a closing thought, I think if this is produced with the return type span the errors will be easier to locate.

Suggested change
pymodule_init = Some(quote! {
#pyo3_path::impl_::pymodule::PyModuleInitResult::into_result(#ident(#(#call_args),*))?;
});
let return_span = item_fn.sig.output.span();
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),*))?;
});

@jonasdedden

Copy link
Copy Markdown
Contributor Author

@davidhewitt applied your suggestions, thanks!

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks!

@davidhewitt
davidhewitt added this pull request to the merge queue Aug 27, 2026
Merged via the queue into PyO3:main with commit 2e4212d Aug 27, 2026
52 of 53 checks passed
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.

3 participants