##// END OF EJS Templates
Merge branch 'main' into create_app_session_for_debugger_prompt
Maor Kleinberger -
r28087:23d5d48f merge
parent child Browse files
Show More
This diff has been collapsed as it changes many lines, (670 lines changed) Show them Hide them
@@ -0,0 +1,670 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(
367 "escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER) & emacs_insert_mode
368 )(auto_suggest.discard)
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 return kb
455
456
457 def reformat_text_before_cursor(buffer, document, shell):
458 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
459 try:
460 formatted_text = shell.reformat_handler(text)
461 buffer.insert_text(formatted_text)
462 except Exception as e:
463 buffer.insert_text(text)
464
465
466 def newline_or_execute_outer(shell):
467 def newline_or_execute(event):
468 """When the user presses return, insert a newline or execute the code."""
469 b = event.current_buffer
470 d = b.document
471
472 if b.complete_state:
473 cc = b.complete_state.current_completion
474 if cc:
475 b.apply_completion(cc)
476 else:
477 b.cancel_completion()
478 return
479
480 # If there's only one line, treat it as if the cursor is at the end.
481 # See https://github.com/ipython/ipython/issues/10425
482 if d.line_count == 1:
483 check_text = d.text
484 else:
485 check_text = d.text[: d.cursor_position]
486 status, indent = shell.check_complete(check_text)
487
488 # if all we have after the cursor is whitespace: reformat current text
489 # before cursor
490 after_cursor = d.text[d.cursor_position :]
491 reformatted = False
492 if not after_cursor.strip():
493 reformat_text_before_cursor(b, d, shell)
494 reformatted = True
495 if not (
496 d.on_last_line
497 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
498 ):
499 if shell.autoindent:
500 b.insert_text("\n" + indent)
501 else:
502 b.insert_text("\n")
503 return
504
505 if (status != "incomplete") and b.accept_handler:
506 if not reformatted:
507 reformat_text_before_cursor(b, d, shell)
508 b.validate_and_handle()
509 else:
510 if shell.autoindent:
511 b.insert_text("\n" + indent)
512 else:
513 b.insert_text("\n")
514
515 newline_or_execute.__qualname__ = "newline_or_execute"
516
517 return newline_or_execute
518
519
520 def previous_history_or_previous_completion(event):
521 """
522 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
523
524 If completer is open this still select previous completion.
525 """
526 event.current_buffer.auto_up()
527
528
529 def next_history_or_next_completion(event):
530 """
531 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
532
533 If completer is open this still select next completion.
534 """
535 event.current_buffer.auto_down()
536
537
538 def dismiss_completion(event):
539 """Dismiss completion"""
540 b = event.current_buffer
541 if b.complete_state:
542 b.cancel_completion()
543
544
545 def reset_buffer(event):
546 """Reset buffer"""
547 b = event.current_buffer
548 if b.complete_state:
549 b.cancel_completion()
550 else:
551 b.reset()
552
553
554 def reset_search_buffer(event):
555 """Reset search buffer"""
556 if event.current_buffer.document.text:
557 event.current_buffer.reset()
558 else:
559 event.app.layout.focus(DEFAULT_BUFFER)
560
561
562 def suspend_to_bg(event):
563 """Suspend to background"""
564 event.app.suspend_to_background()
565
566
567 def quit(event):
568 """
569 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
570
571 On platforms that support SIGQUIT, send SIGQUIT to the current process.
572 On other platforms, just exit the process with a message.
573 """
574 sigquit = getattr(signal, "SIGQUIT", None)
575 if sigquit is not None:
576 os.kill(0, signal.SIGQUIT)
577 else:
578 sys.exit("Quit")
579
580
581 def indent_buffer(event):
582 """Indent buffer"""
583 event.current_buffer.insert_text(" " * 4)
584
585
586 @undoc
587 def newline_with_copy_margin(event):
588 """
589 DEPRECATED since IPython 6.0
590
591 See :any:`newline_autoindent_outer` for a replacement.
592
593 Preserve margin and cursor position when using
594 Control-O to insert a newline in EMACS mode
595 """
596 warnings.warn(
597 "`newline_with_copy_margin(event)` is deprecated since IPython 6.0. "
598 "see `newline_autoindent_outer(shell)(event)` for a replacement.",
599 DeprecationWarning,
600 stacklevel=2,
601 )
602
603 b = event.current_buffer
604 cursor_start_pos = b.document.cursor_position_col
605 b.newline(copy_margin=True)
606 b.cursor_up(count=1)
607 cursor_end_pos = b.document.cursor_position_col
608 if cursor_start_pos != cursor_end_pos:
609 pos_diff = cursor_start_pos - cursor_end_pos
610 b.cursor_right(count=pos_diff)
611
612
613 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
614 """
615 Return a function suitable for inserting a indented newline after the cursor.
616
617 Fancier version of deprecated ``newline_with_copy_margin`` which should
618 compute the correct indentation of the inserted line. That is to say, indent
619 by 4 extra space after a function definition, class definition, context
620 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
621 """
622
623 def newline_autoindent(event):
624 """Insert a newline after the cursor indented appropriately."""
625 b = event.current_buffer
626 d = b.document
627
628 if b.complete_state:
629 b.cancel_completion()
630 text = d.text[: d.cursor_position] + "\n"
631 _, indent = inputsplitter.check_complete(text)
632 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
633
634 newline_autoindent.__qualname__ = "newline_autoindent"
635
636 return newline_autoindent
637
638
639 def open_input_in_editor(event):
640 """Open code from input in external editor"""
641 event.app.current_buffer.open_in_editor()
642
643
644 if sys.platform == "win32":
645 from IPython.core.error import TryNext
646 from IPython.lib.clipboard import (
647 ClipboardEmpty,
648 tkinter_clipboard_get,
649 win32_clipboard_get,
650 )
651
652 @undoc
653 def win_paste(event):
654 try:
655 text = win32_clipboard_get()
656 except TryNext:
657 try:
658 text = tkinter_clipboard_get()
659 except (TryNext, ClipboardEmpty):
660 return
661 except ClipboardEmpty:
662 return
663 event.current_buffer.insert_text(text.replace("\t", " " * 4))
664
665 else:
666
667 @undoc
668 def win_paste(event):
669 """Stub used when auto-generating shortcuts for documentation"""
670 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,378 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 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
79
80 def get_suggestion(
81 self, buffer: Buffer, document: Document
82 ) -> Optional[Suggestion]:
83 text = _get_query(document)
84
85 if text.strip():
86 for suggestion, _ in self._find_next_match(
87 text, self.skip_lines, buffer.history
88 ):
89 return Suggestion(suggestion)
90
91 return None
92
93 def _dismiss(self, buffer, *args, **kwargs):
94 buffer.suggestion = None
95
96 def _find_match(
97 self, text: str, skip_lines: float, history: History, previous: bool
98 ) -> Generator[Tuple[str, float], None, None]:
99 """
100 text : str
101 Text content to find a match for, the user cursor is most of the
102 time at the end of this text.
103 skip_lines : float
104 number of items to skip in the search, this is used to indicate how
105 far in the list the user has navigated by pressing up or down.
106 The float type is used as the base value is +inf
107 history : History
108 prompt_toolkit History instance to fetch previous entries from.
109 previous : bool
110 Direction of the search, whether we are looking previous match
111 (True), or next match (False).
112
113 Yields
114 ------
115 Tuple with:
116 str:
117 current suggestion.
118 float:
119 will actually yield only ints, which is passed back via skip_lines,
120 which may be a +inf (float)
121
122
123 """
124 line_number = -1
125 for string in reversed(list(history.get_strings())):
126 for line in reversed(string.splitlines()):
127 line_number += 1
128 if not previous and line_number < skip_lines:
129 continue
130 # do not return empty suggestions as these
131 # close the auto-suggestion overlay (and are useless)
132 if line.startswith(text) and len(line) > len(text):
133 yield line[len(text) :], line_number
134 if previous and line_number >= skip_lines:
135 return
136
137 def _find_next_match(
138 self, text: str, skip_lines: float, history: History
139 ) -> Generator[Tuple[str, float], None, None]:
140 return self._find_match(text, skip_lines, history, previous=False)
141
142 def _find_previous_match(self, text: str, skip_lines: float, history: History):
143 return reversed(
144 list(self._find_match(text, skip_lines, history, previous=True))
145 )
146
147 def up(self, query: str, other_than: str, history: History) -> None:
148 for suggestion, line_number in self._find_next_match(
149 query, self.skip_lines, history
150 ):
151 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
152 # we want to switch from 'very.b' to 'very.a' because a) if the
153 # suggestion equals current text, prompt-toolkit aborts suggesting
154 # b) user likely would not be interested in 'very' anyways (they
155 # already typed it).
156 if query + suggestion != other_than:
157 self.skip_lines = line_number
158 break
159 else:
160 # no matches found, cycle back to beginning
161 self.skip_lines = 0
162
163 def down(self, query: str, other_than: str, history: History) -> None:
164 for suggestion, line_number in self._find_previous_match(
165 query, self.skip_lines, history
166 ):
167 if query + suggestion != other_than:
168 self.skip_lines = line_number
169 break
170 else:
171 # no matches found, cycle to end
172 for suggestion, line_number in self._find_previous_match(
173 query, float("Inf"), history
174 ):
175 if query + suggestion != other_than:
176 self.skip_lines = line_number
177 break
178
179
180 # Needed for to accept autosuggestions in vi insert mode
181 def accept_in_vi_insert_mode(event: KeyPressEvent):
182 """Apply autosuggestion if at end of line."""
183 buffer = event.current_buffer
184 d = buffer.document
185 after_cursor = d.text[d.cursor_position :]
186 lines = after_cursor.split("\n")
187 end_of_current_line = lines[0].strip()
188 suggestion = buffer.suggestion
189 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
190 buffer.insert_text(suggestion.text)
191 else:
192 nc.end_of_line(event)
193
194
195 def accept(event: KeyPressEvent):
196 """Accept autosuggestion"""
197 buffer = event.current_buffer
198 suggestion = buffer.suggestion
199 if suggestion:
200 buffer.insert_text(suggestion.text)
201 else:
202 nc.forward_char(event)
203
204
205 def discard(event: KeyPressEvent):
206 """Discard autosuggestion"""
207 buffer = event.current_buffer
208 buffer.suggestion = None
209
210
211 def accept_word(event: KeyPressEvent):
212 """Fill partial autosuggestion by word"""
213 buffer = event.current_buffer
214 suggestion = buffer.suggestion
215 if suggestion:
216 t = re.split(r"(\S+\s+)", suggestion.text)
217 buffer.insert_text(next((x for x in t if x), ""))
218 else:
219 nc.forward_word(event)
220
221
222 def accept_character(event: KeyPressEvent):
223 """Fill partial autosuggestion by character"""
224 b = event.current_buffer
225 suggestion = b.suggestion
226 if suggestion and suggestion.text:
227 b.insert_text(suggestion.text[0])
228
229
230 def accept_and_keep_cursor(event: KeyPressEvent):
231 """Accept autosuggestion and keep cursor in place"""
232 buffer = event.current_buffer
233 old_position = buffer.cursor_position
234 suggestion = buffer.suggestion
235 if suggestion:
236 buffer.insert_text(suggestion.text)
237 buffer.cursor_position = old_position
238
239
240 def accept_and_move_cursor_left(event: KeyPressEvent):
241 """Accept autosuggestion and move cursor left in place"""
242 accept_and_keep_cursor(event)
243 nc.backward_char(event)
244
245
246 def _update_hint(buffer: Buffer):
247 if buffer.auto_suggest:
248 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
249 buffer.suggestion = suggestion
250
251
252 def backspace_and_resume_hint(event: KeyPressEvent):
253 """Resume autosuggestions after deleting last character"""
254 current_buffer = event.current_buffer
255
256 def resume_hinting(buffer: Buffer):
257 _update_hint(buffer)
258 current_buffer.on_text_changed.remove_handler(resume_hinting)
259
260 current_buffer.on_text_changed.add_handler(resume_hinting)
261 nc.backward_delete_char(event)
262
263
264 def up_and_update_hint(event: KeyPressEvent):
265 """Go up and update hint"""
266 current_buffer = event.current_buffer
267
268 current_buffer.auto_up(count=event.arg)
269 _update_hint(current_buffer)
270
271
272 def down_and_update_hint(event: KeyPressEvent):
273 """Go down and update hint"""
274 current_buffer = event.current_buffer
275
276 current_buffer.auto_down(count=event.arg)
277 _update_hint(current_buffer)
278
279
280 def accept_token(event: KeyPressEvent):
281 """Fill partial autosuggestion by token"""
282 b = event.current_buffer
283 suggestion = b.suggestion
284
285 if suggestion:
286 prefix = _get_query(b.document)
287 text = prefix + suggestion.text
288
289 tokens: List[Optional[str]] = [None, None, None]
290 substrings = [""]
291 i = 0
292
293 for token in generate_tokens(StringIO(text).readline):
294 if token.type == tokenize.NEWLINE:
295 index = len(text)
296 else:
297 index = text.index(token[1], len(substrings[-1]))
298 substrings.append(text[:index])
299 tokenized_so_far = substrings[-1]
300 if tokenized_so_far.startswith(prefix):
301 if i == 0 and len(tokenized_so_far) > len(prefix):
302 tokens[0] = tokenized_so_far[len(prefix) :]
303 substrings.append(tokenized_so_far)
304 i += 1
305 tokens[i] = token[1]
306 if i == 2:
307 break
308 i += 1
309
310 if tokens[0]:
311 to_insert: str
312 insert_text = substrings[-2]
313 if tokens[1] and len(tokens[1]) == 1:
314 insert_text = substrings[-1]
315 to_insert = insert_text[len(prefix) :]
316 b.insert_text(to_insert)
317 return
318
319 nc.forward_word(event)
320
321
322 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
323
324
325 def _swap_autosuggestion(
326 buffer: Buffer,
327 provider: NavigableAutoSuggestFromHistory,
328 direction_method: Callable,
329 ):
330 """
331 We skip most recent history entry (in either direction) if it equals the
332 current autosuggestion because if user cycles when auto-suggestion is shown
333 they most likely want something else than what was suggested (otherwise
334 they would have accepted the suggestion).
335 """
336 suggestion = buffer.suggestion
337 if not suggestion:
338 return
339
340 query = _get_query(buffer.document)
341 current = query + suggestion.text
342
343 direction_method(query=query, other_than=current, history=buffer.history)
344
345 new_suggestion = provider.get_suggestion(buffer, buffer.document)
346 buffer.suggestion = new_suggestion
347
348
349 def swap_autosuggestion_up(provider: Provider):
350 def swap_autosuggestion_up(event: KeyPressEvent):
351 """Get next autosuggestion from history."""
352 if not isinstance(provider, NavigableAutoSuggestFromHistory):
353 return
354
355 return _swap_autosuggestion(
356 buffer=event.current_buffer, provider=provider, direction_method=provider.up
357 )
358
359 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
360 return swap_autosuggestion_up
361
362
363 def swap_autosuggestion_down(
364 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
365 ):
366 def swap_autosuggestion_down(event: KeyPressEvent):
367 """Get previous autosuggestion from history."""
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 return
370
371 return _swap_autosuggestion(
372 buffer=event.current_buffer,
373 provider=provider,
374 direction_method=provider.down,
375 )
376
377 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
378 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
1 NO CONTENT: new file 100644, binary diff hidden
@@ -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 29 pip install mypy pyflakes flake8
30 30 - name: Lint with mypy
31 31 run: |
32 set -e
32 33 mypy -p IPython.terminal
33 34 mypy -p IPython.core.magics
34 35 mypy -p IPython.core.guarded_eval
35 36 mypy -p IPython.core.completer
36 37 - name: Lint with pyflakes
37 38 run: |
39 set -e
38 40 flake8 IPython/core/magics/script.py
39 41 flake8 IPython/core/magics/packaging.py
@@ -123,9 +123,8 b' class ProfileAwareConfigLoader(PyFileConfigLoader):'
123 123 return super(ProfileAwareConfigLoader, self).load_subconfig(fname, path=path)
124 124
125 125 class BaseIPythonApplication(Application):
126
127 name = u'ipython'
128 description = Unicode(u'IPython: an enhanced interactive Python shell.')
126 name = "ipython"
127 description = "IPython: an enhanced interactive Python shell."
129 128 version = Unicode(release.version)
130 129
131 130 aliases = base_aliases
@@ -311,7 +310,7 b' class BaseIPythonApplication(Application):'
311 310 except OSError as e:
312 311 # this will not be EEXIST
313 312 self.log.error("couldn't create path %s: %s", path, e)
314 self.log.debug("IPYTHONDIR set to: %s" % new)
313 self.log.debug("IPYTHONDIR set to: %s", new)
315 314
316 315 def load_config_file(self, suppress_errors=IPYTHON_SUPPRESS_CONFIG_ERRORS):
317 316 """Load the config file.
@@ -401,7 +400,7 b' class BaseIPythonApplication(Application):'
401 400 self.log.fatal("Profile %r not found."%self.profile)
402 401 self.exit(1)
403 402 else:
404 self.log.debug(f"Using existing profile dir: {p.location!r}")
403 self.log.debug("Using existing profile dir: %r", p.location)
405 404 else:
406 405 location = self.config.ProfileDir.location
407 406 # location is fully specified
@@ -421,7 +420,7 b' class BaseIPythonApplication(Application):'
421 420 self.log.fatal("Profile directory %r not found."%location)
422 421 self.exit(1)
423 422 else:
424 self.log.debug(f"Using existing profile dir: {p.location!r}")
423 self.log.debug("Using existing profile dir: %r", p.location)
425 424 # if profile_dir is specified explicitly, set profile name
426 425 dir_name = os.path.basename(p.location)
427 426 if dir_name.startswith('profile_'):
@@ -468,7 +467,7 b' class BaseIPythonApplication(Application):'
468 467 s = self.generate_config_file()
469 468 config_file = Path(self.profile_dir.location) / self.config_file_name
470 469 if self.overwrite or not config_file.exists():
471 self.log.warning("Generating default config file: %r" % (config_file))
470 self.log.warning("Generating default config file: %r", (config_file))
472 471 config_file.write_text(s, encoding="utf-8")
473 472
474 473 @catch_config_error
@@ -91,7 +91,13 b' class DisplayHook(Configurable):'
91 91 # some uses of ipshellembed may fail here
92 92 return False
93 93
94 sio = _io.StringIO(cell)
94 return self.semicolon_at_end_of_expression(cell)
95
96 @staticmethod
97 def semicolon_at_end_of_expression(expression):
98 """Parse Python expression and detects whether last token is ';'"""
99
100 sio = _io.StringIO(expression)
95 101 tokens = list(tokenize.generate_tokens(sio.readline))
96 102
97 103 for token in reversed(tokens):
@@ -2367,6 +2367,14 b' class InteractiveShell(SingletonConfigurable):'
2367 2367 kwargs['local_ns'] = self.get_local_scope(stack_depth)
2368 2368 with self.builtin_trap:
2369 2369 result = fn(*args, **kwargs)
2370
2371 # The code below prevents the output from being displayed
2372 # when using magics with decodator @output_can_be_silenced
2373 # when the last Python token in the expression is a ';'.
2374 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):
2375 if DisplayHook.semicolon_at_end_of_expression(magic_arg_s):
2376 return None
2377
2370 2378 return result
2371 2379
2372 2380 def get_local_scope(self, stack_depth):
@@ -2420,6 +2428,14 b' class InteractiveShell(SingletonConfigurable):'
2420 2428 with self.builtin_trap:
2421 2429 args = (magic_arg_s, cell)
2422 2430 result = fn(*args, **kwargs)
2431
2432 # The code below prevents the output from being displayed
2433 # when using magics with decodator @output_can_be_silenced
2434 # when the last Python token in the expression is a ';'.
2435 if getattr(fn, magic.MAGIC_OUTPUT_CAN_BE_SILENCED, False):
2436 if DisplayHook.semicolon_at_end_of_expression(cell):
2437 return None
2438
2423 2439 return result
2424 2440
2425 2441 def find_line_magic(self, magic_name):
@@ -3200,6 +3216,7 b' class InteractiveShell(SingletonConfigurable):'
3200 3216 # Execute the user code
3201 3217 interactivity = "none" if silent else self.ast_node_interactivity
3202 3218
3219
3203 3220 has_raised = await self.run_ast_nodes(code_ast.body, cell_name,
3204 3221 interactivity=interactivity, compiler=compiler, result=result)
3205 3222
@@ -198,7 +198,16 b' which already exists. But you must first start the logging process with'
198 198 odata = u'\n'.join([u'#[Out]# %s' % s
199 199 for s in data.splitlines()])
200 200 write(u'%s\n' % odata)
201 try:
201 202 self.logfile.flush()
203 except OSError:
204 print("Failed to flush the log file.")
205 print(
206 f"Please check that {self.logfname} exists and have the right permissions."
207 )
208 print(
209 "Also consider turning off the log with `%logstop` to avoid this warning."
210 )
202 211
203 212 def logstop(self):
204 213 """Fully stop logging and close log file.
@@ -257,7 +257,8 b' def _function_magic_marker(magic_kind):'
257 257 return magic_deco
258 258
259 259
260 MAGIC_NO_VAR_EXPAND_ATTR = '_ipython_magic_no_var_expand'
260 MAGIC_NO_VAR_EXPAND_ATTR = "_ipython_magic_no_var_expand"
261 MAGIC_OUTPUT_CAN_BE_SILENCED = "_ipython_magic_output_can_be_silenced"
261 262
262 263
263 264 def no_var_expand(magic_func):
@@ -276,6 +277,16 b' def no_var_expand(magic_func):'
276 277 return magic_func
277 278
278 279
280 def output_can_be_silenced(magic_func):
281 """Mark a magic function so its output may be silenced.
282
283 The output is silenced if the Python code used as a parameter of
284 the magic ends in a semicolon, not counting a Python comment that can
285 follow it.
286 """
287 setattr(magic_func, MAGIC_OUTPUT_CAN_BE_SILENCED, True)
288 return magic_func
289
279 290 # Create the actual decorators for public use
280 291
281 292 # These three are used to decorate methods in class definitions
@@ -37,6 +37,7 b' from IPython.core.magic import ('
37 37 magics_class,
38 38 needs_local_scope,
39 39 no_var_expand,
40 output_can_be_silenced,
40 41 on_off,
41 42 )
42 43 from IPython.testing.skipdoctest import skip_doctest
@@ -1194,6 +1195,7 b' class ExecutionMagics(Magics):'
1194 1195 @no_var_expand
1195 1196 @needs_local_scope
1196 1197 @line_cell_magic
1198 @output_can_be_silenced
1197 1199 def time(self,line='', cell=None, local_ns=None):
1198 1200 """Time execution of a Python statement or expression.
1199 1201
@@ -468,9 +468,9 b' class OSMagics(Magics):'
468 468 string.
469 469
470 470 Usage:\\
471 %set_env var val: set value for var
472 %set_env var=val: set value for var
473 %set_env var=$val: set value for var, using python expansion if possible
471 :``%set_env var val``: set value for var
472 :``%set_env var=val``: set value for var
473 :``%set_env var=$val``: set value for var, using python expansion if possible
474 474 """
475 475 split = '=' if '=' in parameter_s else ' '
476 476 bits = parameter_s.split(split, 1)
@@ -54,7 +54,7 b' class PylabMagics(Magics):'
54 54 If you are using the inline matplotlib backend in the IPython Notebook
55 55 you can set which figure formats are enabled using the following::
56 56
57 In [1]: from IPython.display import set_matplotlib_formats
57 In [1]: from matplotlib_inline.backend_inline import set_matplotlib_formats
58 58
59 59 In [2]: set_matplotlib_formats('pdf', 'svg')
60 60
@@ -65,9 +65,9 b' class PylabMagics(Magics):'
65 65
66 66 In [3]: %config InlineBackend.print_figure_kwargs = {'bbox_inches':None}
67 67
68 In addition, see the docstring of
69 `IPython.display.set_matplotlib_formats` and
70 `IPython.display.set_matplotlib_close` for more information on
68 In addition, see the docstrings of
69 `matplotlib_inline.backend_inline.set_matplotlib_formats` and
70 `matplotlib_inline.backend_inline.set_matplotlib_close` for more information on
71 71 changing additional behaviors of the inline backend.
72 72
73 73 Examples
@@ -210,7 +210,7 b' class ScriptMagics(Magics):'
210 210
211 211 async def _handle_stream(stream, stream_arg, file_object):
212 212 while True:
213 line = (await stream.readline()).decode("utf8")
213 line = (await stream.readline()).decode("utf8", errors="replace")
214 214 if not line:
215 215 break
216 216 if stream_arg:
@@ -16,7 +16,7 b''
16 16 # release. 'dev' as a _version_extra string means this is a development
17 17 # version
18 18 _version_major = 8
19 _version_minor = 9
19 _version_minor = 10
20 20 _version_patch = 0
21 21 _version_extra = ".dev"
22 22 # _version_extra = "rc1"
@@ -278,7 +278,7 b' class InteractiveShellApp(Configurable):'
278 278 )
279 279 for ext in extensions:
280 280 try:
281 self.log.info("Loading IPython extension: %s" % ext)
281 self.log.info("Loading IPython extension: %s", ext)
282 282 self.shell.extension_manager.load_extension(ext)
283 283 except:
284 284 if self.reraise_ipython_extension_failures:
@@ -416,6 +416,65 b' def test_time():'
416 416 with tt.AssertPrints("hihi", suppress=False):
417 417 ip.run_cell("f('hi')")
418 418
419
420 # ';' at the end of %time prevents instruction value to be printed.
421 # This tests fix for #13837.
422 def test_time_no_output_with_semicolon():
423 ip = get_ipython()
424
425 # Test %time cases
426 with tt.AssertPrints(" 123456"):
427 with tt.AssertPrints("Wall time: ", suppress=False):
428 with tt.AssertPrints("CPU times: ", suppress=False):
429 ip.run_cell("%time 123000+456")
430
431 with tt.AssertNotPrints(" 123456"):
432 with tt.AssertPrints("Wall time: ", suppress=False):
433 with tt.AssertPrints("CPU times: ", suppress=False):
434 ip.run_cell("%time 123000+456;")
435
436 with tt.AssertPrints(" 123456"):
437 with tt.AssertPrints("Wall time: ", suppress=False):
438 with tt.AssertPrints("CPU times: ", suppress=False):
439 ip.run_cell("%time 123000+456 # Comment")
440
441 with tt.AssertNotPrints(" 123456"):
442 with tt.AssertPrints("Wall time: ", suppress=False):
443 with tt.AssertPrints("CPU times: ", suppress=False):
444 ip.run_cell("%time 123000+456; # Comment")
445
446 with tt.AssertPrints(" 123456"):
447 with tt.AssertPrints("Wall time: ", suppress=False):
448 with tt.AssertPrints("CPU times: ", suppress=False):
449 ip.run_cell("%time 123000+456 # ;Comment")
450
451 # Test %%time cases
452 with tt.AssertPrints("123456"):
453 with tt.AssertPrints("Wall time: ", suppress=False):
454 with tt.AssertPrints("CPU times: ", suppress=False):
455 ip.run_cell("%%time\n123000+456\n\n\n")
456
457 with tt.AssertNotPrints("123456"):
458 with tt.AssertPrints("Wall time: ", suppress=False):
459 with tt.AssertPrints("CPU times: ", suppress=False):
460 ip.run_cell("%%time\n123000+456;\n\n\n")
461
462 with tt.AssertPrints("123456"):
463 with tt.AssertPrints("Wall time: ", suppress=False):
464 with tt.AssertPrints("CPU times: ", suppress=False):
465 ip.run_cell("%%time\n123000+456 # Comment\n\n\n")
466
467 with tt.AssertNotPrints("123456"):
468 with tt.AssertPrints("Wall time: ", suppress=False):
469 with tt.AssertPrints("CPU times: ", suppress=False):
470 ip.run_cell("%%time\n123000+456; # Comment\n\n\n")
471
472 with tt.AssertPrints("123456"):
473 with tt.AssertPrints("Wall time: ", suppress=False):
474 with tt.AssertPrints("CPU times: ", suppress=False):
475 ip.run_cell("%%time\n123000+456 # ;Comment\n\n\n")
476
477
419 478 def test_time_last_not_expression():
420 479 ip.run_cell("%%time\n"
421 480 "var_1 = 1\n"
@@ -4,6 +4,7 b' import asyncio'
4 4 import os
5 5 import sys
6 6 from warnings import warn
7 from typing import Union as UnionType
7 8
8 9 from IPython.core.async_helpers import get_asyncio_loop
9 10 from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC
@@ -49,6 +50,10 b' from .pt_inputhooks import get_inputhook_name_and_func'
49 50 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
50 51 from .ptutils import IPythonPTCompleter, IPythonPTLexer
51 52 from .shortcuts import create_ipython_shortcuts
53 from .shortcuts.auto_suggest import (
54 NavigableAutoSuggestFromHistory,
55 AppendAutoSuggestionInAnyLine,
56 )
52 57
53 58 PTK3 = ptk_version.startswith('3.')
54 59
@@ -183,7 +188,10 b' class TerminalInteractiveShell(InteractiveShell):'
183 188 'menus, decrease for short and wide.'
184 189 ).tag(config=True)
185 190
186 pt_app = None
191 pt_app: UnionType[PromptSession, None] = None
192 auto_suggest: UnionType[
193 AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None
194 ] = None
187 195 debugger_history = None
188 196
189 197 debugger_history_file = Unicode(
@@ -376,18 +384,27 b' class TerminalInteractiveShell(InteractiveShell):'
376 384 ).tag(config=True)
377 385
378 386 autosuggestions_provider = Unicode(
379 "AutoSuggestFromHistory",
387 "NavigableAutoSuggestFromHistory",
380 388 help="Specifies from which source automatic suggestions are provided. "
381 "Can be set to `'AutoSuggestFromHistory`' or `None` to disable"
382 "automatic suggestions. Default is `'AutoSuggestFromHistory`'.",
389 "Can be set to ``'NavigableAutoSuggestFromHistory'`` (:kbd:`up` and "
390 ":kbd:`down` swap suggestions), ``'AutoSuggestFromHistory'``, "
391 " or ``None`` to disable automatic suggestions. "
392 "Default is `'NavigableAutoSuggestFromHistory`'.",
383 393 allow_none=True,
384 394 ).tag(config=True)
385 395
386 396 def _set_autosuggestions(self, provider):
397 # disconnect old handler
398 if self.auto_suggest and isinstance(
399 self.auto_suggest, NavigableAutoSuggestFromHistory
400 ):
401 self.auto_suggest.disconnect()
387 402 if provider is None:
388 403 self.auto_suggest = None
389 404 elif provider == "AutoSuggestFromHistory":
390 405 self.auto_suggest = AutoSuggestFromHistory()
406 elif provider == "NavigableAutoSuggestFromHistory":
407 self.auto_suggest = NavigableAutoSuggestFromHistory()
391 408 else:
392 409 raise ValueError("No valid provider.")
393 410 if self.pt_app:
@@ -462,6 +479,8 b' class TerminalInteractiveShell(InteractiveShell):'
462 479 tempfile_suffix=".py",
463 480 **self._extra_prompt_options()
464 481 )
482 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
483 self.auto_suggest.connect(self.pt_app)
465 484
466 485 def _make_style_from_name_or_cls(self, name_or_cls):
467 486 """
@@ -560,22 +579,38 b' class TerminalInteractiveShell(InteractiveShell):'
560 579 get_message = get_message()
561 580
562 581 options = {
563 'complete_in_thread': False,
564 'lexer':IPythonPTLexer(),
565 'reserve_space_for_menu':self.space_for_menu,
566 'message': get_message,
567 'prompt_continuation': (
568 lambda width, lineno, is_soft_wrap:
569 PygmentsTokens(self.prompts.continuation_prompt_tokens(width))),
570 'multiline': True,
571 'complete_style': self.pt_complete_style,
572
582 "complete_in_thread": False,
583 "lexer": IPythonPTLexer(),
584 "reserve_space_for_menu": self.space_for_menu,
585 "message": get_message,
586 "prompt_continuation": (
587 lambda width, lineno, is_soft_wrap: PygmentsTokens(
588 self.prompts.continuation_prompt_tokens(width)
589 )
590 ),
591 "multiline": True,
592 "complete_style": self.pt_complete_style,
593 "input_processors": [
573 594 # Highlight matching brackets, but only when this setting is
574 595 # enabled, and only when the DEFAULT_BUFFER has the focus.
575 'input_processors': [ConditionalProcessor(
576 processor=HighlightMatchingBracketProcessor(chars='[](){}'),
577 filter=HasFocus(DEFAULT_BUFFER) & ~IsDone() &
578 Condition(lambda: self.highlight_matching_brackets))],
596 ConditionalProcessor(
597 processor=HighlightMatchingBracketProcessor(chars="[](){}"),
598 filter=HasFocus(DEFAULT_BUFFER)
599 & ~IsDone()
600 & Condition(lambda: self.highlight_matching_brackets),
601 ),
602 # Show auto-suggestion in lines other than the last line.
603 ConditionalProcessor(
604 processor=AppendAutoSuggestionInAnyLine(),
605 filter=HasFocus(DEFAULT_BUFFER)
606 & ~IsDone()
607 & Condition(
608 lambda: isinstance(
609 self.auto_suggest, NavigableAutoSuggestFromHistory
610 )
611 ),
612 ),
613 ],
579 614 }
580 615 if not PTK3:
581 616 options['inputhook'] = self.inputhook
@@ -647,7 +682,7 b' class TerminalInteractiveShell(InteractiveShell):'
647 682 self.alias_manager.soft_define_alias(cmd, cmd)
648 683
649 684
650 def __init__(self, *args, **kwargs):
685 def __init__(self, *args, **kwargs) -> None:
651 686 super(TerminalInteractiveShell, self).__init__(*args, **kwargs)
652 687 self._set_autosuggestions(self.autosuggestions_provider)
653 688 self.init_prompt_toolkit_cli()
@@ -156,7 +156,7 b" frontend_flags['i'] = ("
156 156 flags.update(frontend_flags)
157 157
158 158 aliases = dict(base_aliases)
159 aliases.update(shell_aliases)
159 aliases.update(shell_aliases) # type: ignore[arg-type]
160 160
161 161 #-----------------------------------------------------------------------------
162 162 # Main classes and functions
@@ -180,7 +180,7 b' class LocateIPythonApp(BaseIPythonApplication):'
180 180 class TerminalIPythonApp(BaseIPythonApplication, InteractiveShellApp):
181 181 name = u'ipython'
182 182 description = usage.cl_usage
183 crash_handler_class = IPAppCrashHandler
183 crash_handler_class = IPAppCrashHandler # typing: ignore[assignment]
184 184 examples = _examples
185 185
186 186 flags = flags
@@ -7,11 +7,25 b' import sys'
7 7 import unittest
8 8 import os
9 9
10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
11
10 12 from IPython.core.inputtransformer import InputTransformer
11 13 from IPython.testing import tools as tt
12 14 from IPython.utils.capture import capture_output
13 15
14 16 from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context
17 from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory
18
19
20 class TestAutoSuggest(unittest.TestCase):
21 def test_changing_provider(self):
22 ip = get_ipython()
23 ip.autosuggestions_provider = None
24 self.assertEqual(ip.auto_suggest, None)
25 ip.autosuggestions_provider = "AutoSuggestFromHistory"
26 self.assertIsInstance(ip.auto_suggest, AutoSuggestFromHistory)
27 ip.autosuggestions_provider = "NavigableAutoSuggestFromHistory"
28 self.assertIsInstance(ip.auto_suggest, NavigableAutoSuggestFromHistory)
15 29
16 30
17 31 class TestElide(unittest.TestCase):
@@ -24,10 +38,10 b' class TestElide(unittest.TestCase):'
24 38 )
25 39
26 40 test_string = os.sep.join(["", 10 * "a", 10 * "b", 10 * "c", ""])
27 expect_stirng = (
41 expect_string = (
28 42 os.sep + "a" + "\N{HORIZONTAL ELLIPSIS}" + "b" + os.sep + 10 * "c"
29 43 )
30 self.assertEqual(_elide(test_string, ""), expect_stirng)
44 self.assertEqual(_elide(test_string, ""), expect_string)
31 45
32 46 def test_elide_typed_normal(self):
33 47 self.assertEqual(
@@ -18,11 +18,6 b' from warnings import warn'
18 18 from IPython.utils.decorators import undoc
19 19 from .capture import CapturedIO, capture_output
20 20
21 # setup stdin/stdout/stderr to sys.stdin/sys.stdout/sys.stderr
22 devnull = open(os.devnull, "w", encoding="utf-8")
23 atexit.register(devnull.close)
24
25
26 21 class Tee(object):
27 22 """A class to duplicate an output stream to stdout/err.
28 23
@@ -1,45 +1,98 b''
1 from dataclasses import dataclass
2 from inspect import getsource
1 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 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)
31 return [k for (k, v) in c.items() if k and v > n]
29 @dataclass
30 class Binding:
31 handler: Handler
32 shortcut: Shortcut
32 33
33 34
34 def multi_filter_str(flt):
35 """Yield readable conditional filter
36 """
37 assert hasattr(flt, 'filters'), 'Conditional filter required'
38 yield name(flt)
35 class _NestedFilter(Filter):
36 """Protocol reflecting non-public prompt_toolkit's `_AndList` and `_OrList`."""
37
38 filters: List[Filter]
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"}
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_}")
39 83
40 84
41 log_filters = {'_AndList': 'And', '_OrList': 'Or'}
42 log_invert = {'_Invert'}
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
95
43 96
44 97 class _DummyTerminal:
45 98 """Used as a buffer to get prompt_toolkit bindings
@@ -48,49 +101,121 b' class _DummyTerminal:'
48 101 input_transformer_manager = None
49 102 display_completions = None
50 103 editing_mode = "emacs"
104 auto_suggest = None
51 105
52 106
53 ipy_bindings = create_ipython_shortcuts(_DummyTerminal()).bindings
54
55 dummy_docs = [] # ignore bindings without proper documentation
56
57 common_docs = most_common([kb.handler.__doc__ for kb in ipy_bindings])
58 if common_docs:
59 dummy_docs.extend(common_docs)
107 def create_identifier(handler: Callable):
108 parts = handler.__module__.split(".")
109 name = handler.__name__
110 package = parts[0]
111 if len(parts) > 1:
112 final_module = parts[-1]
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 = {}
64 multi_filter = {}
65 for kb in ipy_bindings:
66 doc = kb.handler.__doc__
67 if not doc or doc in dummy_docs:
68 continue
169 if keys in INDISTINGUISHABLE_KEYS and add_alternatives:
170 alternative = INDISTINGUISHABLE_KEYS[keys]
69 171
70 shortcut = ' '.join([k if isinstance(k, str) else k.name for k in kb.keys])
71 shortcut += shortcut.endswith('\\') and '\\' or ''
72 if hasattr(kb.filter, 'filters'):
73 flt = ' '.join(multi_filter_str(kb.filter))
74 multi_filter[(shortcut, flt)] = sentencize(doc)
75 else:
76 single_filter[(shortcut, name(kb.filter))] = sentencize(doc)
172 result = (
173 result
174 + " (or "
175 + format_prompt_keys(alternative, add_alternatives=False)
176 + ")"
177 )
77 178
179 return result
78 180
79 181 if __name__ == '__main__':
80 182 here = Path(__file__).parent
81 183 dest = here / "source" / "config" / "shortcuts"
82 184
83 def sort_key(item):
84 k, v = item
85 shortcut, flt = k
86 return (str(shortcut), str(flt))
87
88 for filters, output_filename in [
89 (single_filter, "single_filtered"),
90 (multi_filter, "multi_filtered"),
91 ]:
92 with (dest / "{}.csv".format(output_filename)).open(
93 "w", encoding="utf-8"
94 ) as csv:
95 for (shortcut, flt), v in sorted(filters.items(), key=sort_key):
96 csv.write(":kbd:`{}`\t{}\t{}\n".format(shortcut, flt, v))
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True)
186
187 session = PromptSession(key_bindings=ipy_bindings)
188 prompt_bindings = session.app.key_bindings
189
190 assert prompt_bindings
191 # Ensure that we collected the default shortcuts
192 assert len(prompt_bindings.bindings) > len(ipy_bindings.bindings)
193
194 bindings = bindings_from_prompt_toolkit(prompt_bindings)
195
196 def sort_key(binding: Binding):
197 return binding.handler.identifier, binding.shortcut.filter
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 )
@@ -3,14 +3,14 b' channels:'
3 3 - conda-forge
4 4 - defaults
5 5 dependencies:
6 - python=3.8
7 - setuptools>=18.5
6 - python=3.10
7 - setuptools
8 8 - sphinx>=4.2
9 - sphinx_rtd_theme>=1.0
9 - sphinx_rtd_theme
10 10 - numpy
11 - nose
12 11 - testpath
13 12 - matplotlib
13 - pip
14 14 - pip:
15 15 - docrepr
16 16 - prompt_toolkit
@@ -183,8 +183,7 b" today_fmt = '%B %d, %Y'"
183 183
184 184 # Exclude these glob-style patterns when looking for source files. They are
185 185 # relative to the source/ directory.
186 exclude_patterns = []
187
186 exclude_patterns = ["**.ipynb_checkpoints"]
188 187
189 188 # If true, '()' will be appended to :func: etc. cross-reference text.
190 189 #add_function_parentheses = True
@@ -211,7 +210,6 b" default_role = 'literal'"
211 210 # given in html_static_path.
212 211 # html_style = 'default.css'
213 212
214
215 213 # The name for this set of Sphinx documents. If None, it defaults to
216 214 # "<project> v<release> documentation".
217 215 #html_title = None
@@ -327,6 +325,10 b' texinfo_documents = ['
327 325 modindex_common_prefix = ['IPython.']
328 326
329 327
328 def setup(app):
329 app.add_css_file("theme_overrides.css")
330
331
330 332 # Cleanup
331 333 # -------
332 334 # delete release info to avoid pickling errors from sphinx
@@ -139,13 +139,26 b' Accessing user namespace and local scope'
139 139 ========================================
140 140
141 141 When creating line magics, you may need to access surrounding scope to get user
142 variables (e.g when called inside functions). IPython provide the
142 variables (e.g when called inside functions). IPython provides the
143 143 ``@needs_local_scope`` decorator that can be imported from
144 144 ``IPython.core.magics``. When decorated with ``@needs_local_scope`` a magic will
145 145 be passed ``local_ns`` as an argument. As a convenience ``@needs_local_scope``
146 146 can also be applied to cell magics even if cell magics cannot appear at local
147 147 scope context.
148 148
149 Silencing the magic output
150 ==========================
151
152 Sometimes it may be useful to define a magic that can be silenced the same way
153 that non-magic expressions can, i.e., by appending a semicolon at the end of the Python
154 code to be executed. That can be achieved by decorating the magic function with
155 the decorator ``@output_can_be_silenced`` that can be imported from
156 ``IPython.core.magics``. When this decorator is used, IPython will parse the Python
157 code used by the magic and, if the last token is a ``;``, the output created by the
158 magic will not show up on the screen. If you want to see an example of this decorator
159 in action, take a look on the ``time`` magic defined in
160 ``IPython.core.magics.execution.py``.
161
149 162 Complete Example
150 163 ================
151 164
@@ -4,28 +4,23 b' IPython shortcuts'
4 4
5 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
10 shortcuts. In particular, it may depend on the version of ``prompt_toolkit``
11 installed during the generation of this page.
9 This list is automatically generated. Key bindings defined in ``prompt_toolkit`` may differ
10 between installations depending on the ``prompt_toolkit`` version.
12 11
13 12
14 Single Filtered shortcuts
15 =========================
16
17 .. csv-table::
18 :header: Shortcut,Filter,Description
19 :widths: 30, 30, 100
20 :delim: tab
21 :file: single_filtered.csv
13 * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession.
14 * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously.
15 * Hover over the ⓘ icon in the filter column to see when the shortcut is active.g
22 16
17 .. role:: raw-html(raw)
18 :format: html
23 19
24 Multi Filtered shortcuts
25 ========================
26 20
27 21 .. csv-table::
28 :header: Shortcut,Filter,Description
29 :widths: 30, 30, 100
22 :header: Shortcut,Description and identifier,Filter
30 23 :delim: tab
31 :file: multi_filtered.csv
24 :class: shortcuts
25 :file: table.tsv
26 :widths: 20 75 5
@@ -2,6 +2,54 b''
2 2 8.x Series
3 3 ============
4 4
5 .. _version 8.9.0:
6
7 IPython 8.9.0
8 -------------
9
10 Second release of IPython in 2023, last Friday of the month, we are back on
11 track. This is a small release with a few bug-fixes, and improvements, mostly
12 with respect to terminal shortcuts.
13
14
15 The biggest improvement for 8.9 is a drastic amelioration of the
16 auto-suggestions sponsored by D.E. Shaw and implemented by the more and more
17 active contributor `@krassowski <https://github.com/krassowski>`.
18
19 - ``right`` accepts a single character from suggestion
20 - ``ctrl+right`` accepts a semantic token (macos default shortcuts take
21 precedence and need to be disabled to make this work)
22 - ``backspace`` deletes a character and resumes hinting autosuggestions
23 - ``ctrl-left`` accepts suggestion and moves cursor left one character.
24 - ``backspace`` deletes a character and resumes hinting autosuggestions
25 - ``down`` moves to suggestion to later in history when no lines are present below the cursors.
26 - ``up`` moves to suggestion from earlier in history when no lines are present above the cursor.
27
28 This is best described by the Gif posted by `@krassowski
29 <https://github.com/krassowski>`, and in the PR itself :ghpull:`13888`.
30
31 .. image:: ../_images/autosuggest.gif
32
33 Please report any feedback in order for us to improve the user experience.
34 In particular we are also working on making the shortcuts configurable.
35
36 If you are interested in better terminal shortcuts, I also invite you to
37 participate in issue `13879
38 <https://github.com/ipython/ipython/issues/13879>`__.
39
40
41 As we follow `NEP29
42 <https://numpy.org/neps/nep-0029-deprecation_policy.html>`__, next version of
43 IPython will officially stop supporting numpy 1.20, and will stop supporting
44 Python 3.8 after April release.
45
46 As usual you can find the full list of PRs on GitHub under `the 8.9 milestone
47 <https://github.com/ipython/ipython/milestone/111?closed=1>`__.
48
49
50 Thanks to the `D. E. Shaw group <https://deshaw.com/>`__ for sponsoring
51 work on IPython and related libraries.
52
5 53 .. _version 8.8.0:
6 54
7 55 IPython 8.8.0
@@ -11,11 +59,11 b' First release of IPython in 2023 as there was no release at the end of'
11 59 December.
12 60
13 61 This is an unusually big release (relatively speaking) with more than 15 Pull
14 Requests merge.
62 Requests merged.
15 63
16 64 Of particular interest are:
17 65
18 - :ghpull:`13852` that replace the greedy completer and improve
66 - :ghpull:`13852` that replaces the greedy completer and improves
19 67 completion, in particular for dictionary keys.
20 68 - :ghpull:`13858` that adds ``py.typed`` to ``setup.cfg`` to make sure it is
21 69 bundled in wheels.
@@ -24,7 +72,7 b' Of particular interest are:'
24 72 believe this also needs a recent version of Traitlets.
25 73 - :ghpull:`13865` makes the ``inspector`` class of `InteractiveShell`
26 74 configurable.
27 - :ghpull:`13880` that remove minor-version entrypoints as the minor version
75 - :ghpull:`13880` that removes minor-version entrypoints as the minor version
28 76 entry points that would be included in the wheel would be the one of the
29 77 Python version that was used to build the ``whl`` file.
30 78
@@ -48,8 +96,8 b' IPython 8.7.0'
48 96
49 97
50 98 Small release of IPython with a couple of bug fixes and new features for this
51 month. Next month is end of year, it is unclear if there will be a release close
52 the new year's eve, or if the next release will be at end of January.
99 month. Next month is the end of year, it is unclear if there will be a release
100 close to the new year's eve, or if the next release will be at the end of January.
53 101
54 102 Here are a few of the relevant fixes,
55 103 as usual you can find the full list of PRs on GitHub under `the 8.7 milestone
@@ -73,29 +121,29 b' IPython 8.6.0'
73 121
74 122 Back to a more regular release schedule (at least I try), as Friday is
75 123 already over by more than 24h hours. This is a slightly bigger release with a
76 few new features that contain no less then 25 PRs.
124 few new features that contain no less than 25 PRs.
77 125
78 126 We'll notably found a couple of non negligible changes:
79 127
80 128 The ``install_ext`` and related functions have been removed after being
81 129 deprecated for years. You can use pip to install extensions. ``pip`` did not
82 exists when ``install_ext`` was introduced. You can still load local extensions
130 exist when ``install_ext`` was introduced. You can still load local extensions
83 131 without installing them. Just set your ``sys.path`` for example. :ghpull:`13744`
84 132
85 IPython now have extra entry points that that the major *and minor* version of
86 python. For some of you this mean that you can do a quick ``ipython3.10`` to
133 IPython now has extra entry points that use the major *and minor* version of
134 python. For some of you this means that you can do a quick ``ipython3.10`` to
87 135 launch IPython from the Python 3.10 interpreter, while still using Python 3.11
88 136 as your main Python. :ghpull:`13743`
89 137
90 The completer matcher API have been improved. See :ghpull:`13745`. This should
138 The completer matcher API has been improved. See :ghpull:`13745`. This should
91 139 improve the type inference and improve dict keys completions in many use case.
92 Tanks ``@krassowski`` for all the works, and the D.E. Shaw group for sponsoring
140 Thanks ``@krassowski`` for all the work, and the D.E. Shaw group for sponsoring
93 141 it.
94 142
95 143 The color of error nodes in tracebacks can now be customized. See
96 :ghpull:`13756`. This is a private attribute until someone find the time to
97 properly add a configuration option. Note that with Python 3.11 that also show
98 the relevant nodes in traceback, it would be good to leverage this informations
144 :ghpull:`13756`. This is a private attribute until someone finds the time to
145 properly add a configuration option. Note that with Python 3.11 that also shows
146 the relevant nodes in traceback, it would be good to leverage this information
99 147 (plus the "did you mean" info added on attribute errors). But that's likely work
100 148 I won't have time to do before long, so contributions welcome.
101 149
@@ -108,7 +156,7 b' This mostly occurs in teaching context when incorrect values get passed around.'
108 156
109 157
110 158 The ``?``, ``??``, and corresponding ``pinfo``, ``pinfo2`` magics can now find
111 objects insides arrays. That is to say, the following now works::
159 objects inside arrays. That is to say, the following now works::
112 160
113 161
114 162 >>> def my_func(*arg, **kwargs):pass
@@ -117,7 +165,7 b' objects insides arrays. That is to say, the following now works::'
117 165
118 166
119 167 If ``container`` define a custom ``getitem``, this __will__ trigger the custom
120 method. So don't put side effects in your ``getitems``. Thanks the D.E. Shaw
168 method. So don't put side effects in your ``getitems``. Thanks to the D.E. Shaw
121 169 group for the request and sponsoring the work.
122 170
123 171
@@ -143,12 +191,12 b' an bug fixes.'
143 191 Many thanks to everybody who contributed PRs for your patience in review and
144 192 merges.
145 193
146 Here is a non exhaustive list of changes that have been implemented for IPython
194 Here is a non-exhaustive list of changes that have been implemented for IPython
147 195 8.5.0. As usual you can find the full list of issues and PRs tagged with `the
148 196 8.5 milestone
149 197 <https://github.com/ipython/ipython/pulls?q=is%3Aclosed+milestone%3A8.5+>`__.
150 198
151 - Added shortcut for accepting auto suggestion. The End key shortcut for
199 - Added a shortcut for accepting auto suggestion. The End key shortcut for
152 200 accepting auto-suggestion This binding works in Vi mode too, provided
153 201 ``TerminalInteractiveShell.emacs_bindings_in_vi_insert_mode`` is set to be
154 202 ``True`` :ghpull:`13566`.
@@ -269,12 +317,12 b' IPython 8.3.0'
269 317
270 318
271 319 - :ghpull:`13600`, ``pre_run_*``-hooks will now have a ``cell_id`` attribute on
272 the info object when frontend provide it. This has been backported to 7.33
320 the info object when frontend provides it. This has been backported to 7.33
273 321
274 322 - :ghpull:`13624`, fixed :kbd:`End` key being broken after accepting an
275 323 auto-suggestion.
276 324
277 - :ghpull:`13657` fix issue where history from different sessions would be mixed.
325 - :ghpull:`13657` fixed an issue where history from different sessions would be mixed.
278 326
279 327 .. _version 8.2.0:
280 328
@@ -292,8 +340,8 b' IPython 8.2 mostly bring bugfixes to IPython.'
292 340 - Fixes to ``ultratb`` ipdb support when used outside of IPython. :ghpull:`13498`
293 341
294 342
295 I am still trying to fix and investigate :ghissue:`13598`, which seem to be
296 random, and would appreciate help if you find reproducible minimal case. I've
343 I am still trying to fix and investigate :ghissue:`13598`, which seems to be
344 random, and would appreciate help if you find a reproducible minimal case. I've
297 345 tried to make various changes to the codebase to mitigate it, but a proper fix
298 346 will be difficult without understanding the cause.
299 347
@@ -322,7 +370,7 b' IPython 8.1.0'
322 370 -------------
323 371
324 372 IPython 8.1 is the first minor release after 8.0 and fixes a number of bugs and
325 Update a few behavior that were problematic with the 8.0 as with many new major
373 updates a few behaviors that were problematic with the 8.0 as with many new major
326 374 release.
327 375
328 376 Note that beyond the changes listed here, IPython 8.1.0 also contains all the
@@ -373,8 +421,8 b' We want to remind users that IPython is part of the Jupyter organisations, and'
373 421 thus governed by a Code of Conduct. Some of the behavior we have seen on GitHub is not acceptable.
374 422 Abuse and non-respectful comments on discussion will not be tolerated.
375 423
376 Many thanks to all the contributors to this release, many of the above fixed issue and
377 new features where done by first time contributors, showing there is still
424 Many thanks to all the contributors to this release, many of the above fixed issues and
425 new features were done by first time contributors, showing there is still
378 426 plenty of easy contribution possible in IPython
379 427 . You can find all individual contributions
380 428 to this milestone `on github <https://github.com/ipython/ipython/milestone/91>`__.
@@ -435,7 +483,7 b' IPython 8.0'
435 483
436 484 IPython 8.0 is bringing a large number of new features and improvements to both the
437 485 user of the terminal and of the kernel via Jupyter. The removal of compatibility
438 with older version of Python is also the opportunity to do a couple of
486 with an older version of Python is also the opportunity to do a couple of
439 487 performance improvements in particular with respect to startup time.
440 488 The 8.x branch started diverging from its predecessor around IPython 7.12
441 489 (January 2020).
@@ -444,7 +492,7 b' This release contains 250+ pull requests, in addition to many of the features'
444 492 and backports that have made it to the 7.x branch. Please see the
445 493 `8.0 milestone <https://github.com/ipython/ipython/milestone/73?closed=1>`__ for the full list of pull requests.
446 494
447 Please feel free to send pull requests to updates those notes after release,
495 Please feel free to send pull requests to update those notes after release,
448 496 I have likely forgotten a few things reviewing 250+ PRs.
449 497
450 498 Dependencies changes/downstream packaging
@@ -459,8 +507,8 b' looking for help to do so.'
459 507 - minimal Python is now 3.8
460 508 - ``nose`` is not a testing requirement anymore
461 509 - ``pytest`` replaces nose.
462 - ``iptest``/``iptest3`` cli entrypoints do not exists anymore.
463 - minimum officially support ``numpy`` version has been bumped, but this should
510 - ``iptest``/``iptest3`` cli entrypoints do not exist anymore.
511 - the minimum officially supported ``numpy`` version has been bumped, but this should
464 512 not have much effect on packaging.
465 513
466 514
@@ -37,7 +37,7 b' install_requires ='
37 37 matplotlib-inline
38 38 pexpect>4.3; sys_platform != "win32"
39 39 pickleshare
40 prompt_toolkit>=3.0.11,<3.1.0
40 prompt_toolkit>=3.0.30,<3.1.0
41 41 pygments>=2.4.0
42 42 stack_data
43 43 traitlets>=5
1 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
General Comments 0
You need to be logged in to leave comments. Login now