]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-140870: PyREPL auto-complete module attributes in import statements (#140871)
authorLoïc Simon <loic.simon@espci.org>
Sun, 5 Apr 2026 19:10:59 +0000 (21:10 +0200)
committerGitHub <noreply@github.com>
Sun, 5 Apr 2026 19:10:59 +0000 (19:10 +0000)
Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
Lib/_pyrepl/_module_completer.py
Lib/_pyrepl/completing_reader.py
Lib/_pyrepl/reader.py
Lib/_pyrepl/readline.py
Lib/_pyrepl/types.py
Lib/test/test_pyrepl/test_pyrepl.py
Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst [new file with mode: 0644]

index bba59599e979233a2b9352b425f08ae8da0cf5ef..a22b0297b24ea07238182d9282dda7c6f9cb9a92 100644 (file)
@@ -3,6 +3,7 @@ from __future__ import annotations
 import importlib
 import os
 import pkgutil
+import re
 import sys
 import token
 import tokenize
@@ -16,7 +17,9 @@ from tokenize import TokenInfo
 TYPE_CHECKING = False
 
 if TYPE_CHECKING:
+    from types import ModuleType
     from typing import Any, Iterable, Iterator, Mapping
+    from .types import CompletionAction
 
 
 HARDCODED_SUBMODULES = {
@@ -28,6 +31,17 @@ HARDCODED_SUBMODULES = {
     "xml.parsers.expat": ["errors", "model"],
 }
 
+AUTO_IMPORT_DENYLIST = {
+    # Standard library modules/submodules that have import side effects
+    # and must not be automatically imported to complete attributes
+    re.compile(r"antigravity"),  # Calls webbrowser.open
+    re.compile(r"idlelib\..+"),  # May open IDLE GUI
+    re.compile(r"test\..+"),  # Various side-effects
+    re.compile(r"this"),  # Prints to stdout
+    re.compile(r"_ios_support"),  # Spawns a subprocess
+    re.compile(r".+\.__main__"),  # Should not be imported
+}
+
 
 def make_default_module_completer() -> ModuleCompleter:
     # Inside pyrepl, __package__ is set to None by default
@@ -53,11 +67,17 @@ class ModuleCompleter:
     def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
         self.namespace = namespace or {}
         self._global_cache: list[pkgutil.ModuleInfo] = []
+        self._failed_imports: set[str] = set()
         self._curr_sys_path: list[str] = sys.path[:]
         self._stdlib_path = os.path.dirname(importlib.__path__[0])
 
-    def get_completions(self, line: str) -> list[str] | None:
-        """Return the next possible import completions for 'line'."""
+    def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
+        """Return the next possible import completions for 'line'.
+
+        For attributes completion, if the module to complete from is not
+        imported, also return an action (prompt + callback to run if the
+        user press TAB again) to import the module.
+        """
         result = ImportParser(line).parse()
         if not result:
             return None
@@ -66,24 +86,26 @@ class ModuleCompleter:
         except Exception:
             # Some unexpected error occurred, make it look like
             # no completions are available
-            return []
+            return [], None
 
-    def complete(self, from_name: str | None, name: str | None) -> list[str]:
+    def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
         if from_name is None:
             # import x.y.z<tab>
             assert name is not None
             path, prefix = self.get_path_and_prefix(name)
             modules = self.find_modules(path, prefix)
-            return [self.format_completion(path, module) for module in modules]
+            return [self.format_completion(path, module) for module in modules], None
 
         if name is None:
             # from x.y.z<tab>
             path, prefix = self.get_path_and_prefix(from_name)
             modules = self.find_modules(path, prefix)
-            return [self.format_completion(path, module) for module in modules]
+            return [self.format_completion(path, module) for module in modules], None
 
         # from x.y import z<tab>
-        return self.find_modules(from_name, name)
+        submodules = self.find_modules(from_name, name)
+        attributes, action = self.find_attributes(from_name, name)
+        return sorted({*submodules, *attributes}), action
 
     def find_modules(self, path: str, prefix: str) -> list[str]:
         """Find all modules under 'path' that start with 'prefix'."""
@@ -101,23 +123,25 @@ class ModuleCompleter:
                                    if self.is_suggestion_match(module.name, prefix)]
             return sorted(builtin_modules + third_party_modules)
 
-        if path.startswith('.'):
-            # Convert relative path to absolute path
-            package = self.namespace.get('__package__', '')
-            path = self.resolve_relative_name(path, package)  # type: ignore[assignment]
-            if path is None:
-                return []
+        path = self._resolve_relative_path(path)  # type: ignore[assignment]
+        if path is None:
+            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
+            # Filter modules to those whose name and specs match the
             # imported module to avoid invalid suggestions
             spec = imported_module.__spec__
             if spec:
+                def _safe_find_spec(mod: pkgutil.ModuleInfo) -> bool:
+                    try:
+                        return mod.module_finder.find_spec(mod.name, None) == spec
+                    except Exception:
+                        return False
                 modules = [mod for mod in modules
                            if mod.name == spec.name
-                           and mod.module_finder.find_spec(mod.name, None) == spec]
+                           and _safe_find_spec(mod)]
             else:
                 modules = []
 
@@ -142,6 +166,32 @@ class ModuleCompleter:
         return (isinstance(module_info.module_finder, FileFinder)
                 and module_info.module_finder.path == self._stdlib_path)
 
+    def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
+        """Find all attributes of module 'path' that start with 'prefix'."""
+        attributes, action = self._find_attributes(path, prefix)
+        # Filter out invalid attribute names
+        # (for example those containing dashes that cannot be imported with 'import')
+        return [attr for attr in attributes if attr.isidentifier()], action
+
+    def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
+        path = self._resolve_relative_path(path)  # type: ignore[assignment]
+        if path is None:
+            return [], None
+
+        imported_module = sys.modules.get(path)
+        if not imported_module:
+            if path in self._failed_imports:  # Do not propose to import again
+                return [], None
+            imported_module = self._maybe_import_module(path)
+        if not imported_module:
+            return [], self._get_import_completion_action(path)
+        try:
+            module_attributes = dir(imported_module)
+        except Exception:
+            module_attributes = []
+        return [attr_name for attr_name in module_attributes
+                if self.is_suggestion_match(attr_name, prefix)], None
+
     def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
         if prefix:
             return module_name.startswith(prefix)
@@ -186,6 +236,13 @@ class ModuleCompleter:
             return f'{path}{module}'
         return f'{path}.{module}'
 
+    def _resolve_relative_path(self, path: str) -> str | None:
+        """Resolve a relative import path to absolute. Returns None if unresolvable."""
+        if path.startswith('.'):
+            package = self.namespace.get('__package__', '')
+            return self.resolve_relative_name(path, package)
+        return path
+
     def resolve_relative_name(self, name: str, package: str) -> str | None:
         """Resolve a relative module name to an absolute name.
 
@@ -210,8 +267,39 @@ class ModuleCompleter:
         if not self._global_cache or self._curr_sys_path != sys.path:
             self._curr_sys_path = sys.path[:]
             self._global_cache = list(pkgutil.iter_modules())
+            self._failed_imports.clear()  # retry on sys.path change
         return self._global_cache
 
+    def _maybe_import_module(self, fqname: str) -> ModuleType | None:
+        if any(pattern.fullmatch(fqname) for pattern in AUTO_IMPORT_DENYLIST):
+            # Special-cased modules with known import side-effects
+            return None
+        root = fqname.split(".")[0]
+        mod_info = next((m for m in self.global_cache if m.name == root), None)
+        if not mod_info or not self._is_stdlib_module(mod_info):
+            # Only import stdlib modules (no risk of import side-effects)
+            return None
+        try:
+            return importlib.import_module(fqname)
+        except Exception:
+            sys.modules.pop(fqname, None)  # Clean half-imported module
+            return None
+
+    def _get_import_completion_action(self, path: str) -> CompletionAction:
+        prompt = ("[ module not imported, press again to import it "
+                  "and propose attributes ]")
+
+        def _do_import() -> str | None:
+            try:
+                importlib.import_module(path)
+                return None
+            except Exception as exc:
+                sys.modules.pop(path, None)  # Clean half-imported module
+                self._failed_imports.add(path)
+                return f"[ error during import: {exc} ]"
+
+        return (prompt, _do_import)
+
 
 class ImportParser:
     """
index 5802920a907ca4e6fd88a9aeffea878827f08e6f..39d0a8af5dfaea0501502613e1492233d365eb6f 100644 (file)
@@ -29,8 +29,9 @@ from .reader import Reader
 
 # types
 Command = commands.Command
-if False:
-    from .types import KeySpec, CommandName
+TYPE_CHECKING = False
+if TYPE_CHECKING:
+    from .types import KeySpec, CommandName, CompletionAction
 
 
 def prefix(wordlist: list[str], j: int = 0) -> str:
@@ -168,15 +169,25 @@ class complete(commands.Command):
         r: CompletingReader
         r = self.reader  # type: ignore[assignment]
         last_is_completer = r.last_command_is(self.__class__)
+        if r.cmpltn_action:
+            if last_is_completer:  # double-tab: execute action
+                msg = r.cmpltn_action[1]()
+                r.cmpltn_action = None  # consumed
+                if msg:
+                    r.msg = msg
+            else:  # other input since last tab: cancel action
+                r.cmpltn_action = None
+
         immutable_completions = r.assume_immutable_completions
         completions_unchangable = last_is_completer and immutable_completions
         stem = r.get_stem()
         if not completions_unchangable:
-            r.cmpltn_menu_choices = r.get_completions(stem)
+            r.cmpltn_menu_choices, r.cmpltn_action = r.get_completions(stem)
 
         completions = r.cmpltn_menu_choices
         if not completions:
-            r.error("no matches")
+            if not r.cmpltn_action:
+                r.error("no matches")
         elif len(completions) == 1:
             completion = stripcolor(completions[0])
             if completions_unchangable and len(completion) == len(stem):
@@ -204,6 +215,16 @@ class complete(commands.Command):
                     r.msg = "[ not unique ]"
                     r.dirty = True
 
+        if r.cmpltn_action:
+            if r.msg and r.cmpltn_message_visible:
+                # There is already a message (eg. [ not unique ]) that
+                # would conflict for next tab: cancel action
+                r.cmpltn_action = None
+            else:
+                r.msg = r.cmpltn_action[0]
+                r.cmpltn_message_visible = True
+                r.dirty = True
+
 
 class self_insert(commands.self_insert):
     def do(self) -> None:
@@ -242,6 +263,7 @@ class CompletingReader(Reader):
     cmpltn_message_visible: bool = field(init=False)
     cmpltn_menu_end: int = field(init=False)
     cmpltn_menu_choices: list[str] = field(init=False)
+    cmpltn_action: CompletionAction | None = field(init=False)
 
     def __post_init__(self) -> None:
         super().__post_init__()
@@ -283,6 +305,7 @@ class CompletingReader(Reader):
         self.cmpltn_message_visible = False
         self.cmpltn_menu_end = 0
         self.cmpltn_menu_choices = []
+        self.cmpltn_action = None
 
     def get_stem(self) -> str:
         st = self.syntax_table
@@ -293,8 +316,8 @@ class CompletingReader(Reader):
             p -= 1
         return ''.join(b[p+1:self.pos])
 
-    def get_completions(self, stem: str) -> list[str]:
-        return []
+    def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
+        return [], None
 
     def get_line(self) -> str:
         """Return the current line until the cursor position."""
index 9ab92f64d1ef63dddc245c2887929cac201e8cfe..f35a99fb06a3f9ebe5bb8a035727fa6495c5751f 100644 (file)
@@ -381,9 +381,17 @@ class Reader:
         self.screeninfo = screeninfo
         self.cxy = self.pos2xy()
         if self.msg:
+            width = self.console.width
             for mline in self.msg.split("\n"):
-                screen.append(mline)
-                screeninfo.append((0, []))
+                # If self.msg is larger than console width, make it fit
+                # TODO: try to split between words?
+                if not mline:
+                    screen.append("")
+                    screeninfo.append((0, []))
+                    continue
+                for r in range((len(mline) - 1) // width + 1):
+                    screen.append(mline[r * width : (r + 1) * width])
+                    screeninfo.append((0, []))
 
         self.last_refresh_cache.update_cache(self, screen, screeninfo)
         return screen
@@ -628,7 +636,6 @@ class Reader:
         finally:
             self.can_colorize = old_can_colorize
 
-
     def finish(self) -> None:
         """Called when a command signals that we're finished."""
         pass
index 17319963b1950a37280cb2349e609fd6aa5c1f8b..687084601e77c1674a46e234f5c7350f684d4bc6 100644 (file)
@@ -56,7 +56,7 @@ ENCODING = sys.getdefaultencoding() or "latin1"
 # types
 Command = commands.Command
 from collections.abc import Callable, Collection
-from .types import Callback, Completer, KeySpec, CommandName
+from .types import Callback, Completer, KeySpec, CommandName, CompletionAction
 
 TYPE_CHECKING = False
 
@@ -135,7 +135,7 @@ class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
             p -= 1
         return "".join(b[p + 1 : self.pos])
 
-    def get_completions(self, stem: str) -> list[str]:
+    def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
         module_completions = self.get_module_completions()
         if module_completions is not None:
             return module_completions
@@ -145,7 +145,7 @@ class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
             while p > 0 and b[p - 1] != "\n":
                 p -= 1
             num_spaces = 4 - ((self.pos - p) % 4)
-            return [" " * num_spaces]
+            return [" " * num_spaces], None
         result = []
         function = self.config.readline_completer
         if function is not None:
@@ -166,9 +166,9 @@ class ReadlineAlikeReader(historical_reader.HistoricalReader, CompletingReader):
             # emulate the behavior of the standard readline that sorts
             # the completions before displaying them.
             result.sort()
-        return result
+        return result, None
 
-    def get_module_completions(self) -> list[str] | None:
+    def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None:
         line = self.get_line()
         return self.config.module_completer.get_completions(line)
 
index c5b7ebc1a406bdf43b9bf85ad21ec06db1522f6b..e19607bf18e8b1ab31a419c659c2bd3cfc894b33 100644 (file)
@@ -8,3 +8,4 @@ type EventTuple = tuple[CommandName, str]
 type Completer = Callable[[str, int], str | None]
 type CharBuffer = list[str]
 type CharWidths = list[int]
+type CompletionAction = tuple[str, Callable[[], str | None]]
index 82628f792799309914e663d6b6d85f0c8090323a..c3556823c72476e0541f52e0244856159e77863a 100644 (file)
@@ -1,3 +1,4 @@
+import contextlib
 import importlib
 import io
 import itertools
@@ -13,7 +14,14 @@ import tempfile
 from pkgutil import ModuleInfo
 from unittest import TestCase, skipUnless, skipIf, SkipTest
 from unittest.mock import Mock, patch
-from test.support import force_not_colorized, make_clean_env, Py_DEBUG
+import warnings
+from test.support import (
+    captured_stdout,
+    captured_stderr,
+    force_not_colorized,
+    make_clean_env,
+    Py_DEBUG,
+)
 from test.support import has_subprocess_support, SHORT_TIMEOUT, STDLIB_DIR
 from test.support.import_helper import import_module
 from test.support.os_helper import EnvironmentVarGuard, unlink
@@ -50,6 +58,10 @@ try:
     import readline as readline_module
 except ImportError:
     readline_module = None
+try:
+    import tkinter
+except ImportError:
+    tkinter = None
 
 
 class ReplTestCase(TestCase):
@@ -1050,7 +1062,9 @@ class TestPyReplModuleCompleter(TestCase):
         reader = ReadlineAlikeReader(console=console, config=config)
         return reader
 
-    def test_import_completions(self):
+    @patch.dict(sys.modules,
+                {"importlib.resources": object()})  # don't propose to import it
+    def test_completions(self):
         cases = (
             ("import path\t\n", "import pathlib"),
             ("import importlib.\t\tres\t\n", "import importlib.resources"),
@@ -1104,7 +1118,7 @@ class TestPyReplModuleCompleter(TestCase):
             # Return public methods by default
             ("from foo import \t\n", "from foo import public"),
             # Return private methods if explicitly specified
-            ("from foo import _\t\n", "from foo import _private"),
+            ("from foo import _p\t\n", "from foo import _private"),
         )
         for code, expected in cases:
             with self.subTest(code=code):
@@ -1125,12 +1139,13 @@ class TestPyReplModuleCompleter(TestCase):
                 output = reader.readline()
                 self.assertEqual(output, expected)
 
-    def test_relative_import_completions(self):
+    def test_relative_completions(self):
         cases = (
             (None, "from .readl\t\n", "from .readl"),
             (None, "from . import readl\t\n", "from . import readl"),
             ("_pyrepl", "from .readl\t\n", "from .readline"),
             ("_pyrepl", "from . import readl\t\n", "from . import readline"),
+            ("_pyrepl", "from .readline import mul\t\n", "from .readline import multiline_input"),
             ("_pyrepl", "from .. import toodeep\t\n", "from .. import toodeep"),
             ("concurrent", "from .futures.i\t\n", "from .futures.interpreter"),
         )
@@ -1162,7 +1177,7 @@ class TestPyReplModuleCompleter(TestCase):
         cases = (
             ("import pri\t\n", "import pri"),
             ("from pri\t\n", "from pri"),
-            ("from typing import Na\t\n", "from typing import Na"),
+            ("from typong import Na\t\n", "from typong import Na"),
         )
         for code, expected in cases:
             with self.subTest(code=code):
@@ -1175,8 +1190,8 @@ class TestPyReplModuleCompleter(TestCase):
         with (tempfile.TemporaryDirectory() as _dir1,
               patch.object(sys, "path", [_dir1, *sys.path])):
             dir1 = pathlib.Path(_dir1)
-            (dir1 / "mod_aa.py").mkdir()
-            (dir1 / "mod_bb.py").mkdir()
+            (dir1 / "mod_aa.py").touch()
+            (dir1 / "mod_bb.py").touch()
             events = code_to_events("import mod_a\t\nimport mod_b\t\n")
             reader = self.prepare_reader(events, namespace={})
             output_1, output_2 = reader.readline(), reader.readline()
@@ -1186,7 +1201,7 @@ class TestPyReplModuleCompleter(TestCase):
     def test_hardcoded_stdlib_submodules(self):
         cases = (
             ("import collections.\t\n", "import collections.abc"),
-            ("from os import \t\n", "from os import path"),
+            ("import os.\t\n", "import os.path"),
             ("import math.\t\n", "import math.integer"),
             ("import xml.parsers.expat.\t\te\t\n\n", "import xml.parsers.expat.errors"),
             ("from xml.parsers.expat import \t\tm\t\n\n", "from xml.parsers.expat import model"),
@@ -1300,6 +1315,115 @@ class TestPyReplModuleCompleter(TestCase):
                 self.assertEqual(output, f"import {mod}.")
                 del sys.modules[mod]
 
+    @patch.dict(sys.modules)
+    def test_attribute_completion(self):
+        with tempfile.TemporaryDirectory() as _dir:
+            dir = pathlib.Path(_dir)
+            (dir / "foo.py").write_text("bar = 42")
+            (dir / "bar.py").write_text("baz = 42")
+            (dir / "pack").mkdir()
+            (dir / "pack" / "__init__.py").write_text("attr = 42")
+            (dir / "pack" / "foo.py").touch()
+            (dir / "pack" / "bar.py").touch()
+            (dir / "pack" / "baz.py").touch()
+            sys.modules.pop("graphlib", None)  # test modules may have been imported by previous tests
+            sys.modules.pop("antigravity", None)
+            sys.modules.pop("unittest.__main__", None)
+            with patch.object(sys, "path", [_dir, *sys.path]):
+                pkgutil.get_importer(_dir).invalidate_caches()
+                importlib.import_module("bar")
+                cases = (
+                    # needs 2 tabs to import (show prompt, then import)
+                    ("from foo import \t\n", "from foo import ", set()),
+                    ("from foo import \t\t\n", "from foo import bar", {"foo"}),
+                    ("from foo import ba\t\n", "from foo import ba", set()),
+                    ("from foo import ba\t\t\n", "from foo import bar", {"foo"}),
+                    # reset if a character is inserted between tabs
+                    ("from foo import \tb\ta\t\n", "from foo import ba", set()),
+                    # packages: needs 3 tabs ([ not unique ], prompt, import)
+                    ("from pack import \t\t\n", "from pack import ", set()),
+                    ("from pack import \t\t\t\n", "from pack import ", {"pack"}),
+                    ("from pack import \t\t\ta\t\n", "from pack import attr", {"pack"}),
+                    # one match: needs 2 tabs (insert + show prompt, import)
+                    ("from pack import f\t\n", "from pack import foo", set()),
+                    ("from pack import f\t\t\n", "from pack import foo", {"pack"}),
+                    # common prefix: needs 3 tabs (insert + [ not unique ], prompt, import)
+                    ("from pack import b\t\n", "from pack import ba", set()),
+                    ("from pack import b\t\t\n", "from pack import ba", set()),
+                    ("from pack import b\t\t\t\n", "from pack import ba", {"pack"}),
+                    # module already imported
+                    ("from bar import b\t\n", "from bar import baz", set()),
+                    # stdlib modules are automatically imported
+                    ("from graphlib import T\t\n", "from graphlib import TopologicalSorter", {"graphlib"}),
+                    # except those with known side-effects
+                    ("from antigravity import g\t\n", "from antigravity import g", set()),
+                    ("from unittest.__main__ import \t\n", "from unittest.__main__ import ", set()),
+                )
+                for code, expected, expected_imports in cases:
+                    with self.subTest(code=code), patch.dict(sys.modules):
+                        _imported = set(sys.modules.keys())
+                        events = code_to_events(code)
+                        reader = self.prepare_reader(events, namespace={})
+                        output = reader.readline()
+                        self.assertEqual(output, expected)
+                        new_imports = sys.modules.keys() - _imported
+                        self.assertEqual(new_imports, expected_imports)
+
+    @patch.dict(sys.modules)
+    def test_attribute_completion_error_on_import(self):
+        with tempfile.TemporaryDirectory() as _dir:
+            dir = pathlib.Path(_dir)
+            (dir / "foo.py").write_text("bar = 42")
+            (dir / "boom.py").write_text("1 <> 2")
+            with patch.object(sys, "path", [_dir, *sys.path]):
+                cases = (
+                    ("from boom import \t\t\n", "from boom import "),
+                    ("from foo import \t\t\n", "from foo import bar"), # still working
+                )
+                for code, expected in cases:
+                    with self.subTest(code=code):
+                        events = code_to_events(code)
+                        reader = self.prepare_reader(events, namespace={})
+                        output = reader.readline()
+                        self.assertEqual(output, expected)
+                self.assertNotIn("boom", sys.modules)
+
+    @patch.dict(sys.modules)
+    def test_attribute_completion_error_on_attributes_access(self):
+        with tempfile.TemporaryDirectory() as _dir:
+            dir = pathlib.Path(_dir)
+            (dir / "boom").mkdir()
+            (dir / "boom"/"__init__.py").write_text("def __dir__(): raise ValueError()")
+            (dir / "boom"/"submodule.py").touch()
+            with patch.object(sys, "path", [_dir, *sys.path]):
+                events = code_to_events("from boom import \t\t\n")  # trigger import
+                reader = self.prepare_reader(events, namespace={})
+                output = reader.readline()
+                self.assertIn("boom", sys.modules)
+                # ignore attributes, just propose submodule
+                self.assertEqual(output, "from boom import submodule")
+
+    @patch.dict(sys.modules)
+    def test_attribute_completion_private_and_invalid_names(self):
+        with tempfile.TemporaryDirectory() as _dir:
+            dir = pathlib.Path(_dir)
+            (dir / "foo.py").write_text("_secret = 'bar'")
+            with patch.object(sys, "path", [_dir, *sys.path]):
+                mod = importlib.import_module("foo")
+                mod.__dict__["invalid-identifier"] = "baz"
+                cases = (
+                    ("from foo import \t\n", "from foo import "),
+                    ("from foo import _s\t\n", "from foo import _secret"),
+                    ("from foo import inv\t\n", "from foo import inv"),
+                )
+                for code, expected in cases:
+                    with self.subTest(code=code):
+                        events = code_to_events(code)
+                        reader = self.prepare_reader(events, namespace={})
+                        output = reader.readline()
+                        self.assertEqual(output, expected)
+
+
     def test_get_path_and_prefix(self):
         cases = (
             ('', ('', '')),
@@ -1431,8 +1555,119 @@ class TestPyReplModuleCompleter(TestCase):
             with self.subTest(code=code):
                 self.assertEqual(actual, None)
 
+    @patch.dict(sys.modules)
+    def test_suggestions_and_messages(self) -> None:
+        # more unitary tests checking the exact suggestions provided
+        # (sorting, de-duplication, import action...)
+        _prompt = ("[ module not imported, press again to import it "
+                   "and propose attributes ]")
+        _error = "[ error during import: division by zero ]"
+        with tempfile.TemporaryDirectory() as _dir:
+            dir = pathlib.Path(_dir)
+            (dir / "foo.py").write_text("bar = 42")
+            (dir / "boom.py").write_text("1/0")
+            (dir / "pack").mkdir()
+            (dir / "pack" / "__init__.py").write_text("foo = 1; bar = 2;")
+            (dir / "pack" / "bar.py").touch()
+            sys.modules.pop("graphlib", None)  # test modules may have been imported by previous tests
+            sys.modules.pop("string.templatelib", None)
+            with patch.object(sys, "path", [_dir, *sys.path]):
+                pkgutil.get_importer(_dir).invalidate_caches()
+                # NOTE: Cases are intentionally sequential and share completer
+                # state. Earlier cases may import modules that later cases
+                # depend on. Do NOT reorder without understanding dependencies.
+                cases = (
+                    # no match != not an import
+                    ("import nope", ([], None), set()),
+                    ("improt nope", None, set()),
+                    # names sorting
+                    ("import col", (["collections", "colorsys"], None), set()),
+                    # module auto-import
+                    ("import fo", (["foo"], None), set()),
+                    ("from foo import ", ([], (_prompt, None)), {"foo"}),
+                    ("from foo import ", (["bar"], None), set()), # now imported
+                    ("from foo import ba", (["bar"], None), set()),
+                    # error during import
+                    ("from boom import ", ([], (_prompt, _error)), set()),
+                    ("from boom import ", ([], None), set()), # do not retry
+                    # packages
+                    ("from collections import a", (["abc"], None), set()),
+                    ("from pack import ", (["bar"], (_prompt, None)), {"pack"}),
+                    ("from pack import ", (["bar", "foo"], None), set()),
+                    ("from pack.bar import ", ([], (_prompt, None)), {"pack.bar"}),
+                    ("from pack.bar import ", ([], None), set()),
+                    # stdlib = auto-imported
+                    ("from graphlib import T", (["TopologicalSorter"], None), {"graphlib"}),
+                    ("from string.templatelib import c", (["convert"], None), {"string.templatelib"}),
+                )
+                completer = ModuleCompleter()
+                for i, (code, expected, expected_imports) in enumerate(cases):
+                    with self.subTest(code=code, i=i):
+                        _imported = set(sys.modules.keys())
+                        result = completer.get_completions(code)
+                        self.assertEqual(result is None, expected is None)
+                        if result:
+                            compl, act = result
+                            self.assertEqual(compl, expected[0])
+                            self.assertEqual(act is None, expected[1] is None)
+                            if act:
+                                msg, func = act
+                                self.assertEqual(msg, expected[1][0])
+                                act_result = func()
+                                self.assertEqual(act_result, expected[1][1])
+
+                        new_imports = sys.modules.keys() - _imported
+                        self.assertSetEqual(new_imports, expected_imports)
+
+
+# Audit hook used to check for stdlib modules import side-effects
+# Defined globally to avoid adding one hook per test run (refleak)
+_audit_events: set[str] | None = None
+
+
+def _hook(name: str, _args: tuple):
+    if _audit_events is not None:  # No-op when not activated
+        _audit_events.add(name)
+sys.addaudithook(_hook)
+
+
+@contextlib.contextmanager
+def _capture_audit_events():
+    global _audit_events
+    _audit_events = set()
+    try:
+        yield _audit_events
+    finally:
+        _audit_events = None
+
+
+class TestModuleCompleterAutomaticImports(TestCase):
+    def test_no_side_effects(self):
+        from test.test___all__ import AllTest  # TODO: extract to a helper?
+
+        completer = ModuleCompleter()
+        for _, modname in AllTest().walk_modules(completer._stdlib_path, ""):
+            with self.subTest(modname=modname):
+                with (captured_stdout() as out,
+                      captured_stderr() as err,
+                      _capture_audit_events() as audit_events,
+                      (patch("tkinter._tkinter.create") if tkinter
+                       else contextlib.nullcontext()) as tk_mock,
+                      warnings.catch_warnings(action="ignore")):
+                    completer._maybe_import_module(modname)
+                # Test no module is imported that
+                # 1. prints any text
+                self.assertEqual(out.getvalue(), "")
+                self.assertEqual(err.getvalue(), "")
+                # 2. spawn any subprocess (eg. webbrowser.open)
+                self.assertNotIn("subprocess.Popen", audit_events)
+                # 3. launch a Tk window
+                if tk_mock is not None:
+                    tk_mock.assert_not_called()
+
 
 class TestHardcodedSubmodules(TestCase):
+    @patch.dict(sys.modules)
     def test_hardcoded_stdlib_submodules_are_importable(self):
         for parent_path, submodules in HARDCODED_SUBMODULES.items():
             for module_name in submodules:
diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst
new file mode 100644 (file)
index 0000000..aadf576
--- /dev/null
@@ -0,0 +1,2 @@
+Add support for module attributes in the :term:`REPL` auto-completion of
+imports.