Skip to content

Commit 537133d

Browse files
loic-simonambv
andauthored
gh-69605: Hardcode some stdlib submodules in PyREPL module completion (os.path, collections.abc...) (GH-138268)
Co-authored-by: Łukasz Langa <[email protected]>
1 parent a68efdf commit 537133d

File tree

3 files changed

+80
-14
lines changed

3 files changed

+80
-14
lines changed

Lib/_pyrepl/_module_completer.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
import importlib
4+
import os
35
import pkgutil
46
import sys
57
import token
68
import tokenize
9+
from importlib.machinery import FileFinder
710
from io import StringIO
811
from contextlib import contextmanager
912
from dataclasses import dataclass
@@ -16,6 +19,15 @@
1619
from typing import Any, Iterable, Iterator, Mapping
1720

1821

22+
HARDCODED_SUBMODULES = {
23+
# Standard library submodules that are not detected by pkgutil.iter_modules
24+
# but can be imported, so should be proposed in completion
25+
"collections": ["abc"],
26+
"os": ["path"],
27+
"xml.parsers.expat": ["errors", "model"],
28+
}
29+
30+
1931
def make_default_module_completer() -> ModuleCompleter:
2032
# Inside pyrepl, __package__ is set to None by default
2133
return ModuleCompleter(namespace={'__package__': None})
@@ -41,6 +53,7 @@ def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
4153
self.namespace = namespace or {}
4254
self._global_cache: list[pkgutil.ModuleInfo] = []
4355
self._curr_sys_path: list[str] = sys.path[:]
56+
self._stdlib_path = os.path.dirname(importlib.__path__[0])
4457

4558
def get_completions(self, line: str) -> list[str] | None:
4659
"""Return the next possible import completions for 'line'."""
@@ -95,12 +108,26 @@ def _find_modules(self, path: str, prefix: str) -> list[str]:
95108
return []
96109

97110
modules: Iterable[pkgutil.ModuleInfo] = self.global_cache
111+
is_stdlib_import: bool | None = None
98112
for segment in path.split('.'):
99113
modules = [mod_info for mod_info in modules
100114
if mod_info.ispkg and mod_info.name == segment]
115+
if is_stdlib_import is None:
116+
# Top-level import decide if we import from stdlib or not
117+
is_stdlib_import = all(
118+
self._is_stdlib_module(mod_info) for mod_info in modules
119+
)
101120
modules = self.iter_submodules(modules)
102-
return [module.name for module in modules
103-
if self.is_suggestion_match(module.name, prefix)]
121+
122+
module_names = [module.name for module in modules]
123+
if is_stdlib_import:
124+
module_names.extend(HARDCODED_SUBMODULES.get(path, ()))
125+
return [module_name for module_name in module_names
126+
if self.is_suggestion_match(module_name, prefix)]
127+
128+
def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
129+
return (isinstance(module_info.module_finder, FileFinder)
130+
and module_info.module_finder.path == self._stdlib_path)
104131

105132
def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
106133
if prefix:

Lib/test/test_pyrepl/test_pyrepl.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib
12
import io
23
import itertools
34
import os
@@ -26,9 +27,16 @@
2627
code_to_events,
2728
)
2829
from _pyrepl.console import Event
29-
from _pyrepl._module_completer import ImportParser, ModuleCompleter
30-
from _pyrepl.readline import (ReadlineAlikeReader, ReadlineConfig,
31-
_ReadlineWrapper)
30+
from _pyrepl._module_completer import (
31+
ImportParser,
32+
ModuleCompleter,
33+
HARDCODED_SUBMODULES,
34+
)
35+
from _pyrepl.readline import (
36+
ReadlineAlikeReader,
37+
ReadlineConfig,
38+
_ReadlineWrapper,
39+
)
3240
from _pyrepl.readline import multiline_input as readline_multiline_input
3341

3442
try:
@@ -930,7 +938,6 @@ def test_func(self):
930938

931939
class TestPyReplModuleCompleter(TestCase):
932940
def setUp(self):
933-
import importlib
934941
# Make iter_modules() search only the standard library.
935942
# This makes the test more reliable in case there are
936943
# other user packages/scripts on PYTHONPATH which can
@@ -1013,14 +1020,6 @@ def test_sub_module_private_completions(self):
10131020
self.assertEqual(output, expected)
10141021

10151022
def test_builtin_completion_top_level(self):
1016-
import importlib
1017-
# Make iter_modules() search only the standard library.
1018-
# This makes the test more reliable in case there are
1019-
# other user packages/scripts on PYTHONPATH which can
1020-
# intefere with the completions.
1021-
lib_path = os.path.dirname(importlib.__path__[0])
1022-
sys.path = [lib_path]
1023-
10241023
cases = (
10251024
("import bui\t\n", "import builtins"),
10261025
("from bui\t\n", "from builtins"),
@@ -1076,6 +1075,32 @@ def test_no_fallback_on_regular_completion(self):
10761075
output = reader.readline()
10771076
self.assertEqual(output, expected)
10781077

1078+
def test_hardcoded_stdlib_submodules(self):
1079+
cases = (
1080+
("import collections.\t\n", "import collections.abc"),
1081+
("from os import \t\n", "from os import path"),
1082+
("import xml.parsers.expat.\t\te\t\n\n", "import xml.parsers.expat.errors"),
1083+
("from xml.parsers.expat import \t\tm\t\n\n", "from xml.parsers.expat import model"),
1084+
)
1085+
for code, expected in cases:
1086+
with self.subTest(code=code):
1087+
events = code_to_events(code)
1088+
reader = self.prepare_reader(events, namespace={})
1089+
output = reader.readline()
1090+
self.assertEqual(output, expected)
1091+
1092+
def test_hardcoded_stdlib_submodules_not_proposed_if_local_import(self):
1093+
with tempfile.TemporaryDirectory() as _dir:
1094+
dir = pathlib.Path(_dir)
1095+
(dir / "collections").mkdir()
1096+
(dir / "collections" / "__init__.py").touch()
1097+
(dir / "collections" / "foo.py").touch()
1098+
with patch.object(sys, "path", [dir, *sys.path]):
1099+
events = code_to_events("import collections.\t\n")
1100+
reader = self.prepare_reader(events, namespace={})
1101+
output = reader.readline()
1102+
self.assertEqual(output, "import collections.foo")
1103+
10791104
def test_get_path_and_prefix(self):
10801105
cases = (
10811106
('', ('', '')),
@@ -1204,6 +1229,19 @@ def test_parse_error(self):
12041229
with self.subTest(code=code):
12051230
self.assertEqual(actual, None)
12061231

1232+
1233+
class TestHardcodedSubmodules(TestCase):
1234+
def test_hardcoded_stdlib_submodules_are_importable(self):
1235+
for parent_path, submodules in HARDCODED_SUBMODULES.items():
1236+
for module_name in submodules:
1237+
path = f"{parent_path}.{module_name}"
1238+
with self.subTest(path=path):
1239+
# We can't use importlib.util.find_spec here,
1240+
# since some hardcoded submodules parents are
1241+
# not proper packages
1242+
importlib.import_module(path)
1243+
1244+
12071245
class TestPasteEvent(TestCase):
12081246
def prepare_reader(self, events):
12091247
console = FakeConsole(events)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix some standard library submodules missing from the :term:`REPL` auto-completion of imports.

0 commit comments

Comments
 (0)