experimental-inspect: emit __all__ in the generated stubs - #6242
experimental-inspect: emit __all__ in the generated stubs#6242jonasdedden wants to merge 4 commits into
experimental-inspect: emit __all__ in the generated stubs#6242Conversation
|
https://peps.python.org/pep-0008/#module-level-dunder-names Maybe we should make sure that |
Thanks for linking the PEP8 on this topic! I had a deeper look, since the Let's look at I also checked What I implemented already conformed with |
|
Regarding the sorting I introduced in da59625. @chirizxc , would you care right now for I'm asking because there are also two other stub files on One could therefore argue that compliance to RUF022 (sorting of the |
Tpt
left a comment
There was a problem hiding this comment.
Thank you!
+1 for the approach taken on incomplete modules
| Other, | ||
| } | ||
|
|
||
| fn kind_of(name: &str) -> MemberKind { |
There was a problem hiding this comment.
instead of guessing the kind, what about sorting constants, classes and other separately then concatenate the outputs? This way you can avoid this guess
There was a problem hiding this comment.
Going further, is it correct to be sorting here at all? Wouldn't that potentially lead to the type stub having a different value for the constant compared to the runtime behaviour? Can that cause problems?
If the suggestion in https://github.com/PyO3/pyo3/pull/6242/changes#r3652677133 is to make the macros emit __all__ anyway, should we skip adding any code to pyo3-introspection and just add emission in the macros, where we can be confident of the runtime value?
There was a problem hiding this comment.
I removed the entire sorting again, mainly because I think that convert_members (introspection.rs:205-229) already sorts each of modules/classes/functions/attributes by name, and there also doesn't seem to be too much focus on strict ruff compliance elsewhere right now.
Regarding emitting __all__ in macros: I believe this would be a larger change and has some kinks here and there (small investigation by the side running). For now I'd propose to do it in the way this PR does it, and maybe we can investigate later?
| } | ||
|
|
||
| #[test] | ||
| fn test_dunder_all() { |
There was a problem hiding this comment.
nit: I would not bother writing unit tests for things that are already well covered by the integration tests. It's more code to maintain and not much added coverage
There was a problem hiding this comment.
I deleted test_isort_style_cmp and test_dunder_all_of_incomplete_module (the latter is covered by pytests/stubs/__init__.pyi); I kept one trimmed test_dunder_all for the two cases pytests doesn't cover (submodule in __all__, empty module). Feel free to signal if you want to have this moved into a separate integration-style test.
| /// | ||
| /// [`PyModuleMethods::add`]: https://docs.rs/pyo3/latest/pyo3/types/trait.PyModuleMethods.html#tymethod.add | ||
| fn dunder_all_stubs(module: &Module, imports: &Imports) -> Option<String> { | ||
| if module.incomplete { |
There was a problem hiding this comment.
pedantic nit: I would also check here that the module does not already include a __all__ constant. This way we can move the __all__ generation into the macros without breaking the old pyo3-introspection versions (the generating code here would just stop to run)
There was a problem hiding this comment.
Done, now bailing out if module.attributes already contains an __all__.
e6e8bbb to
cf721d0
Compare
|
@davidhewitt I explored generating Pros of macro-approach:
Contras:
Given that |
3da1fee to
58691aa
Compare
|
In #6271 I implement necessary changes to not lose the emission of |
…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.
58691aa to
5b32604
Compare
|
@davidhewitt & @Tpt : I gave this PR another cleanup; As mentioned in the previous comment, I believe this PR is the easiest way to get functionally correct |
Merging this PR will not alter performance
Comparing Footnotes
|
|
@Tpt cautious ping |
…plete (PyO3#6271) * feat(inspect): let a no-argument #[pymodule_init] keep the module complete `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. * Rename * 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. * Review * Allow `pymodule_init` also to be unfallible * Address review * Update guide/src/module.md Co-authored-by: David Hewitt <mail@davidhewitt.dev> * 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. * Address review: keep `split_off_python_arg` in `pymethod`, span init on return type --------- Co-authored-by: David Hewitt <mail@davidhewitt.dev>
`PyModuleMethods::add` appends every name it adds to the module `__all__`, so every `#[pymodule]` with at least one member has an `__all__` at runtime. The generated stubs never declared one, so the stub and the runtime disagreed and `mypy.stubtest` reported one error per module. `__all__` is only emitted for complete modules: for a module tagged incomplete we don't know the full list of members, and a partial `__all__` would hide from type checkers names that do exist at runtime. Fixes PyO3#6241 It is also followed by an empty line now, so that it never sticks to the first declaration. Both match what `typeshed` does: of the 231 stdlib stubs declaring `__all__`, 229 have it before any class or function and 204 have an empty line after it. `dunder_all_stubs` now sizes the name vector upfront and returns before allocating it at all when the module has no member, as suggested in review. Review comments
…time one Nothing tied the `__all__` in the checked-in stubs to the value the modules actually build at import time, which is what let the `#[pyfunction(name = "...")]` mismatch go unnoticed. The test compares sets rather than lists: the stubs list the members in the order they are declared in, which is not the order `PyModuleMethods::add` appends them in. `__all__` is only ever consumed as a set of names, so that difference is not observable — writing the assertion this way is also the clearest place to record that decision. It needs no `experimental-inspect` build, only the checked-in stubs and the extension, so it runs as part of the normal `pytest` session. Run the `__all__` stub test where the build matches the stubs `ruff`'s `B009` rejected the `getattr` call, and the test itself only holds for a `pyo3_pytests` built with `experimental-async,experimental-inspect`, which is what the checked-in stubs are generated from. `pytests`' own session builds without them, so `annotations` and `with_async` are missing there; run the test from the `test-introspection` session instead.
5b32604 to
18b18f2
Compare
|
Btw., the merged PRs of the last weeks show some arguably impressive results:
The crossed out values reflected the state of PyO3's Remaining issues are 17x undeclared submodules, 1x |
Fixes #6241.
Problem
PyModuleMethods::addappends every name it adds to the module's__all__(src/types/module.rs:500), so every#[pymodule]with at least one member has an__all__at runtime.pyo3-introspectionnever emitted one, so the stub and the runtime disagreed andmypy.stubtestreported one error per module:Beyond
stubtest, a stub without__all__makes type checkers fall back to the implicit re-export rules, which is not what the runtime module actually exports.Change
module_stubsnow emits an__all__listing the module's submodules, classes, functions and attributes - exactly the namesaddputs there at runtime. The names are sorted, and the list is serialized through the existingExprmachinery so string escaping is shared with the rest of the generator.The checked-in
pytests/stubs/*.pyiare regenerated accordingly.The open question in the issue: incomplete modules
Modules with a
#[pymodule]/#[pymodule_init]function are tagged incomplete and get adef __getattr__(name: str) -> Incomplete: ...marker. This PR emits no__all__for those.The alternatives don't hold up:
__all__ = [...]asserts a completeness the introspector cannot verify. It would hide from type checkers names that do exist at runtime, and it does not silencestubtestanyway - it just swaps__all__ is not present in stubfornames exported from the stub do not correspond to the names exported at runtime.__all__: list[str]does not help either:stubtestexplicitly handles that case and still compares the exported names (mypy/stubtest.py,_verify_exported_names), so it reports the same mismatch.Leaving
__all__out keeps the stub accurate. It is easy to revisit if maintainers prefer otherwise - it's the oneif module.incompleteguard indunder_all_stubs.Effect on
pyo3_pytestsMeasured with
stubtest2.3.0 on CPython 3.13, stubs copied next to the extension:__all__errorsstubtesterrorsThe one remaining
__all__error is the rootpyo3_pytestsmodule, which is incomplete because its#[pymodule_init]takes the module. Its runtime__all__is exactly its 17 submodules, and the introspector already knows all of them (Module::modulesis populated, that is how thesibling .pyifiles are written) so the list would be accurate; it is withheld only by theincompleteguard.Tests
pyo3-introspection/src/stubs.rs: the normal case, an incomplete module (no__all__), and an empty module (no__all__, since nothing is everadded and the attribute does not exist at runtime either).nox -s test-introspectionpasses against the regeneratedpytests/stubs.guide/src/type-stub.md) example output and the incomplete-modules note updated.