Format: text/x-python
⬇ Download File (10.7 KB)
   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.key_binding import KeyPressEvent
  17  from prompt_toolkit.filters import Condition, Filter, emacs_insert_mode, has_completions
  18  from prompt_toolkit.filters import has_focus as has_focus_impl
  19  from prompt_toolkit.filters import (
  20      Always,
  21      Never,
  22      has_selection,
  23      has_suggestion,
  24      vi_insert_mode,
  25      vi_mode,
  26  )
  27  from prompt_toolkit.layout.layout import FocusableElement
  28  
  29  from IPython.core.getipython import get_ipython
  30  from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
  31  from IPython.terminal.shortcuts import auto_suggest
  32  from IPython.utils.decorators import undoc
  33  
  34  
  35  @undoc
  36  @Condition
  37  def cursor_in_leading_ws():
  38      before = get_app().current_buffer.document.current_line_before_cursor
  39      return (not before) or before.isspace()
  40  
  41  
  42  def has_focus(value: FocusableElement):
  43      """Wrapper around has_focus adding a nice `__name__` to tester function"""
  44      tester = has_focus_impl(value).func
  45      tester.__name__ = f"is_focused({value})"
  46      return Condition(tester)
  47  
  48  
  49  @undoc
  50  @Condition
  51  def has_line_below() -> bool:
  52      document = get_app().current_buffer.document
  53      return document.cursor_position_row < len(document.lines) - 1
  54  
  55  
  56  @undoc
  57  @Condition
  58  def is_cursor_at_the_end_of_line() -> bool:
  59      document = get_app().current_buffer.document
  60      return document.is_cursor_at_the_end_of_line
  61  
  62  
  63  @undoc
  64  @Condition
  65  def has_line_above() -> bool:
  66      document = get_app().current_buffer.document
  67      return document.cursor_position_row != 0
  68  
  69  
  70  @Condition
  71  def ebivim():
  72      shell = get_ipython()
  73      return shell.emacs_bindings_in_vi_insert_mode
  74  
  75  
  76  @Condition
  77  def supports_suspend():
  78      return hasattr(signal, "SIGTSTP")
  79  
  80  
  81  @Condition
  82  def auto_match():
  83      shell = get_ipython()
  84      return shell.auto_match
  85  
  86  
  87  def all_quotes_paired(quote, buf):
  88      paired = True
  89      i = 0
  90      while i < len(buf):
  91          c = buf[i]
  92          if c == quote:
  93              paired = not paired
  94          elif c == "\\":
  95              i += 1
  96          i += 1
  97      return paired
  98  
  99  
 100  _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
 101  _following_text_cache: Dict[Union[str, Callable], Condition] = {}
 102  
 103  
 104  def preceding_text(pattern: Union[str, Callable]):
 105      if pattern in _preceding_text_cache:
 106          return _preceding_text_cache[pattern]
 107  
 108      if callable(pattern):
 109  
 110          def _preceding_text():
 111              app = get_app()
 112              before_cursor = app.current_buffer.document.current_line_before_cursor
 113              # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
 114              return bool(pattern(before_cursor))  # type: ignore[operator]
 115  
 116      else:
 117          m = re.compile(pattern)
 118  
 119          def _preceding_text():
 120              app = get_app()
 121              before_cursor = app.current_buffer.document.current_line_before_cursor
 122              return bool(m.match(before_cursor))
 123  
 124          _preceding_text.__name__ = f"preceding_text({pattern!r})"
 125  
 126      condition = Condition(_preceding_text)
 127      _preceding_text_cache[pattern] = condition
 128      return condition
 129  
 130  
 131  def following_text(pattern):
 132      try:
 133          return _following_text_cache[pattern]
 134      except KeyError:
 135          pass
 136      m = re.compile(pattern)
 137  
 138      def _following_text():
 139          app = get_app()
 140          return bool(m.match(app.current_buffer.document.current_line_after_cursor))
 141  
 142      _following_text.__name__ = f"following_text({pattern!r})"
 143  
 144      condition = Condition(_following_text)
 145      _following_text_cache[pattern] = condition
 146      return condition
 147  
 148  
 149  @Condition
 150  def not_inside_unclosed_string():
 151      app = get_app()
 152      s = app.current_buffer.document.text_before_cursor
 153      # remove escaped quotes
 154      s = s.replace('\\"', "").replace("\\'", "")
 155      # remove triple-quoted string literals
 156      s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
 157      # remove single-quoted string literals
 158      s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
 159      return not ('"' in s or "'" in s)
 160  
 161  
 162  @Condition
 163  def navigable_suggestions():
 164      shell = get_ipython()
 165      return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
 166  
 167  
 168  @Condition
 169  def readline_like_completions():
 170      shell = get_ipython()
 171      return shell.display_completions == "readlinelike"
 172  
 173  
 174  @Condition
 175  def is_windows_os():
 176      return sys.platform == "win32"
 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  
 209  # these one is callable and re-used multiple times hence needs to be
 210  # only defined once beforehand so that transforming back to human-readable
 211  # names works well in the documentation.
 212  default_buffer_focused = has_focus(DEFAULT_BUFFER)
 213  
 214  KEYBINDING_FILTERS = {
 215      "always": Always(),
 216      # never is used for exposing commands which have no default keybindings
 217      "never": Never(),
 218      "has_line_below": has_line_below,
 219      "has_line_above": has_line_above,
 220      "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line,
 221      "has_selection": has_selection,
 222      "has_suggestion": has_suggestion,
 223      "vi_mode": vi_mode,
 224      "vi_insert_mode": vi_insert_mode,
 225      "emacs_insert_mode": emacs_insert_mode,
 226      # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
 227      # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
 228      # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
 229      # in vi insert mode. Because some of the emacs bindings involve `escape`
 230      # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
 231      # needs to wait to see if there will be another character typed in before
 232      # executing pure `escape` keybinding; in vi insert mode `escape` switches to
 233      # command mode which is common and performance critical action for vi users.
 234      # To avoid the delay users employ a workaround:
 235      # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
 236      # which involves switching `emacs_bindings_in_vi_insert_mode` off.
 237      #
 238      # For the workaround to work:
 239      # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
 240      # 2) all keybindings which would involve `escape` need to respect that
 241      #    toggle by including either:
 242      #      - `vi_insert_mode & ebivim` for actions which have emacs keybindings
 243      #        predefined upstream in prompt-toolkit, or
 244      #      - `emacs_like_insert_mode` for actions which do not have existing
 245      #        emacs keybindings predefined upstream (or need overriding of the
 246      #        upstream bindings to modify behaviour), defined below.
 247      "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
 248      "has_completions": has_completions,
 249      "insert_mode": vi_insert_mode | emacs_insert_mode,
 250      "default_buffer_focused": default_buffer_focused,
 251      "search_buffer_focused": has_focus(SEARCH_BUFFER),
 252      # `ebivim` stands for emacs bindings in vi insert mode
 253      "ebivim": ebivim,
 254      "supports_suspend": supports_suspend,
 255      "is_windows_os": is_windows_os,
 256      "auto_match": auto_match,
 257      "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
 258      "not_inside_unclosed_string": not_inside_unclosed_string,
 259      "readline_like_completions": readline_like_completions,
 260      "preceded_by_paired_double_quotes": preceding_text(
 261          lambda line: all_quotes_paired('"', line)
 262      ),
 263      "preceded_by_paired_single_quotes": preceding_text(
 264          lambda line: all_quotes_paired("'", line)
 265      ),
 266      "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
 267      "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
 268      "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
 269      "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
 270      "preceded_by_opening_round_paren": preceding_text(r".*\($"),
 271      "preceded_by_opening_bracket": preceding_text(r".*\[$"),
 272      "preceded_by_opening_brace": preceding_text(r".*\{$"),
 273      "preceded_by_double_quote": preceding_text('.*"$'),
 274      "preceded_by_single_quote": preceding_text(r".*'$"),
 275      "followed_by_closing_round_paren": following_text(r"^\)"),
 276      "followed_by_closing_bracket": following_text(r"^\]"),
 277      "followed_by_closing_brace": following_text(r"^\}"),
 278      "followed_by_double_quote": following_text('^"'),
 279      "followed_by_single_quote": following_text("^'"),
 280      "navigable_suggestions": navigable_suggestions,
 281      "cursor_in_leading_ws": cursor_in_leading_ws,
 282      "pass_through": pass_through,
 283  }
 284  
 285  
 286  def eval_node(node: Union[ast.AST, None]):
 287      if node is None:
 288          return None
 289      if isinstance(node, ast.Expression):
 290          return eval_node(node.body)
 291      if isinstance(node, ast.BinOp):
 292          left = eval_node(node.left)
 293          right = eval_node(node.right)
 294          dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
 295          if dunders:
 296              return getattr(left, dunders[0])(right)
 297          raise ValueError(f"Unknown binary operation: {node.op}")
 298      if isinstance(node, ast.UnaryOp):
 299          value = eval_node(node.operand)
 300          dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
 301          if dunders:
 302              return getattr(value, dunders[0])()
 303          raise ValueError(f"Unknown unary operation: {node.op}")
 304      if isinstance(node, ast.Name):
 305          if node.id in KEYBINDING_FILTERS:
 306              return KEYBINDING_FILTERS[node.id]
 307          else:
 308              sep = "\n  - "
 309              known_filters = sep.join(sorted(KEYBINDING_FILTERS))
 310              raise NameError(
 311                  f"{node.id} is not a known shortcut filter."
 312                  f" Known filters are: {sep}{known_filters}."
 313              )
 314      raise ValueError("Unhandled node", ast.dump(node))
 315  
 316  
 317  def filter_from_string(code: str):
 318      expression = ast.parse(code, mode="eval")
 319      return eval_node(expression)
 320  
 321  
 322  __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
 323