diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index cf59e007f4df80..b4f24d2d87f9f4 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -107,7 +107,23 @@ 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: + # Module already imported: only look in its location, + # even if a module with the same name would be higher in path + imported_path = (imported_module.__spec__ + and imported_module.__spec__.origin) + if not imported_path: + # Module imported but no spec/origin: propose no suggestions + return [] + if os.path.basename(imported_path) == "__init__.py": # package + imported_path = os.path.dirname(imported_path) + import_location = os.path.dirname(imported_path) + modules = list(pkgutil.iter_modules([import_location])) + else: + modules = self.global_cache + is_stdlib_import: bool | None = None for segment in path.split('.'): modules = [mod_info for mod_info in modules @@ -196,7 +212,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 47d384a209e9ac..397c0e699a4774 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 @@ -1090,17 +1091,84 @@ 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_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_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.