Skip to content

Commit fdee11f

Browse files
loic-simonambv
andauthored
gh-69605: PyREPL: insert "import" after "from foo <tab>" (#148445)
Improve _pyrepl._module_completer.ModuleCompleter.get_completions: * "from x.y.z " [tab]-> "from x.y.z import " * "from foo" [tab]-> "from foo " [tab]-> "from foo import" (if only one suggestion) Co-authored-by: Lukasz Langa <ambv@meta.com>
1 parent 7ed37e9 commit fdee11f

3 files changed

Lines changed: 88 additions & 65 deletions

File tree

‎Lib/_pyrepl/_module_completer.py‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def complete(
9797
self,
9898
from_name: str | None,
9999
name: str | None,
100+
space_end: bool,
100101
*,
101102
include_values: bool = True,
102103
) -> tuple[list[str], list[Any], CompletionAction | None]:
@@ -111,10 +112,16 @@ def complete(
111112
return names, values, None
112113

113114
if name is None:
115+
if space_end and from_name:
116+
# from x.y.z <tab>
117+
return ["import "], [sys] if include_values else [], None
114118
# from x.y.z<tab>
115119
path, prefix = self.get_path_and_prefix(from_name)
116120
modules = self.find_modules(path, prefix)
117121
names = [self.format_completion(path, module) for module in modules]
122+
if names == [from_name]:
123+
# Exact match already written: continue with import statement
124+
names = [f"{from_name} import "]
118125
# These are always modules, use dummy values to get the right color
119126
values = [sys] * len(names) if include_values else []
120127
return names, values, None
@@ -367,11 +374,12 @@ class ImportParser:
367374
suitable for autocomplete suggestions.
368375
369376
Examples:
370-
- import foo -> Result(from_name=None, name='foo')
371-
- import foo. -> Result(from_name=None, name='foo.')
372-
- from foo -> Result(from_name='foo', name=None)
373-
- from foo import bar -> Result(from_name='foo', name='bar')
374-
- from .foo import ( -> Result(from_name='.foo', name='')
377+
- `import foo` -> Result(from_name=None, name='foo')
378+
- `import foo.` -> Result(from_name=None, name='foo.')
379+
- `from foo` -> Result(from_name='foo', name=None)
380+
- `from foo ` -> Result(from_name='foo', name=None, space_end=True)
381+
- `from foo import bar` -> Result(from_name='foo', name='bar')
382+
- `from .foo import (` -> Result(from_name='.foo', name='')
375383
376384
Note that the parser works in reverse order, starting from the
377385
last token in the input string. This makes the parser more robust
@@ -401,10 +409,10 @@ def __init__(self, code: str) -> None:
401409
tokens = []
402410
self.tokens = TokenQueue(tokens[::-1])
403411

404-
def parse(self) -> tuple[str | None, str | None] | None:
412+
def parse(self) -> tuple[str | None, str | None, bool] | None:
405413
if not (res := self._parse()):
406414
return None
407-
return res.from_name, res.name
415+
return res.from_name, res.name, res.space_end
408416

409417
def _parse(self) -> Result | None:
410418
with self.tokens.save_state():
@@ -414,7 +422,7 @@ def _parse(self) -> Result | None:
414422

415423
def parse_import(self) -> Result:
416424
if self.code.rstrip().endswith('import') and self.code.endswith(' '):
417-
return Result(name='')
425+
return Result(name='', space_end=True)
418426
if self.tokens.peek_string(','):
419427
name = ''
420428
else:
@@ -427,27 +435,32 @@ def parse_import(self) -> Result:
427435
self.tokens.pop()
428436
self.parse_dotted_as_name()
429437
if self.tokens.peek_string('import'):
430-
return Result(name=name)
438+
return Result(name=name, space_end=self.code.endswith(' '))
431439
raise ParseError('parse_import')
432440

433441
def parse_from_import(self) -> Result:
442+
space_end = self.code.endswith(' ')
434443
stripped = self.code.rstrip()
435-
if stripped.endswith('import') and self.code.endswith(' '):
436-
return Result(from_name=self.parse_empty_from_import(), name='')
437-
if stripped.endswith('from') and self.code.endswith(' '):
438-
return Result(from_name='')
444+
if stripped.endswith('import') and space_end:
445+
from_name = self.parse_empty_from_import()
446+
return Result(from_name=from_name, name='', space_end=space_end)
447+
if stripped.endswith('from') and space_end:
448+
return Result(from_name='', space_end=space_end)
439449
if self.tokens.peek_string('(') or self.tokens.peek_string(','):
440-
return Result(from_name=self.parse_empty_from_import(), name='')
441-
if self.code.endswith(' '):
442-
raise ParseError('parse_from_import')
450+
from_name = self.parse_empty_from_import()
451+
return Result(from_name=from_name, name='', space_end=space_end)
443452
name = self.parse_dotted_name()
444453
if '.' in name:
454+
if name.endswith(".") and space_end:
455+
raise ParseError('parse_from_import')
445456
self.tokens.pop_string('from')
446-
return Result(from_name=name)
457+
return Result(from_name=name, space_end=space_end)
447458
if self.tokens.peek_string('from'):
448-
return Result(from_name=name)
459+
return Result(from_name=name, space_end=space_end)
460+
if space_end:
461+
raise ParseError('parse_from_import')
449462
from_name = self.parse_empty_from_import()
450-
return Result(from_name=from_name, name=name)
463+
return Result(from_name=from_name, name=name, space_end=space_end)
451464

452465
def parse_empty_from_import(self) -> str:
453466
if self.tokens.peek_string(','):
@@ -513,10 +526,11 @@ class ParseError(Exception):
513526
pass
514527

515528

516-
@dataclass(frozen=True)
529+
@dataclass(frozen=True, kw_only=True)
517530
class Result:
518531
from_name: str | None = None
519532
name: str | None = None
533+
space_end: bool
520534

521535

522536
class TokenQueue:

‎Lib/test/test_pyrepl/test_pyrepl.py‎

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,12 +1231,20 @@ def test_completions(self):
12311231
("import foo, impo\t\n", "import foo, importlib"),
12321232
("import foo as bar, impo\t\n", "import foo as bar, importlib"),
12331233
("from impo\t\n", "from importlib"),
1234+
("from impo\t\t\n", "from importlib import "),
1235+
("from impo\t\t\t\n", "from importlib import "),
1236+
("from impo \t\n", "from impo import "),
1237+
("from importlib\t\n", "from importlib import "),
12341238
("from importlib.res\t\n", "from importlib.resources"),
12351239
("from importlib.\t\tres\t\n", "from importlib.resources"),
1240+
("from importlib.res\t\t\n", "from importlib.resources import "),
1241+
("from importlib.res \t\n", "from importlib.res import "),
1242+
("from importlib.resources\t\n", "from importlib.resources import "),
12361243
("from importlib.resources.ab\t\n", "from importlib.resources.abc"),
12371244
("from importlib import mac\t\n", "from importlib import machinery"),
12381245
("from importlib import res\t\n", "from importlib import resources"),
12391246
("from importlib.res\t import a\t\n", "from importlib.resources import abc"),
1247+
("from importlib.res\t\ta\t\n", "from importlib.resources import abc"),
12401248
("from __phello__ import s\t\n", "from __phello__ import spam"), # frozen module
12411249
("import importlib .res\t\n", "import importlib .resources"),
12421250
("import importlib. res\t\n", "import importlib. resources"),
@@ -1616,46 +1624,48 @@ def test_get_path_and_prefix(self):
16161624

16171625
def test_parse(self):
16181626
cases = (
1619-
('import ', (None, '')),
1620-
('import foo', (None, 'foo')),
1621-
('import foo,', (None, '')),
1622-
('import foo, ', (None, '')),
1623-
('import foo, bar', (None, 'bar')),
1624-
('import foo, bar, baz', (None, 'baz')),
1625-
('import foo as bar,', (None, '')),
1626-
('import foo as bar, ', (None, '')),
1627-
('import foo as bar, baz', (None, 'baz')),
1628-
('import a.', (None, 'a.')),
1629-
('import a.b', (None, 'a.b')),
1630-
('import a.b.', (None, 'a.b.')),
1631-
('import a.b.c', (None, 'a.b.c')),
1632-
('import a.b.c, foo', (None, 'foo')),
1633-
('import a.b.c, foo.bar', (None, 'foo.bar')),
1634-
('import a.b.c, foo.bar,', (None, '')),
1635-
('import a.b.c, foo.bar, ', (None, '')),
1636-
('from foo', ('foo', None)),
1637-
('from a.', ('a.', None)),
1638-
('from a .', ('a.', None)),
1639-
('from a .', ('a.', None)),
1640-
('from a .', ('a.', None)),
1641-
('from a.b', ('a.b', None)),
1642-
('from a.b.', ('a.b.', None)),
1643-
('from a. b.', ('a.b.', None)),
1644-
('from a . b .', ('a.b.', None)),
1645-
('from a.b.c', ('a.b.c', None)),
1646-
('from foo import ', ('foo', '')),
1647-
('from foo import a', ('foo', 'a')),
1648-
('from ', ('', None)),
1649-
('from . import a', ('.', 'a')),
1650-
('from .foo import a', ('.foo', 'a')),
1651-
('from ..foo import a', ('..foo', 'a')),
1652-
('from foo import (', ('foo', '')),
1653-
('from foo import ( ', ('foo', '')),
1654-
('from foo import (a', ('foo', 'a')),
1655-
('from foo import (a,', ('foo', '')),
1656-
('from foo import (a, ', ('foo', '')),
1657-
('from foo import (a, c', ('foo', 'c')),
1658-
('from foo import (a as b, c', ('foo', 'c')),
1627+
('import ', (None, '', True)),
1628+
('import foo', (None, 'foo', False)),
1629+
('import foo,', (None, '', False)),
1630+
('import foo, ', (None, '', True)),
1631+
('import foo, bar', (None, 'bar', False)),
1632+
('import foo, bar, baz', (None, 'baz', False)),
1633+
('import foo as bar,', (None, '', False)),
1634+
('import foo as bar, ', (None, '', True)),
1635+
('import foo as bar, baz', (None, 'baz', False)),
1636+
('import a.', (None, 'a.', False)),
1637+
('import a.b', (None, 'a.b', False)),
1638+
('import a.b.', (None, 'a.b.', False)),
1639+
('import a.b.c', (None, 'a.b.c', False)),
1640+
('import a.b.c, foo', (None, 'foo', False)),
1641+
('import a.b.c, foo.bar', (None, 'foo.bar', False)),
1642+
('import a.b.c, foo.bar,', (None, '', False)),
1643+
('import a.b.c, foo.bar, ', (None, '', True)),
1644+
('from foo', ('foo', None, False)),
1645+
('from foo ', ('foo', None, True)),
1646+
('from a.', ('a.', None, False)),
1647+
('from a .', ('a.', None, False)),
1648+
('from a .', ('a.', None, False)),
1649+
('from a .', ('a.', None, False)),
1650+
('from a.b', ('a.b', None, False)),
1651+
('from a.b.', ('a.b.', None, False)),
1652+
('from a.b ', ('a.b', None, True)),
1653+
('from a. b.', ('a.b.', None, False)),
1654+
('from a . b .', ('a.b.', None, False)),
1655+
('from a.b.c', ('a.b.c', None, False)),
1656+
('from foo import ', ('foo', '', True)),
1657+
('from foo import a', ('foo', 'a', False)),
1658+
('from ', ('', None, True)),
1659+
('from . import a', ('.', 'a', False)),
1660+
('from .foo import a', ('.foo', 'a', False)),
1661+
('from ..foo import a', ('..foo', 'a', False)),
1662+
('from foo import (', ('foo', '', False)),
1663+
('from foo import ( ', ('foo', '', True)),
1664+
('from foo import (a', ('foo', 'a', False)),
1665+
('from foo import (a,', ('foo', '', False)),
1666+
('from foo import (a, ', ('foo', '', True)),
1667+
('from foo import (a, c', ('foo', 'c', False)),
1668+
('from foo import (a as b, c', ('foo', 'c', False)),
16591669
)
16601670
for code, parsed in cases:
16611671
parser = ImportParser(code)
@@ -1679,12 +1689,9 @@ def test_parse_error(self):
16791689
cases = (
16801690
'',
16811691
'import foo ',
1682-
'from foo ',
16831692
'import foo. ',
16841693
'import foo.bar ',
1685-
'from foo ',
16861694
'from foo. ',
1687-
'from foo.bar ',
16881695
'from foo import bar ',
16891696
'from foo import (bar ',
16901697
'from foo import bar, baz ',
@@ -1723,9 +1730,9 @@ def test_parse_error(self):
17231730
'if 1:\n pass\n\tpass', # _tokenize TabError -> tokenize TabError
17241731
)
17251732
for code in cases:
1726-
parser = ImportParser(code)
1727-
actual = parser.parse()
17281733
with self.subTest(code=code):
1734+
parser = ImportParser(code)
1735+
actual = parser.parse()
17291736
self.assertEqual(actual, None)
17301737

17311738
@patch.dict(sys.modules)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Insert ``import`` after ``from x.y.z <tab>`` in the :term:`REPL`
2+
auto-completion.

0 commit comments

Comments
 (0)