https://github.com/python/cpython/commit/b07becb57371b733b9cc91233ab93b02a6b2f014
commit: b07becb57371b733b9cc91233ab93b02a6b2f014
branch: main
author: Loïc Simon <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-04-05T19:10:59Z
summary:

gh-140870: PyREPL auto-complete module attributes in import statements  
(#140871)

Co-authored-by: Pablo Galindo Salgado <[email protected]>

files:
A 
Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst
M Lib/_pyrepl/_module_completer.py
M Lib/_pyrepl/completing_reader.py
M Lib/_pyrepl/reader.py
M Lib/_pyrepl/readline.py
M Lib/_pyrepl/types.py
M Lib/test/test_pyrepl/test_pyrepl.py

diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py
index bba59599e97923..a22b0297b24ea0 100644
--- a/Lib/_pyrepl/_module_completer.py
+++ b/Lib/_pyrepl/_module_completer.py
@@ -3,6 +3,7 @@
 import importlib
 import os
 import pkgutil
+import re
 import sys
 import token
 import tokenize
@@ -16,7 +17,9 @@
 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 @@
     "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 @@ def get_completions(self, line: str) -> list[str] | None:
         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 @@ def _find_modules(self, path: str, prefix: str) -> 
list[str]:
                                    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 @@ 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_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 @@ def format_completion(self, path: str, module: str) -> str:
             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 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]:
         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:
     """
diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py
index 5802920a907ca4..39d0a8af5dfaea 100644
--- a/Lib/_pyrepl/completing_reader.py
+++ b/Lib/_pyrepl/completing_reader.py
@@ -29,8 +29,9 @@
 
 # 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 @@ def do(self) -> None:
         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 @@ def do(self) -> None:
                     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 @@ def cmpltn_reset(self) -> None:
         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 @@ def get_stem(self) -> str:
             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."""
diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py
index 9ab92f64d1ef63..f35a99fb06a3f9 100644
--- a/Lib/_pyrepl/reader.py
+++ b/Lib/_pyrepl/reader.py
@@ -381,9 +381,17 @@ def calc_screen(self) -> list[str]:
         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 @@ def suspend_colorization(self) -> SimpleContextManager:
         finally:
             self.can_colorize = old_can_colorize
 
-
     def finish(self) -> None:
         """Called when a command signals that we're finished."""
         pass
diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py
index 17319963b1950a..687084601e77c1 100644
--- a/Lib/_pyrepl/readline.py
+++ b/Lib/_pyrepl/readline.py
@@ -56,7 +56,7 @@
 # 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 @@ def get_stem(self) -> str:
             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 @@ def get_completions(self, stem: str) -> list[str]:
             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 @@ def get_completions(self, stem: str) -> list[str]:
             # 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)
 
diff --git a/Lib/_pyrepl/types.py b/Lib/_pyrepl/types.py
index c5b7ebc1a406bd..e19607bf18e8b1 100644
--- a/Lib/_pyrepl/types.py
+++ b/Lib/_pyrepl/types.py
@@ -8,3 +8,4 @@
 type Completer = Callable[[str, int], str | None]
 type CharBuffer = list[str]
 type CharWidths = list[int]
+type CompletionAction = tuple[str, Callable[[], str | None]]
diff --git a/Lib/test/test_pyrepl/test_pyrepl.py 
b/Lib/test/test_pyrepl/test_pyrepl.py
index 82628f79279930..c3556823c72476 100644
--- a/Lib/test/test_pyrepl/test_pyrepl.py
+++ b/Lib/test/test_pyrepl/test_pyrepl.py
@@ -1,3 +1,4 @@
+import contextlib
 import importlib
 import io
 import itertools
@@ -13,7 +14,14 @@
 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 @@
     import readline as readline_module
 except ImportError:
     readline_module = None
+try:
+    import tkinter
+except ImportError:
+    tkinter = None
 
 
 class ReplTestCase(TestCase):
@@ -1050,7 +1062,9 @@ def prepare_reader(self, events, namespace):
         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 @@ def test_sub_module_private_completions(self):
             # 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 @@ def test_builtin_completion_top_level(self):
                 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 @@ def test_no_fallback_on_regular_completion(self):
         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 @@ def test_global_cache(self):
         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 @@ def test_global_cache(self):
     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 @@ def 
test_already_imported_module_without_origin_or_spec(self):
                 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 @@ def test_parse_error(self):
             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)
+
+
[email protected]
+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
index 00000000000000..aadf57622a424c
--- /dev/null
+++ 
b/Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst
@@ -0,0 +1,2 @@
+Add support for module attributes in the :term:`REPL` auto-completion of
+imports.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to