##// END OF EJS Templates
Add pass-through filter for shortcuts
krassowski -
Show More
@@ -1,629 +1,630 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 10 import signal
11 11 import sys
12 12 import warnings
13 13 from dataclasses import dataclass
14 14 from typing import Callable, Any, Optional, List
15 15
16 16 from prompt_toolkit.application.current import get_app
17 17 from prompt_toolkit.key_binding import KeyBindings
18 18 from prompt_toolkit.key_binding.key_processor import KeyPressEvent
19 19 from prompt_toolkit.key_binding.bindings import named_commands as nc
20 20 from prompt_toolkit.key_binding.bindings.completion import (
21 21 display_completions_like_readline,
22 22 )
23 23 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
24 24 from prompt_toolkit.filters import Condition
25 25
26 26 from IPython.core.getipython import get_ipython
27 27 from IPython.terminal.shortcuts import auto_match as match
28 28 from IPython.terminal.shortcuts import auto_suggest
29 29 from IPython.terminal.shortcuts.filters import filter_from_string
30 30 from IPython.utils.decorators import undoc
31 31
32 32 from prompt_toolkit.enums import DEFAULT_BUFFER
33 33
34 34 __all__ = ["create_ipython_shortcuts"]
35 35
36 36
37 37 @dataclass
38 38 class BaseBinding:
39 39 command: Callable[[KeyPressEvent], Any]
40 40 keys: List[str]
41 41
42 42
43 43 @dataclass
44 44 class RuntimeBinding(BaseBinding):
45 45 filter: Condition
46 46
47 47
48 48 @dataclass
49 49 class Binding(BaseBinding):
50 50 # while filter could be created by referencing variables directly (rather
51 51 # than created from strings), by using strings we ensure that users will
52 52 # be able to create filters in configuration (e.g. JSON) files too, which
53 53 # also benefits the documentation by enforcing human-readable filter names.
54 54 condition: Optional[str] = None
55 55
56 56 def __post_init__(self):
57 57 if self.condition:
58 58 self.filter = filter_from_string(self.condition)
59 59 else:
60 60 self.filter = None
61 61
62 62
63 63 def create_identifier(handler: Callable):
64 64 parts = handler.__module__.split(".")
65 65 name = handler.__name__
66 66 package = parts[0]
67 67 if len(parts) > 1:
68 68 final_module = parts[-1]
69 69 return f"{package}:{final_module}.{name}"
70 70 else:
71 71 return f"{package}:{name}"
72 72
73 73
74 74 AUTO_MATCH_BINDINGS = [
75 75 *[
76 76 Binding(
77 77 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
78 78 )
79 79 for key, cmd in match.auto_match_parens.items()
80 80 ],
81 81 *[
82 82 # raw string
83 83 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
84 84 for key, cmd in match.auto_match_parens_raw_string.items()
85 85 ],
86 86 Binding(
87 87 match.double_quote,
88 88 ['"'],
89 89 "focused_insert"
90 90 " & auto_match"
91 91 " & not_inside_unclosed_string"
92 92 " & preceded_by_paired_double_quotes"
93 93 " & followed_by_closing_paren_or_end",
94 94 ),
95 95 Binding(
96 96 match.single_quote,
97 97 ["'"],
98 98 "focused_insert"
99 99 " & auto_match"
100 100 " & not_inside_unclosed_string"
101 101 " & preceded_by_paired_single_quotes"
102 102 " & followed_by_closing_paren_or_end",
103 103 ),
104 104 Binding(
105 105 match.docstring_double_quotes,
106 106 ['"'],
107 107 "focused_insert"
108 108 " & auto_match"
109 109 " & not_inside_unclosed_string"
110 110 " & preceded_by_two_double_quotes",
111 111 ),
112 112 Binding(
113 113 match.docstring_single_quotes,
114 114 ["'"],
115 115 "focused_insert"
116 116 " & auto_match"
117 117 " & not_inside_unclosed_string"
118 118 " & preceded_by_two_single_quotes",
119 119 ),
120 120 Binding(
121 121 match.skip_over,
122 122 [")"],
123 123 "focused_insert & auto_match & followed_by_closing_round_paren",
124 124 ),
125 125 Binding(
126 126 match.skip_over,
127 127 ["]"],
128 128 "focused_insert & auto_match & followed_by_closing_bracket",
129 129 ),
130 130 Binding(
131 131 match.skip_over,
132 132 ["}"],
133 133 "focused_insert & auto_match & followed_by_closing_brace",
134 134 ),
135 135 Binding(
136 136 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
137 137 ),
138 138 Binding(
139 139 match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
140 140 ),
141 141 Binding(
142 142 match.delete_pair,
143 143 ["backspace"],
144 144 "focused_insert"
145 145 " & preceded_by_opening_round_paren"
146 146 " & auto_match"
147 147 " & followed_by_closing_round_paren",
148 148 ),
149 149 Binding(
150 150 match.delete_pair,
151 151 ["backspace"],
152 152 "focused_insert"
153 153 " & preceded_by_opening_bracket"
154 154 " & auto_match"
155 155 " & followed_by_closing_bracket",
156 156 ),
157 157 Binding(
158 158 match.delete_pair,
159 159 ["backspace"],
160 160 "focused_insert"
161 161 " & preceded_by_opening_brace"
162 162 " & auto_match"
163 163 " & followed_by_closing_brace",
164 164 ),
165 165 Binding(
166 166 match.delete_pair,
167 167 ["backspace"],
168 168 "focused_insert"
169 169 " & preceded_by_double_quote"
170 170 " & auto_match"
171 171 " & followed_by_double_quote",
172 172 ),
173 173 Binding(
174 174 match.delete_pair,
175 175 ["backspace"],
176 176 "focused_insert"
177 177 " & preceded_by_single_quote"
178 178 " & auto_match"
179 179 " & followed_by_single_quote",
180 180 ),
181 181 ]
182 182
183 183 AUTO_SUGGEST_BINDINGS = [
184 184 # there are two reasons for re-defining bindings defined upstream:
185 185 # 1) prompt-toolkit does not execute autosuggestion bindings in vi mode,
186 186 # 2) prompt-toolkit checks if we are at the end of text, not end of line
187 187 # hence it does not work in multi-line mode of navigable provider
188 188 Binding(
189 189 auto_suggest.accept_or_jump_to_end,
190 190 ["end"],
191 191 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
192 192 ),
193 193 Binding(
194 194 auto_suggest.accept_or_jump_to_end,
195 195 ["c-e"],
196 196 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
197 197 ),
198 198 Binding(
199 199 auto_suggest.accept,
200 200 ["c-f"],
201 201 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
202 202 ),
203 203 Binding(
204 204 auto_suggest.accept,
205 205 ["right"],
206 206 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
207 207 ),
208 208 Binding(
209 209 auto_suggest.accept_word,
210 210 ["escape", "f"],
211 211 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
212 212 ),
213 213 Binding(
214 214 auto_suggest.accept_token,
215 215 ["c-right"],
216 216 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
217 217 ),
218 218 Binding(
219 219 auto_suggest.discard,
220 220 ["escape"],
221 221 # note this one is using `emacs_insert_mode`, not `emacs_like_insert_mode`
222 222 # as in `vi_insert_mode` we do not want `escape` to be shadowed (ever).
223 223 "has_suggestion & default_buffer_focused & emacs_insert_mode",
224 224 ),
225 225 Binding(
226 226 auto_suggest.discard,
227 227 ["delete"],
228 228 "has_suggestion & default_buffer_focused & emacs_insert_mode",
229 229 ),
230 230 Binding(
231 231 auto_suggest.swap_autosuggestion_up,
232 232 ["c-up"],
233 233 "navigable_suggestions"
234 234 " & ~has_line_above"
235 235 " & has_suggestion"
236 236 " & default_buffer_focused",
237 237 ),
238 238 Binding(
239 239 auto_suggest.swap_autosuggestion_down,
240 240 ["c-down"],
241 241 "navigable_suggestions"
242 242 " & ~has_line_below"
243 243 " & has_suggestion"
244 244 " & default_buffer_focused",
245 245 ),
246 246 Binding(
247 247 auto_suggest.up_and_update_hint,
248 248 ["c-up"],
249 249 "has_line_above & navigable_suggestions & default_buffer_focused",
250 250 ),
251 251 Binding(
252 252 auto_suggest.down_and_update_hint,
253 253 ["c-down"],
254 254 "has_line_below & navigable_suggestions & default_buffer_focused",
255 255 ),
256 256 Binding(
257 257 auto_suggest.accept_character,
258 258 ["escape", "right"],
259 259 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
260 260 ),
261 261 Binding(
262 262 auto_suggest.accept_and_move_cursor_left,
263 263 ["c-left"],
264 264 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
265 265 ),
266 266 Binding(
267 267 auto_suggest.accept_and_keep_cursor,
268 268 ["escape", "down"],
269 269 "has_suggestion & default_buffer_focused & emacs_insert_mode",
270 270 ),
271 271 Binding(
272 272 auto_suggest.backspace_and_resume_hint,
273 273 ["backspace"],
274 274 # no `has_suggestion` here to allow resuming if no suggestion
275 275 "default_buffer_focused & emacs_like_insert_mode",
276 276 ),
277 277 Binding(
278 278 auto_suggest.resume_hinting,
279 279 ["right"],
280 280 "is_cursor_at_the_end_of_line"
281 281 " & default_buffer_focused"
282 " & emacs_like_insert_mode",
282 " & emacs_like_insert_mode"
283 " & pass_through",
283 284 ),
284 285 ]
285 286
286 287
287 288 SIMPLE_CONTROL_BINDINGS = [
288 289 Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
289 290 for key, cmd in {
290 291 "c-a": nc.beginning_of_line,
291 292 "c-b": nc.backward_char,
292 293 "c-k": nc.kill_line,
293 294 "c-w": nc.backward_kill_word,
294 295 "c-y": nc.yank,
295 296 "c-_": nc.undo,
296 297 }.items()
297 298 ]
298 299
299 300
300 301 ALT_AND_COMOBO_CONTROL_BINDINGS = [
301 302 Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
302 303 for keys, cmd in {
303 304 # Control Combos
304 305 ("c-x", "c-e"): nc.edit_and_execute,
305 306 ("c-x", "e"): nc.edit_and_execute,
306 307 # Alt
307 308 ("escape", "b"): nc.backward_word,
308 309 ("escape", "c"): nc.capitalize_word,
309 310 ("escape", "d"): nc.kill_word,
310 311 ("escape", "h"): nc.backward_kill_word,
311 312 ("escape", "l"): nc.downcase_word,
312 313 ("escape", "u"): nc.uppercase_word,
313 314 ("escape", "y"): nc.yank_pop,
314 315 ("escape", "."): nc.yank_last_arg,
315 316 }.items()
316 317 ]
317 318
318 319
319 320 def add_binding(bindings: KeyBindings, binding: Binding):
320 321 bindings.add(
321 322 *binding.keys,
322 323 **({"filter": binding.filter} if binding.filter is not None else {}),
323 324 )(binding.command)
324 325
325 326
326 327 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
327 328 """Set up the prompt_toolkit keyboard shortcuts for IPython.
328 329
329 330 Parameters
330 331 ----------
331 332 shell: InteractiveShell
332 333 The current IPython shell Instance
333 334 skip: List[Binding]
334 335 Bindings to skip.
335 336
336 337 Returns
337 338 -------
338 339 KeyBindings
339 340 the keybinding instance for prompt toolkit.
340 341
341 342 """
342 343 kb = KeyBindings()
343 344 skip = skip or []
344 345 for binding in KEY_BINDINGS:
345 346 skip_this_one = False
346 347 for to_skip in skip:
347 348 if (
348 349 to_skip.command == binding.command
349 350 and to_skip.filter == binding.filter
350 351 and to_skip.keys == binding.keys
351 352 ):
352 353 skip_this_one = True
353 354 break
354 355 if skip_this_one:
355 356 continue
356 357 add_binding(kb, binding)
357 358
358 359 def get_input_mode(self):
359 360 app = get_app()
360 361 app.ttimeoutlen = shell.ttimeoutlen
361 362 app.timeoutlen = shell.timeoutlen
362 363
363 364 return self._input_mode
364 365
365 366 def set_input_mode(self, mode):
366 367 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
367 368 cursor = "\x1b[{} q".format(shape)
368 369
369 370 sys.stdout.write(cursor)
370 371 sys.stdout.flush()
371 372
372 373 self._input_mode = mode
373 374
374 375 if shell.editing_mode == "vi" and shell.modal_cursor:
375 376 ViState._input_mode = InputMode.INSERT # type: ignore
376 377 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
377 378
378 379 return kb
379 380
380 381
381 382 def reformat_and_execute(event):
382 383 """Reformat code and execute it"""
383 384 shell = get_ipython()
384 385 reformat_text_before_cursor(
385 386 event.current_buffer, event.current_buffer.document, shell
386 387 )
387 388 event.current_buffer.validate_and_handle()
388 389
389 390
390 391 def reformat_text_before_cursor(buffer, document, shell):
391 392 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
392 393 try:
393 394 formatted_text = shell.reformat_handler(text)
394 395 buffer.insert_text(formatted_text)
395 396 except Exception as e:
396 397 buffer.insert_text(text)
397 398
398 399
399 400 def handle_return_or_newline_or_execute(event):
400 401 shell = get_ipython()
401 402 if getattr(shell, "handle_return", None):
402 403 return shell.handle_return(shell)(event)
403 404 else:
404 405 return newline_or_execute_outer(shell)(event)
405 406
406 407
407 408 def newline_or_execute_outer(shell):
408 409 def newline_or_execute(event):
409 410 """When the user presses return, insert a newline or execute the code."""
410 411 b = event.current_buffer
411 412 d = b.document
412 413
413 414 if b.complete_state:
414 415 cc = b.complete_state.current_completion
415 416 if cc:
416 417 b.apply_completion(cc)
417 418 else:
418 419 b.cancel_completion()
419 420 return
420 421
421 422 # If there's only one line, treat it as if the cursor is at the end.
422 423 # See https://github.com/ipython/ipython/issues/10425
423 424 if d.line_count == 1:
424 425 check_text = d.text
425 426 else:
426 427 check_text = d.text[: d.cursor_position]
427 428 status, indent = shell.check_complete(check_text)
428 429
429 430 # if all we have after the cursor is whitespace: reformat current text
430 431 # before cursor
431 432 after_cursor = d.text[d.cursor_position :]
432 433 reformatted = False
433 434 if not after_cursor.strip():
434 435 reformat_text_before_cursor(b, d, shell)
435 436 reformatted = True
436 437 if not (
437 438 d.on_last_line
438 439 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
439 440 ):
440 441 if shell.autoindent:
441 442 b.insert_text("\n" + indent)
442 443 else:
443 444 b.insert_text("\n")
444 445 return
445 446
446 447 if (status != "incomplete") and b.accept_handler:
447 448 if not reformatted:
448 449 reformat_text_before_cursor(b, d, shell)
449 450 b.validate_and_handle()
450 451 else:
451 452 if shell.autoindent:
452 453 b.insert_text("\n" + indent)
453 454 else:
454 455 b.insert_text("\n")
455 456
456 457 return newline_or_execute
457 458
458 459
459 460 def previous_history_or_previous_completion(event):
460 461 """
461 462 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
462 463
463 464 If completer is open this still select previous completion.
464 465 """
465 466 event.current_buffer.auto_up()
466 467
467 468
468 469 def next_history_or_next_completion(event):
469 470 """
470 471 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
471 472
472 473 If completer is open this still select next completion.
473 474 """
474 475 event.current_buffer.auto_down()
475 476
476 477
477 478 def dismiss_completion(event):
478 479 """Dismiss completion"""
479 480 b = event.current_buffer
480 481 if b.complete_state:
481 482 b.cancel_completion()
482 483
483 484
484 485 def reset_buffer(event):
485 486 """Reset buffer"""
486 487 b = event.current_buffer
487 488 if b.complete_state:
488 489 b.cancel_completion()
489 490 else:
490 491 b.reset()
491 492
492 493
493 494 def reset_search_buffer(event):
494 495 """Reset search buffer"""
495 496 if event.current_buffer.document.text:
496 497 event.current_buffer.reset()
497 498 else:
498 499 event.app.layout.focus(DEFAULT_BUFFER)
499 500
500 501
501 502 def suspend_to_bg(event):
502 503 """Suspend to background"""
503 504 event.app.suspend_to_background()
504 505
505 506
506 507 def quit(event):
507 508 """
508 509 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
509 510
510 511 On platforms that support SIGQUIT, send SIGQUIT to the current process.
511 512 On other platforms, just exit the process with a message.
512 513 """
513 514 sigquit = getattr(signal, "SIGQUIT", None)
514 515 if sigquit is not None:
515 516 os.kill(0, signal.SIGQUIT)
516 517 else:
517 518 sys.exit("Quit")
518 519
519 520
520 521 def indent_buffer(event):
521 522 """Indent buffer"""
522 523 event.current_buffer.insert_text(" " * 4)
523 524
524 525
525 526 def newline_autoindent(event):
526 527 """Insert a newline after the cursor indented appropriately.
527 528
528 529 Fancier version of former ``newline_with_copy_margin`` which should
529 530 compute the correct indentation of the inserted line. That is to say, indent
530 531 by 4 extra space after a function definition, class definition, context
531 532 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
532 533 """
533 534 shell = get_ipython()
534 535 inputsplitter = shell.input_transformer_manager
535 536 b = event.current_buffer
536 537 d = b.document
537 538
538 539 if b.complete_state:
539 540 b.cancel_completion()
540 541 text = d.text[: d.cursor_position] + "\n"
541 542 _, indent = inputsplitter.check_complete(text)
542 543 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
543 544
544 545
545 546 def open_input_in_editor(event):
546 547 """Open code from input in external editor"""
547 548 event.app.current_buffer.open_in_editor()
548 549
549 550
550 551 if sys.platform == "win32":
551 552 from IPython.core.error import TryNext
552 553 from IPython.lib.clipboard import (
553 554 ClipboardEmpty,
554 555 tkinter_clipboard_get,
555 556 win32_clipboard_get,
556 557 )
557 558
558 559 @undoc
559 560 def win_paste(event):
560 561 try:
561 562 text = win32_clipboard_get()
562 563 except TryNext:
563 564 try:
564 565 text = tkinter_clipboard_get()
565 566 except (TryNext, ClipboardEmpty):
566 567 return
567 568 except ClipboardEmpty:
568 569 return
569 570 event.current_buffer.insert_text(text.replace("\t", " " * 4))
570 571
571 572 else:
572 573
573 574 @undoc
574 575 def win_paste(event):
575 576 """Stub used on other platforms"""
576 577 pass
577 578
578 579
579 580 KEY_BINDINGS = [
580 581 Binding(
581 582 handle_return_or_newline_or_execute,
582 583 ["enter"],
583 584 "default_buffer_focused & ~has_selection & insert_mode",
584 585 ),
585 586 Binding(
586 587 reformat_and_execute,
587 588 ["escape", "enter"],
588 589 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
589 590 ),
590 591 Binding(quit, ["c-\\"]),
591 592 Binding(
592 593 previous_history_or_previous_completion,
593 594 ["c-p"],
594 595 "vi_insert_mode & default_buffer_focused",
595 596 ),
596 597 Binding(
597 598 next_history_or_next_completion,
598 599 ["c-n"],
599 600 "vi_insert_mode & default_buffer_focused",
600 601 ),
601 602 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
602 603 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
603 604 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
604 605 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
605 606 Binding(
606 607 indent_buffer,
607 608 ["tab"], # Ctrl+I == Tab
608 609 "default_buffer_focused"
609 610 " & ~has_selection"
610 611 " & insert_mode"
611 612 " & cursor_in_leading_ws",
612 613 ),
613 614 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
614 615 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
615 616 *AUTO_MATCH_BINDINGS,
616 617 *AUTO_SUGGEST_BINDINGS,
617 618 Binding(
618 619 display_completions_like_readline,
619 620 ["c-i"],
620 621 "readline_like_completions"
621 622 " & default_buffer_focused"
622 623 " & ~has_selection"
623 624 " & insert_mode"
624 625 " & ~cursor_in_leading_ws",
625 626 ),
626 627 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
627 628 *SIMPLE_CONTROL_BINDINGS,
628 629 *ALT_AND_COMOBO_CONTROL_BINDINGS,
629 630 ]
@@ -1,396 +1,401 b''
1 1 import re
2 2 import tokenize
3 3 from io import StringIO
4 4 from typing import Callable, List, Optional, Union, Generator, Tuple
5 5 import warnings
6 6
7 7 from prompt_toolkit.buffer import Buffer
8 8 from prompt_toolkit.key_binding import KeyPressEvent
9 9 from prompt_toolkit.key_binding.bindings import named_commands as nc
10 10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
11 11 from prompt_toolkit.document import Document
12 12 from prompt_toolkit.history import History
13 13 from prompt_toolkit.shortcuts import PromptSession
14 14 from prompt_toolkit.layout.processors import (
15 15 Processor,
16 16 Transformation,
17 17 TransformationInput,
18 18 )
19 19
20 20 from IPython.core.getipython import get_ipython
21 21 from IPython.utils.tokenutil import generate_tokens
22 22
23 from .filters import pass_through
24
23 25
24 26 def _get_query(document: Document):
25 27 return document.lines[document.cursor_position_row]
26 28
27 29
28 30 class AppendAutoSuggestionInAnyLine(Processor):
29 31 """
30 32 Append the auto suggestion to lines other than the last (appending to the
31 33 last line is natively supported by the prompt toolkit).
32 34 """
33 35
34 36 def __init__(self, style: str = "class:auto-suggestion") -> None:
35 37 self.style = style
36 38
37 39 def apply_transformation(self, ti: TransformationInput) -> Transformation:
38 40 is_last_line = ti.lineno == ti.document.line_count - 1
39 41 is_active_line = ti.lineno == ti.document.cursor_position_row
40 42
41 43 if not is_last_line and is_active_line:
42 44 buffer = ti.buffer_control.buffer
43 45
44 46 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
45 47 suggestion = buffer.suggestion.text
46 48 else:
47 49 suggestion = ""
48 50
49 51 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
50 52 else:
51 53 return Transformation(fragments=ti.fragments)
52 54
53 55
54 56 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
55 57 """
56 58 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
57 59 suggestion from history. To do so it remembers the current position, but it
58 60 state need to carefully be cleared on the right events.
59 61 """
60 62
61 63 def __init__(
62 64 self,
63 65 ):
64 66 self.skip_lines = 0
65 67 self._connected_apps = []
66 68
67 69 def reset_history_position(self, _: Buffer):
68 70 self.skip_lines = 0
69 71
70 72 def disconnect(self):
71 73 for pt_app in self._connected_apps:
72 74 text_insert_event = pt_app.default_buffer.on_text_insert
73 75 text_insert_event.remove_handler(self.reset_history_position)
74 76
75 77 def connect(self, pt_app: PromptSession):
76 78 self._connected_apps.append(pt_app)
77 79 # note: `on_text_changed` could be used for a bit different behaviour
78 80 # on character deletion (i.e. reseting history position on backspace)
79 81 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
80 82 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
81 83
82 84 def get_suggestion(
83 85 self, buffer: Buffer, document: Document
84 86 ) -> Optional[Suggestion]:
85 87 text = _get_query(document)
86 88
87 89 if text.strip():
88 90 for suggestion, _ in self._find_next_match(
89 91 text, self.skip_lines, buffer.history
90 92 ):
91 93 return Suggestion(suggestion)
92 94
93 95 return None
94 96
95 97 def _dismiss(self, buffer, *args, **kwargs):
96 98 buffer.suggestion = None
97 99
98 100 def _find_match(
99 101 self, text: str, skip_lines: float, history: History, previous: bool
100 102 ) -> Generator[Tuple[str, float], None, None]:
101 103 """
102 104 text : str
103 105 Text content to find a match for, the user cursor is most of the
104 106 time at the end of this text.
105 107 skip_lines : float
106 108 number of items to skip in the search, this is used to indicate how
107 109 far in the list the user has navigated by pressing up or down.
108 110 The float type is used as the base value is +inf
109 111 history : History
110 112 prompt_toolkit History instance to fetch previous entries from.
111 113 previous : bool
112 114 Direction of the search, whether we are looking previous match
113 115 (True), or next match (False).
114 116
115 117 Yields
116 118 ------
117 119 Tuple with:
118 120 str:
119 121 current suggestion.
120 122 float:
121 123 will actually yield only ints, which is passed back via skip_lines,
122 124 which may be a +inf (float)
123 125
124 126
125 127 """
126 128 line_number = -1
127 129 for string in reversed(list(history.get_strings())):
128 130 for line in reversed(string.splitlines()):
129 131 line_number += 1
130 132 if not previous and line_number < skip_lines:
131 133 continue
132 134 # do not return empty suggestions as these
133 135 # close the auto-suggestion overlay (and are useless)
134 136 if line.startswith(text) and len(line) > len(text):
135 137 yield line[len(text) :], line_number
136 138 if previous and line_number >= skip_lines:
137 139 return
138 140
139 141 def _find_next_match(
140 142 self, text: str, skip_lines: float, history: History
141 143 ) -> Generator[Tuple[str, float], None, None]:
142 144 return self._find_match(text, skip_lines, history, previous=False)
143 145
144 146 def _find_previous_match(self, text: str, skip_lines: float, history: History):
145 147 return reversed(
146 148 list(self._find_match(text, skip_lines, history, previous=True))
147 149 )
148 150
149 151 def up(self, query: str, other_than: str, history: History) -> None:
150 152 for suggestion, line_number in self._find_next_match(
151 153 query, self.skip_lines, history
152 154 ):
153 155 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
154 156 # we want to switch from 'very.b' to 'very.a' because a) if the
155 157 # suggestion equals current text, prompt-toolkit aborts suggesting
156 158 # b) user likely would not be interested in 'very' anyways (they
157 159 # already typed it).
158 160 if query + suggestion != other_than:
159 161 self.skip_lines = line_number
160 162 break
161 163 else:
162 164 # no matches found, cycle back to beginning
163 165 self.skip_lines = 0
164 166
165 167 def down(self, query: str, other_than: str, history: History) -> None:
166 168 for suggestion, line_number in self._find_previous_match(
167 169 query, self.skip_lines, history
168 170 ):
169 171 if query + suggestion != other_than:
170 172 self.skip_lines = line_number
171 173 break
172 174 else:
173 175 # no matches found, cycle to end
174 176 for suggestion, line_number in self._find_previous_match(
175 177 query, float("Inf"), history
176 178 ):
177 179 if query + suggestion != other_than:
178 180 self.skip_lines = line_number
179 181 break
180 182
181 183
182 184 def accept_or_jump_to_end(event: KeyPressEvent):
183 185 """Apply autosuggestion or jump to end of line."""
184 186 buffer = event.current_buffer
185 187 d = buffer.document
186 188 after_cursor = d.text[d.cursor_position :]
187 189 lines = after_cursor.split("\n")
188 190 end_of_current_line = lines[0].strip()
189 191 suggestion = buffer.suggestion
190 192 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
191 193 buffer.insert_text(suggestion.text)
192 194 else:
193 195 nc.end_of_line(event)
194 196
195 197
196 198 def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
197 199 """Accept autosuggestion or jump to end of line.
198 200
199 201 .. deprecated:: 8.12
200 202 Use `accept_or_jump_to_end` instead.
201 203 """
202 204 return accept_or_jump_to_end(event)
203 205
204 206
205 207 def accept(event: KeyPressEvent):
206 208 """Accept autosuggestion"""
207 209 buffer = event.current_buffer
208 210 suggestion = buffer.suggestion
209 211 if suggestion:
210 212 buffer.insert_text(suggestion.text)
211 213 else:
212 214 nc.forward_char(event)
213 215
214 216
215 217 def discard(event: KeyPressEvent):
216 218 """Discard autosuggestion"""
217 219 buffer = event.current_buffer
218 220 buffer.suggestion = None
219 221
220 222
221 223 def accept_word(event: KeyPressEvent):
222 224 """Fill partial autosuggestion by word"""
223 225 buffer = event.current_buffer
224 226 suggestion = buffer.suggestion
225 227 if suggestion:
226 228 t = re.split(r"(\S+\s+)", suggestion.text)
227 229 buffer.insert_text(next((x for x in t if x), ""))
228 230 else:
229 231 nc.forward_word(event)
230 232
231 233
232 234 def accept_character(event: KeyPressEvent):
233 235 """Fill partial autosuggestion by character"""
234 236 b = event.current_buffer
235 237 suggestion = b.suggestion
236 238 if suggestion and suggestion.text:
237 239 b.insert_text(suggestion.text[0])
238 240
239 241
240 242 def accept_and_keep_cursor(event: KeyPressEvent):
241 243 """Accept autosuggestion and keep cursor in place"""
242 244 buffer = event.current_buffer
243 245 old_position = buffer.cursor_position
244 246 suggestion = buffer.suggestion
245 247 if suggestion:
246 248 buffer.insert_text(suggestion.text)
247 249 buffer.cursor_position = old_position
248 250
249 251
250 252 def accept_and_move_cursor_left(event: KeyPressEvent):
251 253 """Accept autosuggestion and move cursor left in place"""
252 254 accept_and_keep_cursor(event)
253 255 nc.backward_char(event)
254 256
255 257
256 258 def _update_hint(buffer: Buffer):
257 259 if buffer.auto_suggest:
258 260 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
259 261 buffer.suggestion = suggestion
260 262
261 263
262 264 def backspace_and_resume_hint(event: KeyPressEvent):
263 265 """Resume autosuggestions after deleting last character"""
264 266 nc.backward_delete_char(event)
265 267 _update_hint(event.current_buffer)
266 268
267 269
268 270 def resume_hinting(event: KeyPressEvent):
269 271 """Resume autosuggestions"""
270 return _update_hint(event.current_buffer)
272 pass_through.reply(event)
273 # Order matters: if update happened first and event reply second, the
274 # suggestion would be auto-accepted if both actions are bound to same key.
275 _update_hint(event.current_buffer)
271 276
272 277
273 278 def up_and_update_hint(event: KeyPressEvent):
274 279 """Go up and update hint"""
275 280 current_buffer = event.current_buffer
276 281
277 282 current_buffer.auto_up(count=event.arg)
278 283 _update_hint(current_buffer)
279 284
280 285
281 286 def down_and_update_hint(event: KeyPressEvent):
282 287 """Go down and update hint"""
283 288 current_buffer = event.current_buffer
284 289
285 290 current_buffer.auto_down(count=event.arg)
286 291 _update_hint(current_buffer)
287 292
288 293
289 294 def accept_token(event: KeyPressEvent):
290 295 """Fill partial autosuggestion by token"""
291 296 b = event.current_buffer
292 297 suggestion = b.suggestion
293 298
294 299 if suggestion:
295 300 prefix = _get_query(b.document)
296 301 text = prefix + suggestion.text
297 302
298 303 tokens: List[Optional[str]] = [None, None, None]
299 304 substrings = [""]
300 305 i = 0
301 306
302 307 for token in generate_tokens(StringIO(text).readline):
303 308 if token.type == tokenize.NEWLINE:
304 309 index = len(text)
305 310 else:
306 311 index = text.index(token[1], len(substrings[-1]))
307 312 substrings.append(text[:index])
308 313 tokenized_so_far = substrings[-1]
309 314 if tokenized_so_far.startswith(prefix):
310 315 if i == 0 and len(tokenized_so_far) > len(prefix):
311 316 tokens[0] = tokenized_so_far[len(prefix) :]
312 317 substrings.append(tokenized_so_far)
313 318 i += 1
314 319 tokens[i] = token[1]
315 320 if i == 2:
316 321 break
317 322 i += 1
318 323
319 324 if tokens[0]:
320 325 to_insert: str
321 326 insert_text = substrings[-2]
322 327 if tokens[1] and len(tokens[1]) == 1:
323 328 insert_text = substrings[-1]
324 329 to_insert = insert_text[len(prefix) :]
325 330 b.insert_text(to_insert)
326 331 return
327 332
328 333 nc.forward_word(event)
329 334
330 335
331 336 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
332 337
333 338
334 339 def _swap_autosuggestion(
335 340 buffer: Buffer,
336 341 provider: NavigableAutoSuggestFromHistory,
337 342 direction_method: Callable,
338 343 ):
339 344 """
340 345 We skip most recent history entry (in either direction) if it equals the
341 346 current autosuggestion because if user cycles when auto-suggestion is shown
342 347 they most likely want something else than what was suggested (otherwise
343 348 they would have accepted the suggestion).
344 349 """
345 350 suggestion = buffer.suggestion
346 351 if not suggestion:
347 352 return
348 353
349 354 query = _get_query(buffer.document)
350 355 current = query + suggestion.text
351 356
352 357 direction_method(query=query, other_than=current, history=buffer.history)
353 358
354 359 new_suggestion = provider.get_suggestion(buffer, buffer.document)
355 360 buffer.suggestion = new_suggestion
356 361
357 362
358 363 def swap_autosuggestion_up(event: KeyPressEvent):
359 364 """Get next autosuggestion from history."""
360 365 shell = get_ipython()
361 366 provider = shell.auto_suggest
362 367
363 368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
364 369 return
365 370
366 371 return _swap_autosuggestion(
367 372 buffer=event.current_buffer, provider=provider, direction_method=provider.up
368 373 )
369 374
370 375
371 376 def swap_autosuggestion_down(event: KeyPressEvent):
372 377 """Get previous autosuggestion from history."""
373 378 shell = get_ipython()
374 379 provider = shell.auto_suggest
375 380
376 381 if not isinstance(provider, NavigableAutoSuggestFromHistory):
377 382 return
378 383
379 384 return _swap_autosuggestion(
380 385 buffer=event.current_buffer,
381 386 provider=provider,
382 387 direction_method=provider.down,
383 388 )
384 389
385 390
386 391 def __getattr__(key):
387 392 if key == "accept_in_vi_insert_mode":
388 393 warnings.warn(
389 394 "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
390 395 "renamed to `accept_or_jump_to_end`. Please update your configuration "
391 396 "accordingly",
392 397 DeprecationWarning,
393 398 stacklevel=2,
394 399 )
395 400 return _deprected_accept_in_vi_insert_mode
396 401 raise AttributeError
@@ -1,290 +1,322 b''
1 1 """
2 2 Filters restricting scope of IPython Terminal shortcuts.
3 3 """
4 4
5 5 # Copyright (c) IPython Development Team.
6 6 # Distributed under the terms of the Modified BSD License.
7 7
8 8 import ast
9 9 import re
10 10 import signal
11 11 import sys
12 12 from typing import Callable, Dict, Union
13 13
14 14 from prompt_toolkit.application.current import get_app
15 15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
16 from prompt_toolkit.key_binding import KeyPressEvent
17 from prompt_toolkit.filters import Condition, Filter, emacs_insert_mode, has_completions
17 18 from prompt_toolkit.filters import has_focus as has_focus_impl
18 19 from prompt_toolkit.filters import (
19 20 Always,
20 21 Never,
21 22 has_selection,
22 23 has_suggestion,
23 24 vi_insert_mode,
24 25 vi_mode,
25 26 )
26 27 from prompt_toolkit.layout.layout import FocusableElement
27 28
28 29 from IPython.core.getipython import get_ipython
29 30 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
30 31 from IPython.terminal.shortcuts import auto_suggest
31 32 from IPython.utils.decorators import undoc
32 33
33 34
34 35 @undoc
35 36 @Condition
36 37 def cursor_in_leading_ws():
37 38 before = get_app().current_buffer.document.current_line_before_cursor
38 39 return (not before) or before.isspace()
39 40
40 41
41 42 def has_focus(value: FocusableElement):
42 43 """Wrapper around has_focus adding a nice `__name__` to tester function"""
43 44 tester = has_focus_impl(value).func
44 45 tester.__name__ = f"is_focused({value})"
45 46 return Condition(tester)
46 47
47 48
48 49 @undoc
49 50 @Condition
50 51 def has_line_below() -> bool:
51 52 document = get_app().current_buffer.document
52 53 return document.cursor_position_row < len(document.lines) - 1
53 54
54 55
55 56 @undoc
56 57 @Condition
57 58 def is_cursor_at_the_end_of_line() -> bool:
58 59 document = get_app().current_buffer.document
59 60 return document.is_cursor_at_the_end_of_line
60 61
61 62
62 63 @undoc
63 64 @Condition
64 65 def has_line_above() -> bool:
65 66 document = get_app().current_buffer.document
66 67 return document.cursor_position_row != 0
67 68
68 69
69 70 @Condition
70 71 def ebivim():
71 72 shell = get_ipython()
72 73 return shell.emacs_bindings_in_vi_insert_mode
73 74
74 75
75 76 @Condition
76 77 def supports_suspend():
77 78 return hasattr(signal, "SIGTSTP")
78 79
79 80
80 81 @Condition
81 82 def auto_match():
82 83 shell = get_ipython()
83 84 return shell.auto_match
84 85
85 86
86 87 def all_quotes_paired(quote, buf):
87 88 paired = True
88 89 i = 0
89 90 while i < len(buf):
90 91 c = buf[i]
91 92 if c == quote:
92 93 paired = not paired
93 94 elif c == "\\":
94 95 i += 1
95 96 i += 1
96 97 return paired
97 98
98 99
99 100 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
100 101 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
101 102
102 103
103 104 def preceding_text(pattern: Union[str, Callable]):
104 105 if pattern in _preceding_text_cache:
105 106 return _preceding_text_cache[pattern]
106 107
107 108 if callable(pattern):
108 109
109 110 def _preceding_text():
110 111 app = get_app()
111 112 before_cursor = app.current_buffer.document.current_line_before_cursor
112 113 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
113 114 return bool(pattern(before_cursor)) # type: ignore[operator]
114 115
115 116 else:
116 117 m = re.compile(pattern)
117 118
118 119 def _preceding_text():
119 120 app = get_app()
120 121 before_cursor = app.current_buffer.document.current_line_before_cursor
121 122 return bool(m.match(before_cursor))
122 123
123 124 _preceding_text.__name__ = f"preceding_text({pattern!r})"
124 125
125 126 condition = Condition(_preceding_text)
126 127 _preceding_text_cache[pattern] = condition
127 128 return condition
128 129
129 130
130 131 def following_text(pattern):
131 132 try:
132 133 return _following_text_cache[pattern]
133 134 except KeyError:
134 135 pass
135 136 m = re.compile(pattern)
136 137
137 138 def _following_text():
138 139 app = get_app()
139 140 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
140 141
141 142 _following_text.__name__ = f"following_text({pattern!r})"
142 143
143 144 condition = Condition(_following_text)
144 145 _following_text_cache[pattern] = condition
145 146 return condition
146 147
147 148
148 149 @Condition
149 150 def not_inside_unclosed_string():
150 151 app = get_app()
151 152 s = app.current_buffer.document.text_before_cursor
152 153 # remove escaped quotes
153 154 s = s.replace('\\"', "").replace("\\'", "")
154 155 # remove triple-quoted string literals
155 156 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
156 157 # remove single-quoted string literals
157 158 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
158 159 return not ('"' in s or "'" in s)
159 160
160 161
161 162 @Condition
162 163 def navigable_suggestions():
163 164 shell = get_ipython()
164 165 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
165 166
166 167
167 168 @Condition
168 169 def readline_like_completions():
169 170 shell = get_ipython()
170 171 return shell.display_completions == "readlinelike"
171 172
172 173
173 174 @Condition
174 175 def is_windows_os():
175 176 return sys.platform == "win32"
176 177
177 178
179 class PassThrough(Filter):
180 """A filter allowing to implement pass-through behaviour of keybindings.
181
182 Prompt toolkit key processor dispatches only one event per binding match,
183 which means that adding a new shortcut will suppress the old shortcut
184 if the keybindings are the same (unless one is filtered out).
185
186 To stop a shortcut binding from suppressing other shortcuts:
187 - add the `pass_through` filter to list of filter, and
188 - call `pass_through.reply(event)` in the shortcut handler.
189 """
190
191 def __init__(self):
192 self._is_replying = False
193
194 def reply(self, event: KeyPressEvent):
195 self._is_replying = True
196 try:
197 event.key_processor.reset()
198 event.key_processor.feed_multiple(event.key_sequence)
199 event.key_processor.process_keys()
200 finally:
201 self._is_replying = False
202
203 def __call__(self):
204 return not self._is_replying
205
206
207 pass_through = PassThrough()
208
178 209 # these one is callable and re-used multiple times hence needs to be
179 210 # only defined once beforhand so that transforming back to human-readable
180 211 # names works well in the documentation.
181 212 default_buffer_focused = has_focus(DEFAULT_BUFFER)
182 213
183 214 KEYBINDING_FILTERS = {
184 215 "always": Always(),
185 216 # never is used for exposing commands which have no default keybindings
186 217 "never": Never(),
187 218 "has_line_below": has_line_below,
188 219 "has_line_above": has_line_above,
189 220 "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line,
190 221 "has_selection": has_selection,
191 222 "has_suggestion": has_suggestion,
192 223 "vi_mode": vi_mode,
193 224 "vi_insert_mode": vi_insert_mode,
194 225 "emacs_insert_mode": emacs_insert_mode,
195 226 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
196 227 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
197 228 # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
198 229 # in vi insert mode. Because some of the emacs bindings involve `escape`
199 230 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
200 231 # needs to wait to see if there will be another character typed in before
201 232 # executing pure `escape` keybinding; in vi insert mode `escape` switches to
202 233 # command mode which is common and performance critical action for vi users.
203 234 # To avoid the delay users employ a workaround:
204 235 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
205 236 # which involves switching `emacs_bindings_in_vi_insert_mode` off.
206 237 #
207 238 # For the workaround to work:
208 239 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
209 240 # 2) all keybindings which would involve `escape` need to respect that
210 241 # toggle by including either:
211 242 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings
212 243 # predefined upstream in prompt-toolkit, or
213 244 # - `emacs_like_insert_mode` for actions which do not have existing
214 245 # emacs keybindings predefined upstream (or need overriding of the
215 246 # upstream bindings to modify behaviour), defined below.
216 247 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
217 248 "has_completions": has_completions,
218 249 "insert_mode": vi_insert_mode | emacs_insert_mode,
219 250 "default_buffer_focused": default_buffer_focused,
220 251 "search_buffer_focused": has_focus(SEARCH_BUFFER),
221 252 # `ebivim` stands for emacs bindings in vi insert mode
222 253 "ebivim": ebivim,
223 254 "supports_suspend": supports_suspend,
224 255 "is_windows_os": is_windows_os,
225 256 "auto_match": auto_match,
226 257 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
227 258 "not_inside_unclosed_string": not_inside_unclosed_string,
228 259 "readline_like_completions": readline_like_completions,
229 260 "preceded_by_paired_double_quotes": preceding_text(
230 261 lambda line: all_quotes_paired('"', line)
231 262 ),
232 263 "preceded_by_paired_single_quotes": preceding_text(
233 264 lambda line: all_quotes_paired("'", line)
234 265 ),
235 266 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
236 267 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
237 268 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
238 269 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
239 270 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
240 271 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
241 272 "preceded_by_opening_brace": preceding_text(r".*\{$"),
242 273 "preceded_by_double_quote": preceding_text('.*"$'),
243 274 "preceded_by_single_quote": preceding_text(r".*'$"),
244 275 "followed_by_closing_round_paren": following_text(r"^\)"),
245 276 "followed_by_closing_bracket": following_text(r"^\]"),
246 277 "followed_by_closing_brace": following_text(r"^\}"),
247 278 "followed_by_double_quote": following_text('^"'),
248 279 "followed_by_single_quote": following_text("^'"),
249 280 "navigable_suggestions": navigable_suggestions,
250 281 "cursor_in_leading_ws": cursor_in_leading_ws,
282 "pass_through": pass_through,
251 283 }
252 284
253 285
254 286 def eval_node(node: Union[ast.AST, None]):
255 287 if node is None:
256 288 return None
257 289 if isinstance(node, ast.Expression):
258 290 return eval_node(node.body)
259 291 if isinstance(node, ast.BinOp):
260 292 left = eval_node(node.left)
261 293 right = eval_node(node.right)
262 294 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
263 295 if dunders:
264 296 return getattr(left, dunders[0])(right)
265 297 raise ValueError(f"Unknown binary operation: {node.op}")
266 298 if isinstance(node, ast.UnaryOp):
267 299 value = eval_node(node.operand)
268 300 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
269 301 if dunders:
270 302 return getattr(value, dunders[0])()
271 303 raise ValueError(f"Unknown unary operation: {node.op}")
272 304 if isinstance(node, ast.Name):
273 305 if node.id in KEYBINDING_FILTERS:
274 306 return KEYBINDING_FILTERS[node.id]
275 307 else:
276 308 sep = "\n - "
277 309 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
278 310 raise NameError(
279 311 f"{node.id} is not a known shortcut filter."
280 312 f" Known filters are: {sep}{known_filters}."
281 313 )
282 314 raise ValueError("Unhandled node", ast.dump(node))
283 315
284 316
285 317 def filter_from_string(code: str):
286 318 expression = ast.parse(code, mode="eval")
287 319 return eval_node(expression)
288 320
289 321
290 322 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
General Comments 0
You need to be logged in to leave comments. Login now