Skip to content

Commit 9cdf92d

Browse files
authored
Fix completion tuple (ipython#14594)
In progress work toward ipython#14585 guarded eval strip leading characters until it find soemthing, this is problematic as `(1, x`, becomes valid after 1 char strip: `1, x` is a tuple; So now we trim until it is valid an not a tuple. This is still imperfect as things like `(1, a[" "].y` will be trimmed to `y`, while it should stop with `a[" "].y` ? I think maybe we should back-propagate; build back up from `y`, to `a[" "].y`, greedily until we get the last valid expression – skipping any unbalanced parentheses/quotes if we encounter imblanced.
2 parents 9b18d6c + 1078df7 commit 9cdf92d

File tree

2 files changed

+144
-62
lines changed

2 files changed

+144
-62
lines changed

IPython/core/completer.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@
184184
import inspect
185185
import itertools
186186
import keyword
187+
import ast
187188
import os
188189
import re
189190
import string
@@ -347,7 +348,7 @@ def provisionalcompleter(action='ignore'):
347348
yield
348349

349350

350-
def has_open_quotes(s):
351+
def has_open_quotes(s: str) -> Union[str, bool]:
351352
"""Return whether a string has open quotes.
352353
353354
This simply counts whether the number of quote characters of either type in
@@ -368,7 +369,7 @@ def has_open_quotes(s):
368369
return False
369370

370371

371-
def protect_filename(s, protectables=PROTECTABLES):
372+
def protect_filename(s: str, protectables: str = PROTECTABLES) -> str:
372373
"""Escape a string to protect certain characters."""
373374
if set(s) & set(protectables):
374375
if sys.platform == "win32":
@@ -449,11 +450,11 @@ def completions_sorting_key(word):
449450

450451
if word.startswith('%%'):
451452
# If there's another % in there, this is something else, so leave it alone
452-
if not "%" in word[2:]:
453+
if "%" not in word[2:]:
453454
word = word[2:]
454455
prio2 = 2
455456
elif word.startswith('%'):
456-
if not "%" in word[1:]:
457+
if "%" not in word[1:]:
457458
word = word[1:]
458459
prio2 = 1
459460

@@ -752,7 +753,7 @@ def completion_matcher(
752753
priority: Optional[float] = None,
753754
identifier: Optional[str] = None,
754755
api_version: int = 1,
755-
):
756+
) -> Callable[[Matcher], Matcher]:
756757
"""Adds attributes describing the matcher.
757758
758759
Parameters
@@ -961,8 +962,8 @@ def delims(self, delims):
961962
def split_line(self, line, cursor_pos=None):
962963
"""Split a line of text with a cursor at the given position.
963964
"""
964-
l = line if cursor_pos is None else line[:cursor_pos]
965-
return self._delim_re.split(l)[-1]
965+
cut_line = line if cursor_pos is None else line[:cursor_pos]
966+
return self._delim_re.split(cut_line)[-1]
966967

967968

968969

@@ -1141,8 +1142,13 @@ def attr_matches(self, text):
11411142
"""
11421143
return self._attr_matches(text)[0]
11431144

1144-
def _attr_matches(self, text, include_prefix=True) -> Tuple[Sequence[str], str]:
1145-
m2 = re.match(r"(.+)\.(\w*)$", self.line_buffer)
1145+
# we simple attribute matching with normal identifiers.
1146+
_ATTR_MATCH_RE = re.compile(r"(.+)\.(\w*)$")
1147+
1148+
def _attr_matches(
1149+
self, text: str, include_prefix: bool = True
1150+
) -> Tuple[Sequence[str], str]:
1151+
m2 = self._ATTR_MATCH_RE.match(self.line_buffer)
11461152
if not m2:
11471153
return [], ""
11481154
expr, attr = m2.group(1, 2)
@@ -1204,6 +1210,30 @@ def _attr_matches(self, text, include_prefix=True) -> Tuple[Sequence[str], str]:
12041210
"." + attr,
12051211
)
12061212

1213+
def _trim_expr(self, code: str) -> str:
1214+
"""
1215+
Trim the code until it is a valid expression and not a tuple;
1216+
1217+
return the trimmed expression for guarded_eval.
1218+
"""
1219+
while code:
1220+
code = code[1:]
1221+
try:
1222+
res = ast.parse(code)
1223+
except SyntaxError:
1224+
continue
1225+
1226+
assert res is not None
1227+
if len(res.body) != 1:
1228+
continue
1229+
expr = res.body[0].value
1230+
if isinstance(expr, ast.Tuple) and not code[-1] == ")":
1231+
# we skip implicit tuple, like when trimming `fun(a,b`<completion>
1232+
# as `a,b` would be a tuple, and we actually expect to get only `b`
1233+
continue
1234+
return code
1235+
return ""
1236+
12071237
def _evaluate_expr(self, expr):
12081238
obj = not_found
12091239
done = False
@@ -1225,14 +1255,14 @@ def _evaluate_expr(self, expr):
12251255
# e.g. user starts `(d[`, so we get `expr = '(d'`,
12261256
# where parenthesis is not closed.
12271257
# TODO: make this faster by reusing parts of the computation?
1228-
expr = expr[1:]
1258+
expr = self._trim_expr(expr)
12291259
return obj
12301260

12311261
def get__all__entries(obj):
12321262
"""returns the strings in the __all__ attribute"""
12331263
try:
12341264
words = getattr(obj, '__all__')
1235-
except:
1265+
except Exception:
12361266
return []
12371267

12381268
return [w for w in words if isinstance(w, str)]
@@ -1447,7 +1477,7 @@ def filter_prefix_tuple(key):
14471477
try:
14481478
if not str_key.startswith(prefix_str):
14491479
continue
1450-
except (AttributeError, TypeError, UnicodeError) as e:
1480+
except (AttributeError, TypeError, UnicodeError):
14511481
# Python 3+ TypeError on b'a'.startswith('a') or vice-versa
14521482
continue
14531483

@@ -1495,7 +1525,7 @@ def cursor_to_position(text:str, line:int, column:int)->int:
14951525
lines = text.split('\n')
14961526
assert line <= len(lines), '{} <= {}'.format(str(line), str(len(lines)))
14971527

1498-
return sum(len(l) + 1 for l in lines[:line]) + column
1528+
return sum(len(line) + 1 for line in lines[:line]) + column
14991529

15001530
def position_to_cursor(text:str, offset:int)->Tuple[int, int]:
15011531
"""
@@ -2112,7 +2142,7 @@ def magic_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
21122142
result["suppress"] = is_magic_prefix and bool(result["completions"])
21132143
return result
21142144

2115-
def magic_matches(self, text: str):
2145+
def magic_matches(self, text: str) -> List[str]:
21162146
"""Match magics.
21172147
21182148
.. deprecated:: 8.6
@@ -2469,7 +2499,8 @@ def python_func_kw_matches(self, text):
24692499
# parenthesis before the cursor
24702500
# e.g. for "foo (1+bar(x), pa<cursor>,a=1)", the candidate is "foo"
24712501
tokens = regexp.findall(self.text_until_cursor)
2472-
iterTokens = reversed(tokens); openPar = 0
2502+
iterTokens = reversed(tokens)
2503+
openPar = 0
24732504

24742505
for token in iterTokens:
24752506
if token == ')':
@@ -2489,7 +2520,8 @@ def python_func_kw_matches(self, text):
24892520
try:
24902521
ids.append(next(iterTokens))
24912522
if not isId(ids[-1]):
2492-
ids.pop(); break
2523+
ids.pop()
2524+
break
24932525
if not next(iterTokens) == '.':
24942526
break
24952527
except StopIteration:
@@ -3215,7 +3247,7 @@ def _complete(self, *, cursor_line, cursor_pos, line_buffer=None, text=None,
32153247
else:
32163248
api_version = _get_matcher_api_version(matcher)
32173249
raise ValueError(f"Unsupported API version {api_version}")
3218-
except:
3250+
except BaseException:
32193251
# Show the ugly traceback if the matcher causes an
32203252
# exception, but do NOT crash the kernel!
32213253
sys.excepthook(*sys.exc_info())

IPython/core/tests/test_completer.py

Lines changed: 95 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import sys
1010
import textwrap
1111
import unittest
12+
import random
1213

1314
from importlib.metadata import version
1415

15-
1616
from contextlib import contextmanager
1717

1818
from traitlets.config.loader import Config
@@ -21,6 +21,7 @@
2121
from IPython.utils.tempdir import TemporaryDirectory, TemporaryWorkingDirectory
2222
from IPython.utils.generics import complete_object
2323
from IPython.testing import decorators as dec
24+
from IPython.core.latex_symbols import latex_symbols
2425

2526
from IPython.core.completer import (
2627
Completion,
@@ -31,11 +32,24 @@
3132
completion_matcher,
3233
SimpleCompletion,
3334
CompletionContext,
35+
_unicode_name_compute,
36+
_UNICODE_RANGES,
3437
)
3538

3639
from packaging.version import parse
3740

3841

42+
@contextmanager
43+
def jedi_status(status: bool):
44+
completer = get_ipython().Completer
45+
try:
46+
old = completer.use_jedi
47+
completer.use_jedi = status
48+
yield
49+
finally:
50+
completer.use_jedi = old
51+
52+
3953
# -----------------------------------------------------------------------------
4054
# Test functions
4155
# -----------------------------------------------------------------------------
@@ -66,7 +80,7 @@ def ranges(i):
6680
rg = list(ranges(valid))
6781
lens = []
6882
gap_lens = []
69-
pstart, pstop = 0, 0
83+
_pstart, pstop = 0, 0
7084
for start, stop in rg:
7185
lens.append(stop - start)
7286
gap_lens.append(
@@ -77,7 +91,7 @@ def ranges(i):
7791
f"{round((start - pstop)/0xe01f0*100)}%",
7892
)
7993
)
80-
pstart, pstop = start, stop
94+
_pstart, pstop = start, stop
8195

8296
return sorted(gap_lens)[-1]
8397

@@ -87,7 +101,6 @@ def test_unicode_range():
87101
Test that the ranges we test for unicode names give the same number of
88102
results than testing the full length.
89103
"""
90-
from IPython.core.completer import _unicode_name_compute, _UNICODE_RANGES
91104

92105
expected_list = _unicode_name_compute([(0, 0x110000)])
93106
test = _unicode_name_compute(_UNICODE_RANGES)
@@ -148,45 +161,45 @@ def custom_matchers(matchers):
148161
ip.Completer.custom_matchers.clear()
149162

150163

151-
def test_protect_filename():
152-
if sys.platform == "win32":
153-
pairs = [
154-
("abc", "abc"),
155-
(" abc", '" abc"'),
156-
("a bc", '"a bc"'),
157-
("a bc", '"a bc"'),
158-
(" bc", '" bc"'),
159-
]
160-
else:
161-
pairs = [
162-
("abc", "abc"),
163-
(" abc", r"\ abc"),
164-
("a bc", r"a\ bc"),
165-
("a bc", r"a\ \ bc"),
166-
(" bc", r"\ \ bc"),
167-
# On posix, we also protect parens and other special characters.
168-
("a(bc", r"a\(bc"),
169-
("a)bc", r"a\)bc"),
170-
("a( )bc", r"a\(\ \)bc"),
171-
("a[1]bc", r"a\[1\]bc"),
172-
("a{1}bc", r"a\{1\}bc"),
173-
("a#bc", r"a\#bc"),
174-
("a?bc", r"a\?bc"),
175-
("a=bc", r"a\=bc"),
176-
("a\\bc", r"a\\bc"),
177-
("a|bc", r"a\|bc"),
178-
("a;bc", r"a\;bc"),
179-
("a:bc", r"a\:bc"),
180-
("a'bc", r"a\'bc"),
181-
("a*bc", r"a\*bc"),
182-
('a"bc', r"a\"bc"),
183-
("a^bc", r"a\^bc"),
184-
("a&bc", r"a\&bc"),
185-
]
186-
# run the actual tests
187-
for s1, s2 in pairs:
188-
s1p = completer.protect_filename(s1)
189-
assert s1p == s2
164+
if sys.platform == "win32":
165+
pairs = [
166+
("abc", "abc"),
167+
(" abc", '" abc"'),
168+
("a bc", '"a bc"'),
169+
("a bc", '"a bc"'),
170+
(" bc", '" bc"'),
171+
]
172+
else:
173+
pairs = [
174+
("abc", "abc"),
175+
(" abc", r"\ abc"),
176+
("a bc", r"a\ bc"),
177+
("a bc", r"a\ \ bc"),
178+
(" bc", r"\ \ bc"),
179+
# On posix, we also protect parens and other special characters.
180+
("a(bc", r"a\(bc"),
181+
("a)bc", r"a\)bc"),
182+
("a( )bc", r"a\(\ \)bc"),
183+
("a[1]bc", r"a\[1\]bc"),
184+
("a{1}bc", r"a\{1\}bc"),
185+
("a#bc", r"a\#bc"),
186+
("a?bc", r"a\?bc"),
187+
("a=bc", r"a\=bc"),
188+
("a\\bc", r"a\\bc"),
189+
("a|bc", r"a\|bc"),
190+
("a;bc", r"a\;bc"),
191+
("a:bc", r"a\:bc"),
192+
("a'bc", r"a\'bc"),
193+
("a*bc", r"a\*bc"),
194+
('a"bc', r"a\"bc"),
195+
("a^bc", r"a\^bc"),
196+
("a&bc", r"a\&bc"),
197+
]
198+
199+
200+
@pytest.mark.parametrize("s1,expected", pairs)
201+
def test_protect_filename(s1, expected):
202+
assert completer.protect_filename(s1) == expected
190203

191204

192205
def check_line_split(splitter, test_specs):
@@ -297,8 +310,6 @@ def test_unicode_completions(self):
297310
self.assertIsInstance(matches, list)
298311

299312
def test_latex_completions(self):
300-
from IPython.core.latex_symbols import latex_symbols
301-
import random
302313

303314
ip = get_ipython()
304315
# Test some random unicode symbols
@@ -1734,6 +1745,45 @@ def _(expected):
17341745
_(["completion_a"])
17351746

17361747

1748+
@pytest.mark.parametrize(
1749+
"setup,code,expected,not_expected",
1750+
[
1751+
('a="str"; b=1', "(a, b.", [".bit_count", ".conjugate"], [".count"]),
1752+
('a="str"; b=1', "(a, b).", [".count"], [".bit_count", ".capitalize"]),
1753+
('x="str"; y=1', "x = {1, y.", [".bit_count"], [".count"]),
1754+
('x="str"; y=1', "x = [1, y.", [".bit_count"], [".count"]),
1755+
('x="str"; y=1; fun=lambda x:x', "x = fun(1, y.", [".bit_count"], [".count"]),
1756+
],
1757+
)
1758+
def test_misc_no_jedi_completions(setup, code, expected, not_expected):
1759+
ip = get_ipython()
1760+
c = ip.Completer
1761+
ip.ex(setup)
1762+
with provisionalcompleter(), jedi_status(False):
1763+
matches = c.all_completions(code)
1764+
assert set(expected) - set(matches) == set(), set(matches)
1765+
assert set(matches).intersection(set(not_expected)) == set()
1766+
1767+
1768+
@pytest.mark.parametrize(
1769+
"code,expected",
1770+
[
1771+
(" (a, b", "b"),
1772+
("(a, b", "b"),
1773+
("(a, b)", ""), # trim always start by trimming
1774+
(" (a, b)", "(a, b)"),
1775+
(" [a, b]", "[a, b]"),
1776+
(" a, b", "b"),
1777+
("x = {1, y", "y"),
1778+
("x = [1, y", "y"),
1779+
("x = fun(1, y", "y"),
1780+
],
1781+
)
1782+
def test_trim_expr(code, expected):
1783+
c = get_ipython().Completer
1784+
assert c._trim_expr(code) == expected
1785+
1786+
17371787
@pytest.mark.parametrize(
17381788
"input, expected",
17391789
[

0 commit comments

Comments
 (0)