##// END OF EJS Templates
Allow to customize shortcuts
krassowski -
Show More
@@ -0,0 +1,258 b''
1 """
2 Filters restricting scope of IPython Terminal shortcuts.
3 """
4
5 # Copyright (c) IPython Development Team.
6 # Distributed under the terms of the Modified BSD License.
7
8 import ast
9 import re
10 import signal
11 import sys
12 from typing import Callable, Dict, Union
13
14 from prompt_toolkit.application.current import get_app
15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
17 from prompt_toolkit.filters import has_focus as has_focus_impl
18 from prompt_toolkit.filters import (
19 Always,
20 has_selection,
21 has_suggestion,
22 vi_insert_mode,
23 vi_mode,
24 )
25 from prompt_toolkit.layout.layout import FocusableElement
26
27 from IPython.core.getipython import get_ipython
28 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
29 from IPython.terminal.shortcuts import auto_suggest
30 from IPython.utils.decorators import undoc
31
32
33 @undoc
34 @Condition
35 def cursor_in_leading_ws():
36 before = get_app().current_buffer.document.current_line_before_cursor
37 return (not before) or before.isspace()
38
39
40 def has_focus(value: FocusableElement):
41 """Wrapper around has_focus adding a nice `__name__` to tester function"""
42 tester = has_focus_impl(value).func
43 tester.__name__ = f"is_focused({value})"
44 return Condition(tester)
45
46
47 @undoc
48 @Condition
49 def has_line_below() -> bool:
50 document = get_app().current_buffer.document
51 return document.cursor_position_row < len(document.lines) - 1
52
53
54 @undoc
55 @Condition
56 def has_line_above() -> bool:
57 document = get_app().current_buffer.document
58 return document.cursor_position_row != 0
59
60
61 @Condition
62 def ebivim():
63 shell = get_ipython()
64 return shell.emacs_bindings_in_vi_insert_mode
65
66
67 @Condition
68 def supports_suspend():
69 return hasattr(signal, "SIGTSTP")
70
71
72 @Condition
73 def auto_match():
74 shell = get_ipython()
75 return shell.auto_match
76
77
78 def all_quotes_paired(quote, buf):
79 paired = True
80 i = 0
81 while i < len(buf):
82 c = buf[i]
83 if c == quote:
84 paired = not paired
85 elif c == "\\":
86 i += 1
87 i += 1
88 return paired
89
90
91 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
92 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
93
94
95 def preceding_text(pattern: Union[str, Callable]):
96 if pattern in _preceding_text_cache:
97 return _preceding_text_cache[pattern]
98
99 if callable(pattern):
100
101 def _preceding_text():
102 app = get_app()
103 before_cursor = app.current_buffer.document.current_line_before_cursor
104 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
105 return bool(pattern(before_cursor)) # type: ignore[operator]
106
107 else:
108 m = re.compile(pattern)
109
110 def _preceding_text():
111 app = get_app()
112 before_cursor = app.current_buffer.document.current_line_before_cursor
113 return bool(m.match(before_cursor))
114
115 _preceding_text.__name__ = f"preceding_text({pattern!r})"
116
117 condition = Condition(_preceding_text)
118 _preceding_text_cache[pattern] = condition
119 return condition
120
121
122 def following_text(pattern):
123 try:
124 return _following_text_cache[pattern]
125 except KeyError:
126 pass
127 m = re.compile(pattern)
128
129 def _following_text():
130 app = get_app()
131 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
132
133 _following_text.__name__ = f"following_text({pattern!r})"
134
135 condition = Condition(_following_text)
136 _following_text_cache[pattern] = condition
137 return condition
138
139
140 @Condition
141 def not_inside_unclosed_string():
142 app = get_app()
143 s = app.current_buffer.document.text_before_cursor
144 # remove escaped quotes
145 s = s.replace('\\"', "").replace("\\'", "")
146 # remove triple-quoted string literals
147 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
148 # remove single-quoted string literals
149 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
150 return not ('"' in s or "'" in s)
151
152
153 @Condition
154 def navigable_suggestions():
155 shell = get_ipython()
156 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
157
158
159 @Condition
160 def readline_like_completions():
161 shell = get_ipython()
162 return shell.display_completions == "readlinelike"
163
164
165 @Condition
166 def is_windows_os():
167 return sys.platform == "win32"
168
169
170 # these one is callable and re-used multiple times hence needs to be
171 # only defined once beforhand so that transforming back to human-readable
172 # names works well in the documentation.
173 default_buffer_focused = has_focus(DEFAULT_BUFFER)
174
175 KEYBINDING_FILTERS = {
176 "always": Always(),
177 "has_line_below": has_line_below,
178 "has_line_above": has_line_above,
179 "has_selection": has_selection,
180 "has_suggestion": has_suggestion,
181 "vi_mode": vi_mode,
182 "vi_insert_mode": vi_insert_mode,
183 "emacs_insert_mode": emacs_insert_mode,
184 "has_completions": has_completions,
185 "insert_mode": vi_insert_mode | emacs_insert_mode,
186 "default_buffer_focused": default_buffer_focused,
187 "search_buffer_focused": has_focus(SEARCH_BUFFER),
188 "ebivim": ebivim,
189 "supports_suspend": supports_suspend,
190 "is_windows_os": is_windows_os,
191 "auto_match": auto_match,
192 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
193 "focused_insert_vi": vi_insert_mode & default_buffer_focused,
194 "not_inside_unclosed_string": not_inside_unclosed_string,
195 "readline_like_completions": readline_like_completions,
196 "preceded_by_paired_double_quotes": preceding_text(
197 lambda line: all_quotes_paired('"', line)
198 ),
199 "preceded_by_paired_single_quotes": preceding_text(
200 lambda line: all_quotes_paired("'", line)
201 ),
202 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
203 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
204 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
205 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
206 # match
207 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
208 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
209 "preceded_by_opening_brace": preceding_text(r".*\{$"),
210 "preceded_by_double_quote": preceding_text('.*"$'),
211 "preceded_by_single_quote": preceding_text(r".*'$"),
212 "followed_by_closing_round_paren": following_text(r"^\)"),
213 "followed_by_closing_bracket": following_text(r"^\]"),
214 "followed_by_closing_brace": following_text(r"^\}"),
215 "followed_by_double_quote": following_text('^"'),
216 "followed_by_single_quote": following_text("^'"),
217 "navigable_suggestions": navigable_suggestions,
218 "cursor_in_leading_ws": cursor_in_leading_ws,
219 }
220
221
222 def eval_node(node: Union[ast.AST, None]):
223 if node is None:
224 return None
225 if isinstance(node, ast.Expression):
226 return eval_node(node.body)
227 if isinstance(node, ast.BinOp):
228 left = eval_node(node.left)
229 right = eval_node(node.right)
230 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
231 if dunders:
232 return getattr(left, dunders[0])(right)
233 raise ValueError(f"Unknown binary operation: {node.op}")
234 if isinstance(node, ast.UnaryOp):
235 value = eval_node(node.operand)
236 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
237 if dunders:
238 return getattr(value, dunders[0])()
239 raise ValueError(f"Unknown unary operation: {node.op}")
240 if isinstance(node, ast.Name):
241 if node.id in KEYBINDING_FILTERS:
242 return KEYBINDING_FILTERS[node.id]
243 else:
244 sep = "\n - "
245 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
246 raise NameError(
247 f"{node.id} is not a known shortcut filter."
248 f" Known filters are: {sep}{known_filters}."
249 )
250 raise ValueError("Unhandled node", ast.dump(node))
251
252
253 def filter_from_string(code: str):
254 expression = ast.parse(code, mode="eval")
255 return eval_node(expression)
256
257
258 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
@@ -1,808 +1,955 b''
1 1 """IPython terminal interface using prompt_toolkit"""
2 2
3 3 import asyncio
4 4 import os
5 5 import sys
6 6 from warnings import warn
7 7 from typing import Union as UnionType
8 8
9 9 from IPython.core.async_helpers import get_asyncio_loop
10 10 from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC
11 11 from IPython.utils.py3compat import input
12 12 from IPython.utils.terminal import toggle_set_term_title, set_term_title, restore_term_title
13 13 from IPython.utils.process import abbrev_cwd
14 14 from traitlets import (
15 15 Bool,
16 16 Unicode,
17 17 Dict,
18 18 Integer,
19 List,
19 20 observe,
20 21 Instance,
21 22 Type,
22 23 default,
23 24 Enum,
24 25 Union,
25 26 Any,
26 27 validate,
27 28 Float,
28 29 )
29 30
30 31 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
31 32 from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
32 from prompt_toolkit.filters import (HasFocus, Condition, IsDone)
33 from prompt_toolkit.filters import HasFocus, Condition, IsDone
33 34 from prompt_toolkit.formatted_text import PygmentsTokens
34 35 from prompt_toolkit.history import History
35 36 from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor
36 37 from prompt_toolkit.output import ColorDepth
37 38 from prompt_toolkit.patch_stdout import patch_stdout
38 39 from prompt_toolkit.shortcuts import PromptSession, CompleteStyle, print_formatted_text
39 40 from prompt_toolkit.styles import DynamicStyle, merge_styles
40 41 from prompt_toolkit.styles.pygments import style_from_pygments_cls, style_from_pygments_dict
41 42 from prompt_toolkit import __version__ as ptk_version
42 43
43 44 from pygments.styles import get_style_by_name
44 45 from pygments.style import Style
45 46 from pygments.token import Token
46 47
47 48 from .debugger import TerminalPdb, Pdb
48 49 from .magics import TerminalMagics
49 50 from .pt_inputhooks import get_inputhook_name_and_func
50 51 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
51 52 from .ptutils import IPythonPTCompleter, IPythonPTLexer
52 from .shortcuts import create_ipython_shortcuts
53 from .shortcuts import (
54 create_ipython_shortcuts,
55 create_identifier,
56 RuntimeBinding,
57 Binding,
58 add_binding,
59 )
60 from .shortcuts.filters import KEYBINDING_FILTERS, filter_from_string
53 61 from .shortcuts.auto_suggest import (
54 62 NavigableAutoSuggestFromHistory,
55 63 AppendAutoSuggestionInAnyLine,
56 64 )
57 65
58 66 PTK3 = ptk_version.startswith('3.')
59 67
60 68
61 69 class _NoStyle(Style): pass
62 70
63 71
64 72
65 73 _style_overrides_light_bg = {
66 74 Token.Prompt: '#ansibrightblue',
67 75 Token.PromptNum: '#ansiblue bold',
68 76 Token.OutPrompt: '#ansibrightred',
69 77 Token.OutPromptNum: '#ansired bold',
70 78 }
71 79
72 80 _style_overrides_linux = {
73 81 Token.Prompt: '#ansibrightgreen',
74 82 Token.PromptNum: '#ansigreen bold',
75 83 Token.OutPrompt: '#ansibrightred',
76 84 Token.OutPromptNum: '#ansired bold',
77 85 }
78 86
79 87 def get_default_editor():
80 88 try:
81 89 return os.environ['EDITOR']
82 90 except KeyError:
83 91 pass
84 92 except UnicodeError:
85 93 warn("$EDITOR environment variable is not pure ASCII. Using platform "
86 94 "default editor.")
87 95
88 96 if os.name == 'posix':
89 97 return 'vi' # the only one guaranteed to be there!
90 98 else:
91 99 return 'notepad' # same in Windows!
92 100
93 101 # conservatively check for tty
94 102 # overridden streams can result in things like:
95 103 # - sys.stdin = None
96 104 # - no isatty method
97 105 for _name in ('stdin', 'stdout', 'stderr'):
98 106 _stream = getattr(sys, _name)
99 107 try:
100 108 if not _stream or not hasattr(_stream, "isatty") or not _stream.isatty():
101 109 _is_tty = False
102 110 break
103 111 except ValueError:
104 112 # stream is closed
105 113 _is_tty = False
106 114 break
107 115 else:
108 116 _is_tty = True
109 117
110 118
111 119 _use_simple_prompt = ('IPY_TEST_SIMPLE_PROMPT' in os.environ) or (not _is_tty)
112 120
113 121 def black_reformat_handler(text_before_cursor):
114 122 """
115 123 We do not need to protect against error,
116 124 this is taken care at a higher level where any reformat error is ignored.
117 125 Indeed we may call reformatting on incomplete code.
118 126 """
119 127 import black
120 128
121 129 formatted_text = black.format_str(text_before_cursor, mode=black.FileMode())
122 130 if not text_before_cursor.endswith("\n") and formatted_text.endswith("\n"):
123 131 formatted_text = formatted_text[:-1]
124 132 return formatted_text
125 133
126 134
127 135 def yapf_reformat_handler(text_before_cursor):
128 136 from yapf.yapflib import file_resources
129 137 from yapf.yapflib import yapf_api
130 138
131 139 style_config = file_resources.GetDefaultStyleForDir(os.getcwd())
132 140 formatted_text, was_formatted = yapf_api.FormatCode(
133 141 text_before_cursor, style_config=style_config
134 142 )
135 143 if was_formatted:
136 144 if not text_before_cursor.endswith("\n") and formatted_text.endswith("\n"):
137 145 formatted_text = formatted_text[:-1]
138 146 return formatted_text
139 147 else:
140 148 return text_before_cursor
141 149
142 150
143 151 class PtkHistoryAdapter(History):
144 152 """
145 153 Prompt toolkit has it's own way of handling history, Where it assumes it can
146 154 Push/pull from history.
147 155
148 156 """
149 157
150 158 def __init__(self, shell):
151 159 super().__init__()
152 160 self.shell = shell
153 161 self._refresh()
154 162
155 163 def append_string(self, string):
156 164 # we rely on sql for that.
157 165 self._loaded = False
158 166 self._refresh()
159 167
160 168 def _refresh(self):
161 169 if not self._loaded:
162 170 self._loaded_strings = list(self.load_history_strings())
163 171
164 172 def load_history_strings(self):
165 173 last_cell = ""
166 174 res = []
167 175 for __, ___, cell in self.shell.history_manager.get_tail(
168 176 self.shell.history_load_length, include_latest=True
169 177 ):
170 178 # Ignore blank lines and consecutive duplicates
171 179 cell = cell.rstrip()
172 180 if cell and (cell != last_cell):
173 181 res.append(cell)
174 182 last_cell = cell
175 183 yield from res[::-1]
176 184
177 185 def store_string(self, string: str) -> None:
178 186 pass
179 187
180 188 class TerminalInteractiveShell(InteractiveShell):
181 189 mime_renderers = Dict().tag(config=True)
182 190
183 191 space_for_menu = Integer(6, help='Number of line at the bottom of the screen '
184 192 'to reserve for the tab completion menu, '
185 193 'search history, ...etc, the height of '
186 194 'these menus will at most this value. '
187 195 'Increase it is you prefer long and skinny '
188 196 'menus, decrease for short and wide.'
189 197 ).tag(config=True)
190 198
191 199 pt_app: UnionType[PromptSession, None] = None
192 200 auto_suggest: UnionType[
193 201 AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None
194 202 ] = None
195 203 debugger_history = None
196 204
197 205 debugger_history_file = Unicode(
198 206 "~/.pdbhistory", help="File in which to store and read history"
199 207 ).tag(config=True)
200 208
201 209 simple_prompt = Bool(_use_simple_prompt,
202 210 help="""Use `raw_input` for the REPL, without completion and prompt colors.
203 211
204 212 Useful when controlling IPython as a subprocess, and piping STDIN/OUT/ERR. Known usage are:
205 213 IPython own testing machinery, and emacs inferior-shell integration through elpy.
206 214
207 215 This mode default to `True` if the `IPY_TEST_SIMPLE_PROMPT`
208 216 environment variable is set, or the current terminal is not a tty."""
209 217 ).tag(config=True)
210 218
211 219 @property
212 220 def debugger_cls(self):
213 221 return Pdb if self.simple_prompt else TerminalPdb
214 222
215 223 confirm_exit = Bool(True,
216 224 help="""
217 225 Set to confirm when you try to exit IPython with an EOF (Control-D
218 226 in Unix, Control-Z/Enter in Windows). By typing 'exit' or 'quit',
219 227 you can force a direct exit without any confirmation.""",
220 228 ).tag(config=True)
221 229
222 230 editing_mode = Unicode('emacs',
223 231 help="Shortcut style to use at the prompt. 'vi' or 'emacs'.",
224 232 ).tag(config=True)
225 233
226 234 emacs_bindings_in_vi_insert_mode = Bool(
227 235 True,
228 236 help="Add shortcuts from 'emacs' insert mode to 'vi' insert mode.",
229 237 ).tag(config=True)
230 238
231 239 modal_cursor = Bool(
232 240 True,
233 241 help="""
234 242 Cursor shape changes depending on vi mode: beam in vi insert mode,
235 243 block in nav mode, underscore in replace mode.""",
236 244 ).tag(config=True)
237 245
238 246 ttimeoutlen = Float(
239 247 0.01,
240 248 help="""The time in milliseconds that is waited for a key code
241 249 to complete.""",
242 250 ).tag(config=True)
243 251
244 252 timeoutlen = Float(
245 253 0.5,
246 254 help="""The time in milliseconds that is waited for a mapped key
247 255 sequence to complete.""",
248 256 ).tag(config=True)
249 257
250 258 autoformatter = Unicode(
251 259 None,
252 260 help="Autoformatter to reformat Terminal code. Can be `'black'`, `'yapf'` or `None`",
253 261 allow_none=True
254 262 ).tag(config=True)
255 263
256 264 auto_match = Bool(
257 265 False,
258 266 help="""
259 267 Automatically add/delete closing bracket or quote when opening bracket or quote is entered/deleted.
260 268 Brackets: (), [], {}
261 269 Quotes: '', \"\"
262 270 """,
263 271 ).tag(config=True)
264 272
265 273 mouse_support = Bool(False,
266 274 help="Enable mouse support in the prompt\n(Note: prevents selecting text with the mouse)"
267 275 ).tag(config=True)
268 276
269 277 # We don't load the list of styles for the help string, because loading
270 278 # Pygments plugins takes time and can cause unexpected errors.
271 279 highlighting_style = Union([Unicode('legacy'), Type(klass=Style)],
272 280 help="""The name or class of a Pygments style to use for syntax
273 281 highlighting. To see available styles, run `pygmentize -L styles`."""
274 282 ).tag(config=True)
275 283
276 284 @validate('editing_mode')
277 285 def _validate_editing_mode(self, proposal):
278 286 if proposal['value'].lower() == 'vim':
279 287 proposal['value']= 'vi'
280 288 elif proposal['value'].lower() == 'default':
281 289 proposal['value']= 'emacs'
282 290
283 291 if hasattr(EditingMode, proposal['value'].upper()):
284 292 return proposal['value'].lower()
285 293
286 294 return self.editing_mode
287 295
288 296
289 297 @observe('editing_mode')
290 298 def _editing_mode(self, change):
291 299 if self.pt_app:
292 300 self.pt_app.editing_mode = getattr(EditingMode, change.new.upper())
293 301
294 302 def _set_formatter(self, formatter):
295 303 if formatter is None:
296 304 self.reformat_handler = lambda x:x
297 305 elif formatter == 'black':
298 306 self.reformat_handler = black_reformat_handler
299 307 elif formatter == "yapf":
300 308 self.reformat_handler = yapf_reformat_handler
301 309 else:
302 310 raise ValueError
303 311
304 312 @observe("autoformatter")
305 313 def _autoformatter_changed(self, change):
306 314 formatter = change.new
307 315 self._set_formatter(formatter)
308 316
309 317 @observe('highlighting_style')
310 318 @observe('colors')
311 319 def _highlighting_style_changed(self, change):
312 320 self.refresh_style()
313 321
314 322 def refresh_style(self):
315 323 self._style = self._make_style_from_name_or_cls(self.highlighting_style)
316 324
317 325
318 326 highlighting_style_overrides = Dict(
319 327 help="Override highlighting format for specific tokens"
320 328 ).tag(config=True)
321 329
322 330 true_color = Bool(False,
323 331 help="""Use 24bit colors instead of 256 colors in prompt highlighting.
324 332 If your terminal supports true color, the following command should
325 333 print ``TRUECOLOR`` in orange::
326 334
327 335 printf \"\\x1b[38;2;255;100;0mTRUECOLOR\\x1b[0m\\n\"
328 336 """,
329 337 ).tag(config=True)
330 338
331 339 editor = Unicode(get_default_editor(),
332 340 help="Set the editor used by IPython (default to $EDITOR/vi/notepad)."
333 341 ).tag(config=True)
334 342
335 343 prompts_class = Type(Prompts, help='Class used to generate Prompt token for prompt_toolkit').tag(config=True)
336 344
337 345 prompts = Instance(Prompts)
338 346
339 347 @default('prompts')
340 348 def _prompts_default(self):
341 349 return self.prompts_class(self)
342 350
343 351 # @observe('prompts')
344 352 # def _(self, change):
345 353 # self._update_layout()
346 354
347 355 @default('displayhook_class')
348 356 def _displayhook_class_default(self):
349 357 return RichPromptDisplayHook
350 358
351 359 term_title = Bool(True,
352 360 help="Automatically set the terminal title"
353 361 ).tag(config=True)
354 362
355 363 term_title_format = Unicode("IPython: {cwd}",
356 364 help="Customize the terminal title format. This is a python format string. " +
357 365 "Available substitutions are: {cwd}."
358 366 ).tag(config=True)
359 367
360 368 display_completions = Enum(('column', 'multicolumn','readlinelike'),
361 369 help= ( "Options for displaying tab completions, 'column', 'multicolumn', and "
362 370 "'readlinelike'. These options are for `prompt_toolkit`, see "
363 371 "`prompt_toolkit` documentation for more information."
364 372 ),
365 373 default_value='multicolumn').tag(config=True)
366 374
367 375 highlight_matching_brackets = Bool(True,
368 376 help="Highlight matching brackets.",
369 377 ).tag(config=True)
370 378
371 379 extra_open_editor_shortcuts = Bool(False,
372 380 help="Enable vi (v) or Emacs (C-X C-E) shortcuts to open an external editor. "
373 381 "This is in addition to the F2 binding, which is always enabled."
374 382 ).tag(config=True)
375 383
376 384 handle_return = Any(None,
377 385 help="Provide an alternative handler to be called when the user presses "
378 386 "Return. This is an advanced option intended for debugging, which "
379 387 "may be changed or removed in later releases."
380 388 ).tag(config=True)
381 389
382 390 enable_history_search = Bool(True,
383 391 help="Allows to enable/disable the prompt toolkit history search"
384 392 ).tag(config=True)
385 393
386 394 autosuggestions_provider = Unicode(
387 395 "NavigableAutoSuggestFromHistory",
388 396 help="Specifies from which source automatic suggestions are provided. "
389 397 "Can be set to ``'NavigableAutoSuggestFromHistory'`` (:kbd:`up` and "
390 398 ":kbd:`down` swap suggestions), ``'AutoSuggestFromHistory'``, "
391 399 " or ``None`` to disable automatic suggestions. "
392 400 "Default is `'NavigableAutoSuggestFromHistory`'.",
393 401 allow_none=True,
394 402 ).tag(config=True)
395 403
396 404 def _set_autosuggestions(self, provider):
397 405 # disconnect old handler
398 406 if self.auto_suggest and isinstance(
399 407 self.auto_suggest, NavigableAutoSuggestFromHistory
400 408 ):
401 409 self.auto_suggest.disconnect()
402 410 if provider is None:
403 411 self.auto_suggest = None
404 412 elif provider == "AutoSuggestFromHistory":
405 413 self.auto_suggest = AutoSuggestFromHistory()
406 414 elif provider == "NavigableAutoSuggestFromHistory":
407 415 self.auto_suggest = NavigableAutoSuggestFromHistory()
408 416 else:
409 417 raise ValueError("No valid provider.")
410 418 if self.pt_app:
411 419 self.pt_app.auto_suggest = self.auto_suggest
412 420
413 421 @observe("autosuggestions_provider")
414 422 def _autosuggestions_provider_changed(self, change):
415 423 provider = change.new
416 424 self._set_autosuggestions(provider)
417 425
426 shortcuts = List(
427 trait=Dict(
428 key_trait=Enum(
429 [
430 "command",
431 "match_keys",
432 "match_filter",
433 "new_keys",
434 "new_filter",
435 "create",
436 ]
437 ),
438 per_key_traits={
439 "command": Unicode(),
440 "match_keys": List(Unicode()),
441 "match_filter": Unicode(),
442 "new_keys": List(Unicode()),
443 "new_filter": Unicode(),
444 "create": Bool(default=False),
445 },
446 ),
447 help=f"""Add, disable or modifying shortcuts.
448
449 Each entry on the list should be a dictionary with ``command`` key
450 identifying the target function executed by the shortcut and at least
451 one of the following::
452 - ``match_keys``: list of keys used to match an existing shortcut,
453 - ``match_filter``: shortcut filter used to match an existing shortcut,
454 - ``new_keys``: list of keys to set,
455 - ``new_filter``: a new shortcut filter to set
456
457 The filters have to be composed of pre-defined verbs and joined by one
458 of the following conjunctions: ``&`` (and), ``|` (or), ``~`` (not).
459 The pre-defined verbs are: ({', '.join(KEYBINDING_FILTERS)}).
460
461 To disable a shortcut set ``new_keys`` to an empty list.
462 To add a shortcut add key ``create`` with value ``True``.
463
464 When modifying/disabling shortcuts, ``match_keys``/``match_filter`` can
465 be omitted if the provided specification uniquely identifies a shortcut
466 to be modified/disabled. When modifying a shortcut ``new_filter`` or
467 ``new_keys`` can be omitted which will result in reuse of the existing
468 filter/keys.
469
470 Only shortcuts defined in IPython (and not default prompt toolkit
471 shortcuts) can be modified or disabled.
472 """,
473 ).tag(config=True)
474
475 @observe("shortcuts")
476 def _shortcuts_changed(self, change):
477 user_shortcuts = change.new
478 # rebuild the bindings list from scratch
479 key_bindings = create_ipython_shortcuts(self)
480
481 # for now we only allow adding shortcuts for commands which are already
482 # registered; this is a security precaution.
483 known_commands = {
484 create_identifier(binding.handler): binding.handler
485 for binding in key_bindings.bindings
486 }
487 shortcuts_to_skip = []
488 shortcuts_to_add = []
489
490 for shortcut in user_shortcuts:
491 command_id = shortcut["command"]
492 if command_id not in known_commands:
493 allowed_commands = "\n - ".join(known_commands)
494 raise ValueError(
495 f"{command_id} is not a known shortcut command."
496 f" Allowed commands are: \n - {allowed_commands}"
497 )
498 old_keys = shortcut.get("match_keys", None)
499 old_filter = (
500 filter_from_string(shortcut["match_filter"])
501 if "match_filter" in shortcut
502 else None
503 )
504 matching = [
505 binding
506 for binding in key_bindings.bindings
507 if (
508 (old_filter is None or binding.filter == old_filter)
509 and (old_keys is None or [k for k in binding.keys] == old_keys)
510 and create_identifier(binding.handler) == command_id
511 )
512 ]
513
514 new_keys = shortcut.get("new_keys", None)
515 new_filter = shortcut.get("new_filter", None)
516
517 command = known_commands[command_id]
518
519 creating_new = shortcut.get("create", False)
520 modifying_existing = not creating_new and (
521 new_keys is not None or new_filter
522 )
523
524 if creating_new and new_keys == []:
525 raise ValueError("Cannot add a shortcut without keys")
526
527 if modifying_existing:
528 specification = {
529 key: shortcut[key]
530 for key in ["command", "filter"]
531 if key in shortcut
532 }
533 if len(matching) == 0:
534 raise ValueError(f"No shortcuts matching {specification} found")
535 elif len(matching) > 1:
536 raise ValueError(
537 f"Multiple shortcuts matching {specification} found,"
538 f" please add keys/filter to select one of: {matching}"
539 )
540
541 for matched in matching:
542 shortcuts_to_skip.append(
543 RuntimeBinding(
544 command,
545 keys=[k for k in matching[0].keys],
546 filter=matching[0].filter,
547 )
548 )
549
550 if new_keys != []:
551 shortcuts_to_add.append(
552 Binding(
553 command,
554 keys=new_keys,
555 condition=new_filter if new_filter is not None else "always",
556 )
557 )
558
559 # rebuild the bindings list from scratch
560 key_bindings = create_ipython_shortcuts(self, skip=shortcuts_to_skip)
561 for binding in shortcuts_to_add:
562 add_binding(key_bindings, binding)
563 self.pt_app.key_bindings = key_bindings
564
418 565 prompt_includes_vi_mode = Bool(True,
419 566 help="Display the current vi mode (when using vi editing mode)."
420 567 ).tag(config=True)
421 568
422 569 @observe('term_title')
423 570 def init_term_title(self, change=None):
424 571 # Enable or disable the terminal title.
425 572 if self.term_title and _is_tty:
426 573 toggle_set_term_title(True)
427 574 set_term_title(self.term_title_format.format(cwd=abbrev_cwd()))
428 575 else:
429 576 toggle_set_term_title(False)
430 577
431 578 def restore_term_title(self):
432 579 if self.term_title and _is_tty:
433 580 restore_term_title()
434 581
435 582 def init_display_formatter(self):
436 583 super(TerminalInteractiveShell, self).init_display_formatter()
437 584 # terminal only supports plain text
438 585 self.display_formatter.active_types = ["text/plain"]
439 586
440 587 def init_prompt_toolkit_cli(self):
441 588 if self.simple_prompt:
442 589 # Fall back to plain non-interactive output for tests.
443 590 # This is very limited.
444 591 def prompt():
445 592 prompt_text = "".join(x[1] for x in self.prompts.in_prompt_tokens())
446 593 lines = [input(prompt_text)]
447 594 prompt_continuation = "".join(x[1] for x in self.prompts.continuation_prompt_tokens())
448 595 while self.check_complete('\n'.join(lines))[0] == 'incomplete':
449 596 lines.append( input(prompt_continuation) )
450 597 return '\n'.join(lines)
451 598 self.prompt_for_code = prompt
452 599 return
453 600
454 601 # Set up keyboard shortcuts
455 602 key_bindings = create_ipython_shortcuts(self)
456 603
457 604
458 605 # Pre-populate history from IPython's history database
459 606 history = PtkHistoryAdapter(self)
460 607
461 608 self._style = self._make_style_from_name_or_cls(self.highlighting_style)
462 609 self.style = DynamicStyle(lambda: self._style)
463 610
464 611 editing_mode = getattr(EditingMode, self.editing_mode.upper())
465 612
466 613 self.pt_loop = asyncio.new_event_loop()
467 614 self.pt_app = PromptSession(
468 615 auto_suggest=self.auto_suggest,
469 616 editing_mode=editing_mode,
470 617 key_bindings=key_bindings,
471 618 history=history,
472 619 completer=IPythonPTCompleter(shell=self),
473 620 enable_history_search=self.enable_history_search,
474 621 style=self.style,
475 622 include_default_pygments_style=False,
476 623 mouse_support=self.mouse_support,
477 624 enable_open_in_editor=self.extra_open_editor_shortcuts,
478 625 color_depth=self.color_depth,
479 626 tempfile_suffix=".py",
480 **self._extra_prompt_options()
627 **self._extra_prompt_options(),
481 628 )
482 629 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
483 630 self.auto_suggest.connect(self.pt_app)
484 631
485 632 def _make_style_from_name_or_cls(self, name_or_cls):
486 633 """
487 634 Small wrapper that make an IPython compatible style from a style name
488 635
489 636 We need that to add style for prompt ... etc.
490 637 """
491 638 style_overrides = {}
492 639 if name_or_cls == 'legacy':
493 640 legacy = self.colors.lower()
494 641 if legacy == 'linux':
495 642 style_cls = get_style_by_name('monokai')
496 643 style_overrides = _style_overrides_linux
497 644 elif legacy == 'lightbg':
498 645 style_overrides = _style_overrides_light_bg
499 646 style_cls = get_style_by_name('pastie')
500 647 elif legacy == 'neutral':
501 648 # The default theme needs to be visible on both a dark background
502 649 # and a light background, because we can't tell what the terminal
503 650 # looks like. These tweaks to the default theme help with that.
504 651 style_cls = get_style_by_name('default')
505 652 style_overrides.update({
506 653 Token.Number: '#ansigreen',
507 654 Token.Operator: 'noinherit',
508 655 Token.String: '#ansiyellow',
509 656 Token.Name.Function: '#ansiblue',
510 657 Token.Name.Class: 'bold #ansiblue',
511 658 Token.Name.Namespace: 'bold #ansiblue',
512 659 Token.Name.Variable.Magic: '#ansiblue',
513 660 Token.Prompt: '#ansigreen',
514 661 Token.PromptNum: '#ansibrightgreen bold',
515 662 Token.OutPrompt: '#ansired',
516 663 Token.OutPromptNum: '#ansibrightred bold',
517 664 })
518 665
519 666 # Hack: Due to limited color support on the Windows console
520 667 # the prompt colors will be wrong without this
521 668 if os.name == 'nt':
522 669 style_overrides.update({
523 670 Token.Prompt: '#ansidarkgreen',
524 671 Token.PromptNum: '#ansigreen bold',
525 672 Token.OutPrompt: '#ansidarkred',
526 673 Token.OutPromptNum: '#ansired bold',
527 674 })
528 675 elif legacy =='nocolor':
529 676 style_cls=_NoStyle
530 677 style_overrides = {}
531 678 else :
532 679 raise ValueError('Got unknown colors: ', legacy)
533 680 else :
534 681 if isinstance(name_or_cls, str):
535 682 style_cls = get_style_by_name(name_or_cls)
536 683 else:
537 684 style_cls = name_or_cls
538 685 style_overrides = {
539 686 Token.Prompt: '#ansigreen',
540 687 Token.PromptNum: '#ansibrightgreen bold',
541 688 Token.OutPrompt: '#ansired',
542 689 Token.OutPromptNum: '#ansibrightred bold',
543 690 }
544 691 style_overrides.update(self.highlighting_style_overrides)
545 692 style = merge_styles([
546 693 style_from_pygments_cls(style_cls),
547 694 style_from_pygments_dict(style_overrides),
548 695 ])
549 696
550 697 return style
551 698
552 699 @property
553 700 def pt_complete_style(self):
554 701 return {
555 702 'multicolumn': CompleteStyle.MULTI_COLUMN,
556 703 'column': CompleteStyle.COLUMN,
557 704 'readlinelike': CompleteStyle.READLINE_LIKE,
558 705 }[self.display_completions]
559 706
560 707 @property
561 708 def color_depth(self):
562 709 return (ColorDepth.TRUE_COLOR if self.true_color else None)
563 710
564 711 def _extra_prompt_options(self):
565 712 """
566 713 Return the current layout option for the current Terminal InteractiveShell
567 714 """
568 715 def get_message():
569 716 return PygmentsTokens(self.prompts.in_prompt_tokens())
570 717
571 718 if self.editing_mode == 'emacs':
572 719 # with emacs mode the prompt is (usually) static, so we call only
573 720 # the function once. With VI mode it can toggle between [ins] and
574 721 # [nor] so we can't precompute.
575 722 # here I'm going to favor the default keybinding which almost
576 723 # everybody uses to decrease CPU usage.
577 724 # if we have issues with users with custom Prompts we can see how to
578 725 # work around this.
579 726 get_message = get_message()
580 727
581 728 options = {
582 729 "complete_in_thread": False,
583 730 "lexer": IPythonPTLexer(),
584 731 "reserve_space_for_menu": self.space_for_menu,
585 732 "message": get_message,
586 733 "prompt_continuation": (
587 734 lambda width, lineno, is_soft_wrap: PygmentsTokens(
588 735 self.prompts.continuation_prompt_tokens(width)
589 736 )
590 737 ),
591 738 "multiline": True,
592 739 "complete_style": self.pt_complete_style,
593 740 "input_processors": [
594 741 # Highlight matching brackets, but only when this setting is
595 742 # enabled, and only when the DEFAULT_BUFFER has the focus.
596 743 ConditionalProcessor(
597 744 processor=HighlightMatchingBracketProcessor(chars="[](){}"),
598 745 filter=HasFocus(DEFAULT_BUFFER)
599 746 & ~IsDone()
600 747 & Condition(lambda: self.highlight_matching_brackets),
601 748 ),
602 749 # Show auto-suggestion in lines other than the last line.
603 750 ConditionalProcessor(
604 751 processor=AppendAutoSuggestionInAnyLine(),
605 752 filter=HasFocus(DEFAULT_BUFFER)
606 753 & ~IsDone()
607 754 & Condition(
608 755 lambda: isinstance(
609 756 self.auto_suggest, NavigableAutoSuggestFromHistory
610 757 )
611 758 ),
612 759 ),
613 760 ],
614 761 }
615 762 if not PTK3:
616 763 options['inputhook'] = self.inputhook
617 764
618 765 return options
619 766
620 767 def prompt_for_code(self):
621 768 if self.rl_next_input:
622 769 default = self.rl_next_input
623 770 self.rl_next_input = None
624 771 else:
625 772 default = ''
626 773
627 774 # In order to make sure that asyncio code written in the
628 775 # interactive shell doesn't interfere with the prompt, we run the
629 776 # prompt in a different event loop.
630 777 # If we don't do this, people could spawn coroutine with a
631 778 # while/true inside which will freeze the prompt.
632 779
633 780 policy = asyncio.get_event_loop_policy()
634 781 old_loop = get_asyncio_loop()
635 782
636 783 # FIXME: prompt_toolkit is using the deprecated `asyncio.get_event_loop`
637 784 # to get the current event loop.
638 785 # This will probably be replaced by an attribute or input argument,
639 786 # at which point we can stop calling the soon-to-be-deprecated `set_event_loop` here.
640 787 if old_loop is not self.pt_loop:
641 788 policy.set_event_loop(self.pt_loop)
642 789 try:
643 790 with patch_stdout(raw=True):
644 791 text = self.pt_app.prompt(
645 792 default=default,
646 793 **self._extra_prompt_options())
647 794 finally:
648 795 # Restore the original event loop.
649 796 if old_loop is not None and old_loop is not self.pt_loop:
650 797 policy.set_event_loop(old_loop)
651 798
652 799 return text
653 800
654 801 def enable_win_unicode_console(self):
655 802 # Since IPython 7.10 doesn't support python < 3.6 and PEP 528, Python uses the unicode APIs for the Windows
656 803 # console by default, so WUC shouldn't be needed.
657 804 warn("`enable_win_unicode_console` is deprecated since IPython 7.10, does not do anything and will be removed in the future",
658 805 DeprecationWarning,
659 806 stacklevel=2)
660 807
661 808 def init_io(self):
662 809 if sys.platform not in {'win32', 'cli'}:
663 810 return
664 811
665 812 import colorama
666 813 colorama.init()
667 814
668 815 def init_magics(self):
669 816 super(TerminalInteractiveShell, self).init_magics()
670 817 self.register_magics(TerminalMagics)
671 818
672 819 def init_alias(self):
673 820 # The parent class defines aliases that can be safely used with any
674 821 # frontend.
675 822 super(TerminalInteractiveShell, self).init_alias()
676 823
677 824 # Now define aliases that only make sense on the terminal, because they
678 825 # need direct access to the console in a way that we can't emulate in
679 826 # GUI or web frontend
680 827 if os.name == 'posix':
681 828 for cmd in ('clear', 'more', 'less', 'man'):
682 829 self.alias_manager.soft_define_alias(cmd, cmd)
683 830
684 831
685 832 def __init__(self, *args, **kwargs) -> None:
686 833 super(TerminalInteractiveShell, self).__init__(*args, **kwargs)
687 834 self._set_autosuggestions(self.autosuggestions_provider)
688 835 self.init_prompt_toolkit_cli()
689 836 self.init_term_title()
690 837 self.keep_running = True
691 838 self._set_formatter(self.autoformatter)
692 839
693 840
694 841 def ask_exit(self):
695 842 self.keep_running = False
696 843
697 844 rl_next_input = None
698 845
699 846 def interact(self):
700 847 self.keep_running = True
701 848 while self.keep_running:
702 849 print(self.separate_in, end='')
703 850
704 851 try:
705 852 code = self.prompt_for_code()
706 853 except EOFError:
707 854 if (not self.confirm_exit) \
708 855 or self.ask_yes_no('Do you really want to exit ([y]/n)?','y','n'):
709 856 self.ask_exit()
710 857
711 858 else:
712 859 if code:
713 860 self.run_cell(code, store_history=True)
714 861
715 862 def mainloop(self):
716 863 # An extra layer of protection in case someone mashing Ctrl-C breaks
717 864 # out of our internal code.
718 865 while True:
719 866 try:
720 867 self.interact()
721 868 break
722 869 except KeyboardInterrupt as e:
723 870 print("\n%s escaped interact()\n" % type(e).__name__)
724 871 finally:
725 872 # An interrupt during the eventloop will mess up the
726 873 # internal state of the prompt_toolkit library.
727 874 # Stopping the eventloop fixes this, see
728 875 # https://github.com/ipython/ipython/pull/9867
729 876 if hasattr(self, '_eventloop'):
730 877 self._eventloop.stop()
731 878
732 879 self.restore_term_title()
733 880
734 881 # try to call some at-exit operation optimistically as some things can't
735 882 # be done during interpreter shutdown. this is technically inaccurate as
736 883 # this make mainlool not re-callable, but that should be a rare if not
737 884 # in existent use case.
738 885
739 886 self._atexit_once()
740 887
741 888
742 889 _inputhook = None
743 890 def inputhook(self, context):
744 891 if self._inputhook is not None:
745 892 self._inputhook(context)
746 893
747 894 active_eventloop = None
748 895 def enable_gui(self, gui=None):
749 896 if gui and (gui not in {"inline", "webagg"}):
750 897 self.active_eventloop, self._inputhook = get_inputhook_name_and_func(gui)
751 898 else:
752 899 self.active_eventloop = self._inputhook = None
753 900
754 901 # For prompt_toolkit 3.0. We have to create an asyncio event loop with
755 902 # this inputhook.
756 903 if PTK3:
757 904 import asyncio
758 905 from prompt_toolkit.eventloop import new_eventloop_with_inputhook
759 906
760 907 if gui == 'asyncio':
761 908 # When we integrate the asyncio event loop, run the UI in the
762 909 # same event loop as the rest of the code. don't use an actual
763 910 # input hook. (Asyncio is not made for nesting event loops.)
764 911 self.pt_loop = get_asyncio_loop()
765 912
766 913 elif self._inputhook:
767 914 # If an inputhook was set, create a new asyncio event loop with
768 915 # this inputhook for the prompt.
769 916 self.pt_loop = new_eventloop_with_inputhook(self._inputhook)
770 917 else:
771 918 # When there's no inputhook, run the prompt in a separate
772 919 # asyncio event loop.
773 920 self.pt_loop = asyncio.new_event_loop()
774 921
775 922 # Run !system commands directly, not through pipes, so terminal programs
776 923 # work correctly.
777 924 system = InteractiveShell.system_raw
778 925
779 926 def auto_rewrite_input(self, cmd):
780 927 """Overridden from the parent class to use fancy rewriting prompt"""
781 928 if not self.show_rewritten_input:
782 929 return
783 930
784 931 tokens = self.prompts.rewrite_prompt_tokens()
785 932 if self.pt_app:
786 933 print_formatted_text(PygmentsTokens(tokens), end='',
787 934 style=self.pt_app.app.style)
788 935 print(cmd)
789 936 else:
790 937 prompt = ''.join(s for t, s in tokens)
791 938 print(prompt, cmd, sep='')
792 939
793 940 _prompts_before = None
794 941 def switch_doctest_mode(self, mode):
795 942 """Switch prompts to classic for %doctest_mode"""
796 943 if mode:
797 944 self._prompts_before = self.prompts
798 945 self.prompts = ClassicPrompts(self)
799 946 elif self._prompts_before:
800 947 self.prompts = self._prompts_before
801 948 self._prompts_before = None
802 949 # self._update_layout()
803 950
804 951
805 952 InteractiveShellABC.register(TerminalInteractiveShell)
806 953
807 954 if __name__ == '__main__':
808 955 TerminalInteractiveShell.instance().interact()
This diff has been collapsed as it changes many lines, (712 lines changed) Show them Hide them
@@ -1,670 +1,620 b''
1 1 """
2 2 Module to define and register Terminal IPython shortcuts with
3 3 :mod:`prompt_toolkit`
4 4 """
5 5
6 6 # Copyright (c) IPython Development Team.
7 7 # Distributed under the terms of the Modified BSD License.
8 8
9 9 import os
10 import re
11 10 import signal
12 11 import sys
13 12 import warnings
14 from typing import Callable, Dict, Union
13 from dataclasses import dataclass
14 from typing import Callable, Any, Optional, List
15 15
16 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 17 from prompt_toolkit.key_binding import KeyBindings
18 from prompt_toolkit.key_binding.key_processor import KeyPressEvent
27 19 from prompt_toolkit.key_binding.bindings import named_commands as nc
28 20 from prompt_toolkit.key_binding.bindings.completion import (
29 21 display_completions_like_readline,
30 22 )
31 23 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
32 from prompt_toolkit.layout.layout import FocusableElement
24 from prompt_toolkit.filters import Condition
33 25
26 from IPython.core.getipython import get_ipython
34 27 from IPython.terminal.shortcuts import auto_match as match
35 28 from IPython.terminal.shortcuts import auto_suggest
29 from IPython.terminal.shortcuts.filters import filter_from_string
36 30 from IPython.utils.decorators import undoc
37 31
38 32 __all__ = ["create_ipython_shortcuts"]
39 33
40 34
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
35 @dataclass
36 class BaseBinding:
37 command: Callable[[KeyPressEvent], Any]
38 keys: List[str]
67 39
68 40
69 def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
70 """Set up the prompt_toolkit keyboard shortcuts for IPython.
41 @dataclass
42 class RuntimeBinding(BaseBinding):
43 filter: Condition
71 44
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 45
81 Returns
82 -------
83 KeyBindings
84 the keybinding instance for prompt toolkit.
46 @dataclass
47 class Binding(BaseBinding):
48 # while filter could be created by referencing variables directly (rather
49 # than created from strings), by using strings we ensure that users will
50 # be able to create filters in configuration (e.g. JSON) files too, which
51 # also benefits the documentation by enforcing human-readable filter names.
52 condition: Optional[str] = None
85 53
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)
54 def __post_init__(self):
55 if self.condition:
56 self.filter = filter_from_string(self.condition)
97 57 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
58 self.filter = None
107 59
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 60
61 def create_identifier(handler: Callable):
62 parts = handler.__module__.split(".")
63 name = handler.__name__
64 package = parts[0]
65 if len(parts) > 1:
66 final_module = parts[-1]
67 return f"{package}:{final_module}.{name}"
189 68 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})"
69 return f"{package}:{name}"
198 70
199 condition = Condition(_preceding_text)
200 _preceding_text_cache[pattern] = condition
201 return condition
202 71
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
72 AUTO_MATCH_BINDINGS = [
73 *[
74 Binding(
75 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
236 76 )
237
77 for key, cmd in match.auto_match_parens.items()
78 ],
79 *[
238 80 # 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 )
81 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
82 for key, cmd in match.auto_match_parens_raw_string.items()
83 ],
84 Binding(
85 match.double_quote,
86 ['"'],
87 "focused_insert"
88 " & auto_match"
89 " & not_inside_unclosed_string"
90 " & preceded_by_paired_double_quotes"
91 " & followed_by_closing_paren_or_end",
92 ),
93 Binding(
94 match.single_quote,
95 ["'"],
96 "focused_insert"
97 " & auto_match"
98 " & not_inside_unclosed_string"
99 " & preceded_by_paired_single_quotes"
100 " & followed_by_closing_paren_or_end",
101 ),
102 Binding(
103 match.docstring_double_quotes,
104 ['"'],
105 "focused_insert"
106 " & auto_match"
107 " & not_inside_unclosed_string"
108 " & preceded_by_two_double_quotes",
109 ),
110 Binding(
111 match.docstring_single_quotes,
112 ["'"],
113 "focused_insert"
114 " & auto_match"
115 " & not_inside_unclosed_string"
116 " & preceded_by_two_single_quotes",
117 ),
118 Binding(
119 match.skip_over,
120 [")"],
121 "focused_insert & auto_match & followed_by_closing_round_paren",
122 ),
123 Binding(
124 match.skip_over,
125 ["]"],
126 "focused_insert & auto_match & followed_by_closing_bracket",
127 ),
128 Binding(
129 match.skip_over,
130 ["}"],
131 "focused_insert & auto_match & followed_by_closing_brace",
132 ),
133 Binding(
134 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
135 ),
136 Binding(
137 match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
138 ),
139 Binding(
140 match.delete_pair,
141 ["backspace"],
142 "focused_insert"
143 " & preceded_by_opening_round_paren"
144 " & auto_match"
145 " & followed_by_closing_round_paren",
146 ),
147 Binding(
148 match.delete_pair,
149 ["backspace"],
150 "focused_insert"
151 " & preceded_by_opening_bracket"
152 " & auto_match"
153 " & followed_by_closing_bracket",
154 ),
155 Binding(
156 match.delete_pair,
157 ["backspace"],
158 "focused_insert"
159 " & preceded_by_opening_brace"
160 " & auto_match"
161 " & followed_by_closing_brace",
162 ),
163 Binding(
164 match.delete_pair,
165 ["backspace"],
166 "focused_insert"
167 " & preceded_by_double_quote"
168 " & auto_match"
169 " & followed_by_double_quote",
170 ),
171 Binding(
172 match.delete_pair,
173 ["backspace"],
174 "focused_insert"
175 " & preceded_by_single_quote"
176 " & auto_match"
177 " & followed_by_single_quote",
178 ),
179 ]
295 180
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 )
181 AUTO_SUGGEST_BINDINGS = [
182 Binding(
183 auto_suggest.accept_in_vi_insert_mode,
184 ["end"],
185 "default_buffer_focused & (ebivim | ~vi_insert_mode)",
186 ),
187 Binding(
188 auto_suggest.accept_in_vi_insert_mode, ["c-e"], "focused_insert_vi & ebivim"
189 ),
190 Binding(auto_suggest.accept, ["c-f"], "focused_insert_vi"),
191 Binding(auto_suggest.accept_word, ["escape", "f"], "focused_insert_vi & ebivim"),
192 Binding(
193 auto_suggest.accept_token,
194 ["c-right"],
195 "has_suggestion & default_buffer_focused",
196 ),
197 Binding(
198 auto_suggest.discard,
199 ["escape"],
200 "has_suggestion & default_buffer_focused & emacs_insert_mode",
201 ),
202 Binding(
203 auto_suggest.swap_autosuggestion_up,
204 ["up"],
205 "navigable_suggestions"
206 " & ~has_line_above"
207 " & has_suggestion"
208 " & default_buffer_focused",
209 ),
210 Binding(
211 auto_suggest.swap_autosuggestion_down,
212 ["down"],
213 "navigable_suggestions"
214 " & ~has_line_below"
215 " & has_suggestion"
216 " & default_buffer_focused",
217 ),
218 Binding(
219 auto_suggest.up_and_update_hint,
220 ["up"],
221 "has_line_above & navigable_suggestions & default_buffer_focused",
222 ),
223 Binding(
224 auto_suggest.down_and_update_hint,
225 ["down"],
226 "has_line_below & navigable_suggestions & default_buffer_focused",
227 ),
228 Binding(
229 auto_suggest.accept_character,
230 ["right"],
231 "has_suggestion & default_buffer_focused",
232 ),
233 Binding(
234 auto_suggest.accept_and_move_cursor_left,
235 ["c-left"],
236 "has_suggestion & default_buffer_focused",
237 ),
238 Binding(
239 auto_suggest.accept_and_keep_cursor,
240 ["c-down"],
241 "has_suggestion & default_buffer_focused",
242 ),
243 Binding(
244 auto_suggest.backspace_and_resume_hint,
245 ["backspace"],
246 "has_suggestion & default_buffer_focused",
247 ),
248 ]
354 249
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 250
403 # Simple Control keybindings
404 key_cmd_dict = {
251 SIMPLE_CONTROL_BINDINGS = [
252 Binding(cmd, [key], "focused_insert_vi & ebivim")
253 for key, cmd in {
405 254 "c-a": nc.beginning_of_line,
406 255 "c-b": nc.backward_char,
407 256 "c-k": nc.kill_line,
408 257 "c-w": nc.backward_kill_word,
409 258 "c-y": nc.yank,
410 259 "c-_": nc.undo,
411 }
260 }.items()
261 ]
412 262
413 for key, cmd in key_cmd_dict.items():
414 kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
415 263
416 # Alt and Combo Control keybindings
417 keys_cmd_dict = {
264 ALT_AND_COMOBO_CONTROL_BINDINGS = [
265 Binding(cmd, list(keys), "focused_insert_vi & ebivim")
266 for keys, cmd in {
418 267 # Control Combos
419 268 ("c-x", "c-e"): nc.edit_and_execute,
420 269 ("c-x", "e"): nc.edit_and_execute,
421 270 # Alt
422 271 ("escape", "b"): nc.backward_word,
423 272 ("escape", "c"): nc.capitalize_word,
424 273 ("escape", "d"): nc.kill_word,
425 274 ("escape", "h"): nc.backward_kill_word,
426 275 ("escape", "l"): nc.downcase_word,
427 276 ("escape", "u"): nc.uppercase_word,
428 277 ("escape", "y"): nc.yank_pop,
429 278 ("escape", "."): nc.yank_last_arg,
430 }
279 }.items()
280 ]
281
282
283 def add_binding(bindings: KeyBindings, binding: Binding):
284 bindings.add(
285 *binding.keys,
286 **({"filter": binding.filter} if binding.filter is not None else {}),
287 )(binding.command)
288
289
290 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
291 """Set up the prompt_toolkit keyboard shortcuts for IPython.
292
293 Parameters
294 ----------
295 shell: InteractiveShell
296 The current IPython shell Instance
297 skip: List[Binding]
298 Bindings to skip.
299
300 Returns
301 -------
302 KeyBindings
303 the keybinding instance for prompt toolkit.
431 304
432 for keys, cmd in keys_cmd_dict.items():
433 kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
305 """
306 kb = KeyBindings()
307 skip = skip or []
308 for binding in KEY_BINDINGS:
309 skip_this_one = False
310 for to_skip in skip:
311 if (
312 to_skip.command == binding.command
313 and to_skip.filter == binding.filter
314 and to_skip.keys == binding.keys
315 ):
316 skip_this_one = True
317 break
318 if skip_this_one:
319 continue
320 add_binding(kb, binding)
434 321
435 322 def get_input_mode(self):
436 323 app = get_app()
437 324 app.ttimeoutlen = shell.ttimeoutlen
438 325 app.timeoutlen = shell.timeoutlen
439 326
440 327 return self._input_mode
441 328
442 329 def set_input_mode(self, mode):
443 330 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
444 331 cursor = "\x1b[{} q".format(shape)
445 332
446 333 sys.stdout.write(cursor)
447 334 sys.stdout.flush()
448 335
449 336 self._input_mode = mode
450 337
451 338 if shell.editing_mode == "vi" and shell.modal_cursor:
452 339 ViState._input_mode = InputMode.INSERT # type: ignore
453 340 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
341
454 342 return kb
455 343
456 344
345 def reformat_and_execute(event):
346 """Reformat code and execute it"""
347 shell = get_ipython()
348 reformat_text_before_cursor(
349 event.current_buffer, event.current_buffer.document, shell
350 )
351 event.current_buffer.validate_and_handle()
352
353
457 354 def reformat_text_before_cursor(buffer, document, shell):
458 355 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
459 356 try:
460 357 formatted_text = shell.reformat_handler(text)
461 358 buffer.insert_text(formatted_text)
462 359 except Exception as e:
463 360 buffer.insert_text(text)
464 361
465 362
363 def handle_return_or_newline_or_execute(event):
364 shell = get_ipython()
365 if getattr(shell, "handle_return", None):
366 return shell.handle_return(shell)(event)
367 else:
368 return newline_or_execute_outer(shell)(event)
369
370
466 371 def newline_or_execute_outer(shell):
467 372 def newline_or_execute(event):
468 373 """When the user presses return, insert a newline or execute the code."""
469 374 b = event.current_buffer
470 375 d = b.document
471 376
472 377 if b.complete_state:
473 378 cc = b.complete_state.current_completion
474 379 if cc:
475 380 b.apply_completion(cc)
476 381 else:
477 382 b.cancel_completion()
478 383 return
479 384
480 385 # If there's only one line, treat it as if the cursor is at the end.
481 386 # See https://github.com/ipython/ipython/issues/10425
482 387 if d.line_count == 1:
483 388 check_text = d.text
484 389 else:
485 390 check_text = d.text[: d.cursor_position]
486 391 status, indent = shell.check_complete(check_text)
487 392
488 393 # if all we have after the cursor is whitespace: reformat current text
489 394 # before cursor
490 395 after_cursor = d.text[d.cursor_position :]
491 396 reformatted = False
492 397 if not after_cursor.strip():
493 398 reformat_text_before_cursor(b, d, shell)
494 399 reformatted = True
495 400 if not (
496 401 d.on_last_line
497 402 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
498 403 ):
499 404 if shell.autoindent:
500 405 b.insert_text("\n" + indent)
501 406 else:
502 407 b.insert_text("\n")
503 408 return
504 409
505 410 if (status != "incomplete") and b.accept_handler:
506 411 if not reformatted:
507 412 reformat_text_before_cursor(b, d, shell)
508 413 b.validate_and_handle()
509 414 else:
510 415 if shell.autoindent:
511 416 b.insert_text("\n" + indent)
512 417 else:
513 418 b.insert_text("\n")
514 419
515 newline_or_execute.__qualname__ = "newline_or_execute"
516
517 420 return newline_or_execute
518 421
519 422
520 423 def previous_history_or_previous_completion(event):
521 424 """
522 425 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
523 426
524 427 If completer is open this still select previous completion.
525 428 """
526 429 event.current_buffer.auto_up()
527 430
528 431
529 432 def next_history_or_next_completion(event):
530 433 """
531 434 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
532 435
533 436 If completer is open this still select next completion.
534 437 """
535 438 event.current_buffer.auto_down()
536 439
537 440
538 441 def dismiss_completion(event):
539 442 """Dismiss completion"""
540 443 b = event.current_buffer
541 444 if b.complete_state:
542 445 b.cancel_completion()
543 446
544 447
545 448 def reset_buffer(event):
546 449 """Reset buffer"""
547 450 b = event.current_buffer
548 451 if b.complete_state:
549 452 b.cancel_completion()
550 453 else:
551 454 b.reset()
552 455
553 456
554 457 def reset_search_buffer(event):
555 458 """Reset search buffer"""
556 459 if event.current_buffer.document.text:
557 460 event.current_buffer.reset()
558 461 else:
559 462 event.app.layout.focus(DEFAULT_BUFFER)
560 463
561 464
562 465 def suspend_to_bg(event):
563 466 """Suspend to background"""
564 467 event.app.suspend_to_background()
565 468
566 469
567 470 def quit(event):
568 471 """
569 472 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
570 473
571 474 On platforms that support SIGQUIT, send SIGQUIT to the current process.
572 475 On other platforms, just exit the process with a message.
573 476 """
574 477 sigquit = getattr(signal, "SIGQUIT", None)
575 478 if sigquit is not None:
576 479 os.kill(0, signal.SIGQUIT)
577 480 else:
578 481 sys.exit("Quit")
579 482
580 483
581 484 def indent_buffer(event):
582 485 """Indent buffer"""
583 486 event.current_buffer.insert_text(" " * 4)
584 487
585 488
586 489 @undoc
587 490 def newline_with_copy_margin(event):
588 491 """
589 492 DEPRECATED since IPython 6.0
590 493
591 494 See :any:`newline_autoindent_outer` for a replacement.
592 495
593 496 Preserve margin and cursor position when using
594 497 Control-O to insert a newline in EMACS mode
595 498 """
596 499 warnings.warn(
597 500 "`newline_with_copy_margin(event)` is deprecated since IPython 6.0. "
598 501 "see `newline_autoindent_outer(shell)(event)` for a replacement.",
599 502 DeprecationWarning,
600 503 stacklevel=2,
601 504 )
602 505
603 506 b = event.current_buffer
604 507 cursor_start_pos = b.document.cursor_position_col
605 508 b.newline(copy_margin=True)
606 509 b.cursor_up(count=1)
607 510 cursor_end_pos = b.document.cursor_position_col
608 511 if cursor_start_pos != cursor_end_pos:
609 512 pos_diff = cursor_start_pos - cursor_end_pos
610 513 b.cursor_right(count=pos_diff)
611 514
612 515
613 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
614 """
615 Return a function suitable for inserting a indented newline after the cursor.
516 def newline_autoindent(event):
517 """Insert a newline after the cursor indented appropriately.
616 518
617 519 Fancier version of deprecated ``newline_with_copy_margin`` which should
618 520 compute the correct indentation of the inserted line. That is to say, indent
619 521 by 4 extra space after a function definition, class definition, context
620 522 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
621 523 """
622
623 def newline_autoindent(event):
624 """Insert a newline after the cursor indented appropriately."""
524 shell = get_ipython()
525 inputsplitter = shell.input_transformer_manager
625 526 b = event.current_buffer
626 527 d = b.document
627 528
628 529 if b.complete_state:
629 530 b.cancel_completion()
630 531 text = d.text[: d.cursor_position] + "\n"
631 532 _, indent = inputsplitter.check_complete(text)
632 533 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
633 534
634 newline_autoindent.__qualname__ = "newline_autoindent"
635
636 return newline_autoindent
637
638 535
639 536 def open_input_in_editor(event):
640 537 """Open code from input in external editor"""
641 538 event.app.current_buffer.open_in_editor()
642 539
643 540
644 541 if sys.platform == "win32":
645 542 from IPython.core.error import TryNext
646 543 from IPython.lib.clipboard import (
647 544 ClipboardEmpty,
648 545 tkinter_clipboard_get,
649 546 win32_clipboard_get,
650 547 )
651 548
652 549 @undoc
653 550 def win_paste(event):
654 551 try:
655 552 text = win32_clipboard_get()
656 553 except TryNext:
657 554 try:
658 555 text = tkinter_clipboard_get()
659 556 except (TryNext, ClipboardEmpty):
660 557 return
661 558 except ClipboardEmpty:
662 559 return
663 560 event.current_buffer.insert_text(text.replace("\t", " " * 4))
664 561
665 562 else:
666 563
667 564 @undoc
668 565 def win_paste(event):
669 """Stub used when auto-generating shortcuts for documentation"""
566 """Stub used on other platforms"""
670 567 pass
568
569
570 KEY_BINDINGS = [
571 Binding(
572 handle_return_or_newline_or_execute,
573 ["enter"],
574 "default_buffer_focused & ~has_selection & insert_mode",
575 ),
576 Binding(
577 reformat_and_execute,
578 ["escape", "enter"],
579 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
580 ),
581 Binding(quit, ["c-\\"]),
582 Binding(
583 previous_history_or_previous_completion,
584 ["c-p"],
585 "vi_insert_mode & default_buffer_focused",
586 ),
587 Binding(
588 next_history_or_next_completion,
589 ["c-n"],
590 "vi_insert_mode & default_buffer_focused",
591 ),
592 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
593 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
594 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
595 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
596 Binding(
597 indent_buffer,
598 ["tab"], # Ctrl+I == Tab
599 "default_buffer_focused"
600 " & ~has_selection"
601 " & insert_mode"
602 " & cursor_in_leading_ws",
603 ),
604 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
605 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
606 *AUTO_MATCH_BINDINGS,
607 *AUTO_SUGGEST_BINDINGS,
608 Binding(
609 display_completions_like_readline,
610 ["c-i"],
611 "readline_like_completions"
612 " & default_buffer_focused"
613 " & ~has_selection"
614 " & insert_mode"
615 " & ~cursor_in_leading_ws",
616 ),
617 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
618 *SIMPLE_CONTROL_BINDINGS,
619 *ALT_AND_COMOBO_CONTROL_BINDINGS,
620 ]
@@ -1,104 +1,104 b''
1 1 """
2 2 Utilities function for keybinding with prompt toolkit.
3 3
4 4 This will be bound to specific key press and filter modes,
5 5 like whether we are in edit mode, and whether the completer is open.
6 6 """
7 7 import re
8 8 from prompt_toolkit.key_binding import KeyPressEvent
9 9
10 10
11 11 def parenthesis(event: KeyPressEvent):
12 12 """Auto-close parenthesis"""
13 13 event.current_buffer.insert_text("()")
14 14 event.current_buffer.cursor_left()
15 15
16 16
17 17 def brackets(event: KeyPressEvent):
18 18 """Auto-close brackets"""
19 19 event.current_buffer.insert_text("[]")
20 20 event.current_buffer.cursor_left()
21 21
22 22
23 23 def braces(event: KeyPressEvent):
24 24 """Auto-close braces"""
25 25 event.current_buffer.insert_text("{}")
26 26 event.current_buffer.cursor_left()
27 27
28 28
29 29 def double_quote(event: KeyPressEvent):
30 30 """Auto-close double quotes"""
31 31 event.current_buffer.insert_text('""')
32 32 event.current_buffer.cursor_left()
33 33
34 34
35 35 def single_quote(event: KeyPressEvent):
36 36 """Auto-close single quotes"""
37 37 event.current_buffer.insert_text("''")
38 38 event.current_buffer.cursor_left()
39 39
40 40
41 41 def docstring_double_quotes(event: KeyPressEvent):
42 42 """Auto-close docstring (double quotes)"""
43 43 event.current_buffer.insert_text('""""')
44 44 event.current_buffer.cursor_left(3)
45 45
46 46
47 47 def docstring_single_quotes(event: KeyPressEvent):
48 48 """Auto-close docstring (single quotes)"""
49 49 event.current_buffer.insert_text("''''")
50 50 event.current_buffer.cursor_left(3)
51 51
52 52
53 53 def raw_string_parenthesis(event: KeyPressEvent):
54 54 """Auto-close parenthesis in raw strings"""
55 55 matches = re.match(
56 56 r".*(r|R)[\"'](-*)",
57 57 event.current_buffer.document.current_line_before_cursor,
58 58 )
59 59 dashes = matches.group(2) if matches else ""
60 60 event.current_buffer.insert_text("()" + dashes)
61 61 event.current_buffer.cursor_left(len(dashes) + 1)
62 62
63 63
64 64 def raw_string_bracket(event: KeyPressEvent):
65 65 """Auto-close bracker in raw strings"""
66 66 matches = re.match(
67 67 r".*(r|R)[\"'](-*)",
68 68 event.current_buffer.document.current_line_before_cursor,
69 69 )
70 70 dashes = matches.group(2) if matches else ""
71 71 event.current_buffer.insert_text("[]" + dashes)
72 72 event.current_buffer.cursor_left(len(dashes) + 1)
73 73
74 74
75 75 def raw_string_braces(event: KeyPressEvent):
76 76 """Auto-close braces in raw strings"""
77 77 matches = re.match(
78 78 r".*(r|R)[\"'](-*)",
79 79 event.current_buffer.document.current_line_before_cursor,
80 80 )
81 81 dashes = matches.group(2) if matches else ""
82 82 event.current_buffer.insert_text("{}" + dashes)
83 83 event.current_buffer.cursor_left(len(dashes) + 1)
84 84
85 85
86 86 def skip_over(event: KeyPressEvent):
87 """Skip over automatically added parenthesis.
87 """Skip over automatically added parenthesis/quote.
88 88
89 (rather than adding another parenthesis)"""
89 (rather than adding another parenthesis/quote)"""
90 90 event.current_buffer.cursor_right()
91 91
92 92
93 93 def delete_pair(event: KeyPressEvent):
94 94 """Delete auto-closed parenthesis"""
95 95 event.current_buffer.delete()
96 96 event.current_buffer.delete_before_cursor()
97 97
98 98
99 99 auto_match_parens = {"(": parenthesis, "[": brackets, "{": braces}
100 100 auto_match_parens_raw_string = {
101 101 "(": raw_string_parenthesis,
102 102 "[": raw_string_bracket,
103 103 "{": raw_string_braces,
104 104 }
@@ -1,378 +1,375 b''
1 1 import re
2 2 import tokenize
3 3 from io import StringIO
4 from typing import Callable, List, Optional, Union, Generator, Tuple, Sequence
4 from typing import Callable, List, Optional, Union, Generator, Tuple
5 5
6 6 from prompt_toolkit.buffer import Buffer
7 7 from prompt_toolkit.key_binding import KeyPressEvent
8 8 from prompt_toolkit.key_binding.bindings import named_commands as nc
9 9 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
10 10 from prompt_toolkit.document import Document
11 11 from prompt_toolkit.history import History
12 12 from prompt_toolkit.shortcuts import PromptSession
13 13 from prompt_toolkit.layout.processors import (
14 14 Processor,
15 15 Transformation,
16 16 TransformationInput,
17 17 )
18 18
19 from IPython.core.getipython import get_ipython
19 20 from IPython.utils.tokenutil import generate_tokens
20 21
21 22
22 23 def _get_query(document: Document):
23 24 return document.lines[document.cursor_position_row]
24 25
25 26
26 27 class AppendAutoSuggestionInAnyLine(Processor):
27 28 """
28 29 Append the auto suggestion to lines other than the last (appending to the
29 30 last line is natively supported by the prompt toolkit).
30 31 """
31 32
32 33 def __init__(self, style: str = "class:auto-suggestion") -> None:
33 34 self.style = style
34 35
35 36 def apply_transformation(self, ti: TransformationInput) -> Transformation:
36 37 is_last_line = ti.lineno == ti.document.line_count - 1
37 38 is_active_line = ti.lineno == ti.document.cursor_position_row
38 39
39 40 if not is_last_line and is_active_line:
40 41 buffer = ti.buffer_control.buffer
41 42
42 43 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
43 44 suggestion = buffer.suggestion.text
44 45 else:
45 46 suggestion = ""
46 47
47 48 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
48 49 else:
49 50 return Transformation(fragments=ti.fragments)
50 51
51 52
52 53 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
53 54 """
54 55 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
55 56 suggestion from history. To do so it remembers the current position, but it
56 57 state need to carefully be cleared on the right events.
57 58 """
58 59
59 60 def __init__(
60 61 self,
61 62 ):
62 63 self.skip_lines = 0
63 64 self._connected_apps = []
64 65
65 66 def reset_history_position(self, _: Buffer):
66 67 self.skip_lines = 0
67 68
68 69 def disconnect(self):
69 70 for pt_app in self._connected_apps:
70 71 text_insert_event = pt_app.default_buffer.on_text_insert
71 72 text_insert_event.remove_handler(self.reset_history_position)
72 73
73 74 def connect(self, pt_app: PromptSession):
74 75 self._connected_apps.append(pt_app)
75 76 # note: `on_text_changed` could be used for a bit different behaviour
76 77 # on character deletion (i.e. reseting history position on backspace)
77 78 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
78 79 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
79 80
80 81 def get_suggestion(
81 82 self, buffer: Buffer, document: Document
82 83 ) -> Optional[Suggestion]:
83 84 text = _get_query(document)
84 85
85 86 if text.strip():
86 87 for suggestion, _ in self._find_next_match(
87 88 text, self.skip_lines, buffer.history
88 89 ):
89 90 return Suggestion(suggestion)
90 91
91 92 return None
92 93
93 94 def _dismiss(self, buffer, *args, **kwargs):
94 95 buffer.suggestion = None
95 96
96 97 def _find_match(
97 98 self, text: str, skip_lines: float, history: History, previous: bool
98 99 ) -> Generator[Tuple[str, float], None, None]:
99 100 """
100 101 text : str
101 102 Text content to find a match for, the user cursor is most of the
102 103 time at the end of this text.
103 104 skip_lines : float
104 105 number of items to skip in the search, this is used to indicate how
105 106 far in the list the user has navigated by pressing up or down.
106 107 The float type is used as the base value is +inf
107 108 history : History
108 109 prompt_toolkit History instance to fetch previous entries from.
109 110 previous : bool
110 111 Direction of the search, whether we are looking previous match
111 112 (True), or next match (False).
112 113
113 114 Yields
114 115 ------
115 116 Tuple with:
116 117 str:
117 118 current suggestion.
118 119 float:
119 120 will actually yield only ints, which is passed back via skip_lines,
120 121 which may be a +inf (float)
121 122
122 123
123 124 """
124 125 line_number = -1
125 126 for string in reversed(list(history.get_strings())):
126 127 for line in reversed(string.splitlines()):
127 128 line_number += 1
128 129 if not previous and line_number < skip_lines:
129 130 continue
130 131 # do not return empty suggestions as these
131 132 # close the auto-suggestion overlay (and are useless)
132 133 if line.startswith(text) and len(line) > len(text):
133 134 yield line[len(text) :], line_number
134 135 if previous and line_number >= skip_lines:
135 136 return
136 137
137 138 def _find_next_match(
138 139 self, text: str, skip_lines: float, history: History
139 140 ) -> Generator[Tuple[str, float], None, None]:
140 141 return self._find_match(text, skip_lines, history, previous=False)
141 142
142 143 def _find_previous_match(self, text: str, skip_lines: float, history: History):
143 144 return reversed(
144 145 list(self._find_match(text, skip_lines, history, previous=True))
145 146 )
146 147
147 148 def up(self, query: str, other_than: str, history: History) -> None:
148 149 for suggestion, line_number in self._find_next_match(
149 150 query, self.skip_lines, history
150 151 ):
151 152 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
152 153 # we want to switch from 'very.b' to 'very.a' because a) if the
153 154 # suggestion equals current text, prompt-toolkit aborts suggesting
154 155 # b) user likely would not be interested in 'very' anyways (they
155 156 # already typed it).
156 157 if query + suggestion != other_than:
157 158 self.skip_lines = line_number
158 159 break
159 160 else:
160 161 # no matches found, cycle back to beginning
161 162 self.skip_lines = 0
162 163
163 164 def down(self, query: str, other_than: str, history: History) -> None:
164 165 for suggestion, line_number in self._find_previous_match(
165 166 query, self.skip_lines, history
166 167 ):
167 168 if query + suggestion != other_than:
168 169 self.skip_lines = line_number
169 170 break
170 171 else:
171 172 # no matches found, cycle to end
172 173 for suggestion, line_number in self._find_previous_match(
173 174 query, float("Inf"), history
174 175 ):
175 176 if query + suggestion != other_than:
176 177 self.skip_lines = line_number
177 178 break
178 179
179 180
180 181 # Needed for to accept autosuggestions in vi insert mode
181 182 def accept_in_vi_insert_mode(event: KeyPressEvent):
182 183 """Apply autosuggestion if at end of line."""
183 184 buffer = event.current_buffer
184 185 d = buffer.document
185 186 after_cursor = d.text[d.cursor_position :]
186 187 lines = after_cursor.split("\n")
187 188 end_of_current_line = lines[0].strip()
188 189 suggestion = buffer.suggestion
189 190 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
190 191 buffer.insert_text(suggestion.text)
191 192 else:
192 193 nc.end_of_line(event)
193 194
194 195
195 196 def accept(event: KeyPressEvent):
196 197 """Accept autosuggestion"""
197 198 buffer = event.current_buffer
198 199 suggestion = buffer.suggestion
199 200 if suggestion:
200 201 buffer.insert_text(suggestion.text)
201 202 else:
202 203 nc.forward_char(event)
203 204
204 205
205 206 def discard(event: KeyPressEvent):
206 207 """Discard autosuggestion"""
207 208 buffer = event.current_buffer
208 209 buffer.suggestion = None
209 210
210 211
211 212 def accept_word(event: KeyPressEvent):
212 213 """Fill partial autosuggestion by word"""
213 214 buffer = event.current_buffer
214 215 suggestion = buffer.suggestion
215 216 if suggestion:
216 217 t = re.split(r"(\S+\s+)", suggestion.text)
217 218 buffer.insert_text(next((x for x in t if x), ""))
218 219 else:
219 220 nc.forward_word(event)
220 221
221 222
222 223 def accept_character(event: KeyPressEvent):
223 224 """Fill partial autosuggestion by character"""
224 225 b = event.current_buffer
225 226 suggestion = b.suggestion
226 227 if suggestion and suggestion.text:
227 228 b.insert_text(suggestion.text[0])
228 229
229 230
230 231 def accept_and_keep_cursor(event: KeyPressEvent):
231 232 """Accept autosuggestion and keep cursor in place"""
232 233 buffer = event.current_buffer
233 234 old_position = buffer.cursor_position
234 235 suggestion = buffer.suggestion
235 236 if suggestion:
236 237 buffer.insert_text(suggestion.text)
237 238 buffer.cursor_position = old_position
238 239
239 240
240 241 def accept_and_move_cursor_left(event: KeyPressEvent):
241 242 """Accept autosuggestion and move cursor left in place"""
242 243 accept_and_keep_cursor(event)
243 244 nc.backward_char(event)
244 245
245 246
246 247 def _update_hint(buffer: Buffer):
247 248 if buffer.auto_suggest:
248 249 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
249 250 buffer.suggestion = suggestion
250 251
251 252
252 253 def backspace_and_resume_hint(event: KeyPressEvent):
253 254 """Resume autosuggestions after deleting last character"""
254 255 current_buffer = event.current_buffer
255 256
256 257 def resume_hinting(buffer: Buffer):
257 258 _update_hint(buffer)
258 259 current_buffer.on_text_changed.remove_handler(resume_hinting)
259 260
260 261 current_buffer.on_text_changed.add_handler(resume_hinting)
261 262 nc.backward_delete_char(event)
262 263
263 264
264 265 def up_and_update_hint(event: KeyPressEvent):
265 266 """Go up and update hint"""
266 267 current_buffer = event.current_buffer
267 268
268 269 current_buffer.auto_up(count=event.arg)
269 270 _update_hint(current_buffer)
270 271
271 272
272 273 def down_and_update_hint(event: KeyPressEvent):
273 274 """Go down and update hint"""
274 275 current_buffer = event.current_buffer
275 276
276 277 current_buffer.auto_down(count=event.arg)
277 278 _update_hint(current_buffer)
278 279
279 280
280 281 def accept_token(event: KeyPressEvent):
281 282 """Fill partial autosuggestion by token"""
282 283 b = event.current_buffer
283 284 suggestion = b.suggestion
284 285
285 286 if suggestion:
286 287 prefix = _get_query(b.document)
287 288 text = prefix + suggestion.text
288 289
289 290 tokens: List[Optional[str]] = [None, None, None]
290 291 substrings = [""]
291 292 i = 0
292 293
293 294 for token in generate_tokens(StringIO(text).readline):
294 295 if token.type == tokenize.NEWLINE:
295 296 index = len(text)
296 297 else:
297 298 index = text.index(token[1], len(substrings[-1]))
298 299 substrings.append(text[:index])
299 300 tokenized_so_far = substrings[-1]
300 301 if tokenized_so_far.startswith(prefix):
301 302 if i == 0 and len(tokenized_so_far) > len(prefix):
302 303 tokens[0] = tokenized_so_far[len(prefix) :]
303 304 substrings.append(tokenized_so_far)
304 305 i += 1
305 306 tokens[i] = token[1]
306 307 if i == 2:
307 308 break
308 309 i += 1
309 310
310 311 if tokens[0]:
311 312 to_insert: str
312 313 insert_text = substrings[-2]
313 314 if tokens[1] and len(tokens[1]) == 1:
314 315 insert_text = substrings[-1]
315 316 to_insert = insert_text[len(prefix) :]
316 317 b.insert_text(to_insert)
317 318 return
318 319
319 320 nc.forward_word(event)
320 321
321 322
322 323 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
323 324
324 325
325 326 def _swap_autosuggestion(
326 327 buffer: Buffer,
327 328 provider: NavigableAutoSuggestFromHistory,
328 329 direction_method: Callable,
329 330 ):
330 331 """
331 332 We skip most recent history entry (in either direction) if it equals the
332 333 current autosuggestion because if user cycles when auto-suggestion is shown
333 334 they most likely want something else than what was suggested (otherwise
334 335 they would have accepted the suggestion).
335 336 """
336 337 suggestion = buffer.suggestion
337 338 if not suggestion:
338 339 return
339 340
340 341 query = _get_query(buffer.document)
341 342 current = query + suggestion.text
342 343
343 344 direction_method(query=query, other_than=current, history=buffer.history)
344 345
345 346 new_suggestion = provider.get_suggestion(buffer, buffer.document)
346 347 buffer.suggestion = new_suggestion
347 348
348 349
349 def swap_autosuggestion_up(provider: Provider):
350 350 def swap_autosuggestion_up(event: KeyPressEvent):
351 351 """Get next autosuggestion from history."""
352 shell = get_ipython()
353 provider = shell.auto_suggest
354
352 355 if not isinstance(provider, NavigableAutoSuggestFromHistory):
353 356 return
354 357
355 358 return _swap_autosuggestion(
356 359 buffer=event.current_buffer, provider=provider, direction_method=provider.up
357 360 )
358 361
359 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
360 return swap_autosuggestion_up
361
362 362
363 def swap_autosuggestion_down(
364 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
365 ):
366 363 def swap_autosuggestion_down(event: KeyPressEvent):
367 364 """Get previous autosuggestion from history."""
365 shell = get_ipython()
366 provider = shell.auto_suggest
367
368 368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 369 return
370 370
371 371 return _swap_autosuggestion(
372 372 buffer=event.current_buffer,
373 373 provider=provider,
374 374 direction_method=provider.down,
375 375 )
376
377 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
378 return swap_autosuggestion_down
@@ -1,256 +1,255 b''
1 1 # -*- coding: utf-8 -*-
2 2 """Tests for the TerminalInteractiveShell and related pieces."""
3 3 # Copyright (c) IPython Development Team.
4 4 # Distributed under the terms of the Modified BSD License.
5 5
6 6 import sys
7 7 import unittest
8 8 import os
9 9
10 10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
11 11
12 from IPython.core.inputtransformer import InputTransformer
12
13 13 from IPython.testing import tools as tt
14 from IPython.utils.capture import capture_output
15 14
16 15 from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context
17 16 from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory
18 17
19 18
20 19 class TestAutoSuggest(unittest.TestCase):
21 20 def test_changing_provider(self):
22 21 ip = get_ipython()
23 22 ip.autosuggestions_provider = None
24 23 self.assertEqual(ip.auto_suggest, None)
25 24 ip.autosuggestions_provider = "AutoSuggestFromHistory"
26 25 self.assertIsInstance(ip.auto_suggest, AutoSuggestFromHistory)
27 26 ip.autosuggestions_provider = "NavigableAutoSuggestFromHistory"
28 27 self.assertIsInstance(ip.auto_suggest, NavigableAutoSuggestFromHistory)
29 28
30 29
31 30 class TestElide(unittest.TestCase):
32 31 def test_elide(self):
33 32 _elide("concatenate((a1, a2, ...), axis", "") # do not raise
34 33 _elide("concatenate((a1, a2, ..), . axis", "") # do not raise
35 34 self.assertEqual(
36 35 _elide("aaaa.bbbb.ccccc.dddddd.eeeee.fffff.gggggg.hhhhhh", ""),
37 36 "aaaa.b…g.hhhhhh",
38 37 )
39 38
40 39 test_string = os.sep.join(["", 10 * "a", 10 * "b", 10 * "c", ""])
41 40 expect_string = (
42 41 os.sep + "a" + "\N{HORIZONTAL ELLIPSIS}" + "b" + os.sep + 10 * "c"
43 42 )
44 43 self.assertEqual(_elide(test_string, ""), expect_string)
45 44
46 45 def test_elide_typed_normal(self):
47 46 self.assertEqual(
48 47 _elide(
49 48 "the quick brown fox jumped over the lazy dog",
50 49 "the quick brown fox",
51 50 min_elide=10,
52 51 ),
53 52 "the…fox jumped over the lazy dog",
54 53 )
55 54
56 55 def test_elide_typed_short_match(self):
57 56 """
58 57 if the match is too short we don't elide.
59 58 avoid the "the...the"
60 59 """
61 60 self.assertEqual(
62 61 _elide("the quick brown fox jumped over the lazy dog", "the", min_elide=10),
63 62 "the quick brown fox jumped over the lazy dog",
64 63 )
65 64
66 65 def test_elide_typed_no_match(self):
67 66 """
68 67 if the match is too short we don't elide.
69 68 avoid the "the...the"
70 69 """
71 70 # here we typed red instead of brown
72 71 self.assertEqual(
73 72 _elide(
74 73 "the quick brown fox jumped over the lazy dog",
75 74 "the quick red fox",
76 75 min_elide=10,
77 76 ),
78 77 "the quick brown fox jumped over the lazy dog",
79 78 )
80 79
81 80
82 81 class TestContextAwareCompletion(unittest.TestCase):
83 82 def test_adjust_completion_text_based_on_context(self):
84 83 # Adjusted case
85 84 self.assertEqual(
86 85 _adjust_completion_text_based_on_context("arg1=", "func1(a=)", 7), "arg1"
87 86 )
88 87
89 88 # Untouched cases
90 89 self.assertEqual(
91 90 _adjust_completion_text_based_on_context("arg1=", "func1(a)", 7), "arg1="
92 91 )
93 92 self.assertEqual(
94 93 _adjust_completion_text_based_on_context("arg1=", "func1(a", 7), "arg1="
95 94 )
96 95 self.assertEqual(
97 96 _adjust_completion_text_based_on_context("%magic", "func1(a=)", 7), "%magic"
98 97 )
99 98 self.assertEqual(
100 99 _adjust_completion_text_based_on_context("func2", "func1(a=)", 7), "func2"
101 100 )
102 101
103 102
104 103 # Decorator for interaction loop tests -----------------------------------------
105 104
106 105
107 106 class mock_input_helper(object):
108 107 """Machinery for tests of the main interact loop.
109 108
110 109 Used by the mock_input decorator.
111 110 """
112 111 def __init__(self, testgen):
113 112 self.testgen = testgen
114 113 self.exception = None
115 114 self.ip = get_ipython()
116 115
117 116 def __enter__(self):
118 117 self.orig_prompt_for_code = self.ip.prompt_for_code
119 118 self.ip.prompt_for_code = self.fake_input
120 119 return self
121 120
122 121 def __exit__(self, etype, value, tb):
123 122 self.ip.prompt_for_code = self.orig_prompt_for_code
124 123
125 124 def fake_input(self):
126 125 try:
127 126 return next(self.testgen)
128 127 except StopIteration:
129 128 self.ip.keep_running = False
130 129 return u''
131 130 except:
132 131 self.exception = sys.exc_info()
133 132 self.ip.keep_running = False
134 133 return u''
135 134
136 135 def mock_input(testfunc):
137 136 """Decorator for tests of the main interact loop.
138 137
139 138 Write the test as a generator, yield-ing the input strings, which IPython
140 139 will see as if they were typed in at the prompt.
141 140 """
142 141 def test_method(self):
143 142 testgen = testfunc(self)
144 143 with mock_input_helper(testgen) as mih:
145 144 mih.ip.interact()
146 145
147 146 if mih.exception is not None:
148 147 # Re-raise captured exception
149 148 etype, value, tb = mih.exception
150 149 import traceback
151 150 traceback.print_tb(tb, file=sys.stdout)
152 151 del tb # Avoid reference loop
153 152 raise value
154 153
155 154 return test_method
156 155
157 156 # Test classes -----------------------------------------------------------------
158 157
159 158 class InteractiveShellTestCase(unittest.TestCase):
160 159 def rl_hist_entries(self, rl, n):
161 160 """Get last n readline history entries as a list"""
162 161 return [rl.get_history_item(rl.get_current_history_length() - x)
163 162 for x in range(n - 1, -1, -1)]
164 163
165 164 @mock_input
166 165 def test_inputtransformer_syntaxerror(self):
167 166 ip = get_ipython()
168 167 ip.input_transformers_post.append(syntax_error_transformer)
169 168
170 169 try:
171 170 #raise Exception
172 171 with tt.AssertPrints('4', suppress=False):
173 172 yield u'print(2*2)'
174 173
175 174 with tt.AssertPrints('SyntaxError: input contains', suppress=False):
176 175 yield u'print(2345) # syntaxerror'
177 176
178 177 with tt.AssertPrints('16', suppress=False):
179 178 yield u'print(4*4)'
180 179
181 180 finally:
182 181 ip.input_transformers_post.remove(syntax_error_transformer)
183 182
184 183 def test_repl_not_plain_text(self):
185 184 ip = get_ipython()
186 185 formatter = ip.display_formatter
187 186 assert formatter.active_types == ['text/plain']
188 187
189 188 # terminal may have arbitrary mimetype handler to open external viewer
190 189 # or inline images.
191 190 assert formatter.ipython_display_formatter.enabled
192 191
193 192 class Test(object):
194 193 def __repr__(self):
195 194 return "<Test %i>" % id(self)
196 195
197 196 def _repr_html_(self):
198 197 return '<html>'
199 198
200 199 # verify that HTML repr isn't computed
201 200 obj = Test()
202 201 data, _ = formatter.format(obj)
203 202 self.assertEqual(data, {'text/plain': repr(obj)})
204 203
205 204 class Test2(Test):
206 205 def _ipython_display_(self):
207 206 from IPython.display import display, HTML
208 207
209 208 display(HTML("<custom>"))
210 209
211 210 # verify that mimehandlers are called
212 211 called = False
213 212
214 213 def handler(data, metadata):
215 214 print("Handler called")
216 215 nonlocal called
217 216 called = True
218 217
219 218 ip.display_formatter.active_types.append("text/html")
220 219 ip.display_formatter.formatters["text/html"].enabled = True
221 220 ip.mime_renderers["text/html"] = handler
222 221 try:
223 222 obj = Test()
224 223 display(obj)
225 224 finally:
226 225 ip.display_formatter.formatters["text/html"].enabled = False
227 226 del ip.mime_renderers["text/html"]
228 227
229 228 assert called == True
230 229
231 230
232 231 def syntax_error_transformer(lines):
233 232 """Transformer that throws SyntaxError if 'syntaxerror' is in the code."""
234 233 for line in lines:
235 234 pos = line.find('syntaxerror')
236 235 if pos >= 0:
237 236 e = SyntaxError('input contains "syntaxerror"')
238 237 e.text = line
239 238 e.offset = pos + 1
240 239 raise e
241 240 return lines
242 241
243 242
244 243 class TerminalMagicsTestCase(unittest.TestCase):
245 244 def test_paste_magics_blankline(self):
246 245 """Test that code with a blank line doesn't get split (gh-3246)."""
247 246 ip = get_ipython()
248 247 s = ('def pasted_func(a):\n'
249 248 ' b = a+1\n'
250 249 '\n'
251 250 ' return b')
252 251
253 252 tm = ip.magics_manager.registry['TerminalMagics']
254 253 tm.store_or_execute(s, name=None)
255 254
256 255 self.assertEqual(ip.user_ns['pasted_func'](54), 55)
@@ -1,318 +1,422 b''
1 1 import pytest
2 2 from IPython.terminal.shortcuts.auto_suggest import (
3 3 accept,
4 4 accept_in_vi_insert_mode,
5 5 accept_token,
6 6 accept_character,
7 7 accept_word,
8 8 accept_and_keep_cursor,
9 9 discard,
10 10 NavigableAutoSuggestFromHistory,
11 11 swap_autosuggestion_up,
12 12 swap_autosuggestion_down,
13 13 )
14 from IPython.terminal.shortcuts.auto_match import skip_over
15 from IPython.terminal.shortcuts import create_ipython_shortcuts
14 16
15 17 from prompt_toolkit.history import InMemoryHistory
16 18 from prompt_toolkit.buffer import Buffer
17 19 from prompt_toolkit.document import Document
18 20 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
19 21
20 22 from unittest.mock import patch, Mock
21 23
22 24
23 25 def make_event(text, cursor, suggestion):
24 26 event = Mock()
25 27 event.current_buffer = Mock()
26 28 event.current_buffer.suggestion = Mock()
27 29 event.current_buffer.text = text
28 30 event.current_buffer.cursor_position = cursor
29 31 event.current_buffer.suggestion.text = suggestion
30 32 event.current_buffer.document = Document(text=text, cursor_position=cursor)
31 33 return event
32 34
33 35
34 36 @pytest.mark.parametrize(
35 37 "text, suggestion, expected",
36 38 [
37 39 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):"),
38 40 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):"),
39 41 ],
40 42 )
41 43 def test_accept(text, suggestion, expected):
42 44 event = make_event(text, len(text), suggestion)
43 45 buffer = event.current_buffer
44 46 buffer.insert_text = Mock()
45 47 accept(event)
46 48 assert buffer.insert_text.called
47 49 assert buffer.insert_text.call_args[0] == (expected,)
48 50
49 51
50 52 @pytest.mark.parametrize(
51 53 "text, suggestion",
52 54 [
53 55 ("", "def out(tag: str, n=50):"),
54 56 ("def ", "out(tag: str, n=50):"),
55 57 ],
56 58 )
57 59 def test_discard(text, suggestion):
58 60 event = make_event(text, len(text), suggestion)
59 61 buffer = event.current_buffer
60 62 buffer.insert_text = Mock()
61 63 discard(event)
62 64 assert not buffer.insert_text.called
63 65 assert buffer.suggestion is None
64 66
65 67
66 68 @pytest.mark.parametrize(
67 69 "text, cursor, suggestion, called",
68 70 [
69 71 ("123456", 6, "123456789", True),
70 72 ("123456", 3, "123456789", False),
71 73 ("123456 \n789", 6, "123456789", True),
72 74 ],
73 75 )
74 76 def test_autosuggest_at_EOL(text, cursor, suggestion, called):
75 77 """
76 78 test that autosuggest is only applied at end of line.
77 79 """
78 80
79 81 event = make_event(text, cursor, suggestion)
80 82 event.current_buffer.insert_text = Mock()
81 83 accept_in_vi_insert_mode(event)
82 84 if called:
83 85 event.current_buffer.insert_text.assert_called()
84 86 else:
85 87 event.current_buffer.insert_text.assert_not_called()
86 88 # event.current_buffer.document.get_end_of_line_position.assert_called()
87 89
88 90
89 91 @pytest.mark.parametrize(
90 92 "text, suggestion, expected",
91 93 [
92 94 ("", "def out(tag: str, n=50):", "def "),
93 95 ("d", "ef out(tag: str, n=50):", "ef "),
94 96 ("de ", "f out(tag: str, n=50):", "f "),
95 97 ("def", " out(tag: str, n=50):", " "),
96 98 ("def ", "out(tag: str, n=50):", "out("),
97 99 ("def o", "ut(tag: str, n=50):", "ut("),
98 100 ("def ou", "t(tag: str, n=50):", "t("),
99 101 ("def out", "(tag: str, n=50):", "("),
100 102 ("def out(", "tag: str, n=50):", "tag: "),
101 103 ("def out(t", "ag: str, n=50):", "ag: "),
102 104 ("def out(ta", "g: str, n=50):", "g: "),
103 105 ("def out(tag", ": str, n=50):", ": "),
104 106 ("def out(tag:", " str, n=50):", " "),
105 107 ("def out(tag: ", "str, n=50):", "str, "),
106 108 ("def out(tag: s", "tr, n=50):", "tr, "),
107 109 ("def out(tag: st", "r, n=50):", "r, "),
108 110 ("def out(tag: str", ", n=50):", ", n"),
109 111 ("def out(tag: str,", " n=50):", " n"),
110 112 ("def out(tag: str, ", "n=50):", "n="),
111 113 ("def out(tag: str, n", "=50):", "="),
112 114 ("def out(tag: str, n=", "50):", "50)"),
113 115 ("def out(tag: str, n=5", "0):", "0)"),
114 116 ("def out(tag: str, n=50", "):", "):"),
115 117 ("def out(tag: str, n=50)", ":", ":"),
116 118 ],
117 119 )
118 120 def test_autosuggest_token(text, suggestion, expected):
119 121 event = make_event(text, len(text), suggestion)
120 122 event.current_buffer.insert_text = Mock()
121 123 accept_token(event)
122 124 assert event.current_buffer.insert_text.called
123 125 assert event.current_buffer.insert_text.call_args[0] == (expected,)
124 126
125 127
126 128 @pytest.mark.parametrize(
127 129 "text, suggestion, expected",
128 130 [
129 131 ("", "def out(tag: str, n=50):", "d"),
130 132 ("d", "ef out(tag: str, n=50):", "e"),
131 133 ("de ", "f out(tag: str, n=50):", "f"),
132 134 ("def", " out(tag: str, n=50):", " "),
133 135 ],
134 136 )
135 137 def test_accept_character(text, suggestion, expected):
136 138 event = make_event(text, len(text), suggestion)
137 139 event.current_buffer.insert_text = Mock()
138 140 accept_character(event)
139 141 assert event.current_buffer.insert_text.called
140 142 assert event.current_buffer.insert_text.call_args[0] == (expected,)
141 143
142 144
143 145 @pytest.mark.parametrize(
144 146 "text, suggestion, expected",
145 147 [
146 148 ("", "def out(tag: str, n=50):", "def "),
147 149 ("d", "ef out(tag: str, n=50):", "ef "),
148 150 ("de", "f out(tag: str, n=50):", "f "),
149 151 ("def", " out(tag: str, n=50):", " "),
150 152 # (this is why we also have accept_token)
151 153 ("def ", "out(tag: str, n=50):", "out(tag: "),
152 154 ],
153 155 )
154 156 def test_accept_word(text, suggestion, expected):
155 157 event = make_event(text, len(text), suggestion)
156 158 event.current_buffer.insert_text = Mock()
157 159 accept_word(event)
158 160 assert event.current_buffer.insert_text.called
159 161 assert event.current_buffer.insert_text.call_args[0] == (expected,)
160 162
161 163
162 164 @pytest.mark.parametrize(
163 165 "text, suggestion, expected, cursor",
164 166 [
165 167 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):", 0),
166 168 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):", 4),
167 169 ],
168 170 )
169 171 def test_accept_and_keep_cursor(text, suggestion, expected, cursor):
170 172 event = make_event(text, cursor, suggestion)
171 173 buffer = event.current_buffer
172 174 buffer.insert_text = Mock()
173 175 accept_and_keep_cursor(event)
174 176 assert buffer.insert_text.called
175 177 assert buffer.insert_text.call_args[0] == (expected,)
176 178 assert buffer.cursor_position == cursor
177 179
178 180
179 181 def test_autosuggest_token_empty():
180 182 full = "def out(tag: str, n=50):"
181 183 event = make_event(full, len(full), "")
182 184 event.current_buffer.insert_text = Mock()
183 185
184 186 with patch(
185 187 "prompt_toolkit.key_binding.bindings.named_commands.forward_word"
186 188 ) as forward_word:
187 189 accept_token(event)
188 190 assert not event.current_buffer.insert_text.called
189 191 assert forward_word.called
190 192
191 193
192 194 def test_other_providers():
193 195 """Ensure that swapping autosuggestions does not break with other providers"""
194 196 provider = AutoSuggestFromHistory()
195 up = swap_autosuggestion_up(provider)
196 down = swap_autosuggestion_down(provider)
197 ip = get_ipython()
198 ip.auto_suggest = provider
197 199 event = Mock()
198 200 event.current_buffer = Buffer()
199 assert up(event) is None
200 assert down(event) is None
201 assert swap_autosuggestion_up(event) is None
202 assert swap_autosuggestion_down(event) is None
201 203
202 204
203 205 async def test_navigable_provider():
204 206 provider = NavigableAutoSuggestFromHistory()
205 207 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
206 208 buffer = Buffer(history=history)
209 ip = get_ipython()
210 ip.auto_suggest = provider
207 211
208 212 async for _ in history.load():
209 213 pass
210 214
211 215 buffer.cursor_position = 5
212 216 buffer.text = "very"
213 217
214 up = swap_autosuggestion_up(provider)
215 down = swap_autosuggestion_down(provider)
218 up = swap_autosuggestion_up
219 down = swap_autosuggestion_down
216 220
217 221 event = Mock()
218 222 event.current_buffer = buffer
219 223
220 224 def get_suggestion():
221 225 suggestion = provider.get_suggestion(buffer, buffer.document)
222 226 buffer.suggestion = suggestion
223 227 return suggestion
224 228
225 229 assert get_suggestion().text == "_c"
226 230
227 231 # should go up
228 232 up(event)
229 233 assert get_suggestion().text == "_b"
230 234
231 235 # should skip over 'very' which is identical to buffer content
232 236 up(event)
233 237 assert get_suggestion().text == "_a"
234 238
235 239 # should cycle back to beginning
236 240 up(event)
237 241 assert get_suggestion().text == "_c"
238 242
239 243 # should cycle back through end boundary
240 244 down(event)
241 245 assert get_suggestion().text == "_a"
242 246
243 247 down(event)
244 248 assert get_suggestion().text == "_b"
245 249
246 250 down(event)
247 251 assert get_suggestion().text == "_c"
248 252
249 253 down(event)
250 254 assert get_suggestion().text == "_a"
251 255
252 256
253 257 async def test_navigable_provider_multiline_entries():
254 258 provider = NavigableAutoSuggestFromHistory()
255 259 history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
256 260 buffer = Buffer(history=history)
261 ip = get_ipython()
262 ip.auto_suggest = provider
257 263
258 264 async for _ in history.load():
259 265 pass
260 266
261 267 buffer.cursor_position = 5
262 268 buffer.text = "very"
263 up = swap_autosuggestion_up(provider)
264 down = swap_autosuggestion_down(provider)
269 up = swap_autosuggestion_up
270 down = swap_autosuggestion_down
265 271
266 272 event = Mock()
267 273 event.current_buffer = buffer
268 274
269 275 def get_suggestion():
270 276 suggestion = provider.get_suggestion(buffer, buffer.document)
271 277 buffer.suggestion = suggestion
272 278 return suggestion
273 279
274 280 assert get_suggestion().text == "_c"
275 281
276 282 up(event)
277 283 assert get_suggestion().text == "_b"
278 284
279 285 up(event)
280 286 assert get_suggestion().text == "_a"
281 287
282 288 down(event)
283 289 assert get_suggestion().text == "_b"
284 290
285 291 down(event)
286 292 assert get_suggestion().text == "_c"
287 293
288 294
289 295 def create_session_mock():
290 296 session = Mock()
291 297 session.default_buffer = Buffer()
292 298 return session
293 299
294 300
295 301 def test_navigable_provider_connection():
296 302 provider = NavigableAutoSuggestFromHistory()
297 303 provider.skip_lines = 1
298 304
299 305 session_1 = create_session_mock()
300 306 provider.connect(session_1)
301 307
302 308 assert provider.skip_lines == 1
303 309 session_1.default_buffer.on_text_insert.fire()
304 310 assert provider.skip_lines == 0
305 311
306 312 session_2 = create_session_mock()
307 313 provider.connect(session_2)
308 314 provider.skip_lines = 2
309 315
310 316 assert provider.skip_lines == 2
311 317 session_2.default_buffer.on_text_insert.fire()
312 318 assert provider.skip_lines == 0
313 319
314 320 provider.skip_lines = 3
315 321 provider.disconnect()
316 322 session_1.default_buffer.on_text_insert.fire()
317 323 session_2.default_buffer.on_text_insert.fire()
318 324 assert provider.skip_lines == 3
325
326
327 @pytest.fixture
328 def ipython_with_prompt():
329 ip = get_ipython()
330 ip.pt_app = Mock()
331 ip.pt_app.key_bindings = create_ipython_shortcuts(ip)
332 try:
333 yield ip
334 finally:
335 ip.pt_app = None
336
337
338 def find_bindings_by_command(command):
339 ip = get_ipython()
340 return [
341 binding
342 for binding in ip.pt_app.key_bindings.bindings
343 if binding.handler == command
344 ]
345
346
347 def test_modify_unique_shortcut(ipython_with_prompt):
348 matched = find_bindings_by_command(accept_token)
349 assert len(matched) == 1
350
351 ipython_with_prompt.shortcuts = [
352 {"command": "IPython:auto_suggest.accept_token", "new_keys": ["a", "b", "c"]}
353 ]
354 matched = find_bindings_by_command(accept_token)
355 assert len(matched) == 1
356 assert list(matched[0].keys) == ["a", "b", "c"]
357
358 ipython_with_prompt.shortcuts = [
359 {"command": "IPython:auto_suggest.accept_token", "new_keys": []}
360 ]
361 matched = find_bindings_by_command(accept_token)
362 assert len(matched) == 0
363
364 ipython_with_prompt.shortcuts = []
365 matched = find_bindings_by_command(accept_token)
366 assert len(matched) == 1
367
368
369 def test_modify_shortcut_with_filters(ipython_with_prompt):
370 matched = find_bindings_by_command(skip_over)
371 matched_keys = {m.keys[0] for m in matched}
372 assert matched_keys == {")", "]", "}", "'", '"'}
373
374 with pytest.raises(ValueError, match="Multiple shortcuts matching"):
375 ipython_with_prompt.shortcuts = [
376 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]}
377 ]
378
379 ipython_with_prompt.shortcuts = [
380 {
381 "command": "IPython:auto_match.skip_over",
382 "new_keys": ["x"],
383 "match_filter": "focused_insert & auto_match & followed_by_single_quote",
384 }
385 ]
386 matched = find_bindings_by_command(skip_over)
387 matched_keys = {m.keys[0] for m in matched}
388 assert matched_keys == {")", "]", "}", "x", '"'}
389
390
391 def test_command():
392 pass
393
394
395 def test_add_shortcut_for_new_command(ipython_with_prompt):
396 matched = find_bindings_by_command(test_command)
397 assert len(matched) == 0
398
399 with pytest.raises(ValueError, match="test_command is not a known"):
400 ipython_with_prompt.shortcuts = [{"command": "test_command", "new_keys": ["x"]}]
401 matched = find_bindings_by_command(test_command)
402 assert len(matched) == 0
403
404
405 def test_add_shortcut_for_existing_command(ipython_with_prompt):
406 matched = find_bindings_by_command(skip_over)
407 assert len(matched) == 5
408
409 with pytest.raises(ValueError, match="Cannot add a shortcut without keys"):
410 ipython_with_prompt.shortcuts = [
411 {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True}
412 ]
413
414 ipython_with_prompt.shortcuts = [
415 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
416 ]
417 matched = find_bindings_by_command(skip_over)
418 assert len(matched) == 6
419
420 ipython_with_prompt.shortcuts = []
421 matched = find_bindings_by_command(skip_over)
422 assert len(matched) == 5
@@ -1,221 +1,221 b''
1 1 from dataclasses import dataclass
2 2 from inspect import getsource
3 3 from pathlib import Path
4 from typing import cast, Callable, List, Union
4 from typing import cast, List, Union
5 5 from html import escape as html_escape
6 6 import re
7 7
8 8 from prompt_toolkit.keys import KEY_ALIASES
9 9 from prompt_toolkit.key_binding import KeyBindingsBase
10 10 from prompt_toolkit.filters import Filter, Condition
11 11 from prompt_toolkit.shortcuts import PromptSession
12 12
13 from IPython.terminal.shortcuts import create_ipython_shortcuts
13 from IPython.terminal.shortcuts import create_ipython_shortcuts, create_identifier
14 from IPython.terminal.shortcuts.filters import KEYBINDING_FILTERS
14 15
15 16
16 17 @dataclass
17 18 class Shortcut:
18 19 #: a sequence of keys (each element on the list corresponds to pressing one or more keys)
19 20 keys_sequence: list[str]
20 21 filter: str
21 22
22 23
23 24 @dataclass
24 25 class Handler:
25 26 description: str
26 27 identifier: str
27 28
28 29
29 30 @dataclass
30 31 class Binding:
31 32 handler: Handler
32 33 shortcut: Shortcut
33 34
34 35
35 36 class _NestedFilter(Filter):
36 37 """Protocol reflecting non-public prompt_toolkit's `_AndList` and `_OrList`."""
37 38
38 39 filters: List[Filter]
39 40
40 41
41 42 class _Invert(Filter):
42 43 """Protocol reflecting non-public prompt_toolkit's `_Invert`."""
43 44
44 45 filter: Filter
45 46
46 47
47 conjunctions_labels = {"_AndList": "and", "_OrList": "or"}
48 conjunctions_labels = {"_AndList": "&", "_OrList": "|"}
48 49
49 50 ATOMIC_CLASSES = {"Never", "Always", "Condition"}
50 51
51 52
53 HUMAN_NAMES_FOR_FILTERS = {
54 filter_: name for name, filter_ in KEYBINDING_FILTERS.items()
55 }
56
57
52 58 def format_filter(
53 59 filter_: Union[Filter, _NestedFilter, Condition, _Invert],
54 60 is_top_level=True,
55 61 skip=None,
56 62 ) -> str:
57 63 """Create easily readable description of the filter."""
58 64 s = filter_.__class__.__name__
59 65 if s == "Condition":
60 66 func = cast(Condition, filter_).func
67 if filter_ in HUMAN_NAMES_FOR_FILTERS:
68 return HUMAN_NAMES_FOR_FILTERS[filter_]
61 69 name = func.__name__
62 70 if name == "<lambda>":
63 71 source = getsource(func)
64 72 return source.split("=")[0].strip()
65 73 return func.__name__
66 74 elif s == "_Invert":
67 75 operand = cast(_Invert, filter_).filter
68 76 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)})"
77 return f"~{format_filter(operand, is_top_level=False)}"
78 return f"~({format_filter(operand, is_top_level=False)})"
71 79 elif s in conjunctions_labels:
72 80 filters = cast(_NestedFilter, filter_).filters
81 if filter_ in HUMAN_NAMES_FOR_FILTERS:
82 return HUMAN_NAMES_FOR_FILTERS[filter_]
73 83 conjunction = conjunctions_labels[s]
74 84 glue = f" {conjunction} "
75 85 result = glue.join(format_filter(x, is_top_level=False) for x in filters)
76 86 if len(filters) > 1 and not is_top_level:
77 87 result = f"({result})"
78 88 return result
79 89 elif s in ["Never", "Always"]:
80 90 return s.lower()
81 91 else:
82 92 raise ValueError(f"Unknown filter type: {filter_}")
83 93
84 94
85 95 def sentencize(s) -> str:
86 96 """Extract first sentence"""
87 97 s = re.split(r"\.\W", s.replace("\n", " ").strip())
88 98 s = s[0] if len(s) else ""
89 99 if not s.endswith("."):
90 100 s += "."
91 101 try:
92 102 return " ".join(s.split())
93 103 except AttributeError:
94 104 return s
95 105
96 106
97 107 class _DummyTerminal:
98 108 """Used as a buffer to get prompt_toolkit bindings
99 109 """
100 110 handle_return = None
101 111 input_transformer_manager = None
102 112 display_completions = None
103 113 editing_mode = "emacs"
104 114 auto_suggest = None
105 115
106 116
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 117 def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]:
119 118 """Collect bindings to a simple format that does not depend on prompt-toolkit internals"""
120 119 bindings: List[Binding] = []
121 120
122 121 for kb in prompt_bindings.bindings:
123 122 bindings.append(
124 123 Binding(
125 124 handler=Handler(
126 125 description=kb.handler.__doc__ or "",
127 126 identifier=create_identifier(kb.handler),
128 127 ),
129 128 shortcut=Shortcut(
130 129 keys_sequence=[
131 130 str(k.value) if hasattr(k, "value") else k for k in kb.keys
132 131 ],
133 132 filter=format_filter(kb.filter, skip={"has_focus_filter"}),
134 133 ),
135 134 )
136 135 )
137 136 return bindings
138 137
139 138
140 139 INDISTINGUISHABLE_KEYS = {**KEY_ALIASES, **{v: k for k, v in KEY_ALIASES.items()}}
141 140
142 141
143 142 def format_prompt_keys(keys: str, add_alternatives=True) -> str:
144 143 """Format prompt toolkit key with modifier into an RST representation."""
145 144
146 145 def to_rst(key):
147 146 escaped = key.replace("\\", "\\\\")
148 147 return f":kbd:`{escaped}`"
149 148
150 149 keys_to_press: list[str]
151 150
152 151 prefixes = {
153 152 "c-s-": [to_rst("ctrl"), to_rst("shift")],
154 153 "s-c-": [to_rst("ctrl"), to_rst("shift")],
155 154 "c-": [to_rst("ctrl")],
156 155 "s-": [to_rst("shift")],
157 156 }
158 157
159 158 for prefix, modifiers in prefixes.items():
160 159 if keys.startswith(prefix):
161 160 remainder = keys[len(prefix) :]
162 161 keys_to_press = [*modifiers, to_rst(remainder)]
163 162 break
164 163 else:
165 164 keys_to_press = [to_rst(keys)]
166 165
167 166 result = " + ".join(keys_to_press)
168 167
169 168 if keys in INDISTINGUISHABLE_KEYS and add_alternatives:
170 169 alternative = INDISTINGUISHABLE_KEYS[keys]
171 170
172 171 result = (
173 172 result
174 173 + " (or "
175 174 + format_prompt_keys(alternative, add_alternatives=False)
176 175 + ")"
177 176 )
178 177
179 178 return result
180 179
180
181 181 if __name__ == '__main__':
182 182 here = Path(__file__).parent
183 183 dest = here / "source" / "config" / "shortcuts"
184 184
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True)
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal())
186 186
187 187 session = PromptSession(key_bindings=ipy_bindings)
188 188 prompt_bindings = session.app.key_bindings
189 189
190 190 assert prompt_bindings
191 191 # Ensure that we collected the default shortcuts
192 192 assert len(prompt_bindings.bindings) > len(ipy_bindings.bindings)
193 193
194 194 bindings = bindings_from_prompt_toolkit(prompt_bindings)
195 195
196 196 def sort_key(binding: Binding):
197 197 return binding.handler.identifier, binding.shortcut.filter
198 198
199 199 filters = []
200 200 with (dest / "table.tsv").open("w", encoding="utf-8") as csv:
201 201 for binding in sorted(bindings, key=sort_key):
202 202 sequence = ", ".join(
203 203 [format_prompt_keys(keys) for keys in binding.shortcut.keys_sequence]
204 204 )
205 205 if binding.shortcut.filter == "always":
206 206 condition_label = "-"
207 207 else:
208 208 # we cannot fit all the columns as the filters got too complex over time
209 209 condition_label = "ⓘ"
210 210
211 211 csv.write(
212 212 "\t".join(
213 213 [
214 214 sequence,
215 215 sentencize(binding.handler.description)
216 216 + f" :raw-html:`<br>` `{binding.handler.identifier}`",
217 217 f':raw-html:`<span title="{html_escape(binding.shortcut.filter)}" style="cursor: help">{condition_label}</span>`',
218 218 ]
219 219 )
220 220 + "\n"
221 221 )
General Comments 0
You need to be logged in to leave comments. Login now