Skip to content

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

Open
jonasdedden wants to merge 9 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 9 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
unsafe impl Sync for PyModuleDefSlots {}

/// Used to accept either `()` or `Result<(), E>` from a `#[pymodule_init]` function.
pub trait PyModuleInitResult {

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.

I think this will make the UX nicer

Suggested change
pub trait PyModuleInitResult {
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a suitable return value for `#[pymodule_init]` functions
)]
pub trait PyModuleInitResult {

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 suggested change and added an additional note too

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

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