From 0d50caafce326c49fa727c2ee2ec63d44c830f33 Mon Sep 17 00:00:00 2001 From: ShirGanon <45141251+ShirGanon@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:48:41 +0300 Subject: [PATCH] stubtest: don't report re-exported submodules as missing from the stub When a package binds a submodule as an attribute at runtime (e.g. via `from .submod import *` in `__init__`, or by importing it) and lists it in `__all__`, stubtest reported the submodule as "not present in stub" and as an `__all__` mismatch, even though the submodule has its own stub file and is verified separately. This produced one spurious diagnostic per submodule. Skip reporting such submodules (those present in `_all_stubs`) as missing from the parent package's stub, and exclude them from the `__all__` runtime-vs-stub comparison. Fixes #21328 Co-Authored-By: Claude Opus 4.8 --- mypy/stubtest.py | 30 +++++++++++++++++++++++++++++- mypy/test/teststubtest.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/mypy/stubtest.py b/mypy/stubtest.py index 63b584e8652f4..8cdb67fe6730c 100644 --- a/mypy/stubtest.py +++ b/mypy/stubtest.py @@ -298,14 +298,34 @@ def verify( yield Error(object_path, "is an unknown mypy node", stub, runtime) +def _get_stub_submodules(module_fullname: str) -> set[str]: + """Names of the direct submodules of ``module_fullname`` that have their own stub. + + Python's import system binds an imported submodule as an attribute of its + parent package, so such submodules are implicitly available on the package + at runtime (e.g. via ``from .submod import *`` in ``__init__``) even if the + package's stub does not re-import them explicitly. They are verified + separately as their own modules. + """ + return { + name.rpartition(".")[2] + for name in _all_stubs + if name.rpartition(".")[0] == module_fullname + } + + def _verify_exported_names( object_path: list[str], stub: nodes.MypyFile, runtime_all_as_set: set[str] ) -> Iterator[Error]: # note that this includes the case the stub simply defines `__all__: list[str]` assert "__all__" in stub.names public_names_in_stub = {m for m, o in stub.names.items() if o.module_public} + # Submodules with their own stub are implicitly available as attributes of + # the package at runtime, so don't report them as missing from the stub's + # `__all__`. See #21328. + stub_submodules = _get_stub_submodules(stub.fullname) names_in_stub_not_runtime = sorted(public_names_in_stub - runtime_all_as_set) - names_in_runtime_not_stub = sorted(runtime_all_as_set - public_names_in_stub) + names_in_runtime_not_stub = sorted(runtime_all_as_set - public_names_in_stub - stub_submodules) if not (names_in_runtime_not_stub or names_in_stub_not_runtime): return yield Error( @@ -440,6 +460,14 @@ def _belongs_to_runtime(r: types.ModuleType, attr: str) -> bool: if isinstance(stub_entry, nodes.MypyFile): # Don't recursively check exported modules, since that leads to infinite recursion continue + if isinstance(stub_entry, Missing) and f"{stub.fullname}.{entry}" in _all_stubs: + # A submodule with its own stub is implicitly bound as an attribute of + # its parent package once imported at runtime (e.g. via + # `from .submod import *` in __init__). It is verified separately as + # its own module, so don't report it as missing from the parent's + # stub. See #21328. + if isinstance(getattr(runtime, entry, MISSING), types.ModuleType): + continue assert stub_entry is not None if ( is_probably_private(entry) diff --git a/mypy/test/teststubtest.py b/mypy/test/teststubtest.py index 71555c03b9ad4..62e33cfc7df72 100644 --- a/mypy/test/teststubtest.py +++ b/mypy/test/teststubtest.py @@ -3026,6 +3026,39 @@ def test_reexport_reports_import_location(self) -> None: assert "__init__.pyi" not in filtered_output assert "mod.py:1" in filtered_output + def test_reexported_submodule_in_all(self) -> None: + # A submodule that is bound on the package at runtime (here via + # `from . import submod` in __init__, and listed in `__all__`) and that + # has its own stub should not be reported as missing from the parent + # package's stub, nor as an `__all__` mismatch. See #21328. + with use_tmp_dir(TEST_MODULE_NAME) as tmp_dir: + Path("builtins.pyi").write_text(stubtest_builtins_stub) + Path("typing.pyi").write_text(stubtest_typing_stub) + Path("enum.pyi").write_text(stubtest_enum_stub) + + os.makedirs("test_module", exist_ok=True) + Path("test_module/__init__.py").write_text( + "from . import submod\n__all__ = ['submod']\n" + ) + Path("test_module/__init__.pyi").write_text("__all__ = ['submod']\n") + Path("test_module/submod.py").write_text("x = 1\n") + Path("test_module/submod.pyi").write_text("x: int\n") + + output = io.StringIO() + outerr = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(outerr): + test_stubs(parse_options([TEST_MODULE_NAME]), use_builtins_fixtures=True) + + filtered_output = remove_color_code( + output.getvalue() + .replace(os.path.realpath(tmp_dir) + os.sep, "") + .replace(tmp_dir + os.sep, "") + ) + + assert "submod is not present in stub" not in filtered_output + assert "names exported from the stub do not correspond" not in filtered_output + assert "Success" in filtered_output + def test_ignore_flags(self) -> None: output = run_stubtest( stub="", runtime="__all__ = ['f']\ndef f(): pass", options=["--ignore-missing-stub"]