diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index cf59e007f4df80..2098d0a54aba31 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -108,6 +108,18 @@ def _find_modules(self, path: str, prefix: str) -> list[str]: return [] modules: Iterable[pkgutil.ModuleInfo] = self.global_cache + imported_module = sys.modules.get(path.split('.')[0]) + if imported_module: + # Filter modules to those who name and specs match the + # imported module to avoid invalid suggestions + spec = imported_module.__spec__ + if spec: + modules = [mod for mod in modules + if mod.name == spec.name + and mod.module_finder.find_spec(mod.name, None) == spec] + else: + modules = [] + is_stdlib_import: bool | None = None for segment in path.split('.'): modules = [mod_info for mod_info in modules @@ -196,7 +208,6 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]: """Global module cache""" if not self._global_cache or self._curr_sys_path != sys.path: self._curr_sys_path = sys.path[:] - # print('getting packages') self._global_cache = list(pkgutil.iter_modules()) return self._global_cache diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 65a252c95e5842..adaf2e92945856 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -3,6 +3,7 @@ import itertools import os import pathlib +import pkgutil import re import rlcompleter import select @@ -971,6 +972,7 @@ def test_import_completions(self): ("from importlib import mac\t\n", "from importlib import machinery"), ("from importlib import res\t\n", "from importlib import resources"), ("from importlib.res\t import a\t\n", "from importlib.resources import abc"), + ("from __phello__ import s\t\n", "from __phello__ import spam"), # frozen module ) for code, expected in cases: with self.subTest(code=code): @@ -1104,17 +1106,106 @@ def test_hardcoded_stdlib_submodules(self): self.assertEqual(output, expected) def test_hardcoded_stdlib_submodules_not_proposed_if_local_import(self): - with tempfile.TemporaryDirectory() as _dir: + with (tempfile.TemporaryDirectory() as _dir, + patch.object(sys, "modules", {})): # hide imported module dir = pathlib.Path(_dir) (dir / "collections").mkdir() (dir / "collections" / "__init__.py").touch() (dir / "collections" / "foo.py").touch() - with patch.object(sys, "path", [dir, *sys.path]): + with patch.object(sys, "path", [_dir, *sys.path]): events = code_to_events("import collections.\t\n") reader = self.prepare_reader(events, namespace={}) output = reader.readline() self.assertEqual(output, "import collections.foo") + def test_already_imported_stdlib_module_no_other_suggestions(self): + with (tempfile.TemporaryDirectory() as _dir, + patch.object(sys, "path", [_dir, *sys.path])): + dir = pathlib.Path(_dir) + (dir / "collections").mkdir() + (dir / "collections" / "__init__.py").touch() + (dir / "collections" / "foo.py").touch() + + # collections found in dir, but was already imported + # from stdlib at startup -> suggest stdlib submodules only + events = code_to_events("import collections.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "import collections.abc") + + def test_already_imported_custom_module_no_suggestions(self): + with (tempfile.TemporaryDirectory() as _dir1, + tempfile.TemporaryDirectory() as _dir2, + patch.object(sys, "path", [_dir2, _dir1, *sys.path])): + dir1 = pathlib.Path(_dir1) + (dir1 / "mymodule").mkdir() + (dir1 / "mymodule" / "__init__.py").touch() + (dir1 / "mymodule" / "foo.py").touch() + importlib.import_module("mymodule") + + dir2 = pathlib.Path(_dir2) + (dir2 / "mymodule").mkdir() + (dir2 / "mymodule" / "__init__.py").touch() + (dir2 / "mymodule" / "bar.py").touch() + # Purge FileFinder cache after adding files + pkgutil.get_importer(_dir2).invalidate_caches() + # mymodule found in dir2 before dir1, but it was already imported + # from dir1 -> do not suggest dir2 submodules + events = code_to_events("import mymodule.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "import mymodule.") + + del sys.modules["mymodule"] + # mymodule not imported anymore -> suggest dir2 submodules + events = code_to_events("import mymodule.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "import mymodule.bar") + + def test_already_imported_custom_file_no_suggestions(self): + # Same as before, but mymodule from dir1 has no submodules + # -> propose nothing + with (tempfile.TemporaryDirectory() as _dir1, + tempfile.TemporaryDirectory() as _dir2, + patch.object(sys, "path", [_dir2, _dir1, *sys.path])): + dir1 = pathlib.Path(_dir1) + (dir1 / "mymodule").mkdir() + (dir1 / "mymodule.py").touch() + importlib.import_module("mymodule") + + dir2 = pathlib.Path(_dir2) + (dir2 / "mymodule").mkdir() + (dir2 / "mymodule" / "__init__.py").touch() + (dir2 / "mymodule" / "bar.py").touch() + events = code_to_events("import mymodule.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "import mymodule.") + del sys.modules["mymodule"] + + def test_already_imported_module_without_origin_or_spec(self): + with (tempfile.TemporaryDirectory() as _dir1, + patch.object(sys, "path", [_dir1, *sys.path])): + dir1 = pathlib.Path(_dir1) + for mod in ("no_origin", "not_has_location", "no_spec"): + (dir1 / mod).mkdir() + (dir1 / mod / "__init__.py").touch() + (dir1 / mod / "foo.py").touch() + module = importlib.import_module(mod) + assert module.__spec__ + if mod == "no_origin": + module.__spec__.origin = None + elif mod == "not_has_location": + module.__spec__.has_location = False + else: + module.__spec__ = None + events = code_to_events(f"import {mod}.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, f"import {mod}.") + del sys.modules[mod] + def test_get_path_and_prefix(self): cases = ( ('', ('', '')), diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-09-30-21-59-56.gh-issue-69605.qcmGF3.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-30-21-59-56.gh-issue-69605.qcmGF3.rst new file mode 100644 index 00000000000000..56d74d2583939b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-09-30-21-59-56.gh-issue-69605.qcmGF3.rst @@ -0,0 +1,2 @@ +Fix edge-cases around already imported modules in the :term:`REPL` +auto-completion of imports.