Skip to content

Commit 78d65f0

Browse files
authored
Extract code from line magics for attribute completion (ipython#14982)
Fixes ipython#14981
2 parents 10c8e9c + ff1ba77 commit 78d65f0

File tree

5 files changed

+144
-44
lines changed

5 files changed

+144
-44
lines changed

.github/workflows/downstream.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,4 @@ jobs:
8888
- name: Test pyflyby (IPython integration only)
8989
run: |
9090
cd ../pyflyby
91-
pytest tests/test_interactive.py -k 'not test_timeit_complete_autoimport_member_1'
91+
pytest tests/test_interactive.py

IPython/core/completer.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@
218218
EvaluationContext,
219219
_validate_policy_overrides,
220220
)
221-
from IPython.core.error import TryNext
221+
from IPython.core.error import TryNext, UsageError
222222
from IPython.core.inputtransformer2 import ESC_MAGIC
223223
from IPython.core.latex_symbols import latex_symbols, reverse_latex_symbol
224224
from IPython.testing.skipdoctest import skip_doctest
@@ -1219,6 +1219,10 @@ def _strip_code_before_operator(self, code: str) -> str:
12191219
else:
12201220
return code
12211221

1222+
def _extract_code(self, line: str):
1223+
"""No-op in Completer, but can be used in subclasses to customise behaviour"""
1224+
return line
1225+
12221226
def _attr_matches(
12231227
self, text: str, include_prefix: bool = True
12241228
) -> tuple[Sequence[str], str]:
@@ -2244,6 +2248,57 @@ def file_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
22442248
"suppress": False,
22452249
}
22462250

2251+
def _extract_code(self, line: str) -> str:
2252+
"""Extract code from magics if any."""
2253+
2254+
if not line:
2255+
return line
2256+
maybe_magic, *rest = line.split(maxsplit=1)
2257+
if not rest:
2258+
return line
2259+
args = rest[0]
2260+
known_magics = self.shell.magics_manager.lsmagic()
2261+
line_magics = known_magics["line"]
2262+
magic_name = maybe_magic.lstrip(self.magic_escape)
2263+
if magic_name not in line_magics:
2264+
return line
2265+
2266+
if not maybe_magic.startswith(self.magic_escape):
2267+
all_variables = [*self.namespace.keys(), *self.global_namespace.keys()]
2268+
if magic_name in all_variables:
2269+
# short circuit if we see a line starting with say `time`
2270+
# but time is defined as a variable (in addition to being
2271+
# a magic). In these cases users need to use explicit `%time`.
2272+
return line
2273+
2274+
magic_method = line_magics[magic_name]
2275+
2276+
try:
2277+
if magic_name == "timeit":
2278+
opts, stmt = magic_method.__self__.parse_options(
2279+
args,
2280+
"n:r:tcp:qov:",
2281+
posix=False,
2282+
strict=False,
2283+
preserve_non_opts=True,
2284+
)
2285+
return stmt
2286+
elif magic_name == "prun":
2287+
opts, stmt = magic_method.__self__.parse_options(
2288+
args, "D:l:rs:T:q", list_all=True, posix=False
2289+
)
2290+
return stmt
2291+
elif hasattr(magic_method, "parser") and getattr(
2292+
magic_method, "has_arguments", False
2293+
):
2294+
# e.g. %debug, %time
2295+
args, extra = magic_method.parser.parse_argstring(args, partial=True)
2296+
return " ".join(extra)
2297+
except UsageError:
2298+
return line
2299+
2300+
return line
2301+
22472302
@context_matcher()
22482303
def magic_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
22492304
"""Match magics."""
@@ -2255,7 +2310,7 @@ def magic_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
22552310
line_magics = lsm['line']
22562311
cell_magics = lsm['cell']
22572312
pre = self.magic_escape
2258-
pre2 = pre+pre
2313+
pre2 = pre + pre
22592314

22602315
explicit_magic = text.startswith(pre)
22612316

@@ -2619,6 +2674,7 @@ def _is_in_string_or_comment(self, text):
26192674
def python_matcher(self, context: CompletionContext) -> SimpleMatcherResult:
26202675
"""Match attributes or global python names"""
26212676
text = context.text_until_cursor
2677+
text = self._extract_code(text)
26222678
completion_type = self._determine_completion_context(text)
26232679
if completion_type == self._CompletionContextType.ATTRIBUTE:
26242680
try:
@@ -3476,7 +3532,7 @@ def _complete(self, *, cursor_line, cursor_pos, line_buffer=None, text=None,
34763532
full_text=full_text,
34773533
cursor_position=cursor_pos,
34783534
cursor_line=cursor_line,
3479-
token=text,
3535+
token=self._extract_code(text),
34803536
limit=MATCHES_LIMIT,
34813537
)
34823538

IPython/core/magic_arguments.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,12 @@ def error(self, message):
161161
"""
162162
raise UsageError(message)
163163

164-
def parse_argstring(self, argstring):
164+
def parse_argstring(self, argstring, *, partial=False):
165165
""" Split a string into an argument list and parse that argument list.
166166
"""
167167
argv = arg_split(argstring)
168+
if partial:
169+
return self.parse_known_args(argv)
168170
return self.parse_args(argv)
169171

170172

@@ -190,10 +192,10 @@ def construct_parser(magic_func):
190192
return parser
191193

192194

193-
def parse_argstring(magic_func, argstring):
195+
def parse_argstring(magic_func, argstring, *, partial=False):
194196
""" Parse the string of arguments for the given magic function.
195197
"""
196-
return magic_func.parser.parse_argstring(argstring)
198+
return magic_func.parser.parse_argstring(argstring, partial=partial)
197199

198200

199201
def real_name(magic_func):

IPython/core/magics/execution.py

Lines changed: 19 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ def prun(self, parameter_s='', cell=None):
317317
the magic line is always left unmodified.
318318
319319
"""
320+
# TODO: port to magic_arguments as currently this is duplicated in IPCompleter._extract_code
320321
opts, arg_str = self.parse_options(parameter_s, 'D:l:rs:T:q',
321322
list_all=True, posix=False)
322323
if cell is not None:
@@ -441,11 +442,10 @@ def pdb(self, parameter_s=''):
441442
Set break point at LINE in FILE.
442443
"""
443444
)
444-
@magic_arguments.argument('statement', nargs='*',
445-
help="""
446-
Code to run in debugger.
447-
You can omit this in cell magic mode.
448-
"""
445+
@magic_arguments.kwds(
446+
epilog="""
447+
Any remaining arguments will be treated as code to run in the debugger.
448+
"""
449449
)
450450
@no_var_expand
451451
@line_cell_magic
@@ -454,12 +454,12 @@ def debug(self, line="", cell=None, local_ns=None):
454454
"""Activate the interactive debugger.
455455
456456
This magic command support two ways of activating debugger.
457-
One is to activate debugger before executing code. This way, you
457+
One is to activate debugger before executing code. This way, you
458458
can set a break point, to step through the code from the point.
459459
You can use this mode by giving statements to execute and optionally
460460
a breakpoint.
461461
462-
The other one is to activate debugger in post-mortem mode. You can
462+
The other one is to activate debugger in post-mortem mode. You can
463463
activate this mode simply running %debug without any argument.
464464
If an exception has just occurred, this lets you inspect its stack
465465
frames interactively. Note that this will always work only on the last
@@ -475,9 +475,9 @@ def debug(self, line="", cell=None, local_ns=None):
475475
the magic line is always left unmodified.
476476
477477
"""
478-
args = magic_arguments.parse_argstring(self.debug, line)
478+
args, extra = magic_arguments.parse_argstring(self.debug, line, partial=True)
479479

480-
if not (args.breakpoint or args.statement or cell):
480+
if not (args.breakpoint or extra or cell):
481481
self._debug_post_mortem()
482482
elif not (args.breakpoint or cell):
483483
# If there is no breakpoints, the line is just code to execute
@@ -486,7 +486,7 @@ def debug(self, line="", cell=None, local_ns=None):
486486
# Here we try to reconstruct the code from the output of
487487
# parse_argstring. This might not work if the code has spaces
488488
# For example this fails for `print("a b")`
489-
code = "\n".join(args.statement)
489+
code = " ".join(extra)
490490
if cell:
491491
code += "\n" + cell
492492
self._debug_exec(code, args.breakpoint, local_ns)
@@ -1138,6 +1138,7 @@ def timeit(self, line='', cell=None, local_ns=None):
11381138
does not matter as long as results from timeit.py are not mixed with
11391139
those from ``%timeit``."""
11401140

1141+
# TODO: port to magic_arguments as currently this is duplicated in IPCompleter._extract_code
11411142
opts, stmt = self.parse_options(
11421143
line, "n:r:tcp:qov:", posix=False, strict=False, preserve_non_opts=True
11431144
)
@@ -1266,6 +1267,11 @@ def timeit(self, line='', cell=None, local_ns=None):
12661267
dest="no_raise_error",
12671268
help="If given, don't re-raise exceptions",
12681269
)
1270+
@magic_arguments.kwds(
1271+
epilog="""
1272+
Any remaining arguments will be treated as code to run.
1273+
"""
1274+
)
12691275
@skip_doctest
12701276
@needs_local_scope
12711277
@line_cell_magic
@@ -1337,34 +1343,10 @@ def time(self, line="", cell=None, local_ns=None):
13371343
Wall time: 0.00 s
13381344
Compiler : 0.78 s
13391345
"""
1340-
line_present = False
1341-
# Try to parse --no-raise-error if present, else ignore unrecognized args
1342-
try:
1343-
args = magic_arguments.parse_argstring(self.time, line)
1344-
except UsageError as e:
1345-
# Only ignore UsageError if caused by unrecognized arguments
1346-
# We'll manually check for --no-raise-error and remove it from line
1347-
line_present = True
1348-
1349-
# Check if --no-raise-error is present
1350-
no_raise_error = "--no-raise-error" in line
1351-
1352-
if no_raise_error:
1353-
# Remove --no-raise-error while preserving the rest of the line structure
1354-
line = re.sub(r"\s*--no-raise-error\s*", " ", line).strip()
1355-
# Clean up any double spaces
1356-
line = re.sub(r"\s+", " ", line)
1357-
1358-
class Args:
1359-
def __init__(self, no_raise_error):
1360-
self.no_raise_error = no_raise_error
1361-
1362-
args = Args(no_raise_error)
1363-
else:
1364-
if not hasattr(args, "no_raise_error"):
1365-
args.no_raise_error = False
1346+
args, extra = magic_arguments.parse_argstring(self.time, line, partial=True)
1347+
line = " ".join(extra)
13661348

1367-
if line_present and cell:
1349+
if line and cell:
13681350
raise UsageError("Can't use statement directly after '%%time'!")
13691351

13701352
if cell:

tests/test_completer.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,66 @@ def _bar_cellm(line, cell):
905905
self.assertNotIn("%_bar_cellm", matches)
906906
self.assertIn("%%_bar_cellm", matches)
907907

908+
def test_line_magics_with_code_argument(self):
909+
ip = get_ipython()
910+
c = ip.Completer
911+
c.use_jedi = False
912+
913+
# attribute completion
914+
text, matches = c.complete("%timeit -n 2 -r 1 float.as_integer")
915+
self.assertEqual(matches, [".as_integer_ratio"])
916+
917+
text, matches = c.complete("%debug --breakpoint test float.as_integer")
918+
self.assertEqual(matches, [".as_integer_ratio"])
919+
920+
text, matches = c.complete("%time --no-raise-error float.as_integer")
921+
self.assertEqual(matches, [".as_integer_ratio"])
922+
923+
text, matches = c.complete("%prun -l 0.5 -r float.as_integer")
924+
self.assertEqual(matches, [".as_integer_ratio"])
925+
926+
# implicit magics
927+
text, matches = c.complete("timeit -n 2 -r 1 float.as_integer")
928+
self.assertEqual(matches, [".as_integer_ratio"])
929+
930+
# built-ins completion
931+
text, matches = c.complete("%timeit -n 2 -r 1 flo")
932+
self.assertEqual(matches, ["float"])
933+
934+
# dict completion
935+
text, matches = c.complete("%timeit -n 2 -r 1 {'my_key': 1}['my")
936+
self.assertEqual(matches, ["my_key"])
937+
938+
# invalid arguments - should not throw
939+
text, matches = c.complete("%timeit -n 2 -r 1 -invalid float.as_integer")
940+
self.assertEqual(matches, [])
941+
942+
text, matches = c.complete("%debug --invalid float.as_integer")
943+
self.assertEqual(matches, [])
944+
945+
def test_line_magics_with_code_argument_shadowing(self):
946+
ip = get_ipython()
947+
c = ip.Completer
948+
c.use_jedi = False
949+
950+
# shadow
951+
ip.run_cell("timeit = 1")
952+
953+
# should not suggest on implict magic when shadowed
954+
text, matches = c.complete("timeit -n 2 -r 1 flo")
955+
self.assertEqual(matches, [])
956+
957+
# should suggest on explicit magic
958+
text, matches = c.complete("%timeit -n 2 -r 1 flo")
959+
self.assertEqual(matches, ["float"])
960+
961+
# remove shadow
962+
del ip.user_ns["timeit"]
963+
964+
# should suggest on implicit magic after shadow removal
965+
text, matches = c.complete("timeit -n 2 -r 1 flo")
966+
self.assertEqual(matches, ["float"])
967+
908968
def test_magic_completion_order(self):
909969
ip = get_ipython()
910970
c = ip.Completer

0 commit comments

Comments
 (0)