Skip to content

Commit b07becb

Browse files
gh-140870: PyREPL auto-complete module attributes in import statements (#140871)
Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
1 parent c64baff commit b07becb

File tree

7 files changed

+393
-37
lines changed

7 files changed

+393
-37
lines changed

Lib/_pyrepl/_module_completer.py

Lines changed: 103 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import importlib
44
import os
55
import pkgutil
6+
import re
67
import sys
78
import token
89
import tokenize
@@ -16,7 +17,9 @@
1617
TYPE_CHECKING = False
1718

1819
if TYPE_CHECKING:
20+
from types import ModuleType
1921
from typing import Any, Iterable, Iterator, Mapping
22+
from .types import CompletionAction
2023

2124

2225
HARDCODED_SUBMODULES = {
@@ -28,6 +31,17 @@
2831
"xml.parsers.expat": ["errors", "model"],
2932
}
3033

34+
AUTO_IMPORT_DENYLIST = {
35+
# Standard library modules/submodules that have import side effects
36+
# and must not be automatically imported to complete attributes
37+
re.compile(r"antigravity"), # Calls webbrowser.open
38+
re.compile(r"idlelib\..+"), # May open IDLE GUI
39+
re.compile(r"test\..+"), # Various side-effects
40+
re.compile(r"this"), # Prints to stdout
41+
re.compile(r"_ios_support"), # Spawns a subprocess
42+
re.compile(r".+\.__main__"), # Should not be imported
43+
}
44+
3145

3246
def make_default_module_completer() -> ModuleCompleter:
3347
# Inside pyrepl, __package__ is set to None by default
@@ -53,11 +67,17 @@ class ModuleCompleter:
5367
def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
5468
self.namespace = namespace or {}
5569
self._global_cache: list[pkgutil.ModuleInfo] = []
70+
self._failed_imports: set[str] = set()
5671
self._curr_sys_path: list[str] = sys.path[:]
5772
self._stdlib_path = os.path.dirname(importlib.__path__[0])
5873

59-
def get_completions(self, line: str) -> list[str] | None:
60-
"""Return the next possible import completions for 'line'."""
74+
def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
75+
"""Return the next possible import completions for 'line'.
76+
77+
For attributes completion, if the module to complete from is not
78+
imported, also return an action (prompt + callback to run if the
79+
user press TAB again) to import the module.
80+
"""
6181
result = ImportParser(line).parse()
6282
if not result:
6383
return None
@@ -66,24 +86,26 @@ def get_completions(self, line: str) -> list[str] | None:
6686
except Exception:
6787
# Some unexpected error occurred, make it look like
6888
# no completions are available
69-
return []
89+
return [], None
7090

71-
def complete(self, from_name: str | None, name: str | None) -> list[str]:
91+
def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
7292
if from_name is None:
7393
# import x.y.z<tab>
7494
assert name is not None
7595
path, prefix = self.get_path_and_prefix(name)
7696
modules = self.find_modules(path, prefix)
77-
return [self.format_completion(path, module) for module in modules]
97+
return [self.format_completion(path, module) for module in modules], None
7898

7999
if name is None:
80100
# from x.y.z<tab>
81101
path, prefix = self.get_path_and_prefix(from_name)
82102
modules = self.find_modules(path, prefix)
83-
return [self.format_completion(path, module) for module in modules]
103+
return [self.format_completion(path, module) for module in modules], None
84104

85105
# from x.y import z<tab>
86-
return self.find_modules(from_name, name)
106+
submodules = self.find_modules(from_name, name)
107+
attributes, action = self.find_attributes(from_name, name)
108+
return sorted({*submodules, *attributes}), action
87109

88110
def find_modules(self, path: str, prefix: str) -> list[str]:
89111
"""Find all modules under 'path' that start with 'prefix'."""
@@ -101,23 +123,25 @@ def _find_modules(self, path: str, prefix: str) -> list[str]:
101123
if self.is_suggestion_match(module.name, prefix)]
102124
return sorted(builtin_modules + third_party_modules)
103125

104-
if path.startswith('.'):
105-
# Convert relative path to absolute path
106-
package = self.namespace.get('__package__', '')
107-
path = self.resolve_relative_name(path, package) # type: ignore[assignment]
108-
if path is None:
109-
return []
126+
path = self._resolve_relative_path(path) # type: ignore[assignment]
127+
if path is None:
128+
return []
110129

111130
modules: Iterable[pkgutil.ModuleInfo] = self.global_cache
112131
imported_module = sys.modules.get(path.split('.')[0])
113132
if imported_module:
114-
# Filter modules to those who name and specs match the
133+
# Filter modules to those whose name and specs match the
115134
# imported module to avoid invalid suggestions
116135
spec = imported_module.__spec__
117136
if spec:
137+
def _safe_find_spec(mod: pkgutil.ModuleInfo) -> bool:
138+
try:
139+
return mod.module_finder.find_spec(mod.name, None) == spec
140+
except Exception:
141+
return False
118142
modules = [mod for mod in modules
119143
if mod.name == spec.name
120-
and mod.module_finder.find_spec(mod.name, None) == spec]
144+
and _safe_find_spec(mod)]
121145
else:
122146
modules = []
123147

@@ -142,6 +166,32 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
142166
return (isinstance(module_info.module_finder, FileFinder)
143167
and module_info.module_finder.path == self._stdlib_path)
144168

169+
def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
170+
"""Find all attributes of module 'path' that start with 'prefix'."""
171+
attributes, action = self._find_attributes(path, prefix)
172+
# Filter out invalid attribute names
173+
# (for example those containing dashes that cannot be imported with 'import')
174+
return [attr for attr in attributes if attr.isidentifier()], action
175+
176+
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
177+
path = self._resolve_relative_path(path) # type: ignore[assignment]
178+
if path is None:
179+
return [], None
180+
181+
imported_module = sys.modules.get(path)
182+
if not imported_module:
183+
if path in self._failed_imports: # Do not propose to import again
184+
return [], None
185+
imported_module = self._maybe_import_module(path)
186+
if not imported_module:
187+
return [], self._get_import_completion_action(path)
188+
try:
189+
module_attributes = dir(imported_module)
190+
except Exception:
191+
module_attributes = []
192+
return [attr_name for attr_name in module_attributes
193+
if self.is_suggestion_match(attr_name, prefix)], None
194+
145195
def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
146196
if prefix:
147197
return module_name.startswith(prefix)
@@ -186,6 +236,13 @@ def format_completion(self, path: str, module: str) -> str:
186236
return f'{path}{module}'
187237
return f'{path}.{module}'
188238

239+
def _resolve_relative_path(self, path: str) -> str | None:
240+
"""Resolve a relative import path to absolute. Returns None if unresolvable."""
241+
if path.startswith('.'):
242+
package = self.namespace.get('__package__', '')
243+
return self.resolve_relative_name(path, package)
244+
return path
245+
189246
def resolve_relative_name(self, name: str, package: str) -> str | None:
190247
"""Resolve a relative module name to an absolute name.
191248
@@ -210,8 +267,39 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]:
210267
if not self._global_cache or self._curr_sys_path != sys.path:
211268
self._curr_sys_path = sys.path[:]
212269
self._global_cache = list(pkgutil.iter_modules())
270+
self._failed_imports.clear() # retry on sys.path change
213271
return self._global_cache
214272

273+
def _maybe_import_module(self, fqname: str) -> ModuleType | None:
274+
if any(pattern.fullmatch(fqname) for pattern in AUTO_IMPORT_DENYLIST):
275+
# Special-cased modules with known import side-effects
276+
return None
277+
root = fqname.split(".")[0]
278+
mod_info = next((m for m in self.global_cache if m.name == root), None)
279+
if not mod_info or not self._is_stdlib_module(mod_info):
280+
# Only import stdlib modules (no risk of import side-effects)
281+
return None
282+
try:
283+
return importlib.import_module(fqname)
284+
except Exception:
285+
sys.modules.pop(fqname, None) # Clean half-imported module
286+
return None
287+
288+
def _get_import_completion_action(self, path: str) -> CompletionAction:
289+
prompt = ("[ module not imported, press again to import it "
290+
"and propose attributes ]")
291+
292+
def _do_import() -> str | None:
293+
try:
294+
importlib.import_module(path)
295+
return None
296+
except Exception as exc:
297+
sys.modules.pop(path, None) # Clean half-imported module
298+
self._failed_imports.add(path)
299+
return f"[ error during import: {exc} ]"
300+
301+
return (prompt, _do_import)
302+
215303

216304
class ImportParser:
217305
"""

Lib/_pyrepl/completing_reader.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929

3030
# types
3131
Command = commands.Command
32-
if False:
33-
from .types import KeySpec, CommandName
32+
TYPE_CHECKING = False
33+
if TYPE_CHECKING:
34+
from .types import KeySpec, CommandName, CompletionAction
3435

3536

3637
def prefix(wordlist: list[str], j: int = 0) -> str:
@@ -168,15 +169,25 @@ def do(self) -> None:
168169
r: CompletingReader
169170
r = self.reader # type: ignore[assignment]
170171
last_is_completer = r.last_command_is(self.__class__)
172+
if r.cmpltn_action:
173+
if last_is_completer: # double-tab: execute action
174+
msg = r.cmpltn_action[1]()
175+
r.cmpltn_action = None # consumed
176+
if msg:
177+
r.msg = msg
178+
else: # other input since last tab: cancel action
179+
r.cmpltn_action = None
180+
171181
immutable_completions = r.assume_immutable_completions
172182
completions_unchangable = last_is_completer and immutable_completions
173183
stem = r.get_stem()
174184
if not completions_unchangable:
175-
r.cmpltn_menu_choices = r.get_completions(stem)
185+
r.cmpltn_menu_choices, r.cmpltn_action = r.get_completions(stem)
176186

177187
completions = r.cmpltn_menu_choices
178188
if not completions:
179-
r.error("no matches")
189+
if not r.cmpltn_action:
190+
r.error("no matches")
180191
elif len(completions) == 1:
181192
completion = stripcolor(completions[0])
182193
if completions_unchangable and len(completion) == len(stem):
@@ -204,6 +215,16 @@ def do(self) -> None:
204215
r.msg = "[ not unique ]"
205216
r.dirty = True
206217

218+
if r.cmpltn_action:
219+
if r.msg and r.cmpltn_message_visible:
220+
# There is already a message (eg. [ not unique ]) that
221+
# would conflict for next tab: cancel action
222+
r.cmpltn_action = None
223+
else:
224+
r.msg = r.cmpltn_action[0]
225+
r.cmpltn_message_visible = True
226+
r.dirty = True
227+
207228

208229
class self_insert(commands.self_insert):
209230
def do(self) -> None:
@@ -242,6 +263,7 @@ class CompletingReader(Reader):
242263
cmpltn_message_visible: bool = field(init=False)
243264
cmpltn_menu_end: int = field(init=False)
244265
cmpltn_menu_choices: list[str] = field(init=False)
266+
cmpltn_action: CompletionAction | None = field(init=False)
245267

246268
def __post_init__(self) -> None:
247269
super().__post_init__()
@@ -283,6 +305,7 @@ def cmpltn_reset(self) -> None:
283305
self.cmpltn_message_visible = False
284306
self.cmpltn_menu_end = 0
285307
self.cmpltn_menu_choices = []
308+
self.cmpltn_action = None
286309

287310
def get_stem(self) -> str:
288311
st = self.syntax_table
@@ -293,8 +316,8 @@ def get_stem(self) -> str:
293316
p -= 1
294317
return ''.join(b[p+1:self.pos])
295318

296-
def get_completions(self, stem: str) -> list[str]:
297-
return []
319+
def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
320+
return [], None
298321

299322
def get_line(self) -> str:
300323
"""Return the current line until the cursor position."""

Lib/_pyrepl/reader.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,17 @@ def calc_screen(self) -> list[str]:
381381
self.screeninfo = screeninfo
382382
self.cxy = self.pos2xy()
383383
if self.msg:
384+
width = self.console.width
384385
for mline in self.msg.split("\n"):
385-
screen.append(mline)
386-
screeninfo.append((0, []))
386+
# If self.msg is larger than console width, make it fit
387+
# TODO: try to split between words?
388+
if not mline:
389+
screen.append("")
390+
screeninfo.append((0, []))
391+
continue
392+
for r in range((len(mline) - 1) // width + 1):
393+
screen.append(mline[r * width : (r + 1) * width])
394+
screeninfo.append((0, []))
387395

388396
self.last_refresh_cache.update_cache(self, screen, screeninfo)
389397
return screen
@@ -628,7 +636,6 @@ def suspend_colorization(self) -> SimpleContextManager:
628636
finally:
629637
self.can_colorize = old_can_colorize
630638

631-
632639
def finish(self) -> None:
633640
"""Called when a command signals that we're finished."""
634641
pass

Lib/_pyrepl/readline.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
# types
5757
Command = commands.Command
5858
from collections.abc import Callable, Collection
59-
from .types import Callback, Completer, KeySpec, CommandName
59+
from .types import Callback, Completer, KeySpec, CommandName, CompletionAction
6060

6161
TYPE_CHECKING = False
6262

@@ -135,7 +135,7 @@ def get_stem(self) -> str:
135135
p -= 1
136136
return "".join(b[p + 1 : self.pos])
137137

138-
def get_completions(self, stem: str) -> list[str]:
138+
def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
139139
module_completions = self.get_module_completions()
140140
if module_completions is not None:
141141
return module_completions
@@ -145,7 +145,7 @@ def get_completions(self, stem: str) -> list[str]:
145145
while p > 0 and b[p - 1] != "\n":
146146
p -= 1
147147
num_spaces = 4 - ((self.pos - p) % 4)
148-
return [" " * num_spaces]
148+
return [" " * num_spaces], None
149149
result = []
150150
function = self.config.readline_completer
151151
if function is not None:
@@ -166,9 +166,9 @@ def get_completions(self, stem: str) -> list[str]:
166166
# emulate the behavior of the standard readline that sorts
167167
# the completions before displaying them.
168168
result.sort()
169-
return result
169+
return result, None
170170

171-
def get_module_completions(self) -> list[str] | None:
171+
def get_module_completions(self) -> tuple[list[str], CompletionAction | None] | None:
172172
line = self.get_line()
173173
return self.config.module_completer.get_completions(line)
174174

Lib/_pyrepl/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@
88
type Completer = Callable[[str, int], str | None]
99
type CharBuffer = list[str]
1010
type CharWidths = list[int]
11+
type CompletionAction = tuple[str, Callable[[], str | None]]

0 commit comments

Comments
 (0)