Skip to content

Commit d57de37

Browse files
committed
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.
1 parent 7b9a26d commit d57de37

File tree

4 files changed

+36
-20
lines changed

4 files changed

+36
-20
lines changed

Lib/_pyrepl/_module_completer.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,23 +122,25 @@ def _find_modules(self, path: str, prefix: str) -> list[str]:
122122
if self.is_suggestion_match(module.name, prefix)]
123123
return sorted(builtin_modules + third_party_modules)
124124

125-
if path.startswith('.'):
126-
# Convert relative path to absolute path
127-
package = self.namespace.get('__package__', '')
128-
path = self.resolve_relative_name(path, package) # type: ignore[assignment]
129-
if path is None:
130-
return []
125+
path = self._resolve_relative_path(path) # type: ignore[assignment]
126+
if path is None:
127+
return []
131128

132129
modules: Iterable[pkgutil.ModuleInfo] = self.global_cache
133130
imported_module = sys.modules.get(path.split('.')[0])
134131
if imported_module:
135-
# Filter modules to those who name and specs match the
132+
# Filter modules to those whose name and specs match the
136133
# imported module to avoid invalid suggestions
137134
spec = imported_module.__spec__
138135
if spec:
136+
def _safe_find_spec(mod: pkgutil.ModuleInfo) -> bool:
137+
try:
138+
return mod.module_finder.find_spec(mod.name, None) == spec
139+
except Exception:
140+
return False
139141
modules = [mod for mod in modules
140142
if mod.name == spec.name
141-
and mod.module_finder.find_spec(mod.name, None) == spec]
143+
and _safe_find_spec(mod)]
142144
else:
143145
modules = []
144146

@@ -171,12 +173,9 @@ def find_attributes(self, path: str, prefix: str) -> tuple[list[str], Completion
171173
return [attr for attr in attributes if attr.isidentifier()], action
172174

173175
def _find_attributes(self, path: str, prefix: str) -> tuple[list[str], CompletionAction | None]:
174-
if path.startswith('.'):
175-
# Convert relative path to absolute path
176-
package = self.namespace.get('__package__', '')
177-
path = self.resolve_relative_name(path, package) # type: ignore[assignment]
178-
if path is None:
179-
return [], None
176+
path = self._resolve_relative_path(path) # type: ignore[assignment]
177+
if path is None:
178+
return [], None
180179

181180
imported_module = sys.modules.get(path)
182181
if not imported_module:
@@ -236,6 +235,13 @@ def format_completion(self, path: str, module: str) -> str:
236235
return f'{path}{module}'
237236
return f'{path}.{module}'
238237

238+
def _resolve_relative_path(self, path: str) -> str | None:
239+
"""Resolve a relative import path to absolute. Returns None if unresolvable."""
240+
if path.startswith('.'):
241+
package = self.namespace.get('__package__', '')
242+
return self.resolve_relative_name(path, package)
243+
return path
244+
239245
def resolve_relative_name(self, name: str, package: str) -> str | None:
240246
"""Resolve a relative module name to an absolute name.
241247
@@ -260,6 +266,7 @@ def global_cache(self) -> list[pkgutil.ModuleInfo]:
260266
if not self._global_cache or self._curr_sys_path != sys.path:
261267
self._curr_sys_path = sys.path[:]
262268
self._global_cache = list(pkgutil.iter_modules())
269+
self._failed_imports.clear() # retry on sys.path change
263270
return self._global_cache
264271

265272
def _maybe_import_module(self, fqname: str) -> ModuleType | None:

Lib/_pyrepl/completing_reader.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def do(self) -> None:
172172
if r.cmpltn_action:
173173
if last_is_completer: # double-tab: execute action
174174
msg = r.cmpltn_action[1]()
175+
r.cmpltn_action = None # consumed
175176
if msg:
176177
r.msg = msg
177178
else: # other input since last tab: cancel action

Lib/_pyrepl/reader.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,10 +383,14 @@ def calc_screen(self) -> list[str]:
383383
if self.msg:
384384
width = self.console.width
385385
for mline in self.msg.split("\n"):
386-
# If self.msg is larger that console width, make it fit
386+
# If self.msg is larger than console width, make it fit
387387
# TODO: try to split between words?
388+
if not mline:
389+
screen.append("")
390+
screeninfo.append((0, []))
391+
continue
388392
for r in range((len(mline) - 1) // width + 1):
389-
screen.append(mline[r * width : (r + 1) * width:])
393+
screen.append(mline[r * width : (r + 1) * width])
390394
screeninfo.append((0, []))
391395

392396
self.last_refresh_cache.update_cache(self, screen, screeninfo)
@@ -632,7 +636,6 @@ def suspend_colorization(self) -> SimpleContextManager:
632636
finally:
633637
self.can_colorize = old_can_colorize
634638

635-
636639
def finish(self) -> None:
637640
"""Called when a command signals that we're finished."""
638641
pass

Lib/test/test_pyrepl/test_pyrepl.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,8 +1190,8 @@ def test_global_cache(self):
11901190
with (tempfile.TemporaryDirectory() as _dir1,
11911191
patch.object(sys, "path", [_dir1, *sys.path])):
11921192
dir1 = pathlib.Path(_dir1)
1193-
(dir1 / "mod_aa.py").mkdir()
1194-
(dir1 / "mod_bb.py").mkdir()
1193+
(dir1 / "mod_aa.py").touch()
1194+
(dir1 / "mod_bb.py").touch()
11951195
events = code_to_events("import mod_a\t\nimport mod_b\t\n")
11961196
reader = self.prepare_reader(events, namespace={})
11971197
output_1, output_2 = reader.readline(), reader.readline()
@@ -1559,7 +1559,7 @@ def test_suggestions_and_messages(self) -> None:
15591559
# more unitary tests checking the exact suggestions provided
15601560
# (sorting, de-duplication, import action...)
15611561
_prompt = ("[ module not imported, press again to import it "
1562-
"and propose attributes ]")
1562+
"and propose attributes ]")
15631563
_error = "[ error during import: division by zero ]"
15641564
with tempfile.TemporaryDirectory() as _dir:
15651565
dir = pathlib.Path(_dir)
@@ -1572,6 +1572,9 @@ def test_suggestions_and_messages(self) -> None:
15721572
sys.modules.pop("string.templatelib", None)
15731573
with patch.object(sys, "path", [_dir, *sys.path]):
15741574
pkgutil.get_importer(_dir).invalidate_caches()
1575+
# NOTE: Cases are intentionally sequential and share completer
1576+
# state. Earlier cases may import modules that later cases
1577+
# depend on. Do NOT reorder without understanding dependencies.
15751578
cases = (
15761579
# no match != not an import
15771580
("import nope", ([], None), set()),
@@ -1619,6 +1622,8 @@ def test_suggestions_and_messages(self) -> None:
16191622
# Audit hook used to check for stdlib modules import side-effects
16201623
# Defined globally to avoid adding one hook per test run (refleak)
16211624
_audit_events: set[str] | None = None
1625+
1626+
16221627
def _hook(name: str, _args: tuple):
16231628
if _audit_events is not None: # No-op when not activated
16241629
_audit_events.add(name)

0 commit comments

Comments
 (0)