##// END OF EJS Templates
back to dev
back to dev

File last commit:

r28199:a7d8defd merge
r28269:d016fdb9
Show More
filters.py
282 lines | 9.5 KiB | text/x-python | PythonLexer
"""
Filters restricting scope of IPython Terminal shortcuts.
"""
# Copyright (c) IPython Development Team.
# Distributed under the terms of the Modified BSD License.
import ast
import re
import signal
import sys
from typing import Callable, Dict, Union
from prompt_toolkit.application.current import get_app
from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
from prompt_toolkit.filters import has_focus as has_focus_impl
from prompt_toolkit.filters import (
Always,
Never,
has_selection,
has_suggestion,
vi_insert_mode,
vi_mode,
)
from prompt_toolkit.layout.layout import FocusableElement
from IPython.core.getipython import get_ipython
from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
from IPython.terminal.shortcuts import auto_suggest
from IPython.utils.decorators import undoc
@undoc
@Condition
def cursor_in_leading_ws():
before = get_app().current_buffer.document.current_line_before_cursor
return (not before) or before.isspace()
def has_focus(value: FocusableElement):
"""Wrapper around has_focus adding a nice `__name__` to tester function"""
tester = has_focus_impl(value).func
tester.__name__ = f"is_focused({value})"
return Condition(tester)
@undoc
@Condition
def has_line_below() -> bool:
document = get_app().current_buffer.document
return document.cursor_position_row < len(document.lines) - 1
@undoc
@Condition
def has_line_above() -> bool:
document = get_app().current_buffer.document
return document.cursor_position_row != 0
@Condition
def ebivim():
shell = get_ipython()
return shell.emacs_bindings_in_vi_insert_mode
@Condition
def supports_suspend():
return hasattr(signal, "SIGTSTP")
@Condition
def auto_match():
shell = get_ipython()
return shell.auto_match
def all_quotes_paired(quote, buf):
paired = True
i = 0
while i < len(buf):
c = buf[i]
if c == quote:
paired = not paired
elif c == "\\":
i += 1
i += 1
return paired
_preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
_following_text_cache: Dict[Union[str, Callable], Condition] = {}
def preceding_text(pattern: Union[str, Callable]):
if pattern in _preceding_text_cache:
return _preceding_text_cache[pattern]
if callable(pattern):
def _preceding_text():
app = get_app()
before_cursor = app.current_buffer.document.current_line_before_cursor
# mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
return bool(pattern(before_cursor)) # type: ignore[operator]
else:
m = re.compile(pattern)
def _preceding_text():
app = get_app()
before_cursor = app.current_buffer.document.current_line_before_cursor
return bool(m.match(before_cursor))
_preceding_text.__name__ = f"preceding_text({pattern!r})"
condition = Condition(_preceding_text)
_preceding_text_cache[pattern] = condition
return condition
def following_text(pattern):
try:
return _following_text_cache[pattern]
except KeyError:
pass
m = re.compile(pattern)
def _following_text():
app = get_app()
return bool(m.match(app.current_buffer.document.current_line_after_cursor))
_following_text.__name__ = f"following_text({pattern!r})"
condition = Condition(_following_text)
_following_text_cache[pattern] = condition
return condition
@Condition
def not_inside_unclosed_string():
app = get_app()
s = app.current_buffer.document.text_before_cursor
# remove escaped quotes
s = s.replace('\\"', "").replace("\\'", "")
# remove triple-quoted string literals
s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
# remove single-quoted string literals
s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
return not ('"' in s or "'" in s)
@Condition
def navigable_suggestions():
shell = get_ipython()
return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
@Condition
def readline_like_completions():
shell = get_ipython()
return shell.display_completions == "readlinelike"
@Condition
def is_windows_os():
return sys.platform == "win32"
# these one is callable and re-used multiple times hence needs to be
# only defined once beforhand so that transforming back to human-readable
# names works well in the documentation.
default_buffer_focused = has_focus(DEFAULT_BUFFER)
KEYBINDING_FILTERS = {
"always": Always(),
# never is used for exposing commands which have no default keybindings
"never": Never(),
"has_line_below": has_line_below,
"has_line_above": has_line_above,
"has_selection": has_selection,
"has_suggestion": has_suggestion,
"vi_mode": vi_mode,
"vi_insert_mode": vi_insert_mode,
"emacs_insert_mode": emacs_insert_mode,
# https://github.com/ipython/ipython/pull/12603 argued for inclusion of
# emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
# toggle; when the toggle is on user can access keybindigns like `ctrl + e`
# in vi insert mode. Because some of the emacs bindings involve `escape`
# followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
# needs to wait to see if there will be another character typed in before
# executing pure `escape` keybinding; in vi insert mode `escape` switches to
# command mode which is common and performance critical action for vi users.
# To avoid the delay users employ a workaround:
# https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
# which involves switching `emacs_bindings_in_vi_insert_mode` off.
#
# For the workaround to work:
# 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
# 2) all keybindings which would involve `escape` need to respect that
# toggle by including either:
# - `vi_insert_mode & ebivim` for actions which have emacs keybindings
# predefined upstream in prompt-toolkit, or
# - `emacs_like_insert_mode` for actions which do not have existing
# emacs keybindings predefined upstream (or need overriding of the
# upstream bindings to modify behaviour), defined below.
"emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
"has_completions": has_completions,
"insert_mode": vi_insert_mode | emacs_insert_mode,
"default_buffer_focused": default_buffer_focused,
"search_buffer_focused": has_focus(SEARCH_BUFFER),
# `ebivim` stands for emacs bindings in vi insert mode
"ebivim": ebivim,
"supports_suspend": supports_suspend,
"is_windows_os": is_windows_os,
"auto_match": auto_match,
"focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
"not_inside_unclosed_string": not_inside_unclosed_string,
"readline_like_completions": readline_like_completions,
"preceded_by_paired_double_quotes": preceding_text(
lambda line: all_quotes_paired('"', line)
),
"preceded_by_paired_single_quotes": preceding_text(
lambda line: all_quotes_paired("'", line)
),
"preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
"preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
"preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
"followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
"preceded_by_opening_round_paren": preceding_text(r".*\($"),
"preceded_by_opening_bracket": preceding_text(r".*\[$"),
"preceded_by_opening_brace": preceding_text(r".*\{$"),
"preceded_by_double_quote": preceding_text('.*"$'),
"preceded_by_single_quote": preceding_text(r".*'$"),
"followed_by_closing_round_paren": following_text(r"^\)"),
"followed_by_closing_bracket": following_text(r"^\]"),
"followed_by_closing_brace": following_text(r"^\}"),
"followed_by_double_quote": following_text('^"'),
"followed_by_single_quote": following_text("^'"),
"navigable_suggestions": navigable_suggestions,
"cursor_in_leading_ws": cursor_in_leading_ws,
}
def eval_node(node: Union[ast.AST, None]):
if node is None:
return None
if isinstance(node, ast.Expression):
return eval_node(node.body)
if isinstance(node, ast.BinOp):
left = eval_node(node.left)
right = eval_node(node.right)
dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
if dunders:
return getattr(left, dunders[0])(right)
raise ValueError(f"Unknown binary operation: {node.op}")
if isinstance(node, ast.UnaryOp):
value = eval_node(node.operand)
dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
if dunders:
return getattr(value, dunders[0])()
raise ValueError(f"Unknown unary operation: {node.op}")
if isinstance(node, ast.Name):
if node.id in KEYBINDING_FILTERS:
return KEYBINDING_FILTERS[node.id]
else:
sep = "\n - "
known_filters = sep.join(sorted(KEYBINDING_FILTERS))
raise NameError(
f"{node.id} is not a known shortcut filter."
f" Known filters are: {sep}{known_filters}."
)
raise ValueError("Unhandled node", ast.dump(node))
def filter_from_string(code: str):
expression = ast.parse(code, mode="eval")
return eval_node(expression)
__all__ = ["KEYBINDING_FILTERS", "filter_from_string"]