]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-69605: Check for already imported modules in PyREPL module completion (GH-139461)
authorLoïc Simon <loic.simon@napta.io>
Mon, 5 Jan 2026 15:18:54 +0000 (16:18 +0100)
committerGitHub <noreply@github.com>
Mon, 5 Jan 2026 15:18:54 +0000 (16:18 +0100)
Co-authored-by: Tomas R. <tomas.roun8@gmail.com>
Lib/_pyrepl/_module_completer.py
Lib/test/test_pyrepl/test_pyrepl.py
Misc/NEWS.d/next/Core_and_Builtins/2025-09-30-21-59-56.gh-issue-69605.qcmGF3.rst [new file with mode: 0644]

index cf59e007f4df80a7ae0edb99e004968b05eceb14..2098d0a54aba31660b99a58b427d02e808b4c761 100644 (file)
@@ -108,6 +108,18 @@ class ModuleCompleter:
                 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 @@ class ModuleCompleter:
         """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
 
index 6cf87522af2bc30c0f836096cc76d2ae8298dab1..961787b6f9058b18eadfb4b58aacd44b1cc96cf7 100644 (file)
@@ -3,6 +3,7 @@ import io
 import itertools
 import os
 import pathlib
+import pkgutil
 import re
 import rlcompleter
 import select
@@ -971,6 +972,7 @@ class TestPyReplModuleCompleter(TestCase):
             ("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 @@ class TestPyReplModuleCompleter(TestCase):
                 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 (file)
index 0000000..56d74d2
--- /dev/null
@@ -0,0 +1,2 @@
+Fix edge-cases around already imported modules in the :term:`REPL`
+auto-completion of imports.