Skip to content

experimental-inspect: emit __all__ in the generated stubs - #6242

Open
jonasdedden wants to merge 2 commits into
PyO3:mainfrom
jonasdedden:introspection-dunder-all
Open

experimental-inspect: emit __all__ in the generated stubs#6242
jonasdedden wants to merge 2 commits into
PyO3:mainfrom
jonasdedden:introspection-dunder-all

Conversation

@jonasdedden

Copy link
Copy Markdown

Fixes #6241.

Problem

PyModuleMethods::add appends 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-introspection never
emitted one, so the stub and the runtime disagreed and mypy.stubtest reported one error per module:

error: pyo3_pytests.misc.__all__ is not present in stub

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_stubs now emits an __all__ listing the module's submodules, classes, functions and
attributes — exactly the names add puts there at runtime. The names are sorted, and the list is
serialized through the existing Expr machinery so string escaping is shared with the rest of the
generator.

from typing import final

__all__ = ["SubDict", "Subclass", "Subclassable"]

@final
class SubDict(dict): ...

The checked-in pytests/stubs/*.pyi are regenerated accordingly.

The open question in the issue: incomplete modules

Modules with a #[pymodule]/#[pymodule_init] function are tagged incomplete and get a
def __getattr__(name: str) -> Incomplete: ... marker. This PR emits no __all__ for those.

The alternatives don't hold up:

  • A partial __all__ = [...] asserts a completeness the introspector cannot verify. It would hide
    from type checkers names that do exist at runtime, and it does not silence stubtest anyway — it
    just swaps __all__ is not present in stub for names exported from the stub do not correspond to the names exported at runtime.
  • A bare __all__: list[str] does not help either: stubtest explicitly 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 one if module.incomplete guard in dunder_all_stubs.

Effect on pyo3_pytests

Measured with stubtest 2.3.0 on CPython 3.13, stubs copied next to the extension:

before after
__all__ errors 15 1
total stubtest errors 100 86

The one remaining __all__ error is the root pyo3_pytests module, which is incomplete because of
its #[pymodule_init]. Its runtime __all__ consists entirely of submodules, which the stubs do not
declare yet — that is the separate submodule-declaration issue, and the two fixes compose.

Tests

  • Three unit tests in pyo3-introspection/src/stubs.rs: the normal case, an incomplete module (no
    __all__), and an empty module (no __all__, since nothing is ever added and the attribute
    does not exist at runtime either).
  • nox -s test-introspection passes against the regenerated pytests/stubs.
  • Guide (guide/src/type-stub.md) example output and the incomplete-modules note updated.

@chirizxc

Copy link
Copy Markdown
Contributor

https://peps.python.org/pep-0008/#module-level-dunder-names

Maybe we should make sure that __all__ = () is always at the top?

Comment thread pyo3-introspection/src/stubs.rs Outdated
Comment thread pyo3-introspection/src/stubs.rs
@jonasdedden

jonasdedden commented Jul 25, 2026

Copy link
Copy Markdown
Author

https://peps.python.org/pep-0008/#module-level-dunder-names

Maybe we should make sure that all = () is always at the top?

Thanks for linking the PEP8 on this topic! I had a deeper look, since the __all__ even before stdlib or first or third party imports script struck me as weird, I've never seen this in other Python code. I think one also has to differentiate a bit between real Python code and typestubs, as they can have slightly different formatting and other rules.

Let's look at typeshed as the prime example, these are stdlib typestubs. Here, __all__ is typically put after the import block, as shown here for example. I also checked the typeshed package in general, this is the pattern used throughout 229 stub files in it. I think we should adhere to typeshed's formatting here, this also seems to be the __all__ placement used in other libraries.

I also checked ruff linting & formatting rules, and even with all rules enabled (ruff check --select ALL) it seems to be fine with both orderings, on both .py as well as .pyi files. The only placement-adjacent rule is E402 module-import-not-at-top-of-file (https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/), and it seems to have an explicit ignore for __all__. The 12 ruff rules that touch __all__/dunders (RUF022, RUF068, F822, PLE0604, PLE0605, PYI056, …) are all about content, not position.

What I implemented already conformed with typeshed's (and other libraries) behavior on positioning of __all__, but nevertheless I applied some improvements in da59625. I noticed that the ordering within the __all__ block is actually checked in ruff and expected in a certain way, specifically in rule RUF022. There also could be some standardization of how many lines have to follow an __all__ block.

@jonasdedden

jonasdedden commented Jul 25, 2026

Copy link
Copy Markdown
Author

Regarding the sorting I introduced in da59625. @chirizxc , would you care right now for ruff check --select I (isort) or ruff format compliance?

I'm asking because there are also two other stub files on main (pytests/stubs/exception.pyi and pytests/stubs/pyclasses.pyi) and a test that actually don't conform to ruff's isort-ing.

One could therefore argue that compliance to RUF022 (sorting of the __all__ block) is also not important, at least right now? Maybe we could leave ruff compliance out for now (i.e. remove isort_style_cmp and natural_cmp by reverting da59625), and then tackle ruff isort and RUF022 compliance in a followup?

@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!

+1 for the approach taken on incomplete modules

Comment thread pyo3-introspection/src/stubs.rs Outdated
Other,
}

fn kind_of(name: &str) -> MemberKind {

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.

instead of guessing the kind, what about sorting constants, classes and other separately then concatenate the outputs? This way you can avoid this guess

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.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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?

Comment thread pyo3-introspection/src/stubs.rs Outdated
}

#[test]
fn test_dunder_all() {

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.

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

@jonasdedden jonasdedden Jul 26, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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 {

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.

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)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, now bailing out if module.attributes already contains an __all__.

@jonasdedden
jonasdedden force-pushed the introspection-dunder-all branch from e6e8bbb to cf721d0 Compare July 26, 2026 21:04
@jonasdedden

Copy link
Copy Markdown
Author

@davidhewitt I explored generating __all__ directly from macros (which also uncovered edge cases in this PR I'm going to push a bugfix for shortly), and it seems doable. Let me give you a rough Pro & Contra list though why I think this PR's approach is still superior:

Pros of macro-approach:

  • Not an "inference"-ish approach, but creating __all__ right where modules & classes & co. are declared
  • Guaranteed exact same ordering in __all__ as the runtime extension exposes

Contras:

  • Instead of one self-contained function, it is roughly 6x the amount of code and touches 6x as many files, at least in my implementation. I also had to do an API change by introducing a Module::dunder_all field.
  • For backwards compatibility (i.e. new reader reading old data), I believe the approach chosen in this PR actually also has to be implemented as an additional fallback into the macro approach.
  • No forward compatibility in the shape I implemented (dedicated chunk field, ignored by older readers). Tpt's original suggestion — emitting it as a plain attribute chunk — would be forward-compatible, but it can't express 'name unknown at compile time', so it breaks for modules exporting native types.

Given that mypy and stubtest to the best of my knowledge consume __all__ purely as sets, there isn't really a user-facing advantage of doing it from macros (i.e. ordering is not important, as long as all runtime-exposed symbols are in __all__), but happy to continue exploring that approach if you think it's better.

`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.
@jonasdedden
jonasdedden force-pushed the introspection-dunder-all branch from 3da1fee to 58691aa Compare July 27, 2026 22:14
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.

experimental-inspect: __all__ is set at runtime by #[pymodule] but never emitted into the stubs

4 participants