From daeb9ecca789d50cc732cb570996e4a7faf8c024 Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Sat, 25 Jul 2026 18:17:51 +0200 Subject: [PATCH 1/2] `experimental-inspect`: emit `__all__` in the generated stubs `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 #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 --- guide/src/type-stub.md | 3 + newsfragments/6242.fixed.md | 2 + pyo3-introspection/src/stubs.rs | 121 ++++++++++++++++++++++++++++++-- pytests/stubs/annotations.pyi | 2 + pytests/stubs/awaitable.pyi | 2 + pytests/stubs/buf_and_str.pyi | 8 +++ pytests/stubs/comparisons.pyi | 10 +++ pytests/stubs/consts.pyi | 2 + pytests/stubs/datetime.pyi | 19 +++++ pytests/stubs/dict_iter.pyi | 2 + pytests/stubs/enums.pyi | 13 ++++ pytests/stubs/misc.pyi | 9 +++ pytests/stubs/objstore.pyi | 2 + pytests/stubs/othermod.pyi | 2 + pytests/stubs/path.pyi | 2 + pytests/stubs/pyclasses.pyi | 14 ++++ pytests/stubs/pyfunctions.pyi | 13 ++++ pytests/stubs/sequence.pyi | 2 + pytests/stubs/subclassing.pyi | 2 + 19 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 newsfragments/6242.fixed.md diff --git a/guide/src/type-stub.md b/guide/src/type-stub.md index 3c5272ac50f..3dac1419b57 100644 --- a/guide/src/type-stub.md +++ b/guide/src/type-stub.md @@ -55,6 +55,8 @@ will generate the following stub file: ```python import typing +__all__ = ["CONSTANT", "Class", "list_of_int_identity"] + CONSTANT: typing.Final = "FOO" @@ -89,3 +91,4 @@ PyO3 also provides the smaller `pyo3-introspection` binary that allows to genera - `FromPyObject::INPUT_TYPE` and `IntoPyObject::OUTPUT_TYPE` must be implemented for PyO3 to get the proper input/output type annotations to use. - PyO3 is not able to introspect the content of `#[pymodule]` and `#[pymodule_init]` functions. If they are present, the module is tagged as incomplete using a fake `def __getattr__(name: str) -> Incomplete: ...` function [following best practices](https://typing.python.org/en/latest/guides/writing_stubs.html#incomplete-stubs). + Such a module also gets no `__all__` declaration, because the set of names it exports at runtime is not known. diff --git a/newsfragments/6242.fixed.md b/newsfragments/6242.fixed.md new file mode 100644 index 00000000000..d2d2c8cf3a8 --- /dev/null +++ b/newsfragments/6242.fixed.md @@ -0,0 +1,2 @@ +`experimental-inspect`: generate the `__all__` declaration of complete modules in the type stubs, matching the `__all__` that `#[pymodule]` builds at runtime. +It is placed directly after the imports, following the conventions used by `typeshed`, and lists the members in the order the stub declares them. diff --git a/pyo3-introspection/src/stubs.rs b/pyo3-introspection/src/stubs.rs index 97ef14bcfc3..2db5b6f43c3 100644 --- a/pyo3-introspection/src/stubs.rs +++ b/pyo3-introspection/src/stubs.rs @@ -92,24 +92,28 @@ fn module_stubs(module: &Module, parents: &[&str]) -> String { )); } + let dunder_all = dunder_all_stubs(module, &imports); + let mut final_elements = Vec::new(); if let Some(docstring) = &module.docstring { final_elements.push(format!("\"\"\"\n{docstring}\n\"\"\"")); } final_elements.extend(imports.imports); + final_elements.extend(dunder_all); final_elements.extend(elements); let mut output = String::new(); - // We insert two line jumps (i.e. empty strings) only above and below multiple line elements (classes with methods, functions with decorators) + // We insert two line jumps (i.e. empty strings) only above and below multiple line elements + // (classes with methods, functions with decorators) and the `__all__` declaration for element in final_elements { - let is_multiline = element.contains('\n'); - if is_multiline && !output.is_empty() && !output.ends_with("\n\n") { + let needs_empty_lines = element.contains('\n') || element.starts_with("__all__"); + if needs_empty_lines && !output.is_empty() && !output.ends_with("\n\n") { output.push('\n'); } output.push_str(&element); output.push('\n'); - if is_multiline { + if needs_empty_lines { output.push('\n'); } } @@ -121,6 +125,54 @@ fn module_stubs(module: &Module, parents: &[&str]) -> String { output } +/// Generates the `__all__` declaration of a module, if we are able to write an accurate one. +/// +/// [`PyModuleMethods::add`] appends every name it adds to the module `__all__`, so any `#[pymodule]` +/// with at least one member has an `__all__` at runtime and the stub must declare it too. +/// +/// We only emit it for complete modules: for an incomplete one we do not know the full list of +/// members, and an `__all__` missing some of them would hide from type checkers names that do exist +/// at runtime. +/// +/// [`PyModuleMethods::add`]: https://docs.rs/pyo3/latest/pyo3/types/trait.PyModuleMethods.html#tymethod.add +fn dunder_all_stubs(module: &Module, imports: &Imports) -> Option { + if module.incomplete { + return None; + } + if module.attributes.iter().any(|a| a.name == "__all__") { + // The introspection data carries an explicit `__all__`, it is more accurate than ours + return None; + } + let member_count = module.attributes.len() + + module.classes.len() + + module.functions.len() + + module.modules.len(); + if member_count == 0 { + // Nothing was ever added to the module, so it has no `__all__` at runtime either + return None; + } + + // Each of these lists is already sorted by name, so we just list the members in the order the + // stub declares them, with the submodules (declared in their own file) last. + let mut elts = Vec::with_capacity(member_count); + elts.extend( + module + .attributes + .iter() + .map(|a| &a.name) + .chain(module.classes.iter().map(|c| &c.name)) + .chain(module.functions.iter().map(|f| &f.name)) + .chain(module.modules.iter().map(|m| &m.name)) + .map(|name| Expr::Constant { + value: Constant::Str(name.clone()), + }), + ); + + let mut buffer = "__all__ = ".to_string(); + imports.serialize_expr(&Expr::List { elts }, &mut buffer); + Some(buffer) +} + fn class_stubs(class: &Class, imports: &Imports) -> String { let mut buffer = String::new(); for decorator in &class.decorators { @@ -905,6 +957,67 @@ mod tests { assert_eq!(output, "dict[A, (A3.C, A3.D, B, A2, int, int2, float)]"); } + fn empty_module(name: &str) -> Module { + Module { + name: name.into(), + modules: Vec::new(), + classes: Vec::new(), + functions: Vec::new(), + attributes: Vec::new(), + incomplete: false, + docstring: None, + } + } + + /// The `pytests` stubs cover the common cases, we only test the ones they don't have: + /// a module with a submodule, and an empty module. + #[test] + fn test_dunder_all() { + let module = Module { + modules: vec![empty_module("sub")], + classes: vec![Class { + name: "Zulu".into(), + bases: Vec::new(), + methods: Vec::new(), + attributes: Vec::new(), + decorators: Vec::new(), + inner_classes: Vec::new(), + docstring: None, + }], + functions: vec![Function { + name: "func".into(), + decorators: Vec::new(), + arguments: Arguments { + positional_only_arguments: Vec::new(), + arguments: Vec::new(), + vararg: None, + keyword_only_arguments: Vec::new(), + kwarg: None, + }, + returns: None, + is_async: false, + docstring: None, + }], + attributes: vec![Attribute { + name: "CONST".into(), + value: Some(Expr::Constant { + value: Constant::Int("1".into()), + }), + annotation: None, + docstring: None, + }], + ..empty_module("bar") + }; + // The names are the ones `PyModuleMethods::add` puts in `__all__` at runtime, including + // the submodule which is declared in its own stub file. + assert_eq!( + module_stubs(&module, &["foo"]), + "__all__ = [\"CONST\", \"Zulu\", \"func\", \"sub\"]\n\nCONST = 1\nclass Zulu: ...\ndef func(): ...\n" + ); + // Nothing was added to an empty module, so it has no `__all__` at runtime either + assert_eq!(module_stubs(&empty_module("bar"), &["foo"]), ""); + } + #[test] fn test_make_module_path_relative() { assert_eq!( diff --git a/pytests/stubs/annotations.pyi b/pytests/stubs/annotations.pyi index b9059252602..4f2ef34e9b4 100644 --- a/pytests/stubs/annotations.pyi +++ b/pytests/stubs/annotations.pyi @@ -1,5 +1,7 @@ from .pyclasses import EmptyClass +__all__ = ["cross_module_imports", "with_custom_type_annotations"] + def cross_module_imports(_a: EmptyClass) -> None: ... def with_custom_type_annotations( a: "list[int]", *_args: "str", _b: "int | None" = None, **_kwargs: "bool" diff --git a/pytests/stubs/awaitable.pyi b/pytests/stubs/awaitable.pyi index fb5bc19d6c5..38e3ec7d612 100644 --- a/pytests/stubs/awaitable.pyi +++ b/pytests/stubs/awaitable.pyi @@ -1,5 +1,7 @@ from typing import Any, final +__all__ = ["FutureAwaitable", "IterAwaitable"] + @final class FutureAwaitable: def __await__(self, /) -> FutureAwaitable: ... diff --git a/pytests/stubs/buf_and_str.pyi b/pytests/stubs/buf_and_str.pyi index a6b64db3862..d049abe284a 100644 --- a/pytests/stubs/buf_and_str.pyi +++ b/pytests/stubs/buf_and_str.pyi @@ -5,6 +5,14 @@ Objects related to PyBuffer and PyStr from collections.abc import Sequence from typing import Any, final +__all__ = [ + "BytesExtractor", + "map_byte_cow", + "map_byte_slice", + "map_byte_vec", + "return_memoryview", +] + @final class BytesExtractor: """ diff --git a/pytests/stubs/comparisons.pyi b/pytests/stubs/comparisons.pyi index 0cbfe04f94b..565054b5ea6 100644 --- a/pytests/stubs/comparisons.pyi +++ b/pytests/stubs/comparisons.pyi @@ -1,5 +1,15 @@ from typing import final +__all__ = [ + "Eq", + "EqDefaultNe", + "EqDerived", + "Ordered", + "OrderedDefaultNe", + "OrderedDerived", + "OrderedRichCmp", +] + @final class Eq: def __eq__(self, /, other: object) -> bool: ... diff --git a/pytests/stubs/consts.pyi b/pytests/stubs/consts.pyi index 695dda43165..26ff92d4a70 100644 --- a/pytests/stubs/consts.pyi +++ b/pytests/stubs/consts.pyi @@ -1,5 +1,7 @@ from typing import Final, final +__all__ = ["ESCAPING", "PI", "ClassWithConst"] + ESCAPING: Final = "S\0\x01\t\n\r\"'\\" """ We experiment with "escaping" diff --git a/pytests/stubs/datetime.pyi b/pytests/stubs/datetime.pyi index 312fd11f683..635dfd044f2 100644 --- a/pytests/stubs/datetime.pyi +++ b/pytests/stubs/datetime.pyi @@ -1,6 +1,25 @@ from datetime import date, datetime, time, timedelta, tzinfo from typing import final +__all__ = [ + "TzClass", + "date_from_timestamp", + "datetime_from_timestamp", + "get_date_tuple", + "get_datetime_tuple", + "get_datetime_tuple_fold", + "get_datetime_tzinfo", + "get_delta_tuple", + "get_time_tuple", + "get_time_tuple_fold", + "get_time_tzinfo", + "make_date", + "make_datetime", + "make_delta", + "make_time", + "time_with_fold", +] + @final class TzClass(tzinfo): def __new__(cls, /) -> TzClass: ... diff --git a/pytests/stubs/dict_iter.pyi b/pytests/stubs/dict_iter.pyi index 92aee6e79ad..a6884763faa 100644 --- a/pytests/stubs/dict_iter.pyi +++ b/pytests/stubs/dict_iter.pyi @@ -1,5 +1,7 @@ from typing import final +__all__ = ["DictSize"] + @final class DictSize: def __new__(cls, /, expected: int) -> DictSize: ... diff --git a/pytests/stubs/enums.pyi b/pytests/stubs/enums.pyi index 59d3d281016..21ac351ce49 100644 --- a/pytests/stubs/enums.pyi +++ b/pytests/stubs/enums.pyi @@ -1,5 +1,18 @@ from typing import Any, Final, final +__all__ = [ + "ComplexEnum", + "MixedComplexEnum", + "SimpleEnum", + "SimpleEnumWithoutDerive", + "SimpleTupleEnum", + "TupleEnum", + "do_complex_stuff", + "do_mixed_complex_stuff", + "do_simple_stuff", + "do_tuple_stuff", +] + class ComplexEnum: @final class EmptyStruct(ComplexEnum): diff --git a/pytests/stubs/misc.pyi b/pytests/stubs/misc.pyi index 797c6a74520..b984297ab40 100644 --- a/pytests/stubs/misc.pyi +++ b/pytests/stubs/misc.pyi @@ -1,5 +1,14 @@ from typing import Any +__all__ = [ + "accepts_bool", + "detach_during_finalization", + "get_item_and_run_callback", + "get_type_fully_qualified_name", + "hammer_attaching_in_thread", + "issue_219", +] + def accepts_bool(val: bool) -> bool: ... def detach_during_finalization() -> Any: ... def get_item_and_run_callback(dict: dict, callback: Any) -> None: ... diff --git a/pytests/stubs/objstore.pyi b/pytests/stubs/objstore.pyi index c8ee5a638cf..5b6af241b5c 100644 --- a/pytests/stubs/objstore.pyi +++ b/pytests/stubs/objstore.pyi @@ -1,5 +1,7 @@ from typing import Any, final +__all__ = ["ObjStore"] + @final class ObjStore: def __new__(cls, /) -> ObjStore: ... diff --git a/pytests/stubs/othermod.pyi b/pytests/stubs/othermod.pyi index ec7e6acb8cc..5a0300b7800 100644 --- a/pytests/stubs/othermod.pyi +++ b/pytests/stubs/othermod.pyi @@ -1,5 +1,7 @@ from typing import Final, final +__all__ = ["USIZE_MAX", "USIZE_MIN", "ModClass", "double"] + USIZE_MAX: Final[int] USIZE_MIN: Final[int] diff --git a/pytests/stubs/path.pyi b/pytests/stubs/path.pyi index 03bbb36a2e7..f2decbff304 100644 --- a/pytests/stubs/path.pyi +++ b/pytests/stubs/path.pyi @@ -1,5 +1,7 @@ from os import PathLike from pathlib import Path +__all__ = ["make_path", "take_pathbuf"] + def make_path() -> Path: ... def take_pathbuf(path: str | PathLike[str]) -> Path: ... diff --git a/pytests/stubs/pyclasses.pyi b/pytests/stubs/pyclasses.pyi index 9dd201c2d03..aa056dd1be4 100644 --- a/pytests/stubs/pyclasses.pyi +++ b/pytests/stubs/pyclasses.pyi @@ -1,6 +1,20 @@ from _typeshed import Incomplete from typing import Final, final +__all__ = [ + "AssertingBaseClass", + "ClassWithDecorators", + "ClassWithDict", + "ClassWithoutConstructor", + "EmptyClass", + "Number", + "PlainObject", + "PyClassIter", + "PyClassThreadIter", + "SubClassWithInit", + "map_a_class", +] + class AssertingBaseClass: """ Demonstrates a base class which can operate on the relevant subclass in its constructor. diff --git a/pytests/stubs/pyfunctions.pyi b/pytests/stubs/pyfunctions.pyi index 322f2642339..440dfa106a8 100644 --- a/pytests/stubs/pyfunctions.pyi +++ b/pytests/stubs/pyfunctions.pyi @@ -1,5 +1,18 @@ from typing import Any +__all__ = [ + "args_kwargs", + "many_keyword_arguments", + "none", + "positional_only", + "simple", + "simple_args", + "simple_args_kwargs", + "simple_kwargs", + "with_async", + "with_typed_args", +] + def args_kwargs(*args, **kwargs) -> tuple[tuple, dict | None]: ... def many_keyword_arguments( *, diff --git a/pytests/stubs/sequence.pyi b/pytests/stubs/sequence.pyi index 698255e8461..9cd126b288a 100644 --- a/pytests/stubs/sequence.pyi +++ b/pytests/stubs/sequence.pyi @@ -1,5 +1,7 @@ from collections.abc import Sequence +__all__ = ["array_to_array_i32", "vec_to_vec_i32", "vec_to_vec_pystring"] + def array_to_array_i32(arr: Sequence[int]) -> list[int]: ... def vec_to_vec_i32(vec: Sequence[int]) -> list[int]: ... def vec_to_vec_pystring(vec: Sequence[str]) -> list[str]: ... diff --git a/pytests/stubs/subclassing.pyi b/pytests/stubs/subclassing.pyi index b3bac489802..9aecfdc233a 100644 --- a/pytests/stubs/subclassing.pyi +++ b/pytests/stubs/subclassing.pyi @@ -1,5 +1,7 @@ from typing import final +__all__ = ["SubDict", "Subclass", "Subclassable"] + @final class SubDict(dict): def __new__(cls, /) -> SubDict: ... From 58691aa4d2923eca2e25c6854275c83feb588fdb Mon Sep 17 00:00:00 2001 From: Jonas Dedden Date: Mon, 27 Jul 2026 18:51:01 +0200 Subject: [PATCH 2/2] `experimental-inspect`: check the generated `__all__` against the runtime one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- noxfile.py | 5 +++++ pytests/noxfile.py | 5 ++++- pytests/tests/test_stubs.py | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 pytests/tests/test_stubs.py diff --git a/noxfile.py b/noxfile.py index 983fdf882fe..da0519ae2a0 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1700,6 +1700,8 @@ def test_introspection(session: nox.Session): with tempfile.TemporaryDirectory() as stub_dir: session.install("maturin") session.install("ruff") + # `pytest-benchmark` backs the `--benchmark-disable` in `pytests/pyproject.toml` + session.install("pytest", "pytest-benchmark") options = [] target = os.environ.get("CARGO_BUILD_TARGET") if target is not None: @@ -1735,6 +1737,9 @@ def test_introspection(session: nox.Session): ) _run(session, "ruff", "format", stub_dir) _ensure_directory_equals(Path(stub_dir), Path("pytests/stubs")) + # The stubs only match a module built with the features used above, so this test + # is skipped by the regular `pytests` session and runs here instead. + _run(session, "pytest", "pytests/tests/test_stubs.py") def _ensure_directory_equals(expected_dir: Path, actual_dir: Path): diff --git a/pytests/noxfile.py b/pytests/noxfile.py index 6d8fc94d5c9..c7dfbf189df 100644 --- a/pytests/noxfile.py +++ b/pytests/noxfile.py @@ -26,7 +26,10 @@ def try_install_binary(package: str, constraint: str): # - is a dependency of gevent try_install_binary("zope.interface", "<7") try_install_binary("gevent", ">=22.10.2") - ignored_paths = [] + # The stubs are generated from a build with `experimental-async,experimental-inspect`, + # so only such a build exports the members they declare. The `test-introspection` + # session runs this test against one. + ignored_paths = ["tests/test_stubs.py"] if sys.version_info < (3, 10): # Match syntax is only available in Python >= 3.10 ignored_paths.append("tests/test_enums_match.py") diff --git a/pytests/tests/test_stubs.py b/pytests/tests/test_stubs.py new file mode 100644 index 00000000000..aff334c7fa8 --- /dev/null +++ b/pytests/tests/test_stubs.py @@ -0,0 +1,45 @@ +import ast +from pathlib import Path + +import pyo3_pytests +import pytest + +STUBS_DIR = Path(__file__).parent.parent / "stubs" + + +def _stub_dunder_all(path: Path): + """The `__all__` a stub file declares, or `None` if it declares none.""" + for node in ast.parse(path.read_text()).body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "__all__" + for target in node.targets + ): + return ast.literal_eval(node.value) + return None + + +def _module_for_stub(path: Path): + root = pyo3_pytests.pyo3_pytests + return root if path.name == "__init__.pyi" else getattr(root, path.stem) + + +@pytest.mark.parametrize( + "stub_file", sorted(STUBS_DIR.glob("*.pyi")), ids=lambda path: path.name +) +def test_stub_dunder_all_matches_runtime(stub_file: Path): + """The `__all__` in the stubs must name what the module exports at import time. + + We compare sorted lists rather than the lists themselves: 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. + + This needs `pyo3_pytests` built with the features the stubs were generated from, so it + runs in the `test-introspection` nox session rather than in `pytests`' own one. + """ + stub_all = _stub_dunder_all(stub_file) + if stub_all is None: + pytest.skip("incomplete modules get no `__all__`, see `guide/src/type-stub.md`") + runtime_all = _module_for_stub(stub_file).__all__ + assert sorted(stub_all) == sorted(runtime_all) + assert len(stub_all) == len(set(stub_all)), "`__all__` has duplicates"