ipython-postfix-completion 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ipython_postfix_completion/__init__.py +557 -0
- ipython_postfix_completion-0.1.0.dist-info/METADATA +150 -0
- ipython_postfix_completion-0.1.0.dist-info/RECORD +6 -0
- ipython_postfix_completion-0.1.0.dist-info/WHEEL +5 -0
- ipython_postfix_completion-0.1.0.dist-info/licenses/LICENSE +29 -0
- ipython_postfix_completion-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Postfix completion extension for IPython."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
import string
|
|
9
|
+
import tokenize
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from IPython.core.completer import (
|
|
14
|
+
CompletionContext,
|
|
15
|
+
SimpleCompletion,
|
|
16
|
+
context_matcher,
|
|
17
|
+
)
|
|
18
|
+
from IPython.core.error import UsageError
|
|
19
|
+
from IPython.terminal.ptutils import IPythonPTCompleter
|
|
20
|
+
from traitlets import Dict as TraitletsDict
|
|
21
|
+
from traitlets import List as TraitletsList
|
|
22
|
+
from traitlets import Unicode
|
|
23
|
+
from traitlets.config.configurable import Configurable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_POSTFIX_RE = re.compile(
|
|
27
|
+
r"^(?P<indent>[ \t]*)(?P<body>.*)\.(?P<prefix>[A-Za-z_]*)$"
|
|
28
|
+
)
|
|
29
|
+
_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
30
|
+
_STATE_NAME = "postfix_completion"
|
|
31
|
+
_STYLE = "bg:#44475a #f8f8f2"
|
|
32
|
+
_SELECTED_STYLE = "bg:#6272a4 #ffffff"
|
|
33
|
+
_TEMPLATE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
34
|
+
_ALLOWED_TEMPLATE_FIELDS = {"expr", "indent"}
|
|
35
|
+
_MAGIC_NAME = "postfix_template"
|
|
36
|
+
_NO_MAGIC = object()
|
|
37
|
+
_NO_MAGIC_ATTR = object()
|
|
38
|
+
_ACTIVE_STATE: "PostfixState | None" = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _TemplateFormatter(string.Formatter):
|
|
42
|
+
def get_value(self, key, args, kwargs):
|
|
43
|
+
if isinstance(key, str):
|
|
44
|
+
return kwargs[key]
|
|
45
|
+
return super().get_value(key, args, kwargs)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_FORMATTER = _TemplateFormatter()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULT_TEMPLATES: dict[str, str] = {
|
|
52
|
+
"print": "print({expr})",
|
|
53
|
+
"len": "len({expr})",
|
|
54
|
+
"not": "not {expr}",
|
|
55
|
+
"par": "({expr})",
|
|
56
|
+
"return": "return {expr}",
|
|
57
|
+
"if": "if {expr}:\n{indent} ",
|
|
58
|
+
"while": "while {expr}:\n{indent} ",
|
|
59
|
+
"raise": "raise {expr}",
|
|
60
|
+
"yield": "yield {expr}",
|
|
61
|
+
"str": "str({expr})",
|
|
62
|
+
"list": "list({expr})",
|
|
63
|
+
"set": "set({expr})",
|
|
64
|
+
"dict": "dict({expr})",
|
|
65
|
+
"tuple": "tuple({expr})",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PostfixCompletionConfig(Configurable):
|
|
70
|
+
"""Configurable postfix completion templates."""
|
|
71
|
+
|
|
72
|
+
templates = TraitletsDict(
|
|
73
|
+
default_value={},
|
|
74
|
+
help="Postfix template strings keyed by template name.",
|
|
75
|
+
).tag(config=True)
|
|
76
|
+
disabled_templates = TraitletsList(
|
|
77
|
+
Unicode(),
|
|
78
|
+
default_value=[],
|
|
79
|
+
help="Template names to disable.",
|
|
80
|
+
).tag(config=True)
|
|
81
|
+
style = Unicode(
|
|
82
|
+
_STYLE,
|
|
83
|
+
help="Prompt-toolkit style for postfix completion menu entries.",
|
|
84
|
+
).tag(config=True)
|
|
85
|
+
selected_style = Unicode(
|
|
86
|
+
_SELECTED_STYLE,
|
|
87
|
+
help="Prompt-toolkit selected style for postfix completion menu entries.",
|
|
88
|
+
).tag(config=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_template_name(name: str) -> None:
|
|
92
|
+
if not _TEMPLATE_NAME_RE.match(name):
|
|
93
|
+
raise UsageError(
|
|
94
|
+
f"Invalid postfix template name {name!r}: expected "
|
|
95
|
+
"[A-Za-z_][A-Za-z0-9_]*"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_template(template: str) -> None:
|
|
100
|
+
fields = {
|
|
101
|
+
field_name
|
|
102
|
+
for _, field_name, _, _ in _FORMATTER.parse(template)
|
|
103
|
+
if field_name is not None
|
|
104
|
+
}
|
|
105
|
+
unknown = fields - _ALLOWED_TEMPLATE_FIELDS
|
|
106
|
+
if unknown:
|
|
107
|
+
names = ", ".join(sorted(f"{{{name}}}" for name in unknown))
|
|
108
|
+
raise UsageError(
|
|
109
|
+
"Postfix template may only use {expr} and {indent}; "
|
|
110
|
+
f"unknown field(s): {names}"
|
|
111
|
+
)
|
|
112
|
+
if "expr" not in fields:
|
|
113
|
+
raise UsageError("Postfix template must include {expr}.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_templates(templates: dict[str, str]) -> None:
|
|
117
|
+
for name, template in templates.items():
|
|
118
|
+
_validate_template_name(name)
|
|
119
|
+
if not isinstance(template, str):
|
|
120
|
+
raise UsageError(f"Postfix template {name!r} must be a string.")
|
|
121
|
+
_validate_template(template)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_template(template: str, expr: str, indent: str = "") -> str:
|
|
125
|
+
return _FORMATTER.format(template, expr=expr, indent=indent)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class PostfixState:
|
|
130
|
+
matcher: Callable[[CompletionContext], dict]
|
|
131
|
+
config: PostfixCompletionConfig
|
|
132
|
+
original_key_bindings: object | None = None
|
|
133
|
+
original_completer: object | None = None
|
|
134
|
+
original_magic: object = _NO_MAGIC
|
|
135
|
+
original_magic_attr: object = _NO_MAGIC_ATTR
|
|
136
|
+
runtime_templates: dict[str, str] | None = None
|
|
137
|
+
runtime_disabled_templates: set[str] | None = None
|
|
138
|
+
|
|
139
|
+
def __post_init__(self) -> None:
|
|
140
|
+
if self.runtime_templates is None:
|
|
141
|
+
self.runtime_templates = {}
|
|
142
|
+
if self.runtime_disabled_templates is None:
|
|
143
|
+
self.runtime_disabled_templates = set()
|
|
144
|
+
|
|
145
|
+
def effective_templates(self) -> dict[str, str]:
|
|
146
|
+
_validate_templates(dict(self.config.templates))
|
|
147
|
+
templates = dict(DEFAULT_TEMPLATES)
|
|
148
|
+
templates.update(self.config.templates)
|
|
149
|
+
templates.update(self.runtime_templates or {})
|
|
150
|
+
disabled = set(self.config.disabled_templates) | (
|
|
151
|
+
self.runtime_disabled_templates or set()
|
|
152
|
+
)
|
|
153
|
+
for name in disabled:
|
|
154
|
+
templates.pop(name, None)
|
|
155
|
+
return templates
|
|
156
|
+
|
|
157
|
+
def template_origin(self, name: str) -> str:
|
|
158
|
+
if name in (self.runtime_disabled_templates or set()) or name in set(
|
|
159
|
+
self.config.disabled_templates
|
|
160
|
+
):
|
|
161
|
+
return "disabled"
|
|
162
|
+
if name in (self.runtime_templates or {}):
|
|
163
|
+
return "custom"
|
|
164
|
+
if name in self.config.templates:
|
|
165
|
+
return "custom"
|
|
166
|
+
if name in DEFAULT_TEMPLATES:
|
|
167
|
+
return "builtin"
|
|
168
|
+
return "custom"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _line_prefix(line: str) -> tuple[str, str, str] | None:
|
|
172
|
+
match = _POSTFIX_RE.match(line)
|
|
173
|
+
if not match:
|
|
174
|
+
return None
|
|
175
|
+
expr = match.group("body")
|
|
176
|
+
prefix = match.group("prefix")
|
|
177
|
+
if not expr or (prefix and not _PREFIX_RE.match(prefix)):
|
|
178
|
+
return None
|
|
179
|
+
return match.group("indent"), expr, prefix
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _valid_expression(expr: str) -> bool:
|
|
183
|
+
try:
|
|
184
|
+
ast.parse(expr, mode="eval")
|
|
185
|
+
except SyntaxError:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
tokens = list(tokenize.generate_tokens(io.StringIO(expr).readline))
|
|
190
|
+
except tokenize.TokenError:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
meaningful = [
|
|
194
|
+
token
|
|
195
|
+
for token in tokens
|
|
196
|
+
if token.type
|
|
197
|
+
not in {
|
|
198
|
+
tokenize.ENCODING,
|
|
199
|
+
tokenize.ENDMARKER,
|
|
200
|
+
tokenize.NL,
|
|
201
|
+
tokenize.NEWLINE,
|
|
202
|
+
tokenize.INDENT,
|
|
203
|
+
tokenize.DEDENT,
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
if not meaningful:
|
|
207
|
+
return False
|
|
208
|
+
if any(
|
|
209
|
+
token.type in {tokenize.COMMENT, tokenize.ERRORTOKEN} for token in meaningful
|
|
210
|
+
):
|
|
211
|
+
return False
|
|
212
|
+
return meaningful[-1].end[1] == len(expr.rstrip())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _state_or_default(state: PostfixState | None = None) -> PostfixState | None:
|
|
216
|
+
return state or _ACTIVE_STATE
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _effective_templates(state: PostfixState | None = None) -> dict[str, str]:
|
|
220
|
+
state = _state_or_default(state)
|
|
221
|
+
if state is None:
|
|
222
|
+
return dict(DEFAULT_TEMPLATES)
|
|
223
|
+
return state.effective_templates()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _apply_template(
|
|
227
|
+
name: str, expr: str, indent: str = "", state: PostfixState | None = None
|
|
228
|
+
) -> str:
|
|
229
|
+
return _render_template(_effective_templates(state)[name], expr, indent)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _postfix_candidates(
|
|
233
|
+
line: str, *, exact: bool = False, state: PostfixState | None = None
|
|
234
|
+
) -> tuple[str, list[str]]:
|
|
235
|
+
parsed = _line_prefix(line)
|
|
236
|
+
if parsed is None:
|
|
237
|
+
return "", []
|
|
238
|
+
indent, expr, prefix = parsed
|
|
239
|
+
if not _valid_expression(expr):
|
|
240
|
+
return "", []
|
|
241
|
+
|
|
242
|
+
templates = _effective_templates(state)
|
|
243
|
+
names = [
|
|
244
|
+
name
|
|
245
|
+
for name in templates
|
|
246
|
+
if (name == prefix if exact else name.startswith(prefix))
|
|
247
|
+
]
|
|
248
|
+
return f"{expr}.{prefix}", [
|
|
249
|
+
_render_template(templates[name], expr, indent) for name in names
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _template_name(
|
|
254
|
+
fragment: str,
|
|
255
|
+
expansion: str,
|
|
256
|
+
indent: str = "",
|
|
257
|
+
state: PostfixState | None = None,
|
|
258
|
+
) -> str | None:
|
|
259
|
+
expr, _, prefix = fragment.rpartition(".")
|
|
260
|
+
for name, template in _effective_templates(state).items():
|
|
261
|
+
if not name.startswith(prefix):
|
|
262
|
+
continue
|
|
263
|
+
if _render_template(template, expr, indent) == expansion:
|
|
264
|
+
return name
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@context_matcher(identifier="ipython_postfix_completion")
|
|
269
|
+
def postfix_matcher(context: CompletionContext) -> dict:
|
|
270
|
+
line = context.line_with_cursor[: context.cursor_position]
|
|
271
|
+
matched_fragment, candidates = _postfix_candidates(line)
|
|
272
|
+
return {
|
|
273
|
+
"completions": [
|
|
274
|
+
SimpleCompletion(candidate, type="postfix") for candidate in candidates
|
|
275
|
+
],
|
|
276
|
+
"matched_fragment": matched_fragment or context.token,
|
|
277
|
+
"suppress": False,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _expand_buffer(buffer) -> bool:
|
|
282
|
+
document = buffer.document
|
|
283
|
+
line = document.current_line_before_cursor
|
|
284
|
+
matched_fragment, candidates = _postfix_candidates(line, exact=True)
|
|
285
|
+
if len(candidates) != 1:
|
|
286
|
+
return False
|
|
287
|
+
buffer.delete_before_cursor(len(matched_fragment))
|
|
288
|
+
buffer.insert_text(candidates[0])
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _insert_trigger_and_maybe_complete(buffer) -> bool:
|
|
293
|
+
buffer.insert_text(".")
|
|
294
|
+
if not _postfix_candidates(buffer.document.current_line_before_cursor)[1]:
|
|
295
|
+
return False
|
|
296
|
+
buffer.start_completion(select_first=False)
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _make_key_bindings():
|
|
301
|
+
from prompt_toolkit.application.current import get_app
|
|
302
|
+
from prompt_toolkit.filters import Condition
|
|
303
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
304
|
+
|
|
305
|
+
key_bindings = KeyBindings()
|
|
306
|
+
|
|
307
|
+
@Condition
|
|
308
|
+
def has_exact_postfix() -> bool:
|
|
309
|
+
buffer = get_app().current_buffer
|
|
310
|
+
return bool(
|
|
311
|
+
_postfix_candidates(
|
|
312
|
+
buffer.document.current_line_before_cursor, exact=True
|
|
313
|
+
)[1]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
@key_bindings.add("tab", filter=has_exact_postfix)
|
|
317
|
+
def expand_postfix(event) -> None:
|
|
318
|
+
_expand_buffer(event.current_buffer)
|
|
319
|
+
|
|
320
|
+
@key_bindings.add(".")
|
|
321
|
+
def insert_trigger(event) -> None:
|
|
322
|
+
_insert_trigger_and_maybe_complete(event.current_buffer)
|
|
323
|
+
|
|
324
|
+
return key_bindings
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _strip_optional_quotes(value: str) -> str:
|
|
328
|
+
value = value.strip()
|
|
329
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
330
|
+
try:
|
|
331
|
+
parsed = ast.literal_eval(value)
|
|
332
|
+
except (SyntaxError, ValueError):
|
|
333
|
+
return value[1:-1]
|
|
334
|
+
if isinstance(parsed, str):
|
|
335
|
+
return parsed
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _postfix_template_magic(line: str) -> None:
|
|
340
|
+
state = _ACTIVE_STATE
|
|
341
|
+
if state is None:
|
|
342
|
+
raise UsageError("Postfix completion extension is not loaded.")
|
|
343
|
+
|
|
344
|
+
command_line = line.strip()
|
|
345
|
+
if not command_line:
|
|
346
|
+
raise UsageError("Usage: %postfix_template list|add|remove|reset ...")
|
|
347
|
+
|
|
348
|
+
command, _, rest = command_line.partition(" ")
|
|
349
|
+
rest = rest.strip()
|
|
350
|
+
|
|
351
|
+
if command == "list":
|
|
352
|
+
if rest:
|
|
353
|
+
raise UsageError("Usage: %postfix_template list")
|
|
354
|
+
_list_postfix_templates(state)
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
if command == "add":
|
|
358
|
+
name, sep, template = rest.partition(" ")
|
|
359
|
+
if not sep or not template.strip():
|
|
360
|
+
raise UsageError("Usage: %postfix_template add NAME TEMPLATE")
|
|
361
|
+
template = _strip_optional_quotes(template)
|
|
362
|
+
_validate_template_name(name)
|
|
363
|
+
_validate_template(template)
|
|
364
|
+
assert state.runtime_templates is not None
|
|
365
|
+
assert state.runtime_disabled_templates is not None
|
|
366
|
+
state.runtime_templates[name] = template
|
|
367
|
+
state.runtime_disabled_templates.discard(name)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if command == "remove":
|
|
371
|
+
if not rest or " " in rest:
|
|
372
|
+
raise UsageError("Usage: %postfix_template remove NAME")
|
|
373
|
+
_validate_template_name(rest)
|
|
374
|
+
assert state.runtime_templates is not None
|
|
375
|
+
assert state.runtime_disabled_templates is not None
|
|
376
|
+
state.runtime_templates.pop(rest, None)
|
|
377
|
+
state.runtime_disabled_templates.add(rest)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if command == "reset":
|
|
381
|
+
if rest == "--all":
|
|
382
|
+
assert state.runtime_templates is not None
|
|
383
|
+
assert state.runtime_disabled_templates is not None
|
|
384
|
+
state.runtime_templates.clear()
|
|
385
|
+
state.runtime_disabled_templates.clear()
|
|
386
|
+
return
|
|
387
|
+
if not rest or " " in rest:
|
|
388
|
+
raise UsageError("Usage: %postfix_template reset NAME|--all")
|
|
389
|
+
_validate_template_name(rest)
|
|
390
|
+
assert state.runtime_templates is not None
|
|
391
|
+
assert state.runtime_disabled_templates is not None
|
|
392
|
+
state.runtime_templates.pop(rest, None)
|
|
393
|
+
state.runtime_disabled_templates.discard(rest)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
raise UsageError("Usage: %postfix_template list|add|remove|reset ...")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _list_postfix_templates(state: PostfixState) -> None:
|
|
400
|
+
effective = state.effective_templates()
|
|
401
|
+
names = set(DEFAULT_TEMPLATES) | set(state.config.templates) | set(
|
|
402
|
+
state.runtime_templates or {}
|
|
403
|
+
) | set(state.config.disabled_templates) | set(
|
|
404
|
+
state.runtime_disabled_templates or set()
|
|
405
|
+
)
|
|
406
|
+
for name in sorted(names):
|
|
407
|
+
origin = state.template_origin(name)
|
|
408
|
+
template = effective.get(name, "<disabled>")
|
|
409
|
+
print(f"{name}\t{origin}\t{template}")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class PostfixPTCompleter(IPythonPTCompleter):
|
|
413
|
+
"""Prompt-toolkit adapter that gives postfix completions distinct display."""
|
|
414
|
+
|
|
415
|
+
def __init__(self, wrapped: IPythonPTCompleter):
|
|
416
|
+
super().__init__(
|
|
417
|
+
ipy_completer=getattr(wrapped, "_ipy_completer", None),
|
|
418
|
+
shell=getattr(wrapped, "shell", None),
|
|
419
|
+
)
|
|
420
|
+
self._wrapped = wrapped
|
|
421
|
+
|
|
422
|
+
def _get_completions(self, body, offset, cursor_position, ipyc):
|
|
423
|
+
from IPython.core.completer import _deduplicate_completions
|
|
424
|
+
from prompt_toolkit.completion import Completion
|
|
425
|
+
|
|
426
|
+
completions = _deduplicate_completions(body, ipyc.completions(body, offset))
|
|
427
|
+
for completion in completions:
|
|
428
|
+
if completion.type != "postfix":
|
|
429
|
+
yield from self._wrapped._get_completions(
|
|
430
|
+
body, offset, cursor_position, _SingleCompletion(completion)
|
|
431
|
+
)
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
fragment = body[completion.start : completion.end]
|
|
435
|
+
line_start = body.rfind("\n", 0, completion.start) + 1
|
|
436
|
+
indent = body[line_start:completion.start]
|
|
437
|
+
state = _state_or_default()
|
|
438
|
+
key = _template_name(fragment, completion.text, indent, state) or "postfix"
|
|
439
|
+
display = f".{key} -> {completion.text}"
|
|
440
|
+
config = state.config if state is not None else None
|
|
441
|
+
yield Completion(
|
|
442
|
+
completion.text,
|
|
443
|
+
start_position=completion.start - offset,
|
|
444
|
+
display=display,
|
|
445
|
+
display_meta="postfix",
|
|
446
|
+
style=config.style if config is not None else _STYLE,
|
|
447
|
+
selected_style=(
|
|
448
|
+
config.selected_style if config is not None else _SELECTED_STYLE
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class _SingleCompletion:
|
|
454
|
+
def __init__(self, completion):
|
|
455
|
+
self._completion = completion
|
|
456
|
+
self.debug = False
|
|
457
|
+
|
|
458
|
+
def completions(self, body, offset):
|
|
459
|
+
yield self._completion
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _install_completer(ip, state: PostfixState) -> None:
|
|
463
|
+
pt_app = getattr(ip, "pt_app", None)
|
|
464
|
+
if pt_app is None:
|
|
465
|
+
return
|
|
466
|
+
original = getattr(pt_app, "completer", None)
|
|
467
|
+
if not isinstance(original, IPythonPTCompleter):
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
state.original_completer = original
|
|
471
|
+
pt_app.completer = PostfixPTCompleter(original)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _install_key_binding(ip, state: PostfixState) -> None:
|
|
475
|
+
pt_app = getattr(ip, "pt_app", None)
|
|
476
|
+
if pt_app is None:
|
|
477
|
+
return
|
|
478
|
+
original = getattr(pt_app, "key_bindings", None)
|
|
479
|
+
if original is None:
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
from prompt_toolkit.key_binding import merge_key_bindings
|
|
483
|
+
|
|
484
|
+
state.original_key_bindings = original
|
|
485
|
+
pt_app.key_bindings = merge_key_bindings([_make_key_bindings(), original])
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def load_ipython_extension(ip) -> None:
|
|
489
|
+
global _ACTIVE_STATE
|
|
490
|
+
existing = getattr(ip.meta, _STATE_NAME, None)
|
|
491
|
+
if existing is not None:
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
config = PostfixCompletionConfig(parent=ip)
|
|
495
|
+
_validate_templates(dict(config.templates))
|
|
496
|
+
for name in config.disabled_templates:
|
|
497
|
+
_validate_template_name(name)
|
|
498
|
+
|
|
499
|
+
state = PostfixState(matcher=postfix_matcher, config=config)
|
|
500
|
+
state.original_magic = ip.magics_manager.magics["line"].get(_MAGIC_NAME, _NO_MAGIC)
|
|
501
|
+
state.original_magic_attr = getattr(
|
|
502
|
+
ip.magics_manager.user_magics, _MAGIC_NAME, _NO_MAGIC_ATTR
|
|
503
|
+
)
|
|
504
|
+
ip.Completer.custom_matchers.insert(0, postfix_matcher)
|
|
505
|
+
ip.register_magic_function(_postfix_template_magic, "line", _MAGIC_NAME)
|
|
506
|
+
_install_completer(ip, state)
|
|
507
|
+
_install_key_binding(ip, state)
|
|
508
|
+
setattr(ip.meta, _STATE_NAME, state)
|
|
509
|
+
_ACTIVE_STATE = state
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def unload_ipython_extension(ip) -> None:
|
|
513
|
+
global _ACTIVE_STATE
|
|
514
|
+
state = getattr(ip.meta, _STATE_NAME, None)
|
|
515
|
+
if state is None:
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
ip.Completer.custom_matchers.remove(state.matcher)
|
|
520
|
+
except ValueError:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
pt_app = getattr(ip, "pt_app", None)
|
|
524
|
+
if pt_app is not None and state.original_key_bindings is not None:
|
|
525
|
+
pt_app.key_bindings = state.original_key_bindings
|
|
526
|
+
if pt_app is not None and state.original_completer is not None:
|
|
527
|
+
pt_app.completer = state.original_completer
|
|
528
|
+
|
|
529
|
+
current_magic = ip.magics_manager.magics["line"].get(_MAGIC_NAME)
|
|
530
|
+
if current_magic is _postfix_template_magic:
|
|
531
|
+
if state.original_magic is _NO_MAGIC:
|
|
532
|
+
ip.magics_manager.magics["line"].pop(_MAGIC_NAME, None)
|
|
533
|
+
if getattr(ip.magics_manager, "user_magics", None) is not None:
|
|
534
|
+
try:
|
|
535
|
+
delattr(ip.magics_manager.user_magics, _MAGIC_NAME)
|
|
536
|
+
except AttributeError:
|
|
537
|
+
pass
|
|
538
|
+
else:
|
|
539
|
+
ip.magics_manager.magics["line"][_MAGIC_NAME] = state.original_magic
|
|
540
|
+
if state.original_magic_attr is _NO_MAGIC_ATTR:
|
|
541
|
+
try:
|
|
542
|
+
delattr(ip.magics_manager.user_magics, _MAGIC_NAME)
|
|
543
|
+
except AttributeError:
|
|
544
|
+
pass
|
|
545
|
+
else:
|
|
546
|
+
setattr(
|
|
547
|
+
ip.magics_manager.user_magics,
|
|
548
|
+
_MAGIC_NAME,
|
|
549
|
+
state.original_magic_attr,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
del ip.meta[_STATE_NAME]
|
|
554
|
+
except KeyError:
|
|
555
|
+
pass
|
|
556
|
+
if _ACTIVE_STATE is state:
|
|
557
|
+
_ACTIVE_STATE = None
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipython-postfix-completion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configurable postfix completion extension for IPython.
|
|
5
|
+
Author: IPython Postfix Completion Contributors
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Keywords: ipython,completion,postfix,extension
|
|
8
|
+
Classifier: Framework :: IPython
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: ipython>=9.0
|
|
20
|
+
Requires-Dist: traitlets>=5.13
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build; extra == "dev"
|
|
25
|
+
Requires-Dist: twine; extra == "dev"
|
|
26
|
+
Requires-Dist: ipython-postfix-completion[test]; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# IPython Postfix Completion
|
|
30
|
+
|
|
31
|
+
Configurable postfix completion extension for IPython.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
From a local checkout:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python -m pip install -e .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From PyPI after publishing:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python -m pip install ipython-postfix-completion
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Load
|
|
48
|
+
|
|
49
|
+
Inside IPython:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
%load_ext ipython_postfix_completion
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
To load it automatically, add this to `ipython_config.py`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Runtime Templates
|
|
62
|
+
|
|
63
|
+
Add a template for the current session:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
%postfix_template add forin "for item in {expr}:\n{indent} "
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Use it:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
items.forin<Tab>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This expands to:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
for item in items:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
List effective templates:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
%postfix_template list
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Disable a template for the current session:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
%postfix_template remove tuple
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Reset one runtime change:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
%postfix_template reset forin
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Reset all runtime changes:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
%postfix_template reset --all
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Persistent Config
|
|
107
|
+
|
|
108
|
+
Add persistent templates in `ipython_config.py`:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
c.PostfixCompletionConfig.templates = {
|
|
112
|
+
"debug": "print({expr}=)",
|
|
113
|
+
"forin": "for item in {expr}:\n{indent} ",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
c.PostfixCompletionConfig.disabled_templates = ["tuple"]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Template names must match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
120
|
+
|
|
121
|
+
Templates must include `{expr}` and may also use `{indent}`. No other template fields are allowed.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
Run tests:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python -m pip install -e ".[test]"
|
|
129
|
+
python -m pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Build release artifacts:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python -m pip install build twine
|
|
136
|
+
python -m build
|
|
137
|
+
python -m twine check dist/*
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Publish to TestPyPI:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
python -m twine upload --repository testpypi dist/*
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Publish to PyPI:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
python -m twine upload dist/*
|
|
150
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ipython_postfix_completion/__init__.py,sha256=DRxZk3AHYjxgytPi07L7mumRC35xpxJIvFQmqING7Hs,18092
|
|
2
|
+
ipython_postfix_completion-0.1.0.dist-info/licenses/LICENSE,sha256=omLR-7QW1K3l6AG7ih1N5L8Z58UY-ROrQL82ANN9PnM,1547
|
|
3
|
+
ipython_postfix_completion-0.1.0.dist-info/METADATA,sha256=TufiR1ICd7vXLBVvvdj7zYPaiiSkiySe3Ub9wkxDneo,2850
|
|
4
|
+
ipython_postfix_completion-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
ipython_postfix_completion-0.1.0.dist-info/top_level.txt,sha256=W2sJCvsBheoCB2nbzJeD7-p5PR6lVDNicVLxNxmeraY,27
|
|
6
|
+
ipython_postfix_completion-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, IPython Postfix Completion Contributors
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ipython_postfix_completion
|