[3.14] gh-69605: Check for already imported modules in PyREPL module completion (GH-143438)

Co-authored-by: Loïc Simon <loic.simon@napta.io>
Co-authored-by: Tomas R. <tomas.roun8@gmail.com>

Cherry picked from commits 7db209b564 and aed90508b3.
This commit is contained in:
Łukasz Langa 2026-01-05 19:47:26 +01:00 committed by GitHub
parent d0e9f4445a
commit 98a255699d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 108 additions and 3 deletions

View file

@ -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

View file

@ -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,107 @@ 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()
pkgutil.get_importer(_dir1).invalidate_caches()
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 = (
('', ('', '')),

View file

@ -0,0 +1,2 @@
Fix edge-cases around already imported modules in the :term:`REPL`
auto-completion of imports.