Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3255e6f

Browse files
authored
Strictly suppress file_matcher in attribute completions (#15081)
### Fixes #15031 ### Description The current logic only suppresses matchers if they are in the suppress set and there are matching results. As a result, `file_matcher` can still display file matches when attribute completion fails. This PR updates the logic to always suppress `file_matcher` during attribute completion, regardless of whether any attributes were successfully matched.
2 parents 135015a + 28d3ccd commit 3255e6f

2 files changed

Lines changed: 106 additions & 11 deletions

File tree

IPython/core/completer.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2213,6 +2213,17 @@ def file_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
22132213
# starts with `/home/`, `C:\`, etc)
22142214

22152215
text = context.token
2216+
code_until_cursor = self._extract_code(context.text_until_cursor)
2217+
completion_type = self._determine_completion_context(code_until_cursor)
2218+
in_cli_context = self._is_completing_in_cli_context(code_until_cursor)
2219+
if (
2220+
completion_type == self._CompletionContextType.ATTRIBUTE
2221+
and not in_cli_context
2222+
):
2223+
return {
2224+
"completions": [],
2225+
"suppress": False,
2226+
}
22162227

22172228
# chars that require escaping with backslash - i.e. chars
22182229
# that readline treats incorrectly as delimiters, but we
@@ -2482,8 +2493,9 @@ def _jedi_matcher(self, context: CompletionContext) -> _JediMatcherResult:
24822493
)
24832494
return {
24842495
"completions": matches,
2485-
# static analysis should not suppress other matchers
2486-
"suppress": {_get_matcher_id(self.file_matcher)} if matches else False,
2496+
# static analysis should not suppress other matcher
2497+
# NOTE: file_matcher is automatically suppressed on attribute completions
2498+
"suppress": False,
24872499
}
24882500

24892501
def _jedi_matches(
@@ -2599,12 +2611,47 @@ def _determine_completion_context(self, line):
25992611
return self._CompletionContextType.GLOBAL
26002612

26012613
# Handle all other attribute matches np.ran, d[0].k, (a,b).count
2602-
chain_match = re.search(r".*(.+\.(?:[a-zA-Z]\w*)?)$", line)
2614+
chain_match = re.search(r".*(.+(?<!\s)\.(?:[a-zA-Z]\w*)?)$", line)
26032615
if chain_match:
26042616
return self._CompletionContextType.ATTRIBUTE
26052617

26062618
return self._CompletionContextType.GLOBAL
26072619

2620+
def _is_completing_in_cli_context(self, text: str) -> bool:
2621+
"""
2622+
Determine if we are completing in a CLI alias, line magic, or bang expression context.
2623+
"""
2624+
stripped = text.lstrip()
2625+
if stripped.startswith("!") or stripped.startswith("%"):
2626+
return True
2627+
# Check for CLI aliases
2628+
try:
2629+
tokens = stripped.split(None, 1)
2630+
if not tokens:
2631+
return False
2632+
first_token = tokens[0]
2633+
2634+
# Must have arguments after the command for this to apply
2635+
if len(tokens) < 2:
2636+
return False
2637+
2638+
# Check if first token is a known alias
2639+
if not any(
2640+
alias[0] == first_token for alias in self.shell.alias_manager.aliases
2641+
):
2642+
return False
2643+
2644+
try:
2645+
if first_token in self.shell.user_ns:
2646+
# There's a variable defined, so the alias is overshadowed
2647+
return False
2648+
except (AttributeError, KeyError):
2649+
pass
2650+
2651+
return True
2652+
except Exception:
2653+
return False
2654+
26082655
def _is_in_string_or_comment(self, text):
26092656
"""
26102657
Determine if the cursor is inside a string or comment.
@@ -2726,7 +2773,11 @@ def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
27262773
"""Match attributes or global python names"""
27272774
text = context.text_until_cursor
27282775
text = self._extract_code(text)
2729-
completion_type = self._determine_completion_context(text)
2776+
in_cli_context = self._is_completing_in_cli_context(text)
2777+
if in_cli_context:
2778+
completion_type = self._CompletionContextType.GLOBAL
2779+
else:
2780+
completion_type = self._determine_completion_context(text)
27302781
if completion_type == self._CompletionContextType.ATTRIBUTE:
27312782
try:
27322783
matches, fragment = self._attr_matches(
@@ -2746,8 +2797,6 @@ def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
27462797
matches = _convert_matcher_v1_result_to_v2(
27472798
matches, type="attribute", fragment=fragment
27482799
)
2749-
if matches["completions"]:
2750-
matches["suppress"] = {_get_matcher_id(self.file_matcher)}
27512800
return matches
27522801
except NameError:
27532802
# catches <undefined attributes>.<tab>

tests/test_completer.py

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -424,11 +424,17 @@ def test_local_file_completions(self):
424424
c = ip.complete(prefix)[1]
425425
self.assertEqual(c, names)
426426

427-
# Now check with a function call
428-
cmd = 'a = f("%s' % prefix
429-
c = ip.complete(prefix, cmd)[1]
430-
comp = {prefix + s for s in suffixes}
431-
self.assertTrue(comp.issubset(set(c)))
427+
test_cases = {
428+
"function call": 'a = f("',
429+
"shell bang": "!ls ",
430+
"ls magic": r"%ls ",
431+
"alias ls": "ls ",
432+
}
433+
for name, code in test_cases.items():
434+
cmd = f"{code}{prefix}"
435+
c = ip.complete(prefix, cmd)[1]
436+
comp = {prefix + s for s in suffixes}
437+
self.assertTrue(comp.issubset(set(c)), msg=f"completes in {name}")
432438

433439
def test_quoted_file_completions(self):
434440
ip = get_ipython()
@@ -2638,6 +2644,14 @@ def test_undefined_variables_without_jedi(code, insert_text):
26382644
"x.b[0].",
26392645
]
26402646
),
2647+
"\n".join(
2648+
[
2649+
"class MyClass():",
2650+
" b: list[str]",
2651+
"x = MyClass()",
2652+
"x.fake_attr().",
2653+
]
2654+
),
26412655
],
26422656
)
26432657
def test_no_file_completions_in_attr_access(code):
@@ -2706,6 +2720,7 @@ def test_no_file_completions_in_attr_access(code):
27062720
('f"formatted {obj.attr}', "global"),
27072721
("dict_with_dots = {'key.with.dots': value.attr", "attribute"),
27082722
("d[f'{a}']['{a.", "global"),
2723+
("ls .", "global"),
27092724
],
27102725
)
27112726
def test_completion_context(line, expected):
@@ -2716,6 +2731,37 @@ def test_completion_context(line, expected):
27162731
assert result.value == expected, f"Failed on input: '{line}'"
27172732

27182733

2734+
@pytest.mark.parametrize(
2735+
"line,expected,expected_after_assignment",
2736+
[
2737+
("test_alias file", True, False), # overshadowed by variable
2738+
("test_alias .", True, False),
2739+
("test_alias file.", True, False),
2740+
("%test_alias .file.ext", True, True), # magic, not affected by variable
2741+
("!test_alias file.", True, True), # bang, not affected by variable
2742+
],
2743+
)
2744+
def test_completion_in_cli_context(line, expected, expected_after_assignment):
2745+
"""Test completion context with and without variable overshadowing"""
2746+
ip = get_ipython()
2747+
ip.run_cell("alias test_alias echo test_alias")
2748+
get_context = ip.Completer._is_completing_in_cli_context
2749+
2750+
# Normal case
2751+
result = get_context(line)
2752+
assert result == expected, f"Failed on input: '{line}'"
2753+
2754+
# Test with alias assigned as a variable
2755+
try:
2756+
ip.user_ns["test_alias"] = "some_value"
2757+
result_after_assignment = get_context(line)
2758+
assert (
2759+
result_after_assignment == expected_after_assignment
2760+
), f"Failed after assigning 'ls' as a variable for input: '{line}'"
2761+
finally:
2762+
ip.user_ns.pop("test_alias", None)
2763+
2764+
27192765
@pytest.mark.xfail(reason="Completion context not yet supported")
27202766
@pytest.mark.parametrize(
27212767
"line, expected",

0 commit comments

Comments
 (0)