Skip to content

Commit 1bbfb9b

Browse files
committed
gh-140870 PyREPL: add import completion for attributes
1 parent e34a5e3 commit 1bbfb9b

File tree

7 files changed

+261
-31
lines changed

7 files changed

+261
-31
lines changed

Lib/_pyrepl/_module_completer.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
if TYPE_CHECKING:
1919
from typing import Any, Iterable, Iterator, Mapping
20+
from .types import CompletionAction
2021

2122

2223
HARDCODED_SUBMODULES = {
@@ -52,11 +53,17 @@ class ModuleCompleter:
5253
def __init__(self, namespace: Mapping[str, Any] | None = None) -> None:
5354
self.namespace = namespace or {}
5455
self._global_cache: list[pkgutil.ModuleInfo] = []
56+
self._failed_imports: set[str] = set()
5557
self._curr_sys_path: list[str] = sys.path[:]
5658
self._stdlib_path = os.path.dirname(importlib.__path__[0])
5759

58-
def get_completions(self, line: str) -> list[str] | None:
59-
"""Return the next possible import completions for 'line'."""
60+
def get_completions(self, line: str) -> tuple[list[str], CompletionAction | None] | None:
61+
"""Return the next possible import completions for 'line'.
62+
63+
For attributes completion, if the module to complete from is not
64+
imported, also return an action (prompt + callback to run if the
65+
user press TAB again) to import the module.
66+
"""
6067
result = ImportParser(line).parse()
6168
if not result:
6269
return None
@@ -65,24 +72,26 @@ def get_completions(self, line: str) -> list[str] | None:
6572
except Exception:
6673
# Some unexpected error occurred, make it look like
6774
# no completions are available
68-
return []
75+
return [], None
6976

70-
def complete(self, from_name: str | None, name: str | None) -> list[str]:
77+
def complete(self, from_name: str | None, name: str | None) -> tuple[list[str], CompletionAction | None]:
7178
if from_name is None:
7279
# import x.y.z<tab>
7380
assert name is not None
7481
path, prefix = self.get_path_and_prefix(name)
7582
modules = self.find_modules(path, prefix)
76-
return [self.format_completion(path, module) for module in modules]
83+
return [self.format_completion(path, module) for module in modules], None
7784

7885
if name is None:
7986
# from x.y.z<tab>
8087
path, prefix = self.get_path_and_prefix(from_name)
8188
modules = self.find_modules(path, prefix)
82-
return [self.format_completion(path, module) for module in modules]
89+
return [self.format_completion(path, module) for module in modules], None
8390

8491
# from x.y import z<tab>
85-
return self.find_modules(from_name, name)
92+
submodules = self.find_modules(from_name, name)
93+
attributes, action = self.find_attributes(from_name, name)
94+
return sorted({*submodules, *attributes}), action
8695

8796
def find_modules(self, path: str, prefix: str) -> list[str]:
8897
"""Find all modules under 'path' that start with 'prefix'."""
@@ -129,6 +138,33 @@ def _is_stdlib_module(self, module_info: pkgutil.ModuleInfo) -> bool:
129138
return (isinstance(module_info.module_finder, FileFinder)
130139
and module_info.module_finder.path == self._stdlib_path)
131140

141+
def find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
142+
"""Find all attributes of module 'path' that start with 'prefix'."""
143+
attributes, action = self._find_attributes(path, prefix)
144+
# Filter out invalid attribute names
145+
# (for example those containing dashes that cannot be imported with 'import')
146+
return [attr for attr in attributes if attr.isidentifier()], action
147+
148+
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
149+
if path.startswith('.'):
150+
# Convert relative path to absolute path
151+
package = self.namespace.get('__package__', '')
152+
path = self.resolve_relative_name(path, package) # type: ignore[assignment]
153+
if path is None:
154+
return [], None
155+
156+
imported_module = sys.modules.get(path)
157+
if not imported_module:
158+
if path in self._failed_imports: # Do not propose to import again
159+
return [], None
160+
return [], self._get_import_completion_action(path)
161+
try:
162+
module_attributes = dir(imported_module)
163+
except Exception:
164+
module_attributes = []
165+
return [attr_name for attr_name in module_attributes
166+
if self.is_suggestion_match(attr_name, prefix)], None
167+
132168
def is_suggestion_match(self, module_name: str, prefix: str) -> bool:
133169
if prefix:
134170
return module_name.startswith(prefix)
@@ -200,6 +236,21 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]:
200236
self._global_cache = list(pkgutil.iter_modules())
201237
return self._global_cache
202238

239+
def _get_import_completion_action(self, path: str) -> CompletionAction:
240+
prompt = ("[ module not imported, press again to import it "
241+
"and propose attributes ]")
242+
243+
def _do_import() -> str | None:
244+
try:
245+
importlib.import_module(path)
246+
return None
247+
except Exception as exc:
248+
sys.modules.pop(path, None) # Clean half-imported module
249+
self._failed_imports.add(path)
250+
return f"[ error during import: {exc} ]"
251+
252+
return (prompt, _do_import)
253+
203254

204255
class ImportParser:
205256
"""

Lib/_pyrepl/completing_reader.py

Lines changed: 32 additions & 11 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,23 +169,33 @@ 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+
if msg:
176+
r.msg = msg
177+
else: # other input since last tab: cancel action
178+
r.cmpltn_action = None
179+
171180
immutable_completions = r.assume_immutable_completions
172181
completions_unchangable = last_is_completer and immutable_completions
173182
stem = r.get_stem()
174183
if not completions_unchangable:
175-
r.cmpltn_menu_choices = r.get_completions(stem)
184+
r.cmpltn_menu_choices, r.cmpltn_action = r.get_completions(stem)
176185

177186
completions = r.cmpltn_menu_choices
178187
if not completions:
179-
r.error("no matches")
188+
if not r.cmpltn_action:
189+
r.error("no matches")
180190
elif len(completions) == 1:
181-
if completions_unchangable and len(completions[0]) == len(stem):
182-
r.msg = "[ sole completion ]"
183-
r.dirty = True
184-
r.insert(completions[0][len(stem):])
191+
if not r.cmpltn_action:
192+
if completions_unchangable and len(completions[0]) == len(stem):
193+
r.msg = "[ sole completion ]"
194+
r.dirty = True
195+
r.insert(completions[0][len(stem):])
185196
else:
186197
p = prefix(completions, len(stem))
187-
if p:
198+
if p and not r.cmpltn_action:
188199
r.insert(p)
189200
if last_is_completer:
190201
r.cmpltn_menu_visible = True
@@ -202,6 +213,14 @@ def do(self) -> None:
202213
r.msg = "[ not unique ]"
203214
r.dirty = True
204215

216+
if r.cmpltn_action:
217+
if r.msg:
218+
r.msg += "\n" + r.cmpltn_action[0]
219+
else:
220+
r.msg = r.cmpltn_action[0]
221+
r.cmpltn_message_visible = True
222+
r.dirty = True
223+
205224

206225
class self_insert(commands.self_insert):
207226
def do(self) -> None:
@@ -240,6 +259,7 @@ class CompletingReader(Reader):
240259
cmpltn_message_visible: bool = field(init=False)
241260
cmpltn_menu_end: int = field(init=False)
242261
cmpltn_menu_choices: list[str] = field(init=False)
262+
cmpltn_action: CompletionAction | None = field(init=False)
243263

244264
def __post_init__(self) -> None:
245265
super().__post_init__()
@@ -281,6 +301,7 @@ def cmpltn_reset(self) -> None:
281301
self.cmpltn_message_visible = False
282302
self.cmpltn_menu_end = 0
283303
self.cmpltn_menu_choices = []
304+
self.cmpltn_action = None
284305

285306
def get_stem(self) -> str:
286307
st = self.syntax_table
@@ -291,8 +312,8 @@ def get_stem(self) -> str:
291312
p -= 1
292313
return ''.join(b[p+1:self.pos])
293314

294-
def get_completions(self, stem: str) -> list[str]:
295-
return []
315+
def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
316+
return [], None
296317

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

Lib/_pyrepl/reader.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,13 @@ 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 that console width, make it fit
387+
# TODO: try to split between words?
388+
for r in range((len(mline) - 1) // width + 1):
389+
screen.append(mline[r * width : (r + 1) * width:])
390+
screeninfo.append((0, []))
387391

388392
self.last_refresh_cache.update_cache(self, screen, screeninfo)
389393
return screen

Lib/_pyrepl/readline.py

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

6060
TYPE_CHECKING = False
6161

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

137-
def get_completions(self, stem: str) -> list[str]:
137+
def get_completions(self, stem: str) -> tuple[list[str], CompletionAction | None]:
138138
module_completions = self.get_module_completions()
139139
if module_completions is not None:
140140
return module_completions
@@ -144,7 +144,7 @@ def get_completions(self, stem: str) -> list[str]:
144144
while p > 0 and b[p - 1] != "\n":
145145
p -= 1
146146
num_spaces = 4 - ((self.pos - p) % 4)
147-
return [" " * num_spaces]
147+
return [" " * num_spaces], None
148148
result = []
149149
function = self.config.readline_completer
150150
if function is not None:
@@ -165,9 +165,9 @@ def get_completions(self, stem: str) -> list[str]:
165165
# emulate the behavior of the standard readline that sorts
166166
# the completions before displaying them.
167167
result.sort()
168-
return result
168+
return result, None
169169

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

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)