File: filters.py

package info (click to toggle)
ipython 8.35.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 11,696 kB
  • sloc: python: 42,461; sh: 376; makefile: 243
file content (322 lines) | stat: -rw-r--r-- 10,998 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
"""
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.key_binding import KeyPressEvent
from prompt_toolkit.filters import Condition, Filter, 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 is_cursor_at_the_end_of_line() -> bool:
    document = get_app().current_buffer.document
    return document.is_cursor_at_the_end_of_line


@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"


class PassThrough(Filter):
    """A filter allowing to implement pass-through behaviour of keybindings.

    Prompt toolkit key processor dispatches only one event per binding match,
    which means that adding a new shortcut will suppress the old shortcut
    if the keybindings are the same (unless one is filtered out).

    To stop a shortcut binding from suppressing other shortcuts:
    - add the `pass_through` filter to list of filter, and
    - call `pass_through.reply(event)` in the shortcut handler.
    """

    def __init__(self):
        self._is_replying = False

    def reply(self, event: KeyPressEvent):
        self._is_replying = True
        try:
            event.key_processor.reset()
            event.key_processor.feed_multiple(event.key_sequence)
            event.key_processor.process_keys()
        finally:
            self._is_replying = False

    def __call__(self):
        return not self._is_replying


pass_through = PassThrough()

# these one is callable and re-used multiple times hence needs to be
# only defined once beforehand 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,
    "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line,
    "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,
    "pass_through": pass_through,
}


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"]