##// END OF EJS Templates
Merge branch 'main' into shaperilio/autoreload-verbosity
Emilio Graff -
r28047:ad333091 merge
parent child Browse files
Show More
This diff has been collapsed as it changes many lines, (671 lines changed) Show them Hide them
@@ -0,0 +1,671 b''
1 """
2 Module to define and register Terminal IPython shortcuts with
3 :mod:`prompt_toolkit`
4 """
5
6 # Copyright (c) IPython Development Team.
7 # Distributed under the terms of the Modified BSD License.
8
9 import os
10 import re
11 import signal
12 import sys
13 import warnings
14 from typing import Callable, Dict, Union
15
16 from prompt_toolkit.application.current import get_app
17 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
18 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
19 from prompt_toolkit.filters import has_focus as has_focus_impl
20 from prompt_toolkit.filters import (
21 has_selection,
22 has_suggestion,
23 vi_insert_mode,
24 vi_mode,
25 )
26 from prompt_toolkit.key_binding import KeyBindings
27 from prompt_toolkit.key_binding.bindings import named_commands as nc
28 from prompt_toolkit.key_binding.bindings.completion import (
29 display_completions_like_readline,
30 )
31 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
32 from prompt_toolkit.layout.layout import FocusableElement
33
34 from IPython.terminal.shortcuts import auto_match as match
35 from IPython.terminal.shortcuts import auto_suggest
36 from IPython.utils.decorators import undoc
37
38 __all__ = ["create_ipython_shortcuts"]
39
40
41 @undoc
42 @Condition
43 def cursor_in_leading_ws():
44 before = get_app().current_buffer.document.current_line_before_cursor
45 return (not before) or before.isspace()
46
47
48 def has_focus(value: FocusableElement):
49 """Wrapper around has_focus adding a nice `__name__` to tester function"""
50 tester = has_focus_impl(value).func
51 tester.__name__ = f"is_focused({value})"
52 return Condition(tester)
53
54
55 @undoc
56 @Condition
57 def has_line_below() -> bool:
58 document = get_app().current_buffer.document
59 return document.cursor_position_row < len(document.lines) - 1
60
61
62 @undoc
63 @Condition
64 def has_line_above() -> bool:
65 document = get_app().current_buffer.document
66 return document.cursor_position_row != 0
67
68
69 def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
70 """Set up the prompt_toolkit keyboard shortcuts for IPython.
71
72 Parameters
73 ----------
74 shell: InteractiveShell
75 The current IPython shell Instance
76 for_all_platforms: bool (default false)
77 This parameter is mostly used in generating the documentation
78 to create the shortcut binding for all the platforms, and export
79 them.
80
81 Returns
82 -------
83 KeyBindings
84 the keybinding instance for prompt toolkit.
85
86 """
87 # Warning: if possible, do NOT define handler functions in the locals
88 # scope of this function, instead define functions in the global
89 # scope, or a separate module, and include a user-friendly docstring
90 # describing the action.
91
92 kb = KeyBindings()
93 insert_mode = vi_insert_mode | emacs_insert_mode
94
95 if getattr(shell, "handle_return", None):
96 return_handler = shell.handle_return(shell)
97 else:
98 return_handler = newline_or_execute_outer(shell)
99
100 kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))(
101 return_handler
102 )
103
104 @Condition
105 def ebivim():
106 return shell.emacs_bindings_in_vi_insert_mode
107
108 @kb.add(
109 "escape",
110 "enter",
111 filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode & ebivim),
112 )
113 def reformat_and_execute(event):
114 """Reformat code and execute it"""
115 reformat_text_before_cursor(
116 event.current_buffer, event.current_buffer.document, shell
117 )
118 event.current_buffer.validate_and_handle()
119
120 kb.add("c-\\")(quit)
121
122 kb.add("c-p", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
123 previous_history_or_previous_completion
124 )
125
126 kb.add("c-n", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
127 next_history_or_next_completion
128 )
129
130 kb.add("c-g", filter=(has_focus(DEFAULT_BUFFER) & has_completions))(
131 dismiss_completion
132 )
133
134 kb.add("c-c", filter=has_focus(DEFAULT_BUFFER))(reset_buffer)
135
136 kb.add("c-c", filter=has_focus(SEARCH_BUFFER))(reset_search_buffer)
137
138 supports_suspend = Condition(lambda: hasattr(signal, "SIGTSTP"))
139 kb.add("c-z", filter=supports_suspend)(suspend_to_bg)
140
141 # Ctrl+I == Tab
142 kb.add(
143 "tab",
144 filter=(
145 has_focus(DEFAULT_BUFFER)
146 & ~has_selection
147 & insert_mode
148 & cursor_in_leading_ws
149 ),
150 )(indent_buffer)
151 kb.add("c-o", filter=(has_focus(DEFAULT_BUFFER) & emacs_insert_mode))(
152 newline_autoindent_outer(shell.input_transformer_manager)
153 )
154
155 kb.add("f2", filter=has_focus(DEFAULT_BUFFER))(open_input_in_editor)
156
157 @Condition
158 def auto_match():
159 return shell.auto_match
160
161 def all_quotes_paired(quote, buf):
162 paired = True
163 i = 0
164 while i < len(buf):
165 c = buf[i]
166 if c == quote:
167 paired = not paired
168 elif c == "\\":
169 i += 1
170 i += 1
171 return paired
172
173 focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER)
174 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
175 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
176
177 def preceding_text(pattern: Union[str, Callable]):
178 if pattern in _preceding_text_cache:
179 return _preceding_text_cache[pattern]
180
181 if callable(pattern):
182
183 def _preceding_text():
184 app = get_app()
185 before_cursor = app.current_buffer.document.current_line_before_cursor
186 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
187 return bool(pattern(before_cursor)) # type: ignore[operator]
188
189 else:
190 m = re.compile(pattern)
191
192 def _preceding_text():
193 app = get_app()
194 before_cursor = app.current_buffer.document.current_line_before_cursor
195 return bool(m.match(before_cursor))
196
197 _preceding_text.__name__ = f"preceding_text({pattern!r})"
198
199 condition = Condition(_preceding_text)
200 _preceding_text_cache[pattern] = condition
201 return condition
202
203 def following_text(pattern):
204 try:
205 return _following_text_cache[pattern]
206 except KeyError:
207 pass
208 m = re.compile(pattern)
209
210 def _following_text():
211 app = get_app()
212 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
213
214 _following_text.__name__ = f"following_text({pattern!r})"
215
216 condition = Condition(_following_text)
217 _following_text_cache[pattern] = condition
218 return condition
219
220 @Condition
221 def not_inside_unclosed_string():
222 app = get_app()
223 s = app.current_buffer.document.text_before_cursor
224 # remove escaped quotes
225 s = s.replace('\\"', "").replace("\\'", "")
226 # remove triple-quoted string literals
227 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
228 # remove single-quoted string literals
229 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
230 return not ('"' in s or "'" in s)
231
232 # auto match
233 for key, cmd in match.auto_match_parens.items():
234 kb.add(key, filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))(
235 cmd
236 )
237
238 # raw string
239 for key, cmd in match.auto_match_parens_raw_string.items():
240 kb.add(
241 key,
242 filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"),
243 )(cmd)
244
245 kb.add(
246 '"',
247 filter=focused_insert
248 & auto_match
249 & not_inside_unclosed_string
250 & preceding_text(lambda line: all_quotes_paired('"', line))
251 & following_text(r"[,)}\]]|$"),
252 )(match.double_quote)
253
254 kb.add(
255 "'",
256 filter=focused_insert
257 & auto_match
258 & not_inside_unclosed_string
259 & preceding_text(lambda line: all_quotes_paired("'", line))
260 & following_text(r"[,)}\]]|$"),
261 )(match.single_quote)
262
263 kb.add(
264 '"',
265 filter=focused_insert
266 & auto_match
267 & not_inside_unclosed_string
268 & preceding_text(r'^.*""$'),
269 )(match.docstring_double_quotes)
270
271 kb.add(
272 "'",
273 filter=focused_insert
274 & auto_match
275 & not_inside_unclosed_string
276 & preceding_text(r"^.*''$"),
277 )(match.docstring_single_quotes)
278
279 # just move cursor
280 kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))(
281 match.skip_over
282 )
283 kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))(
284 match.skip_over
285 )
286 kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))(
287 match.skip_over
288 )
289 kb.add('"', filter=focused_insert & auto_match & following_text('^"'))(
290 match.skip_over
291 )
292 kb.add("'", filter=focused_insert & auto_match & following_text("^'"))(
293 match.skip_over
294 )
295
296 kb.add(
297 "backspace",
298 filter=focused_insert
299 & preceding_text(r".*\($")
300 & auto_match
301 & following_text(r"^\)"),
302 )(match.delete_pair)
303 kb.add(
304 "backspace",
305 filter=focused_insert
306 & preceding_text(r".*\[$")
307 & auto_match
308 & following_text(r"^\]"),
309 )(match.delete_pair)
310 kb.add(
311 "backspace",
312 filter=focused_insert
313 & preceding_text(r".*\{$")
314 & auto_match
315 & following_text(r"^\}"),
316 )(match.delete_pair)
317 kb.add(
318 "backspace",
319 filter=focused_insert
320 & preceding_text('.*"$')
321 & auto_match
322 & following_text('^"'),
323 )(match.delete_pair)
324 kb.add(
325 "backspace",
326 filter=focused_insert
327 & preceding_text(r".*'$")
328 & auto_match
329 & following_text(r"^'"),
330 )(match.delete_pair)
331
332 if shell.display_completions == "readlinelike":
333 kb.add(
334 "c-i",
335 filter=(
336 has_focus(DEFAULT_BUFFER)
337 & ~has_selection
338 & insert_mode
339 & ~cursor_in_leading_ws
340 ),
341 )(display_completions_like_readline)
342
343 if sys.platform == "win32" or for_all_platforms:
344 kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste)
345
346 focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode
347
348 # autosuggestions
349 @Condition
350 def navigable_suggestions():
351 return isinstance(
352 shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory
353 )
354
355 kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))(
356 auto_suggest.accept_in_vi_insert_mode
357 )
358 kb.add("c-e", filter=focused_insert_vi & ebivim)(
359 auto_suggest.accept_in_vi_insert_mode
360 )
361 kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept)
362 kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word)
363 kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
364 auto_suggest.accept_token
365 )
366 kb.add("escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
367 auto_suggest.discard
368 )
369 kb.add(
370 "up",
371 filter=navigable_suggestions
372 & ~has_line_above
373 & has_suggestion
374 & has_focus(DEFAULT_BUFFER),
375 )(auto_suggest.swap_autosuggestion_up(shell.auto_suggest))
376 kb.add(
377 "down",
378 filter=navigable_suggestions
379 & ~has_line_below
380 & has_suggestion
381 & has_focus(DEFAULT_BUFFER),
382 )(auto_suggest.swap_autosuggestion_down(shell.auto_suggest))
383 kb.add(
384 "up", filter=has_line_above & navigable_suggestions & has_focus(DEFAULT_BUFFER)
385 )(auto_suggest.up_and_update_hint)
386 kb.add(
387 "down",
388 filter=has_line_below & navigable_suggestions & has_focus(DEFAULT_BUFFER),
389 )(auto_suggest.down_and_update_hint)
390 kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
391 auto_suggest.accept_character
392 )
393 kb.add("c-left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
394 auto_suggest.accept_and_move_cursor_left
395 )
396 kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
397 auto_suggest.accept_and_keep_cursor
398 )
399 kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
400 auto_suggest.backspace_and_resume_hint
401 )
402
403 # Simple Control keybindings
404 key_cmd_dict = {
405 "c-a": nc.beginning_of_line,
406 "c-b": nc.backward_char,
407 "c-k": nc.kill_line,
408 "c-w": nc.backward_kill_word,
409 "c-y": nc.yank,
410 "c-_": nc.undo,
411 }
412
413 for key, cmd in key_cmd_dict.items():
414 kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
415
416 # Alt and Combo Control keybindings
417 keys_cmd_dict = {
418 # Control Combos
419 ("c-x", "c-e"): nc.edit_and_execute,
420 ("c-x", "e"): nc.edit_and_execute,
421 # Alt
422 ("escape", "b"): nc.backward_word,
423 ("escape", "c"): nc.capitalize_word,
424 ("escape", "d"): nc.kill_word,
425 ("escape", "h"): nc.backward_kill_word,
426 ("escape", "l"): nc.downcase_word,
427 ("escape", "u"): nc.uppercase_word,
428 ("escape", "y"): nc.yank_pop,
429 ("escape", "."): nc.yank_last_arg,
430 }
431
432 for keys, cmd in keys_cmd_dict.items():
433 kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
434
435 def get_input_mode(self):
436 app = get_app()
437 app.ttimeoutlen = shell.ttimeoutlen
438 app.timeoutlen = shell.timeoutlen
439
440 return self._input_mode
441
442 def set_input_mode(self, mode):
443 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
444 cursor = "\x1b[{} q".format(shape)
445
446 sys.stdout.write(cursor)
447 sys.stdout.flush()
448
449 self._input_mode = mode
450
451 if shell.editing_mode == "vi" and shell.modal_cursor:
452 ViState._input_mode = InputMode.INSERT # type: ignore
453 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
454
455 return kb
456
457
458 def reformat_text_before_cursor(buffer, document, shell):
459 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
460 try:
461 formatted_text = shell.reformat_handler(text)
462 buffer.insert_text(formatted_text)
463 except Exception as e:
464 buffer.insert_text(text)
465
466
467 def newline_or_execute_outer(shell):
468 def newline_or_execute(event):
469 """When the user presses return, insert a newline or execute the code."""
470 b = event.current_buffer
471 d = b.document
472
473 if b.complete_state:
474 cc = b.complete_state.current_completion
475 if cc:
476 b.apply_completion(cc)
477 else:
478 b.cancel_completion()
479 return
480
481 # If there's only one line, treat it as if the cursor is at the end.
482 # See https://github.com/ipython/ipython/issues/10425
483 if d.line_count == 1:
484 check_text = d.text
485 else:
486 check_text = d.text[: d.cursor_position]
487 status, indent = shell.check_complete(check_text)
488
489 # if all we have after the cursor is whitespace: reformat current text
490 # before cursor
491 after_cursor = d.text[d.cursor_position :]
492 reformatted = False
493 if not after_cursor.strip():
494 reformat_text_before_cursor(b, d, shell)
495 reformatted = True
496 if not (
497 d.on_last_line
498 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
499 ):
500 if shell.autoindent:
501 b.insert_text("\n" + indent)
502 else:
503 b.insert_text("\n")
504 return
505
506 if (status != "incomplete") and b.accept_handler:
507 if not reformatted:
508 reformat_text_before_cursor(b, d, shell)
509 b.validate_and_handle()
510 else:
511 if shell.autoindent:
512 b.insert_text("\n" + indent)
513 else:
514 b.insert_text("\n")
515
516 newline_or_execute.__qualname__ = "newline_or_execute"
517
518 return newline_or_execute
519
520
521 def previous_history_or_previous_completion(event):
522 """
523 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
524
525 If completer is open this still select previous completion.
526 """
527 event.current_buffer.auto_up()
528
529
530 def next_history_or_next_completion(event):
531 """
532 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
533
534 If completer is open this still select next completion.
535 """
536 event.current_buffer.auto_down()
537
538
539 def dismiss_completion(event):
540 """Dismiss completion"""
541 b = event.current_buffer
542 if b.complete_state:
543 b.cancel_completion()
544
545
546 def reset_buffer(event):
547 """Reset buffer"""
548 b = event.current_buffer
549 if b.complete_state:
550 b.cancel_completion()
551 else:
552 b.reset()
553
554
555 def reset_search_buffer(event):
556 """Reset search buffer"""
557 if event.current_buffer.document.text:
558 event.current_buffer.reset()
559 else:
560 event.app.layout.focus(DEFAULT_BUFFER)
561
562
563 def suspend_to_bg(event):
564 """Suspend to background"""
565 event.app.suspend_to_background()
566
567
568 def quit(event):
569 """
570 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
571
572 On platforms that support SIGQUIT, send SIGQUIT to the current process.
573 On other platforms, just exit the process with a message.
574 """
575 sigquit = getattr(signal, "SIGQUIT", None)
576 if sigquit is not None:
577 os.kill(0, signal.SIGQUIT)
578 else:
579 sys.exit("Quit")
580
581
582 def indent_buffer(event):
583 """Indent buffer"""
584 event.current_buffer.insert_text(" " * 4)
585
586
587 @undoc
588 def newline_with_copy_margin(event):
589 """
590 DEPRECATED since IPython 6.0
591
592 See :any:`newline_autoindent_outer` for a replacement.
593
594 Preserve margin and cursor position when using
595 Control-O to insert a newline in EMACS mode
596 """
597 warnings.warn(
598 "`newline_with_copy_margin(event)` is deprecated since IPython 6.0. "
599 "see `newline_autoindent_outer(shell)(event)` for a replacement.",
600 DeprecationWarning,
601 stacklevel=2,
602 )
603
604 b = event.current_buffer
605 cursor_start_pos = b.document.cursor_position_col
606 b.newline(copy_margin=True)
607 b.cursor_up(count=1)
608 cursor_end_pos = b.document.cursor_position_col
609 if cursor_start_pos != cursor_end_pos:
610 pos_diff = cursor_start_pos - cursor_end_pos
611 b.cursor_right(count=pos_diff)
612
613
614 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
615 """
616 Return a function suitable for inserting a indented newline after the cursor.
617
618 Fancier version of deprecated ``newline_with_copy_margin`` which should
619 compute the correct indentation of the inserted line. That is to say, indent
620 by 4 extra space after a function definition, class definition, context
621 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
622 """
623
624 def newline_autoindent(event):
625 """Insert a newline after the cursor indented appropriately."""
626 b = event.current_buffer
627 d = b.document
628
629 if b.complete_state:
630 b.cancel_completion()
631 text = d.text[: d.cursor_position] + "\n"
632 _, indent = inputsplitter.check_complete(text)
633 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
634
635 newline_autoindent.__qualname__ = "newline_autoindent"
636
637 return newline_autoindent
638
639
640 def open_input_in_editor(event):
641 """Open code from input in external editor"""
642 event.app.current_buffer.open_in_editor()
643
644
645 if sys.platform == "win32":
646 from IPython.core.error import TryNext
647 from IPython.lib.clipboard import (
648 ClipboardEmpty,
649 tkinter_clipboard_get,
650 win32_clipboard_get,
651 )
652
653 @undoc
654 def win_paste(event):
655 try:
656 text = win32_clipboard_get()
657 except TryNext:
658 try:
659 text = tkinter_clipboard_get()
660 except (TryNext, ClipboardEmpty):
661 return
662 except ClipboardEmpty:
663 return
664 event.current_buffer.insert_text(text.replace("\t", " " * 4))
665
666 else:
667
668 @undoc
669 def win_paste(event):
670 """Stub used when auto-generating shortcuts for documentation"""
671 pass
@@ -0,0 +1,104 b''
1 """
2 Utilities function for keybinding with prompt toolkit.
3
4 This will be bound to specific key press and filter modes,
5 like whether we are in edit mode, and whether the completer is open.
6 """
7 import re
8 from prompt_toolkit.key_binding import KeyPressEvent
9
10
11 def parenthesis(event: KeyPressEvent):
12 """Auto-close parenthesis"""
13 event.current_buffer.insert_text("()")
14 event.current_buffer.cursor_left()
15
16
17 def brackets(event: KeyPressEvent):
18 """Auto-close brackets"""
19 event.current_buffer.insert_text("[]")
20 event.current_buffer.cursor_left()
21
22
23 def braces(event: KeyPressEvent):
24 """Auto-close braces"""
25 event.current_buffer.insert_text("{}")
26 event.current_buffer.cursor_left()
27
28
29 def double_quote(event: KeyPressEvent):
30 """Auto-close double quotes"""
31 event.current_buffer.insert_text('""')
32 event.current_buffer.cursor_left()
33
34
35 def single_quote(event: KeyPressEvent):
36 """Auto-close single quotes"""
37 event.current_buffer.insert_text("''")
38 event.current_buffer.cursor_left()
39
40
41 def docstring_double_quotes(event: KeyPressEvent):
42 """Auto-close docstring (double quotes)"""
43 event.current_buffer.insert_text('""""')
44 event.current_buffer.cursor_left(3)
45
46
47 def docstring_single_quotes(event: KeyPressEvent):
48 """Auto-close docstring (single quotes)"""
49 event.current_buffer.insert_text("''''")
50 event.current_buffer.cursor_left(3)
51
52
53 def raw_string_parenthesis(event: KeyPressEvent):
54 """Auto-close parenthesis in raw strings"""
55 matches = re.match(
56 r".*(r|R)[\"'](-*)",
57 event.current_buffer.document.current_line_before_cursor,
58 )
59 dashes = matches.group(2) if matches else ""
60 event.current_buffer.insert_text("()" + dashes)
61 event.current_buffer.cursor_left(len(dashes) + 1)
62
63
64 def raw_string_bracket(event: KeyPressEvent):
65 """Auto-close bracker in raw strings"""
66 matches = re.match(
67 r".*(r|R)[\"'](-*)",
68 event.current_buffer.document.current_line_before_cursor,
69 )
70 dashes = matches.group(2) if matches else ""
71 event.current_buffer.insert_text("[]" + dashes)
72 event.current_buffer.cursor_left(len(dashes) + 1)
73
74
75 def raw_string_braces(event: KeyPressEvent):
76 """Auto-close braces in raw strings"""
77 matches = re.match(
78 r".*(r|R)[\"'](-*)",
79 event.current_buffer.document.current_line_before_cursor,
80 )
81 dashes = matches.group(2) if matches else ""
82 event.current_buffer.insert_text("{}" + dashes)
83 event.current_buffer.cursor_left(len(dashes) + 1)
84
85
86 def skip_over(event: KeyPressEvent):
87 """Skip over automatically added parenthesis.
88
89 (rather than adding another parenthesis)"""
90 event.current_buffer.cursor_right()
91
92
93 def delete_pair(event: KeyPressEvent):
94 """Delete auto-closed parenthesis"""
95 event.current_buffer.delete()
96 event.current_buffer.delete_before_cursor()
97
98
99 auto_match_parens = {"(": parenthesis, "[": brackets, "{": braces}
100 auto_match_parens_raw_string = {
101 "(": raw_string_parenthesis,
102 "[": raw_string_bracket,
103 "{": raw_string_braces,
104 }
@@ -0,0 +1,374 b''
1 import re
2 import tokenize
3 from io import StringIO
4 from typing import Callable, List, Optional, Union, Generator, Tuple, Sequence
5
6 from prompt_toolkit.buffer import Buffer
7 from prompt_toolkit.key_binding import KeyPressEvent
8 from prompt_toolkit.key_binding.bindings import named_commands as nc
9 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
10 from prompt_toolkit.document import Document
11 from prompt_toolkit.history import History
12 from prompt_toolkit.shortcuts import PromptSession
13 from prompt_toolkit.layout.processors import (
14 Processor,
15 Transformation,
16 TransformationInput,
17 )
18
19 from IPython.utils.tokenutil import generate_tokens
20
21
22 def _get_query(document: Document):
23 return document.lines[document.cursor_position_row]
24
25
26 class AppendAutoSuggestionInAnyLine(Processor):
27 """
28 Append the auto suggestion to lines other than the last (appending to the
29 last line is natively supported by the prompt toolkit).
30 """
31
32 def __init__(self, style: str = "class:auto-suggestion") -> None:
33 self.style = style
34
35 def apply_transformation(self, ti: TransformationInput) -> Transformation:
36 is_last_line = ti.lineno == ti.document.line_count - 1
37 is_active_line = ti.lineno == ti.document.cursor_position_row
38
39 if not is_last_line and is_active_line:
40 buffer = ti.buffer_control.buffer
41
42 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
43 suggestion = buffer.suggestion.text
44 else:
45 suggestion = ""
46
47 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
48 else:
49 return Transformation(fragments=ti.fragments)
50
51
52 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
53 """
54 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
55 suggestion from history. To do so it remembers the current position, but it
56 state need to carefully be cleared on the right events.
57 """
58
59 def __init__(
60 self,
61 ):
62 self.skip_lines = 0
63 self._connected_apps = []
64
65 def reset_history_position(self, _: Buffer):
66 self.skip_lines = 0
67
68 def disconnect(self):
69 for pt_app in self._connected_apps:
70 text_insert_event = pt_app.default_buffer.on_text_insert
71 text_insert_event.remove_handler(self.reset_history_position)
72
73 def connect(self, pt_app: PromptSession):
74 self._connected_apps.append(pt_app)
75 # note: `on_text_changed` could be used for a bit different behaviour
76 # on character deletion (i.e. reseting history position on backspace)
77 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
78
79 def get_suggestion(
80 self, buffer: Buffer, document: Document
81 ) -> Optional[Suggestion]:
82 text = _get_query(document)
83
84 if text.strip():
85 for suggestion, _ in self._find_next_match(
86 text, self.skip_lines, buffer.history
87 ):
88 return Suggestion(suggestion)
89
90 return None
91
92 def _find_match(
93 self, text: str, skip_lines: float, history: History, previous: bool
94 ) -> Generator[Tuple[str, float], None, None]:
95 """
96 text : str
97 Text content to find a match for, the user cursor is most of the
98 time at the end of this text.
99 skip_lines : float
100 number of items to skip in the search, this is used to indicate how
101 far in the list the user has navigated by pressing up or down.
102 The float type is used as the base value is +inf
103 history : History
104 prompt_toolkit History instance to fetch previous entries from.
105 previous : bool
106 Direction of the search, whether we are looking previous match
107 (True), or next match (False).
108
109 Yields
110 ------
111 Tuple with:
112 str:
113 current suggestion.
114 float:
115 will actually yield only ints, which is passed back via skip_lines,
116 which may be a +inf (float)
117
118
119 """
120 line_number = -1
121 for string in reversed(list(history.get_strings())):
122 for line in reversed(string.splitlines()):
123 line_number += 1
124 if not previous and line_number < skip_lines:
125 continue
126 # do not return empty suggestions as these
127 # close the auto-suggestion overlay (and are useless)
128 if line.startswith(text) and len(line) > len(text):
129 yield line[len(text) :], line_number
130 if previous and line_number >= skip_lines:
131 return
132
133 def _find_next_match(
134 self, text: str, skip_lines: float, history: History
135 ) -> Generator[Tuple[str, float], None, None]:
136 return self._find_match(text, skip_lines, history, previous=False)
137
138 def _find_previous_match(self, text: str, skip_lines: float, history: History):
139 return reversed(
140 list(self._find_match(text, skip_lines, history, previous=True))
141 )
142
143 def up(self, query: str, other_than: str, history: History) -> None:
144 for suggestion, line_number in self._find_next_match(
145 query, self.skip_lines, history
146 ):
147 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
148 # we want to switch from 'very.b' to 'very.a' because a) if the
149 # suggestion equals current text, prompt-toolkit aborts suggesting
150 # b) user likely would not be interested in 'very' anyways (they
151 # already typed it).
152 if query + suggestion != other_than:
153 self.skip_lines = line_number
154 break
155 else:
156 # no matches found, cycle back to beginning
157 self.skip_lines = 0
158
159 def down(self, query: str, other_than: str, history: History) -> None:
160 for suggestion, line_number in self._find_previous_match(
161 query, self.skip_lines, history
162 ):
163 if query + suggestion != other_than:
164 self.skip_lines = line_number
165 break
166 else:
167 # no matches found, cycle to end
168 for suggestion, line_number in self._find_previous_match(
169 query, float("Inf"), history
170 ):
171 if query + suggestion != other_than:
172 self.skip_lines = line_number
173 break
174
175
176 # Needed for to accept autosuggestions in vi insert mode
177 def accept_in_vi_insert_mode(event: KeyPressEvent):
178 """Apply autosuggestion if at end of line."""
179 buffer = event.current_buffer
180 d = buffer.document
181 after_cursor = d.text[d.cursor_position :]
182 lines = after_cursor.split("\n")
183 end_of_current_line = lines[0].strip()
184 suggestion = buffer.suggestion
185 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
186 buffer.insert_text(suggestion.text)
187 else:
188 nc.end_of_line(event)
189
190
191 def accept(event: KeyPressEvent):
192 """Accept autosuggestion"""
193 buffer = event.current_buffer
194 suggestion = buffer.suggestion
195 if suggestion:
196 buffer.insert_text(suggestion.text)
197 else:
198 nc.forward_char(event)
199
200
201 def discard(event: KeyPressEvent):
202 """Discard autosuggestion"""
203 buffer = event.current_buffer
204 buffer.suggestion = None
205
206
207 def accept_word(event: KeyPressEvent):
208 """Fill partial autosuggestion by word"""
209 buffer = event.current_buffer
210 suggestion = buffer.suggestion
211 if suggestion:
212 t = re.split(r"(\S+\s+)", suggestion.text)
213 buffer.insert_text(next((x for x in t if x), ""))
214 else:
215 nc.forward_word(event)
216
217
218 def accept_character(event: KeyPressEvent):
219 """Fill partial autosuggestion by character"""
220 b = event.current_buffer
221 suggestion = b.suggestion
222 if suggestion and suggestion.text:
223 b.insert_text(suggestion.text[0])
224
225
226 def accept_and_keep_cursor(event: KeyPressEvent):
227 """Accept autosuggestion and keep cursor in place"""
228 buffer = event.current_buffer
229 old_position = buffer.cursor_position
230 suggestion = buffer.suggestion
231 if suggestion:
232 buffer.insert_text(suggestion.text)
233 buffer.cursor_position = old_position
234
235
236 def accept_and_move_cursor_left(event: KeyPressEvent):
237 """Accept autosuggestion and move cursor left in place"""
238 accept_and_keep_cursor(event)
239 nc.backward_char(event)
240
241
242 def _update_hint(buffer: Buffer):
243 if buffer.auto_suggest:
244 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
245 buffer.suggestion = suggestion
246
247
248 def backspace_and_resume_hint(event: KeyPressEvent):
249 """Resume autosuggestions after deleting last character"""
250 current_buffer = event.current_buffer
251
252 def resume_hinting(buffer: Buffer):
253 _update_hint(buffer)
254 current_buffer.on_text_changed.remove_handler(resume_hinting)
255
256 current_buffer.on_text_changed.add_handler(resume_hinting)
257 nc.backward_delete_char(event)
258
259
260 def up_and_update_hint(event: KeyPressEvent):
261 """Go up and update hint"""
262 current_buffer = event.current_buffer
263
264 current_buffer.auto_up(count=event.arg)
265 _update_hint(current_buffer)
266
267
268 def down_and_update_hint(event: KeyPressEvent):
269 """Go down and update hint"""
270 current_buffer = event.current_buffer
271
272 current_buffer.auto_down(count=event.arg)
273 _update_hint(current_buffer)
274
275
276 def accept_token(event: KeyPressEvent):
277 """Fill partial autosuggestion by token"""
278 b = event.current_buffer
279 suggestion = b.suggestion
280
281 if suggestion:
282 prefix = _get_query(b.document)
283 text = prefix + suggestion.text
284
285 tokens: List[Optional[str]] = [None, None, None]
286 substrings = [""]
287 i = 0
288
289 for token in generate_tokens(StringIO(text).readline):
290 if token.type == tokenize.NEWLINE:
291 index = len(text)
292 else:
293 index = text.index(token[1], len(substrings[-1]))
294 substrings.append(text[:index])
295 tokenized_so_far = substrings[-1]
296 if tokenized_so_far.startswith(prefix):
297 if i == 0 and len(tokenized_so_far) > len(prefix):
298 tokens[0] = tokenized_so_far[len(prefix) :]
299 substrings.append(tokenized_so_far)
300 i += 1
301 tokens[i] = token[1]
302 if i == 2:
303 break
304 i += 1
305
306 if tokens[0]:
307 to_insert: str
308 insert_text = substrings[-2]
309 if tokens[1] and len(tokens[1]) == 1:
310 insert_text = substrings[-1]
311 to_insert = insert_text[len(prefix) :]
312 b.insert_text(to_insert)
313 return
314
315 nc.forward_word(event)
316
317
318 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
319
320
321 def _swap_autosuggestion(
322 buffer: Buffer,
323 provider: NavigableAutoSuggestFromHistory,
324 direction_method: Callable,
325 ):
326 """
327 We skip most recent history entry (in either direction) if it equals the
328 current autosuggestion because if user cycles when auto-suggestion is shown
329 they most likely want something else than what was suggested (otherwise
330 they would have accepted the suggestion).
331 """
332 suggestion = buffer.suggestion
333 if not suggestion:
334 return
335
336 query = _get_query(buffer.document)
337 current = query + suggestion.text
338
339 direction_method(query=query, other_than=current, history=buffer.history)
340
341 new_suggestion = provider.get_suggestion(buffer, buffer.document)
342 buffer.suggestion = new_suggestion
343
344
345 def swap_autosuggestion_up(provider: Provider):
346 def swap_autosuggestion_up(event: KeyPressEvent):
347 """Get next autosuggestion from history."""
348 if not isinstance(provider, NavigableAutoSuggestFromHistory):
349 return
350
351 return _swap_autosuggestion(
352 buffer=event.current_buffer, provider=provider, direction_method=provider.up
353 )
354
355 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
356 return swap_autosuggestion_up
357
358
359 def swap_autosuggestion_down(
360 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
361 ):
362 def swap_autosuggestion_down(event: KeyPressEvent):
363 """Get previous autosuggestion from history."""
364 if not isinstance(provider, NavigableAutoSuggestFromHistory):
365 return
366
367 return _swap_autosuggestion(
368 buffer=event.current_buffer,
369 provider=provider,
370 direction_method=provider.down,
371 )
372
373 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
374 return swap_autosuggestion_down
@@ -0,0 +1,318 b''
1 import pytest
2 from IPython.terminal.shortcuts.auto_suggest import (
3 accept,
4 accept_in_vi_insert_mode,
5 accept_token,
6 accept_character,
7 accept_word,
8 accept_and_keep_cursor,
9 discard,
10 NavigableAutoSuggestFromHistory,
11 swap_autosuggestion_up,
12 swap_autosuggestion_down,
13 )
14
15 from prompt_toolkit.history import InMemoryHistory
16 from prompt_toolkit.buffer import Buffer
17 from prompt_toolkit.document import Document
18 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
19
20 from unittest.mock import patch, Mock
21
22
23 def make_event(text, cursor, suggestion):
24 event = Mock()
25 event.current_buffer = Mock()
26 event.current_buffer.suggestion = Mock()
27 event.current_buffer.text = text
28 event.current_buffer.cursor_position = cursor
29 event.current_buffer.suggestion.text = suggestion
30 event.current_buffer.document = Document(text=text, cursor_position=cursor)
31 return event
32
33
34 @pytest.mark.parametrize(
35 "text, suggestion, expected",
36 [
37 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):"),
38 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):"),
39 ],
40 )
41 def test_accept(text, suggestion, expected):
42 event = make_event(text, len(text), suggestion)
43 buffer = event.current_buffer
44 buffer.insert_text = Mock()
45 accept(event)
46 assert buffer.insert_text.called
47 assert buffer.insert_text.call_args[0] == (expected,)
48
49
50 @pytest.mark.parametrize(
51 "text, suggestion",
52 [
53 ("", "def out(tag: str, n=50):"),
54 ("def ", "out(tag: str, n=50):"),
55 ],
56 )
57 def test_discard(text, suggestion):
58 event = make_event(text, len(text), suggestion)
59 buffer = event.current_buffer
60 buffer.insert_text = Mock()
61 discard(event)
62 assert not buffer.insert_text.called
63 assert buffer.suggestion is None
64
65
66 @pytest.mark.parametrize(
67 "text, cursor, suggestion, called",
68 [
69 ("123456", 6, "123456789", True),
70 ("123456", 3, "123456789", False),
71 ("123456 \n789", 6, "123456789", True),
72 ],
73 )
74 def test_autosuggest_at_EOL(text, cursor, suggestion, called):
75 """
76 test that autosuggest is only applied at end of line.
77 """
78
79 event = make_event(text, cursor, suggestion)
80 event.current_buffer.insert_text = Mock()
81 accept_in_vi_insert_mode(event)
82 if called:
83 event.current_buffer.insert_text.assert_called()
84 else:
85 event.current_buffer.insert_text.assert_not_called()
86 # event.current_buffer.document.get_end_of_line_position.assert_called()
87
88
89 @pytest.mark.parametrize(
90 "text, suggestion, expected",
91 [
92 ("", "def out(tag: str, n=50):", "def "),
93 ("d", "ef out(tag: str, n=50):", "ef "),
94 ("de ", "f out(tag: str, n=50):", "f "),
95 ("def", " out(tag: str, n=50):", " "),
96 ("def ", "out(tag: str, n=50):", "out("),
97 ("def o", "ut(tag: str, n=50):", "ut("),
98 ("def ou", "t(tag: str, n=50):", "t("),
99 ("def out", "(tag: str, n=50):", "("),
100 ("def out(", "tag: str, n=50):", "tag: "),
101 ("def out(t", "ag: str, n=50):", "ag: "),
102 ("def out(ta", "g: str, n=50):", "g: "),
103 ("def out(tag", ": str, n=50):", ": "),
104 ("def out(tag:", " str, n=50):", " "),
105 ("def out(tag: ", "str, n=50):", "str, "),
106 ("def out(tag: s", "tr, n=50):", "tr, "),
107 ("def out(tag: st", "r, n=50):", "r, "),
108 ("def out(tag: str", ", n=50):", ", n"),
109 ("def out(tag: str,", " n=50):", " n"),
110 ("def out(tag: str, ", "n=50):", "n="),
111 ("def out(tag: str, n", "=50):", "="),
112 ("def out(tag: str, n=", "50):", "50)"),
113 ("def out(tag: str, n=5", "0):", "0)"),
114 ("def out(tag: str, n=50", "):", "):"),
115 ("def out(tag: str, n=50)", ":", ":"),
116 ],
117 )
118 def test_autosuggest_token(text, suggestion, expected):
119 event = make_event(text, len(text), suggestion)
120 event.current_buffer.insert_text = Mock()
121 accept_token(event)
122 assert event.current_buffer.insert_text.called
123 assert event.current_buffer.insert_text.call_args[0] == (expected,)
124
125
126 @pytest.mark.parametrize(
127 "text, suggestion, expected",
128 [
129 ("", "def out(tag: str, n=50):", "d"),
130 ("d", "ef out(tag: str, n=50):", "e"),
131 ("de ", "f out(tag: str, n=50):", "f"),
132 ("def", " out(tag: str, n=50):", " "),
133 ],
134 )
135 def test_accept_character(text, suggestion, expected):
136 event = make_event(text, len(text), suggestion)
137 event.current_buffer.insert_text = Mock()
138 accept_character(event)
139 assert event.current_buffer.insert_text.called
140 assert event.current_buffer.insert_text.call_args[0] == (expected,)
141
142
143 @pytest.mark.parametrize(
144 "text, suggestion, expected",
145 [
146 ("", "def out(tag: str, n=50):", "def "),
147 ("d", "ef out(tag: str, n=50):", "ef "),
148 ("de", "f out(tag: str, n=50):", "f "),
149 ("def", " out(tag: str, n=50):", " "),
150 # (this is why we also have accept_token)
151 ("def ", "out(tag: str, n=50):", "out(tag: "),
152 ],
153 )
154 def test_accept_word(text, suggestion, expected):
155 event = make_event(text, len(text), suggestion)
156 event.current_buffer.insert_text = Mock()
157 accept_word(event)
158 assert event.current_buffer.insert_text.called
159 assert event.current_buffer.insert_text.call_args[0] == (expected,)
160
161
162 @pytest.mark.parametrize(
163 "text, suggestion, expected, cursor",
164 [
165 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):", 0),
166 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):", 4),
167 ],
168 )
169 def test_accept_and_keep_cursor(text, suggestion, expected, cursor):
170 event = make_event(text, cursor, suggestion)
171 buffer = event.current_buffer
172 buffer.insert_text = Mock()
173 accept_and_keep_cursor(event)
174 assert buffer.insert_text.called
175 assert buffer.insert_text.call_args[0] == (expected,)
176 assert buffer.cursor_position == cursor
177
178
179 def test_autosuggest_token_empty():
180 full = "def out(tag: str, n=50):"
181 event = make_event(full, len(full), "")
182 event.current_buffer.insert_text = Mock()
183
184 with patch(
185 "prompt_toolkit.key_binding.bindings.named_commands.forward_word"
186 ) as forward_word:
187 accept_token(event)
188 assert not event.current_buffer.insert_text.called
189 assert forward_word.called
190
191
192 def test_other_providers():
193 """Ensure that swapping autosuggestions does not break with other providers"""
194 provider = AutoSuggestFromHistory()
195 up = swap_autosuggestion_up(provider)
196 down = swap_autosuggestion_down(provider)
197 event = Mock()
198 event.current_buffer = Buffer()
199 assert up(event) is None
200 assert down(event) is None
201
202
203 async def test_navigable_provider():
204 provider = NavigableAutoSuggestFromHistory()
205 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
206 buffer = Buffer(history=history)
207
208 async for _ in history.load():
209 pass
210
211 buffer.cursor_position = 5
212 buffer.text = "very"
213
214 up = swap_autosuggestion_up(provider)
215 down = swap_autosuggestion_down(provider)
216
217 event = Mock()
218 event.current_buffer = buffer
219
220 def get_suggestion():
221 suggestion = provider.get_suggestion(buffer, buffer.document)
222 buffer.suggestion = suggestion
223 return suggestion
224
225 assert get_suggestion().text == "_c"
226
227 # should go up
228 up(event)
229 assert get_suggestion().text == "_b"
230
231 # should skip over 'very' which is identical to buffer content
232 up(event)
233 assert get_suggestion().text == "_a"
234
235 # should cycle back to beginning
236 up(event)
237 assert get_suggestion().text == "_c"
238
239 # should cycle back through end boundary
240 down(event)
241 assert get_suggestion().text == "_a"
242
243 down(event)
244 assert get_suggestion().text == "_b"
245
246 down(event)
247 assert get_suggestion().text == "_c"
248
249 down(event)
250 assert get_suggestion().text == "_a"
251
252
253 async def test_navigable_provider_multiline_entries():
254 provider = NavigableAutoSuggestFromHistory()
255 history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
256 buffer = Buffer(history=history)
257
258 async for _ in history.load():
259 pass
260
261 buffer.cursor_position = 5
262 buffer.text = "very"
263 up = swap_autosuggestion_up(provider)
264 down = swap_autosuggestion_down(provider)
265
266 event = Mock()
267 event.current_buffer = buffer
268
269 def get_suggestion():
270 suggestion = provider.get_suggestion(buffer, buffer.document)
271 buffer.suggestion = suggestion
272 return suggestion
273
274 assert get_suggestion().text == "_c"
275
276 up(event)
277 assert get_suggestion().text == "_b"
278
279 up(event)
280 assert get_suggestion().text == "_a"
281
282 down(event)
283 assert get_suggestion().text == "_b"
284
285 down(event)
286 assert get_suggestion().text == "_c"
287
288
289 def create_session_mock():
290 session = Mock()
291 session.default_buffer = Buffer()
292 return session
293
294
295 def test_navigable_provider_connection():
296 provider = NavigableAutoSuggestFromHistory()
297 provider.skip_lines = 1
298
299 session_1 = create_session_mock()
300 provider.connect(session_1)
301
302 assert provider.skip_lines == 1
303 session_1.default_buffer.on_text_insert.fire()
304 assert provider.skip_lines == 0
305
306 session_2 = create_session_mock()
307 provider.connect(session_2)
308 provider.skip_lines = 2
309
310 assert provider.skip_lines == 2
311 session_2.default_buffer.on_text_insert.fire()
312 assert provider.skip_lines == 0
313
314 provider.skip_lines = 3
315 provider.disconnect()
316 session_1.default_buffer.on_text_insert.fire()
317 session_2.default_buffer.on_text_insert.fire()
318 assert provider.skip_lines == 3
@@ -0,0 +1,7 b''
1 /*
2 Needed to revert problematic lack of wrapping in sphinx_rtd_theme, see:
3 https://github.com/readthedocs/sphinx_rtd_theme/issues/117
4 */
5 .wy-table-responsive table.shortcuts td, .wy-table-responsive table.shortcuts th {
6 white-space: normal!important;
7 }
@@ -29,11 +29,13 b' jobs:'
29 pip install mypy pyflakes flake8
29 pip install mypy pyflakes flake8
30 - name: Lint with mypy
30 - name: Lint with mypy
31 run: |
31 run: |
32 set -e
32 mypy -p IPython.terminal
33 mypy -p IPython.terminal
33 mypy -p IPython.core.magics
34 mypy -p IPython.core.magics
34 mypy -p IPython.core.guarded_eval
35 mypy -p IPython.core.guarded_eval
35 mypy -p IPython.core.completer
36 mypy -p IPython.core.completer
36 - name: Lint with pyflakes
37 - name: Lint with pyflakes
37 run: |
38 run: |
39 set -e
38 flake8 IPython/core/magics/script.py
40 flake8 IPython/core/magics/script.py
39 flake8 IPython/core/magics/packaging.py
41 flake8 IPython/core/magics/packaging.py
@@ -123,9 +123,8 b' class ProfileAwareConfigLoader(PyFileConfigLoader):'
123 return super(ProfileAwareConfigLoader, self).load_subconfig(fname, path=path)
123 return super(ProfileAwareConfigLoader, self).load_subconfig(fname, path=path)
124
124
125 class BaseIPythonApplication(Application):
125 class BaseIPythonApplication(Application):
126
126 name = "ipython"
127 name = u'ipython'
127 description = "IPython: an enhanced interactive Python shell."
128 description = Unicode(u'IPython: an enhanced interactive Python shell.')
129 version = Unicode(release.version)
128 version = Unicode(release.version)
130
129
131 aliases = base_aliases
130 aliases = base_aliases
@@ -4,6 +4,7 b' import asyncio'
4 import os
4 import os
5 import sys
5 import sys
6 from warnings import warn
6 from warnings import warn
7 from typing import Union as UnionType
7
8
8 from IPython.core.async_helpers import get_asyncio_loop
9 from IPython.core.async_helpers import get_asyncio_loop
9 from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC
10 from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC
@@ -49,6 +50,10 b' from .pt_inputhooks import get_inputhook_name_and_func'
49 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
50 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
50 from .ptutils import IPythonPTCompleter, IPythonPTLexer
51 from .ptutils import IPythonPTCompleter, IPythonPTLexer
51 from .shortcuts import create_ipython_shortcuts
52 from .shortcuts import create_ipython_shortcuts
53 from .shortcuts.auto_suggest import (
54 NavigableAutoSuggestFromHistory,
55 AppendAutoSuggestionInAnyLine,
56 )
52
57
53 PTK3 = ptk_version.startswith('3.')
58 PTK3 = ptk_version.startswith('3.')
54
59
@@ -142,6 +147,10 b' class PtkHistoryAdapter(History):'
142
147
143 """
148 """
144
149
150 auto_suggest: UnionType[
151 AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None
152 ]
153
145 def __init__(self, shell):
154 def __init__(self, shell):
146 super().__init__()
155 super().__init__()
147 self.shell = shell
156 self.shell = shell
@@ -183,7 +192,7 b' class TerminalInteractiveShell(InteractiveShell):'
183 'menus, decrease for short and wide.'
192 'menus, decrease for short and wide.'
184 ).tag(config=True)
193 ).tag(config=True)
185
194
186 pt_app = None
195 pt_app: UnionType[PromptSession, None] = None
187 debugger_history = None
196 debugger_history = None
188
197
189 debugger_history_file = Unicode(
198 debugger_history_file = Unicode(
@@ -376,18 +385,27 b' class TerminalInteractiveShell(InteractiveShell):'
376 ).tag(config=True)
385 ).tag(config=True)
377
386
378 autosuggestions_provider = Unicode(
387 autosuggestions_provider = Unicode(
379 "AutoSuggestFromHistory",
388 "NavigableAutoSuggestFromHistory",
380 help="Specifies from which source automatic suggestions are provided. "
389 help="Specifies from which source automatic suggestions are provided. "
381 "Can be set to `'AutoSuggestFromHistory`' or `None` to disable"
390 "Can be set to ``'NavigableAutoSuggestFromHistory'`` (:kbd:`up` and "
382 "automatic suggestions. Default is `'AutoSuggestFromHistory`'.",
391 ":kbd:`down` swap suggestions), ``'AutoSuggestFromHistory'``, "
392 " or ``None`` to disable automatic suggestions. "
393 "Default is `'NavigableAutoSuggestFromHistory`'.",
383 allow_none=True,
394 allow_none=True,
384 ).tag(config=True)
395 ).tag(config=True)
385
396
386 def _set_autosuggestions(self, provider):
397 def _set_autosuggestions(self, provider):
398 # disconnect old handler
399 if self.auto_suggest and isinstance(
400 self.auto_suggest, NavigableAutoSuggestFromHistory
401 ):
402 self.auto_suggest.disconnect()
387 if provider is None:
403 if provider is None:
388 self.auto_suggest = None
404 self.auto_suggest = None
389 elif provider == "AutoSuggestFromHistory":
405 elif provider == "AutoSuggestFromHistory":
390 self.auto_suggest = AutoSuggestFromHistory()
406 self.auto_suggest = AutoSuggestFromHistory()
407 elif provider == "NavigableAutoSuggestFromHistory":
408 self.auto_suggest = NavigableAutoSuggestFromHistory()
391 else:
409 else:
392 raise ValueError("No valid provider.")
410 raise ValueError("No valid provider.")
393 if self.pt_app:
411 if self.pt_app:
@@ -462,6 +480,8 b' class TerminalInteractiveShell(InteractiveShell):'
462 tempfile_suffix=".py",
480 tempfile_suffix=".py",
463 **self._extra_prompt_options()
481 **self._extra_prompt_options()
464 )
482 )
483 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
484 self.auto_suggest.connect(self.pt_app)
465
485
466 def _make_style_from_name_or_cls(self, name_or_cls):
486 def _make_style_from_name_or_cls(self, name_or_cls):
467 """
487 """
@@ -560,23 +580,39 b' class TerminalInteractiveShell(InteractiveShell):'
560 get_message = get_message()
580 get_message = get_message()
561
581
562 options = {
582 options = {
563 'complete_in_thread': False,
583 "complete_in_thread": False,
564 'lexer':IPythonPTLexer(),
584 "lexer": IPythonPTLexer(),
565 'reserve_space_for_menu':self.space_for_menu,
585 "reserve_space_for_menu": self.space_for_menu,
566 'message': get_message,
586 "message": get_message,
567 'prompt_continuation': (
587 "prompt_continuation": (
568 lambda width, lineno, is_soft_wrap:
588 lambda width, lineno, is_soft_wrap: PygmentsTokens(
569 PygmentsTokens(self.prompts.continuation_prompt_tokens(width))),
589 self.prompts.continuation_prompt_tokens(width)
570 'multiline': True,
590 )
571 'complete_style': self.pt_complete_style,
591 ),
572
592 "multiline": True,
593 "complete_style": self.pt_complete_style,
594 "input_processors": [
573 # Highlight matching brackets, but only when this setting is
595 # Highlight matching brackets, but only when this setting is
574 # enabled, and only when the DEFAULT_BUFFER has the focus.
596 # enabled, and only when the DEFAULT_BUFFER has the focus.
575 'input_processors': [ConditionalProcessor(
597 ConditionalProcessor(
576 processor=HighlightMatchingBracketProcessor(chars='[](){}'),
598 processor=HighlightMatchingBracketProcessor(chars="[](){}"),
577 filter=HasFocus(DEFAULT_BUFFER) & ~IsDone() &
599 filter=HasFocus(DEFAULT_BUFFER)
578 Condition(lambda: self.highlight_matching_brackets))],
600 & ~IsDone()
579 }
601 & Condition(lambda: self.highlight_matching_brackets),
602 ),
603 # Show auto-suggestion in lines other than the last line.
604 ConditionalProcessor(
605 processor=AppendAutoSuggestionInAnyLine(),
606 filter=HasFocus(DEFAULT_BUFFER)
607 & ~IsDone()
608 & Condition(
609 lambda: isinstance(
610 self.auto_suggest, NavigableAutoSuggestFromHistory
611 )
612 ),
613 ),
614 ],
615 }
580 if not PTK3:
616 if not PTK3:
581 options['inputhook'] = self.inputhook
617 options['inputhook'] = self.inputhook
582
618
@@ -647,8 +683,9 b' class TerminalInteractiveShell(InteractiveShell):'
647 self.alias_manager.soft_define_alias(cmd, cmd)
683 self.alias_manager.soft_define_alias(cmd, cmd)
648
684
649
685
650 def __init__(self, *args, **kwargs):
686 def __init__(self, *args, **kwargs) -> None:
651 super(TerminalInteractiveShell, self).__init__(*args, **kwargs)
687 super(TerminalInteractiveShell, self).__init__(*args, **kwargs)
688 self.auto_suggest = None
652 self._set_autosuggestions(self.autosuggestions_provider)
689 self._set_autosuggestions(self.autosuggestions_provider)
653 self.init_prompt_toolkit_cli()
690 self.init_prompt_toolkit_cli()
654 self.init_term_title()
691 self.init_term_title()
@@ -156,7 +156,7 b" frontend_flags['i'] = ("
156 flags.update(frontend_flags)
156 flags.update(frontend_flags)
157
157
158 aliases = dict(base_aliases)
158 aliases = dict(base_aliases)
159 aliases.update(shell_aliases)
159 aliases.update(shell_aliases) # type: ignore[arg-type]
160
160
161 #-----------------------------------------------------------------------------
161 #-----------------------------------------------------------------------------
162 # Main classes and functions
162 # Main classes and functions
@@ -180,7 +180,7 b' class LocateIPythonApp(BaseIPythonApplication):'
180 class TerminalIPythonApp(BaseIPythonApplication, InteractiveShellApp):
180 class TerminalIPythonApp(BaseIPythonApplication, InteractiveShellApp):
181 name = u'ipython'
181 name = u'ipython'
182 description = usage.cl_usage
182 description = usage.cl_usage
183 crash_handler_class = IPAppCrashHandler
183 crash_handler_class = IPAppCrashHandler # typing: ignore[assignment]
184 examples = _examples
184 examples = _examples
185
185
186 flags = flags
186 flags = flags
@@ -1,45 +1,98 b''
1 from dataclasses import dataclass
2 from inspect import getsource
1 from pathlib import Path
3 from pathlib import Path
4 from typing import cast, Callable, List, Union
5 from html import escape as html_escape
6 import re
7
8 from prompt_toolkit.keys import KEY_ALIASES
9 from prompt_toolkit.key_binding import KeyBindingsBase
10 from prompt_toolkit.filters import Filter, Condition
11 from prompt_toolkit.shortcuts import PromptSession
2
12
3 from IPython.terminal.shortcuts import create_ipython_shortcuts
13 from IPython.terminal.shortcuts import create_ipython_shortcuts
4
14
5 def name(c):
6 s = c.__class__.__name__
7 if s == '_Invert':
8 return '(Not: %s)' % name(c.filter)
9 if s in log_filters.keys():
10 return '(%s: %s)' % (log_filters[s], ', '.join(name(x) for x in c.filters))
11 return log_filters[s] if s in log_filters.keys() else s
12
15
16 @dataclass
17 class Shortcut:
18 #: a sequence of keys (each element on the list corresponds to pressing one or more keys)
19 keys_sequence: list[str]
20 filter: str
13
21
14 def sentencize(s):
15 """Extract first sentence
16 """
17 s = s.replace('\n', ' ').strip().split('.')
18 s = s[0] if len(s) else s
19 try:
20 return " ".join(s.split())
21 except AttributeError:
22 return s
23
22
23 @dataclass
24 class Handler:
25 description: str
26 identifier: str
24
27
25 def most_common(lst, n=3):
26 """Most common elements occurring more then `n` times
27 """
28 from collections import Counter
29
28
30 c = Counter(lst)
29 @dataclass
31 return [k for (k, v) in c.items() if k and v > n]
30 class Binding:
31 handler: Handler
32 shortcut: Shortcut
32
33
33
34
34 def multi_filter_str(flt):
35 class _NestedFilter(Filter):
35 """Yield readable conditional filter
36 """Protocol reflecting non-public prompt_toolkit's `_AndList` and `_OrList`."""
36 """
37
37 assert hasattr(flt, 'filters'), 'Conditional filter required'
38 filters: List[Filter]
38 yield name(flt)
39
40
41 class _Invert(Filter):
42 """Protocol reflecting non-public prompt_toolkit's `_Invert`."""
43
44 filter: Filter
45
46
47 conjunctions_labels = {"_AndList": "and", "_OrList": "or"}
39
48
49 ATOMIC_CLASSES = {"Never", "Always", "Condition"}
50
51
52 def format_filter(
53 filter_: Union[Filter, _NestedFilter, Condition, _Invert],
54 is_top_level=True,
55 skip=None,
56 ) -> str:
57 """Create easily readable description of the filter."""
58 s = filter_.__class__.__name__
59 if s == "Condition":
60 func = cast(Condition, filter_).func
61 name = func.__name__
62 if name == "<lambda>":
63 source = getsource(func)
64 return source.split("=")[0].strip()
65 return func.__name__
66 elif s == "_Invert":
67 operand = cast(_Invert, filter_).filter
68 if operand.__class__.__name__ in ATOMIC_CLASSES:
69 return f"not {format_filter(operand, is_top_level=False)}"
70 return f"not ({format_filter(operand, is_top_level=False)})"
71 elif s in conjunctions_labels:
72 filters = cast(_NestedFilter, filter_).filters
73 conjunction = conjunctions_labels[s]
74 glue = f" {conjunction} "
75 result = glue.join(format_filter(x, is_top_level=False) for x in filters)
76 if len(filters) > 1 and not is_top_level:
77 result = f"({result})"
78 return result
79 elif s in ["Never", "Always"]:
80 return s.lower()
81 else:
82 raise ValueError(f"Unknown filter type: {filter_}")
83
84
85 def sentencize(s) -> str:
86 """Extract first sentence"""
87 s = re.split(r"\.\W", s.replace("\n", " ").strip())
88 s = s[0] if len(s) else ""
89 if not s.endswith("."):
90 s += "."
91 try:
92 return " ".join(s.split())
93 except AttributeError:
94 return s
40
95
41 log_filters = {'_AndList': 'And', '_OrList': 'Or'}
42 log_invert = {'_Invert'}
43
96
44 class _DummyTerminal:
97 class _DummyTerminal:
45 """Used as a buffer to get prompt_toolkit bindings
98 """Used as a buffer to get prompt_toolkit bindings
@@ -48,49 +101,121 b' class _DummyTerminal:'
48 input_transformer_manager = None
101 input_transformer_manager = None
49 display_completions = None
102 display_completions = None
50 editing_mode = "emacs"
103 editing_mode = "emacs"
104 auto_suggest = None
51
105
52
106
53 ipy_bindings = create_ipython_shortcuts(_DummyTerminal()).bindings
107 def create_identifier(handler: Callable):
54
108 parts = handler.__module__.split(".")
55 dummy_docs = [] # ignore bindings without proper documentation
109 name = handler.__name__
56
110 package = parts[0]
57 common_docs = most_common([kb.handler.__doc__ for kb in ipy_bindings])
111 if len(parts) > 1:
58 if common_docs:
112 final_module = parts[-1]
59 dummy_docs.extend(common_docs)
113 return f"{package}:{final_module}.{name}"
114 else:
115 return f"{package}:{name}"
116
117
118 def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]:
119 """Collect bindings to a simple format that does not depend on prompt-toolkit internals"""
120 bindings: List[Binding] = []
121
122 for kb in prompt_bindings.bindings:
123 bindings.append(
124 Binding(
125 handler=Handler(
126 description=kb.handler.__doc__ or "",
127 identifier=create_identifier(kb.handler),
128 ),
129 shortcut=Shortcut(
130 keys_sequence=[
131 str(k.value) if hasattr(k, "value") else k for k in kb.keys
132 ],
133 filter=format_filter(kb.filter, skip={"has_focus_filter"}),
134 ),
135 )
136 )
137 return bindings
138
139
140 INDISTINGUISHABLE_KEYS = {**KEY_ALIASES, **{v: k for k, v in KEY_ALIASES.items()}}
141
142
143 def format_prompt_keys(keys: str, add_alternatives=True) -> str:
144 """Format prompt toolkit key with modifier into an RST representation."""
145
146 def to_rst(key):
147 escaped = key.replace("\\", "\\\\")
148 return f":kbd:`{escaped}`"
149
150 keys_to_press: list[str]
151
152 prefixes = {
153 "c-s-": [to_rst("ctrl"), to_rst("shift")],
154 "s-c-": [to_rst("ctrl"), to_rst("shift")],
155 "c-": [to_rst("ctrl")],
156 "s-": [to_rst("shift")],
157 }
158
159 for prefix, modifiers in prefixes.items():
160 if keys.startswith(prefix):
161 remainder = keys[len(prefix) :]
162 keys_to_press = [*modifiers, to_rst(remainder)]
163 break
164 else:
165 keys_to_press = [to_rst(keys)]
60
166
61 dummy_docs = list(set(dummy_docs))
167 result = " + ".join(keys_to_press)
62
168
63 single_filter = {}
169 if keys in INDISTINGUISHABLE_KEYS and add_alternatives:
64 multi_filter = {}
170 alternative = INDISTINGUISHABLE_KEYS[keys]
65 for kb in ipy_bindings:
66 doc = kb.handler.__doc__
67 if not doc or doc in dummy_docs:
68 continue
69
171
70 shortcut = ' '.join([k if isinstance(k, str) else k.name for k in kb.keys])
172 result = (
71 shortcut += shortcut.endswith('\\') and '\\' or ''
173 result
72 if hasattr(kb.filter, 'filters'):
174 + " (or "
73 flt = ' '.join(multi_filter_str(kb.filter))
175 + format_prompt_keys(alternative, add_alternatives=False)
74 multi_filter[(shortcut, flt)] = sentencize(doc)
176 + ")"
75 else:
177 )
76 single_filter[(shortcut, name(kb.filter))] = sentencize(doc)
77
178
179 return result
78
180
79 if __name__ == '__main__':
181 if __name__ == '__main__':
80 here = Path(__file__).parent
182 here = Path(__file__).parent
81 dest = here / "source" / "config" / "shortcuts"
183 dest = here / "source" / "config" / "shortcuts"
82
184
83 def sort_key(item):
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True)
84 k, v = item
186
85 shortcut, flt = k
187 session = PromptSession(key_bindings=ipy_bindings)
86 return (str(shortcut), str(flt))
188 prompt_bindings = session.app.key_bindings
87
189
88 for filters, output_filename in [
190 assert prompt_bindings
89 (single_filter, "single_filtered"),
191 # Ensure that we collected the default shortcuts
90 (multi_filter, "multi_filtered"),
192 assert len(prompt_bindings.bindings) > len(ipy_bindings.bindings)
91 ]:
193
92 with (dest / "{}.csv".format(output_filename)).open(
194 bindings = bindings_from_prompt_toolkit(prompt_bindings)
93 "w", encoding="utf-8"
195
94 ) as csv:
196 def sort_key(binding: Binding):
95 for (shortcut, flt), v in sorted(filters.items(), key=sort_key):
197 return binding.handler.identifier, binding.shortcut.filter
96 csv.write(":kbd:`{}`\t{}\t{}\n".format(shortcut, flt, v))
198
199 filters = []
200 with (dest / "table.tsv").open("w", encoding="utf-8") as csv:
201 for binding in sorted(bindings, key=sort_key):
202 sequence = ", ".join(
203 [format_prompt_keys(keys) for keys in binding.shortcut.keys_sequence]
204 )
205 if binding.shortcut.filter == "always":
206 condition_label = "-"
207 else:
208 # we cannot fit all the columns as the filters got too complex over time
209 condition_label = "ⓘ"
210
211 csv.write(
212 "\t".join(
213 [
214 sequence,
215 sentencize(binding.handler.description)
216 + f" :raw-html:`<br>` `{binding.handler.identifier}`",
217 f':raw-html:`<span title="{html_escape(binding.shortcut.filter)}" style="cursor: help">{condition_label}</span>`',
218 ]
219 )
220 + "\n"
221 )
@@ -211,7 +211,6 b" default_role = 'literal'"
211 # given in html_static_path.
211 # given in html_static_path.
212 # html_style = 'default.css'
212 # html_style = 'default.css'
213
213
214
215 # The name for this set of Sphinx documents. If None, it defaults to
214 # The name for this set of Sphinx documents. If None, it defaults to
216 # "<project> v<release> documentation".
215 # "<project> v<release> documentation".
217 #html_title = None
216 #html_title = None
@@ -327,6 +326,10 b' texinfo_documents = ['
327 modindex_common_prefix = ['IPython.']
326 modindex_common_prefix = ['IPython.']
328
327
329
328
329 def setup(app):
330 app.add_css_file("theme_overrides.css")
331
332
330 # Cleanup
333 # Cleanup
331 # -------
334 # -------
332 # delete release info to avoid pickling errors from sphinx
335 # delete release info to avoid pickling errors from sphinx
@@ -4,28 +4,23 b' IPython shortcuts'
4
4
5 Available shortcuts in an IPython terminal.
5 Available shortcuts in an IPython terminal.
6
6
7 .. warning::
7 .. note::
8
8
9 This list is automatically generated, and may not hold all available
9 This list is automatically generated. Key bindings defined in ``prompt_toolkit`` may differ
10 shortcuts. In particular, it may depend on the version of ``prompt_toolkit``
10 between installations depending on the ``prompt_toolkit`` version.
11 installed during the generation of this page.
12
11
13
12
14 Single Filtered shortcuts
13 * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession.
15 =========================
14 * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously.
16
15 * Hover over the ⓘ icon in the filter column to see when the shortcut is active.g
17 .. csv-table::
18 :header: Shortcut,Filter,Description
19 :widths: 30, 30, 100
20 :delim: tab
21 :file: single_filtered.csv
22
16
17 .. role:: raw-html(raw)
18 :format: html
23
19
24 Multi Filtered shortcuts
25 ========================
26
20
27 .. csv-table::
21 .. csv-table::
28 :header: Shortcut,Filter,Description
22 :header: Shortcut,Description and identifier,Filter
29 :widths: 30, 30, 100
30 :delim: tab
23 :delim: tab
31 :file: multi_filtered.csv
24 :class: shortcuts
25 :file: table.tsv
26 :widths: 20 75 5
@@ -37,7 +37,7 b' install_requires ='
37 matplotlib-inline
37 matplotlib-inline
38 pexpect>4.3; sys_platform != "win32"
38 pexpect>4.3; sys_platform != "win32"
39 pickleshare
39 pickleshare
40 prompt_toolkit>=3.0.11,<3.1.0
40 prompt_toolkit>=3.0.30,<3.1.0
41 pygments>=2.4.0
41 pygments>=2.4.0
42 stack_data
42 stack_data
43 traitlets>=5
43 traitlets>=5
1 NO CONTENT: file was removed
NO CONTENT: file was removed
This diff has been collapsed as it changes many lines, (608 lines changed) Show them Hide them
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now