Skip to content

Commit 5c8bc51

Browse files
authored
Fix completion in indented lines dropping prefix when jedi is disabled (#14474)
Fixes #14470 I would appreciate someone testing this PR.
2 parents 6bde8f6 + 5827cc8 commit 5c8bc51

File tree

2 files changed

+90
-37
lines changed

2 files changed

+90
-37
lines changed

IPython/core/completer.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -898,7 +898,7 @@ def rectify_completions(text: str, completions: _IC, *, _debug: bool = False) ->
898898
new_text = text[new_start:c.start] + c.text + text[c.end:new_end]
899899
if c._origin == 'jedi':
900900
seen_jedi.add(new_text)
901-
elif c._origin == 'IPCompleter.python_matches':
901+
elif c._origin == "IPCompleter.python_matcher":
902902
seen_python_matches.add(new_text)
903903
yield Completion(new_start, new_end, new_text, type=c.type, _origin=c._origin, signature=c.signature)
904904
diff = seen_python_matches.difference(seen_jedi)
@@ -1139,15 +1139,18 @@ def attr_matches(self, text):
11391139
with a __getattr__ hook is evaluated.
11401140
11411141
"""
1142+
return self._attr_matches(text)[0]
1143+
1144+
def _attr_matches(self, text, include_prefix=True) -> Tuple[Sequence[str], str]:
11421145
m2 = re.match(r"(.+)\.(\w*)$", self.line_buffer)
11431146
if not m2:
1144-
return []
1147+
return [], ""
11451148
expr, attr = m2.group(1, 2)
11461149

11471150
obj = self._evaluate_expr(expr)
11481151

11491152
if obj is not_found:
1150-
return []
1153+
return [], ""
11511154

11521155
if self.limit_to__all__ and hasattr(obj, '__all__'):
11531156
words = get__all__entries(obj)
@@ -1170,28 +1173,36 @@ def attr_matches(self, text):
11701173
# reconciliator would know that we intend to append to rather than
11711174
# replace the input text; this requires refactoring to return range
11721175
# which ought to be replaced (as does jedi).
1173-
tokens = _parse_tokens(expr)
1174-
rev_tokens = reversed(tokens)
1175-
skip_over = {tokenize.ENDMARKER, tokenize.NEWLINE}
1176-
name_turn = True
1177-
1178-
parts = []
1179-
for token in rev_tokens:
1180-
if token.type in skip_over:
1181-
continue
1182-
if token.type == tokenize.NAME and name_turn:
1183-
parts.append(token.string)
1184-
name_turn = False
1185-
elif token.type == tokenize.OP and token.string == "." and not name_turn:
1186-
parts.append(token.string)
1187-
name_turn = True
1188-
else:
1189-
# short-circuit if not empty nor name token
1190-
break
1176+
if include_prefix:
1177+
tokens = _parse_tokens(expr)
1178+
rev_tokens = reversed(tokens)
1179+
skip_over = {tokenize.ENDMARKER, tokenize.NEWLINE}
1180+
name_turn = True
1181+
1182+
parts = []
1183+
for token in rev_tokens:
1184+
if token.type in skip_over:
1185+
continue
1186+
if token.type == tokenize.NAME and name_turn:
1187+
parts.append(token.string)
1188+
name_turn = False
1189+
elif (
1190+
token.type == tokenize.OP and token.string == "." and not name_turn
1191+
):
1192+
parts.append(token.string)
1193+
name_turn = True
1194+
else:
1195+
# short-circuit if not empty nor name token
1196+
break
11911197

1192-
prefix_after_space = "".join(reversed(parts))
1198+
prefix_after_space = "".join(reversed(parts))
1199+
else:
1200+
prefix_after_space = ""
11931201

1194-
return ["%s.%s" % (prefix_after_space, w) for w in words if w[:n] == attr]
1202+
return (
1203+
["%s.%s" % (prefix_after_space, w) for w in words if w[:n] == attr],
1204+
"." + attr,
1205+
)
11951206

11961207
def _evaluate_expr(self, expr):
11971208
obj = not_found
@@ -1973,9 +1984,8 @@ def matchers(self) -> List[Matcher]:
19731984
*self.magic_arg_matchers,
19741985
self.custom_completer_matcher,
19751986
self.dict_key_matcher,
1976-
# TODO: convert python_matches to v2 API
19771987
self.magic_matcher,
1978-
self.python_matches,
1988+
self.python_matcher,
19791989
self.file_matcher,
19801990
self.python_func_kw_matcher,
19811991
]
@@ -2316,9 +2326,42 @@ def _jedi_matches(
23162326
else:
23172327
return iter([])
23182328

2329+
@context_matcher()
2330+
def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
2331+
"""Match attributes or global python names"""
2332+
text = context.line_with_cursor
2333+
if "." in text:
2334+
try:
2335+
matches, fragment = self._attr_matches(text, include_prefix=False)
2336+
if text.endswith(".") and self.omit__names:
2337+
if self.omit__names == 1:
2338+
# true if txt is _not_ a __ name, false otherwise:
2339+
no__name = lambda txt: re.match(r".*\.__.*?__", txt) is None
2340+
else:
2341+
# true if txt is _not_ a _ name, false otherwise:
2342+
no__name = (
2343+
lambda txt: re.match(r"\._.*?", txt[txt.rindex(".") :])
2344+
is None
2345+
)
2346+
matches = filter(no__name, matches)
2347+
return _convert_matcher_v1_result_to_v2(
2348+
matches, type="attribute", fragment=fragment
2349+
)
2350+
except NameError:
2351+
# catches <undefined attributes>.<tab>
2352+
matches = []
2353+
return _convert_matcher_v1_result_to_v2(matches, type="attribute")
2354+
else:
2355+
matches = self.global_matches(context.token)
2356+
# TODO: maybe distinguish between functions, modules and just "variables"
2357+
return _convert_matcher_v1_result_to_v2(matches, type="variable")
2358+
23192359
@completion_matcher(api_version=1)
23202360
def python_matches(self, text: str) -> Iterable[str]:
2321-
"""Match attributes or global python names"""
2361+
"""Match attributes or global python names.
2362+
2363+
.. deprecated:: 8.27
2364+
You can use :meth:`python_matcher` instead."""
23222365
if "." in text:
23232366
try:
23242367
matches = self.attr_matches(text)

IPython/core/tests/test_completer.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,10 @@ def test_all_completions_dups(self):
462462
matches = c.all_completions("TestClass.")
463463
assert len(matches) > 2, (jedi_status, matches)
464464
matches = c.all_completions("TestClass.a")
465-
assert matches == ['TestClass.a', 'TestClass.a1'], jedi_status
465+
if jedi_status:
466+
assert matches == ["TestClass.a", "TestClass.a1"], jedi_status
467+
else:
468+
assert matches == [".a", ".a1"], jedi_status
466469

467470
@pytest.mark.xfail(
468471
sys.version_info.releaselevel in ("alpha",),
@@ -594,7 +597,7 @@ def _(line, cursor_pos, expect, message, completion):
594597
ip.Completer.use_jedi = True
595598
with provisionalcompleter():
596599
completions = ip.Completer.completions(line, cursor_pos)
597-
self.assertIn(completion, completions)
600+
self.assertIn(completion, list(completions))
598601

599602
with provisionalcompleter():
600603
_(
@@ -622,7 +625,7 @@ def _(line, cursor_pos, expect, message, completion):
622625
_(
623626
"assert str.star",
624627
14,
625-
"str.startswith",
628+
".startswith",
626629
"Should have completed on `assert str.star`: %s",
627630
Completion(11, 14, "startswith"),
628631
)
@@ -633,6 +636,13 @@ def _(line, cursor_pos, expect, message, completion):
633636
"Should have completed on `d['a b'].str`: %s",
634637
Completion(9, 12, "strip"),
635638
)
639+
_(
640+
"a.app",
641+
4,
642+
".append",
643+
"Should have completed on `a.app`: %s",
644+
Completion(2, 4, "append"),
645+
)
636646

637647
def test_omit__names(self):
638648
# also happens to test IPCompleter as a configurable
@@ -647,8 +657,8 @@ def test_omit__names(self):
647657
with provisionalcompleter():
648658
c.use_jedi = False
649659
s, matches = c.complete("ip.")
650-
self.assertIn("ip.__str__", matches)
651-
self.assertIn("ip._hidden_attr", matches)
660+
self.assertIn(".__str__", matches)
661+
self.assertIn("._hidden_attr", matches)
652662

653663
# c.use_jedi = True
654664
# completions = set(c.completions('ip.', 3))
@@ -661,7 +671,7 @@ def test_omit__names(self):
661671
with provisionalcompleter():
662672
c.use_jedi = False
663673
s, matches = c.complete("ip.")
664-
self.assertNotIn("ip.__str__", matches)
674+
self.assertNotIn(".__str__", matches)
665675
# self.assertIn('ip._hidden_attr', matches)
666676

667677
# c.use_jedi = True
@@ -675,8 +685,8 @@ def test_omit__names(self):
675685
with provisionalcompleter():
676686
c.use_jedi = False
677687
s, matches = c.complete("ip.")
678-
self.assertNotIn("ip.__str__", matches)
679-
self.assertNotIn("ip._hidden_attr", matches)
688+
self.assertNotIn(".__str__", matches)
689+
self.assertNotIn("._hidden_attr", matches)
680690

681691
# c.use_jedi = True
682692
# completions = set(c.completions('ip.', 3))
@@ -686,7 +696,7 @@ def test_omit__names(self):
686696
with provisionalcompleter():
687697
c.use_jedi = False
688698
s, matches = c.complete("ip._x.")
689-
self.assertIn("ip._x.keys", matches)
699+
self.assertIn(".keys", matches)
690700

691701
# c.use_jedi = True
692702
# completions = set(c.completions('ip._x.', 6))
@@ -697,7 +707,7 @@ def test_omit__names(self):
697707

698708
def test_limit_to__all__False_ok(self):
699709
"""
700-
Limit to all is deprecated, once we remove it this test can go away.
710+
Limit to all is deprecated, once we remove it this test can go away.
701711
"""
702712
ip = get_ipython()
703713
c = ip.Completer
@@ -708,7 +718,7 @@ def test_limit_to__all__False_ok(self):
708718
cfg.IPCompleter.limit_to__all__ = False
709719
c.update_config(cfg)
710720
s, matches = c.complete("d.")
711-
self.assertIn("d.x", matches)
721+
self.assertIn(".x", matches)
712722

713723
def test_get__all__entries_ok(self):
714724
class A:

0 commit comments

Comments
 (0)