Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion mypy/stubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions mypy/test/teststubtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading