diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index cf59e007f4df80..7bf4821bc199ea 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -16,6 +16,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: + from types import ModuleType from typing import Any, Iterable, Iterator, Mapping @@ -107,7 +108,13 @@ def _find_modules(self, path: str, prefix: str) -> list[str]: if path is None: return [] - modules: Iterable[pkgutil.ModuleInfo] = self.global_cache + modules: Iterable[pkgutil.ModuleInfo] + imported_module = sys.modules.get(path.split('.')[0]) + if imported_module: + modules = self._find_already_imported_module_infos(imported_module) + else: + modules = self.global_cache + is_stdlib_import: bool | None = None for segment in path.split('.'): modules = [mod_info for mod_info in modules @@ -129,6 +136,32 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool: return (isinstance(module_info.module_finder, FileFinder) and module_info.module_finder.path == self._stdlib_path) + def _find_already_imported_module_infos(self, imported_module: ModuleType) -> list[pkgutil.ModuleInfo]: + # Module already imported: only look in its location, + # even if a module with the same name would be higher in path + module_location = self._get_module_location(imported_module) + if not module_location: + # If we cannot find the module source, propose no suggestions + return [] + import_location = os.path.dirname(module_location) + return list(pkgutil.iter_modules([import_location])) + + def _get_module_location(self, imported_module: ModuleType) -> str | None: + spec = imported_module.__spec__ + if not spec: + return None + if not spec.has_location: + if spec.origin == "frozen": # See Tools/build/freeze_modules.py + return os.path.join(self._stdlib_path, f"{spec.name}.py") + return None + if not spec.origin: + return None + if imported_module.__package__: + # Package: the module location is the parent folder + return os.path.dirname(spec.origin) + else: + return spec.origin + def is_suggestion_match(self, module_name: str, prefix: str) -> bool: if prefix: return module_name.startswith(prefix) @@ -196,7 +229,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..c42fc716390952 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,114 @@ 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") + + @patch.dict(sys.modules) + def test_already_imported_frozen_module(self): + importlib.import_module("__phello__") + events = code_to_events("from __phello__ import s\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "from __phello__ import spam") + + def test_already_imported_custom_module_no_other_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 -> suggest dir1 submodules only + events = code_to_events("import mymodule.\t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + self.assertEqual(output, "import mymodule.foo") + + 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.