From 099cf712f37610d2ce563572fd9b3f07bb3dd299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 1 Nov 2025 01:52:34 +0100 Subject: [PATCH 01/13] gh-140870 PyREPL: add import completion for attributes --- Lib/_pyrepl/_module_completer.py | 65 ++++++- Lib/_pyrepl/completing_reader.py | 45 +++-- Lib/_pyrepl/reader.py | 8 +- Lib/_pyrepl/readline.py | 10 +- Lib/_pyrepl/types.py | 1 + Lib/test/test_pyrepl/test_pyrepl.py | 161 +++++++++++++++++- ...-11-01-01-49-52.gh-issue-140870.iknc12.rst | 2 + 7 files changed, 261 insertions(+), 31 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-11-01-01-49-52.gh-issue-140870.iknc12.rst diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index 2098d0a54aba31..d19c38f6193fa8 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from typing import Any, Iterable, Iterator, Mapping + from .types import CompletionAction HARDCODED_SUBMODULES = { @@ -52,11 +53,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 @@ -65,24 +72,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 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 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 - 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'.""" @@ -141,6 +150,33 @@ 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]: + 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 [], 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 + 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) @@ -211,6 +247,21 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]: self._global_cache = list(pkgutil.iter_modules()) return self._global_cache + 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..84f90beaea868e 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,25 +169,35 @@ 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]() + 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): - r.msg = "[ sole completion ]" - r.dirty = True - r.insert(completion[len(stem):]) + if not r.cmpltn_action: + completion = stripcolor(completions[0]) + if completions_unchangable and len(completion) == len(stem): + r.msg = "[ sole completion ]" + r.dirty = True + r.insert(completion[len(stem):]) else: clean_completions = [stripcolor(word) for word in completions] p = prefix(clean_completions, len(stem)) - if p: + if p and not r.cmpltn_action: r.insert(p) if last_is_completer: r.cmpltn_menu_visible = True @@ -204,6 +215,14 @@ def do(self) -> None: r.msg = "[ not unique ]" r.dirty = True + if r.cmpltn_action: + if r.msg: + r.msg += "\n" + r.cmpltn_action[0] + 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 +261,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 +303,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 +314,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..28815024d06ddf 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -381,9 +381,13 @@ 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 that console width, make it fit + # TODO: try to split between words? + 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 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 18e88ce4e7724a..d5778b25bc7ccf 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1050,7 +1050,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"), @@ -1099,12 +1101,13 @@ def test_private_completions(self): ModuleInfo(None, "_private", True), ], ) + @patch.dict(sys.modules, {"foo": object()}) # don't propose to import it def test_sub_module_private_completions(self): cases = ( # 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 +1128,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 +1166,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): @@ -1186,7 +1190,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 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"), ) @@ -1299,6 +1303,98 @@ def test_already_imported_module_without_origin_or_spec(self): self.assertEqual(output, f"import {mod}.") del sys.modules[mod] + def test_attribute_completion_module_already_imported(self): + cases = ( + ("from collections import def\t\n", "from collections import defaultdict"), + ("from collections.abc import \tB\t\n", "from collections.abc import Buffer"), + ) + 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_attribute_completion_module_on_demand(self): + with tempfile.TemporaryDirectory() as _dir: + dir = pathlib.Path(_dir) + (dir / "foo.py").write_text("bar = 42") + (dir / "pack").mkdir() + (dir / "pack" / "__init__.py").touch() + (dir / "pack" / "bar.py").touch() + with patch.object(sys, "path", [_dir, *sys.path]): + cases = ( + ("from foo import \t\n", "from foo import ", False), + ("from foo import \t\t\n", "from foo import bar", True), + ("from foo import ba\t\n", "from foo import ba", False), + ("from foo import ba\t\t\n", "from foo import bar", True), + ("from foo import \tb\ta\t\n", "from foo import ba", False), + # only one suggestion but message: do not complete + ("from pack import \t\n", "from pack import ", False), + ) + for code, expected, is_foo_imported 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.assertEqual("foo" in sys.modules, is_foo_imported) + if is_foo_imported: + del sys.modules["foo"] + + 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) + del sys.modules["foo"] + + def test_attribute_completion_error_on_attributes_access(self): + class BrokenModule: + def __dir__(self): + raise ValueError("boom") + + with (patch.dict(sys.modules, {"boom": BrokenModule()}), + patch("_pyrepl._module_completer.ModuleCompleter.iter_submodules", + lambda *_: [ModuleInfo(None, "submodule", False)])): + events = code_to_events("from boom import \t\n") + reader = self.prepare_reader(events, namespace={}) + output = reader.readline() + # ignore attributes, just propose submodule + self.assertEqual(output, "from boom import submodule") + + 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) + del sys.modules["foo"] + def test_get_path_and_prefix(self): cases = ( ('', ('', '')), @@ -1430,6 +1526,61 @@ def test_parse_error(self): with self.subTest(code=code): self.assertEqual(actual, None) + 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() + with patch.object(sys, "path", [_dir, *sys.path]): + 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()), + ) + 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) + for mod in new_imports: + self.addCleanup(sys.modules.pop, mod) class TestHardcodedSubmodules(TestCase): def test_hardcoded_stdlib_submodules_are_importable(self): 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. From 42e734cadc94ab5e63b1ade97bf828f4926f6389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Tue, 4 Nov 2025 23:03:40 +0100 Subject: [PATCH 02/13] Improve behavior when both submodules + prompt --- Lib/_pyrepl/completing_reader.py | 23 ++++++++------- Lib/test/test_pyrepl/test_pyrepl.py | 46 +++++++++++++++++++---------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py index 84f90beaea868e..b54980ee2d3fbf 100644 --- a/Lib/_pyrepl/completing_reader.py +++ b/Lib/_pyrepl/completing_reader.py @@ -188,16 +188,15 @@ def do(self) -> None: if not r.cmpltn_action: r.error("no matches") elif len(completions) == 1: - if not r.cmpltn_action: - completion = stripcolor(completions[0]) - if completions_unchangable and len(completion) == len(stem): - r.msg = "[ sole completion ]" - r.dirty = True - r.insert(completion[len(stem):]) + completion = stripcolor(completions[0]) + if completions_unchangable and len(completion) == len(stem): + r.msg = "[ sole completion ]" + r.dirty = True + r.insert(completion[len(stem):]) else: clean_completions = [stripcolor(word) for word in completions] p = prefix(clean_completions, len(stem)) - if p and not r.cmpltn_action: + if p: r.insert(p) if last_is_completer: r.cmpltn_menu_visible = True @@ -216,12 +215,14 @@ def do(self) -> None: r.dirty = True if r.cmpltn_action: - if r.msg: - r.msg += "\n" + r.cmpltn_action[0] + 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 + r.cmpltn_message_visible = True + r.dirty = True class self_insert(commands.self_insert): diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index d5778b25bc7ccf..dac38d4ec1ef9f 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1320,28 +1320,42 @@ def test_attribute_completion_module_on_demand(self): dir = pathlib.Path(_dir) (dir / "foo.py").write_text("bar = 42") (dir / "pack").mkdir() - (dir / "pack" / "__init__.py").touch() + (dir / "pack" / "__init__.py").write_text("attr = 42") + (dir / "pack" / "foo.py").touch() (dir / "pack" / "bar.py").touch() + (dir / "pack" / "baz.py").touch() with patch.object(sys, "path", [_dir, *sys.path]): cases = ( - ("from foo import \t\n", "from foo import ", False), - ("from foo import \t\t\n", "from foo import bar", True), - ("from foo import ba\t\n", "from foo import ba", False), - ("from foo import ba\t\t\n", "from foo import bar", True), - ("from foo import \tb\ta\t\n", "from foo import ba", False), - # only one suggestion but message: do not complete - ("from pack import \t\n", "from pack import ", False), + # 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"}), ) - for code, expected, is_foo_imported in cases: - with self.subTest(code=code): + 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) - self.assertEqual("foo" in sys.modules, is_foo_imported) - if is_foo_imported: - del sys.modules["foo"] + 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) @@ -1359,7 +1373,7 @@ def test_attribute_completion_error_on_import(self): output = reader.readline() self.assertEqual(output, expected) self.assertNotIn("boom", sys.modules) - del sys.modules["foo"] + def test_attribute_completion_error_on_attributes_access(self): class BrokenModule: @@ -1375,6 +1389,7 @@ def __dir__(self): # 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) @@ -1393,7 +1408,7 @@ def test_attribute_completion_private_and_invalid_names(self): reader = self.prepare_reader(events, namespace={}) output = reader.readline() self.assertEqual(output, expected) - del sys.modules["foo"] + def test_get_path_and_prefix(self): cases = ( @@ -1583,6 +1598,7 @@ def test_suggestions_and_messages(self) -> None: self.addCleanup(sys.modules.pop, mod) 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: From 78e0ce65fb059e9db323ee0d5dc0205e30777525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Thu, 1 Jan 2026 19:05:53 +0100 Subject: [PATCH 03/13] Auto-import stdlib modules --- Lib/_pyrepl/_module_completer.py | 10 ++++++++++ Lib/test/test_pyrepl/test_pyrepl.py | 10 ++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index d19c38f6193fa8..ce837bacaa1b2c 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -169,6 +169,16 @@ def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], Completio if not imported_module: if path in self._failed_imports: # Do not propose to import again return [], None + root = path.split(".")[0] + mod_info = next((m for m in self.global_cache if m.name == root), + None) + if mod_info and self._is_stdlib_module(mod_info): + # Stdlib module: auto-import (no risk of dangerous side-effect) + try: + imported_module = importlib.import_module(path) + except Exception: + sys.modules.pop(path, None) # Clean half-imported module + if not imported_module: return [], self._get_import_completion_action(path) try: module_attributes = dir(imported_module) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index dac38d4ec1ef9f..ef5daf107a98f7 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1324,6 +1324,7 @@ def test_attribute_completion_module_on_demand(self): (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 with patch.object(sys, "path", [_dir, *sys.path]): cases = ( # needs 2 tabs to import (show prompt, then import) @@ -1344,6 +1345,8 @@ def test_attribute_completion_module_on_demand(self): ("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"}), + # stdlib modules are automatically imported + ("from graphlib import T\t\n", "from graphlib import TopologicalSorter", {"graphlib"}), ) for code, expected, expected_imports in cases: with self.subTest(code=code), patch.dict(sys.modules): @@ -1554,6 +1557,8 @@ def test_suggestions_and_messages(self) -> None: (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("compression.zstd", None) with patch.object(sys, "path", [_dir, *sys.path]): cases = ( # no match != not an import @@ -1575,6 +1580,9 @@ def test_suggestions_and_messages(self) -> None: ("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 compression.zstd import c", (["compress"], None), {"compression.zstd"}), ) completer = ModuleCompleter() for i, (code, expected, expected_imports) in enumerate(cases): @@ -1594,8 +1602,6 @@ def test_suggestions_and_messages(self) -> None: new_imports = sys.modules.keys() - _imported self.assertSetEqual(new_imports, expected_imports) - for mod in new_imports: - self.addCleanup(sys.modules.pop, mod) class TestHardcodedSubmodules(TestCase): @patch.dict(sys.modules) From 4af851bd76f1ce14d615443ebc77d97522a4b552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 3 Jan 2026 00:53:08 +0100 Subject: [PATCH 04/13] Blacklist some stdlib modules from auto-import --- Lib/_pyrepl/_module_completer.py | 34 +++++++++++++++++++++-------- Lib/test/test_pyrepl/test_pyrepl.py | 3 +++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index ce837bacaa1b2c..83bdda2f6e0502 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -16,6 +16,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: + from types import ModuleType from typing import Any, Iterable, Iterator, Mapping from .types import CompletionAction @@ -28,6 +29,14 @@ "xml.parsers.expat": ["errors", "model"], } +AUTO_IMPORT_BLACKLIST = { + # Standard library modules/submodules that have import side effects + # and must not be automatically imported to complete attributes + "antigravity", + "this", + "idlelib.idle", +} + def make_default_module_completer() -> ModuleCompleter: # Inside pyrepl, __package__ is set to None by default @@ -169,15 +178,7 @@ def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], Completio if not imported_module: if path in self._failed_imports: # Do not propose to import again return [], None - root = path.split(".")[0] - mod_info = next((m for m in self.global_cache if m.name == root), - None) - if mod_info and self._is_stdlib_module(mod_info): - # Stdlib module: auto-import (no risk of dangerous side-effect) - try: - imported_module = importlib.import_module(path) - except Exception: - sys.modules.pop(path, None) # Clean half-imported module + imported_module = self._maybe_import_module(path) if not imported_module: return [], self._get_import_completion_action(path) try: @@ -257,6 +258,21 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]: self._global_cache = list(pkgutil.iter_modules()) return self._global_cache + def _maybe_import_module(self, fqname: str) -> ModuleType | None: + if fqname in AUTO_IMPORT_BLACKLIST or fqname.endswith(".__main__"): + # 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 ]") diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index ef5daf107a98f7..59214581dfe162 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1347,6 +1347,9 @@ def test_attribute_completion_module_on_demand(self): ("from pack import b\t\t\t\n", "from pack import ba", {"pack"}), # 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): From 61e797a545bb28452a9b94c30058cde816a3d7b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Tue, 6 Jan 2026 00:19:59 +0100 Subject: [PATCH 05/13] Fix tests post rebase --- Lib/test/test_pyrepl/test_pyrepl.py | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 59214581dfe162..f6c572aa3d2674 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1101,7 +1101,6 @@ def test_private_completions(self): ModuleInfo(None, "_private", True), ], ) - @patch.dict(sys.modules, {"foo": object()}) # don't propose to import it def test_sub_module_private_completions(self): cases = ( # Return public methods by default @@ -1380,20 +1379,20 @@ def test_attribute_completion_error_on_import(self): self.assertEqual(output, expected) self.assertNotIn("boom", sys.modules) - + @patch.dict(sys.modules) def test_attribute_completion_error_on_attributes_access(self): - class BrokenModule: - def __dir__(self): - raise ValueError("boom") - - with (patch.dict(sys.modules, {"boom": BrokenModule()}), - patch("_pyrepl._module_completer.ModuleCompleter.iter_submodules", - lambda *_: [ModuleInfo(None, "submodule", False)])): - events = code_to_events("from boom import \t\n") - reader = self.prepare_reader(events, namespace={}) - output = reader.readline() - # ignore attributes, just propose submodule - self.assertEqual(output, "from boom import submodule") + 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): From f02fc8f11f8bd93b984d4e9ec6b129838359eab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 10 Jan 2026 21:15:12 +0100 Subject: [PATCH 06/13] Improve & test auto-import blacklist --- Lib/_pyrepl/_module_completer.py | 11 +++--- Lib/test/test_pyrepl/test_pyrepl.py | 55 ++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index 83bdda2f6e0502..2e4eedd27f9495 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 @@ -32,9 +33,11 @@ AUTO_IMPORT_BLACKLIST = { # Standard library modules/submodules that have import side effects # and must not be automatically imported to complete attributes - "antigravity", - "this", - "idlelib.idle", + re.compile(r"antigravity"), + re.compile(r"this"), + re.compile(r"idlelib\..+"), + re.compile(r"test\..+"), + re.compile(r".+\.__main__"), } @@ -259,7 +262,7 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]: return self._global_cache def _maybe_import_module(self, fqname: str) -> ModuleType | None: - if fqname in AUTO_IMPORT_BLACKLIST or fqname.endswith(".__main__"): + if any(pattern.fullmatch(fqname) for pattern in AUTO_IMPORT_BLACKLIST): # Special-cased modules with known import side-effects return None root = fqname.split(".")[0] diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index f6c572aa3d2674..6a1decf8631448 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1,3 +1,5 @@ +from collections.abc import Iterator +import contextlib import importlib import io import itertools @@ -13,7 +15,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 @@ -1605,6 +1614,50 @@ def test_suggestions_and_messages(self) -> None: new_imports = sys.modules.keys() - _imported self.assertSetEqual(new_imports, expected_imports) +class TestModuleCompleterAutomaticImports(TestCase): + """Out of TestPyReplModuleCompleter case because it blocks module import.""" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls._audit_events: set[str] | None = None + def _hook(name: str, _args: tuple): + if cls._audit_events is not None: + cls._audit_events.add(name) + sys.addaudithook(_hook) + + @classmethod + @contextlib.contextmanager + def _capture_audit_events(cls) -> Iterator[set[str]]: + cls._audit_events = set() + try: + yield cls._audit_events + finally: + cls._audit_events = None + + 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, + self._capture_audit_events() as audit_events, + patch("tkinter._tkinter.create") as tk_mock, + warnings.catch_warnings(action="ignore"), + patch.dict(sys.modules)): + 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 + tk_mock.assert_not_called() + + class TestHardcodedSubmodules(TestCase): @patch.dict(sys.modules) def test_hardcoded_stdlib_submodules_are_importable(self): From 17599edbaed94c170a3baa47cbe3a03ed316170e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 10 Jan 2026 22:31:50 +0100 Subject: [PATCH 07/13] Add _ios_support to auto import blacklist --- Lib/_pyrepl/_module_completer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index 2e4eedd27f9495..c668fd8759c803 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -33,11 +33,12 @@ AUTO_IMPORT_BLACKLIST = { # Standard library modules/submodules that have import side effects # and must not be automatically imported to complete attributes - re.compile(r"antigravity"), - re.compile(r"this"), - re.compile(r"idlelib\..+"), - re.compile(r"test\..+"), - re.compile(r".+\.__main__"), + 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 } From 5125a0df762abc2770dc4cce841a82e184df3a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 10 Jan 2026 23:27:01 +0100 Subject: [PATCH 08/13] Fix tests requiring tkinter --- Lib/test/test_pyrepl/test_pyrepl.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 6a1decf8631448..2a498d61388005 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -59,6 +59,10 @@ import readline as readline_module except ImportError: readline_module = None +try: + import tkinter +except ImportError: + tkinter = None class ReplTestCase(TestCase): @@ -1644,7 +1648,8 @@ def test_no_side_effects(self): with (captured_stdout() as out, captured_stderr() as err, self._capture_audit_events() as audit_events, - patch("tkinter._tkinter.create") as tk_mock, + (patch("tkinter._tkinter.create") if tkinter + else contextlib.nullcontext()) as tk_mock, warnings.catch_warnings(action="ignore"), patch.dict(sys.modules)): completer._maybe_import_module(modname) @@ -1655,7 +1660,8 @@ def test_no_side_effects(self): # 2. spawn any subprocess (eg. webbrowser.open) self.assertNotIn("subprocess.Popen", audit_events) # 3. launch a Tk window - tk_mock.assert_not_called() + if tk_mock is not None: + tk_mock.assert_not_called() class TestHardcodedSubmodules(TestCase): From 3bb88d16aa5c91319015e5c687d6b411e47c09e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sun, 11 Jan 2026 17:18:32 +0100 Subject: [PATCH 09/13] Avoid charged terminology --- Lib/_pyrepl/_module_completer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index c668fd8759c803..b48c83dea8b4d8 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -30,7 +30,7 @@ "xml.parsers.expat": ["errors", "model"], } -AUTO_IMPORT_BLACKLIST = { +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 @@ -263,7 +263,7 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]: return self._global_cache def _maybe_import_module(self, fqname: str) -> ModuleType | None: - if any(pattern.fullmatch(fqname) for pattern in AUTO_IMPORT_BLACKLIST): + 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] From 101391290e82ce3c099ff3620b76edd1dcf00045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sun, 11 Jan 2026 21:59:56 +0100 Subject: [PATCH 10/13] Use always available modules in tests + invalidate caches --- Lib/test/test_pyrepl/test_pyrepl.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 2a498d61388005..28a5166aa041e8 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1315,29 +1315,23 @@ def test_already_imported_module_without_origin_or_spec(self): self.assertEqual(output, f"import {mod}.") del sys.modules[mod] - def test_attribute_completion_module_already_imported(self): - cases = ( - ("from collections import def\t\n", "from collections import defaultdict"), - ("from collections.abc import \tB\t\n", "from collections.abc import Buffer"), - ) - 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_attribute_completion_module_on_demand(self): + @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()), @@ -1357,6 +1351,8 @@ def test_attribute_completion_module_on_demand(self): ("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 @@ -1573,8 +1569,9 @@ def test_suggestions_and_messages(self) -> None: (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("compression.zstd", None) + sys.modules.pop("html.entities", None) with patch.object(sys, "path", [_dir, *sys.path]): + pkgutil.get_importer(_dir).invalidate_caches() cases = ( # no match != not an import ("import nope", ([], None), set()), @@ -1597,7 +1594,7 @@ def test_suggestions_and_messages(self) -> None: ("from pack.bar import ", ([], None), set()), # stdlib = auto-imported ("from graphlib import T", (["TopologicalSorter"], None), {"graphlib"}), - ("from compression.zstd import c", (["compress"], None), {"compression.zstd"}), + ("from html.entities import h", (["html5"], None), {"html", "html.entities"}), ) completer = ModuleCompleter() for i, (code, expected, expected_imports) in enumerate(cases): From 0d1105f3f51a22559aec8a32a2c5899d0d83ec0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sun, 11 Jan 2026 22:32:14 +0100 Subject: [PATCH 11/13] Don't test with html module either --- Lib/test/test_pyrepl/test_pyrepl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index 28a5166aa041e8..c189086f4ffb92 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1569,7 +1569,7 @@ def test_suggestions_and_messages(self) -> None: (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("html.entities", None) + sys.modules.pop("string.templatelib", None) with patch.object(sys, "path", [_dir, *sys.path]): pkgutil.get_importer(_dir).invalidate_caches() cases = ( @@ -1594,7 +1594,7 @@ def test_suggestions_and_messages(self) -> None: ("from pack.bar import ", ([], None), set()), # stdlib = auto-imported ("from graphlib import T", (["TopologicalSorter"], None), {"graphlib"}), - ("from html.entities import h", (["html5"], None), {"html", "html.entities"}), + ("from string.templatelib import c", (["convert"], None), {"string.templatelib"}), ) completer = ModuleCompleter() for i, (code, expected, expected_imports) in enumerate(cases): From 7b9a26d91f16a52216beb702cf9d1239fb4b2122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Simon?= Date: Sat, 24 Jan 2026 20:10:23 +0100 Subject: [PATCH 12/13] Fix refleaks in test_pyrepl.py --- Lib/test/test_pyrepl/test_pyrepl.py | 47 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index c189086f4ffb92..c4e86d83258c53 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1,4 +1,3 @@ -from collections.abc import Iterator import contextlib import importlib import io @@ -1555,6 +1554,7 @@ 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...) @@ -1615,27 +1615,27 @@ def test_suggestions_and_messages(self) -> None: new_imports = sys.modules.keys() - _imported self.assertSetEqual(new_imports, expected_imports) -class TestModuleCompleterAutomaticImports(TestCase): - """Out of TestPyReplModuleCompleter case because it blocks module import.""" - - @classmethod - def setUpClass(cls) -> None: - super().setUpClass() - cls._audit_events: set[str] | None = None - def _hook(name: str, _args: tuple): - if cls._audit_events is not None: - cls._audit_events.add(name) - sys.addaudithook(_hook) - - @classmethod - @contextlib.contextmanager - def _capture_audit_events(cls) -> Iterator[set[str]]: - cls._audit_events = set() - try: - yield cls._audit_events - finally: - cls._audit_events = None +# 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? @@ -1644,11 +1644,10 @@ def test_no_side_effects(self): with self.subTest(modname=modname): with (captured_stdout() as out, captured_stderr() as err, - self._capture_audit_events() as audit_events, + _capture_audit_events() as audit_events, (patch("tkinter._tkinter.create") if tkinter else contextlib.nullcontext()) as tk_mock, - warnings.catch_warnings(action="ignore"), - patch.dict(sys.modules)): + warnings.catch_warnings(action="ignore")): completer._maybe_import_module(modname) # Test no module is imported that # 1. prints any text From d57de373565c387db34865ec06924e0c9450620e Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sun, 5 Apr 2026 18:49:13 +0100 Subject: [PATCH 13/13] Fix pyrepl import completion edge cases Tighten the pyrepl completer around a few import-completion edge cases. Relative imports now share one resolver, find_spec failures no longer bubble out, failed-import tracking resets when sys.path changes, and double-tab import actions are cleared once consumed. The patch also keeps empty message lines intact in reader output and updates the targeted tests and comments to match the intended behavior. --- Lib/_pyrepl/_module_completer.py | 35 +++++++++++++++++------------ Lib/_pyrepl/completing_reader.py | 1 + Lib/_pyrepl/reader.py | 9 +++++--- Lib/test/test_pyrepl/test_pyrepl.py | 11 ++++++--- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/Lib/_pyrepl/_module_completer.py b/Lib/_pyrepl/_module_completer.py index b48c83dea8b4d8..948f102c85c7da 100644 --- a/Lib/_pyrepl/_module_completer.py +++ b/Lib/_pyrepl/_module_completer.py @@ -122,23 +122,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 = [] @@ -171,12 +173,9 @@ def find_attributes(self, path: str, prefix: str) -> tuple[list[str], Completion return [attr for attr in attributes if attr.isidentifier()], action def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]: - 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 [], 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: @@ -236,6 +235,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. @@ -260,6 +266,7 @@ 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: diff --git a/Lib/_pyrepl/completing_reader.py b/Lib/_pyrepl/completing_reader.py index b54980ee2d3fbf..39d0a8af5dfaea 100644 --- a/Lib/_pyrepl/completing_reader.py +++ b/Lib/_pyrepl/completing_reader.py @@ -172,6 +172,7 @@ def do(self) -> None: 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 diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index 28815024d06ddf..f35a99fb06a3f9 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -383,10 +383,14 @@ def calc_screen(self) -> list[str]: if self.msg: width = self.console.width for mline in self.msg.split("\n"): - # If self.msg is larger that console width, make it fit + # 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:]) + screen.append(mline[r * width : (r + 1) * width]) screeninfo.append((0, [])) self.last_refresh_cache.update_cache(self, screen, screeninfo) @@ -632,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/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index c4e86d83258c53..d082864f7db28c 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -1190,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() @@ -1559,7 +1559,7 @@ 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 ]") + "and propose attributes ]") _error = "[ error during import: division by zero ]" with tempfile.TemporaryDirectory() as _dir: dir = pathlib.Path(_dir) @@ -1572,6 +1572,9 @@ def test_suggestions_and_messages(self) -> None: 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()), @@ -1619,6 +1622,8 @@ def test_suggestions_and_messages(self) -> None: # 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)