ipython-postfix-completion 0.1.1__py3-none-any.whl → 0.2.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 +377 -23
- {ipython_postfix_completion-0.1.1.dist-info → ipython_postfix_completion-0.2.0.dist-info}/METADATA +72 -8
- ipython_postfix_completion-0.2.0.dist-info/RECORD +6 -0
- {ipython_postfix_completion-0.1.1.dist-info → ipython_postfix_completion-0.2.0.dist-info}/WHEEL +1 -1
- ipython_postfix_completion-0.1.1.dist-info/RECORD +0 -6
- {ipython_postfix_completion-0.1.1.dist-info → ipython_postfix_completion-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {ipython_postfix_completion-0.1.1.dist-info → ipython_postfix_completion-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -7,8 +7,8 @@ import io
|
|
|
7
7
|
import re
|
|
8
8
|
import string
|
|
9
9
|
import tokenize
|
|
10
|
+
from collections.abc import Callable
|
|
10
11
|
from dataclasses import dataclass
|
|
11
|
-
from typing import Callable
|
|
12
12
|
|
|
13
13
|
from IPython.core.completer import (
|
|
14
14
|
CompletionContext,
|
|
@@ -17,15 +17,12 @@ from IPython.core.completer import (
|
|
|
17
17
|
)
|
|
18
18
|
from IPython.core.error import UsageError
|
|
19
19
|
from IPython.terminal.ptutils import IPythonPTCompleter
|
|
20
|
+
from traitlets import Bool, Unicode
|
|
20
21
|
from traitlets import Dict as TraitletsDict
|
|
21
22
|
from traitlets import List as TraitletsList
|
|
22
|
-
from traitlets import Unicode
|
|
23
23
|
from traitlets.config.configurable import Configurable
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
_POSTFIX_RE = re.compile(
|
|
27
|
-
r"^(?P<indent>[ \t]*)(?P<body>.*)\.(?P<prefix>[A-Za-z_]*)$"
|
|
28
|
-
)
|
|
25
|
+
_POSTFIX_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<body>.*)\.(?P<prefix>[A-Za-z_]*)$")
|
|
29
26
|
_PREFIX_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
30
27
|
_STATE_NAME = "postfix_completion"
|
|
31
28
|
_STYLE = "bg:#44475a #f8f8f2"
|
|
@@ -35,7 +32,13 @@ _ALLOWED_TEMPLATE_FIELDS = {"expr", "indent"}
|
|
|
35
32
|
_MAGIC_NAME = "postfix_template"
|
|
36
33
|
_NO_MAGIC = object()
|
|
37
34
|
_NO_MAGIC_ATTR = object()
|
|
38
|
-
_ACTIVE_STATE:
|
|
35
|
+
_ACTIVE_STATE: PostfixState | None = None
|
|
36
|
+
_VAR_TEMPLATE = "key = {expr}"
|
|
37
|
+
_VAR_PLACEHOLDER = "key"
|
|
38
|
+
_PLACEHOLDER_ATTR = "_postfix_completion_placeholder"
|
|
39
|
+
_OPENING_BRACKETS = {"(": ")", "[": "]", "{": "}"}
|
|
40
|
+
_CLOSING_BRACKETS = {closer: opener for opener, closer in _OPENING_BRACKETS.items()}
|
|
41
|
+
_STRING_START_RE = re.compile(r"(?i)^(?P<prefix>[rubf]*)(?P<quote>'''|\"\"\"|'|\")")
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
class _TemplateFormatter(string.Formatter):
|
|
@@ -53,7 +56,7 @@ DEFAULT_TEMPLATES: dict[str, str] = {
|
|
|
53
56
|
"len": "len({expr})",
|
|
54
57
|
"not": "not {expr}",
|
|
55
58
|
"par": "({expr})",
|
|
56
|
-
"var":
|
|
59
|
+
"var": _VAR_TEMPLATE,
|
|
57
60
|
"await": "await {expr}",
|
|
58
61
|
"return": "return {expr}",
|
|
59
62
|
"if": "if {expr}:\n{indent} ",
|
|
@@ -88,22 +91,35 @@ class PostfixCompletionConfig(Configurable):
|
|
|
88
91
|
_SELECTED_STYLE,
|
|
89
92
|
help="Prompt-toolkit selected style for postfix completion menu entries.",
|
|
90
93
|
).tag(config=True)
|
|
94
|
+
smart_tab_jump = Bool(
|
|
95
|
+
True,
|
|
96
|
+
help="Move Tab over adjacent Python string and bracket closing tokens.",
|
|
97
|
+
).tag(config=True)
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
def _validate_template_name(name: str) -> None:
|
|
94
101
|
if not _TEMPLATE_NAME_RE.match(name):
|
|
95
102
|
raise UsageError(
|
|
96
|
-
f"Invalid postfix template name {name!r}: expected "
|
|
97
|
-
"[A-Za-z_][A-Za-z0-9_]*"
|
|
103
|
+
f"Invalid postfix template name {name!r}: expected [A-Za-z_][A-Za-z0-9_]*"
|
|
98
104
|
)
|
|
99
105
|
|
|
100
106
|
|
|
101
107
|
def _validate_template(template: str) -> None:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
try:
|
|
109
|
+
parsed = list(_FORMATTER.parse(template))
|
|
110
|
+
except ValueError as error:
|
|
111
|
+
raise UsageError(f"Invalid postfix template: {error}") from error
|
|
112
|
+
|
|
113
|
+
fields = set()
|
|
114
|
+
for _, field_name, format_spec, conversion in parsed:
|
|
115
|
+
if field_name is None:
|
|
116
|
+
continue
|
|
117
|
+
if format_spec or conversion is not None:
|
|
118
|
+
raise UsageError(
|
|
119
|
+
"Postfix template fields must use plain {expr} or {indent}."
|
|
120
|
+
)
|
|
121
|
+
fields.add(field_name)
|
|
122
|
+
|
|
107
123
|
unknown = fields - _ALLOWED_TEMPLATE_FIELDS
|
|
108
124
|
if unknown:
|
|
109
125
|
names = ", ".join(sorted(f"{{{name}}}" for name in unknown))
|
|
@@ -286,8 +302,320 @@ def _expand_buffer(buffer) -> bool:
|
|
|
286
302
|
matched_fragment, candidates = _postfix_candidates(line, exact=True)
|
|
287
303
|
if len(candidates) != 1:
|
|
288
304
|
return False
|
|
305
|
+
parsed = _line_prefix(line)
|
|
289
306
|
buffer.delete_before_cursor(len(matched_fragment))
|
|
307
|
+
expansion_start = buffer.cursor_position
|
|
290
308
|
buffer.insert_text(candidates[0])
|
|
309
|
+
if (
|
|
310
|
+
parsed is not None
|
|
311
|
+
and parsed[2] == "var"
|
|
312
|
+
and _effective_templates().get("var") == _VAR_TEMPLATE
|
|
313
|
+
):
|
|
314
|
+
_select_placeholder(
|
|
315
|
+
buffer,
|
|
316
|
+
expansion_start,
|
|
317
|
+
expansion_start + len(_VAR_PLACEHOLDER),
|
|
318
|
+
buffer.cursor_position,
|
|
319
|
+
)
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _select_placeholder(buffer, start: int, end: int, final_cursor: int) -> None:
|
|
324
|
+
from prompt_toolkit.selection import SelectionState, SelectionType
|
|
325
|
+
|
|
326
|
+
buffer.cursor_position = end
|
|
327
|
+
buffer.selection_state = SelectionState(start, SelectionType.CHARACTERS)
|
|
328
|
+
buffer.selection_state.enter_shift_mode()
|
|
329
|
+
setattr(buffer, _PLACEHOLDER_ATTR, (start, end, final_cursor))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _has_active_placeholder(buffer) -> bool:
|
|
333
|
+
placeholder = getattr(buffer, _PLACEHOLDER_ATTR, None)
|
|
334
|
+
selection = buffer.selection_state
|
|
335
|
+
if placeholder is None or selection is None:
|
|
336
|
+
return False
|
|
337
|
+
start, end, _ = placeholder
|
|
338
|
+
return (
|
|
339
|
+
selection.shift_mode
|
|
340
|
+
and selection.original_cursor_position == start
|
|
341
|
+
and buffer.cursor_position == end
|
|
342
|
+
and buffer.text[start:end] == _VAR_PLACEHOLDER
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _accept_placeholder(buffer) -> bool:
|
|
347
|
+
if not _has_active_placeholder(buffer):
|
|
348
|
+
return False
|
|
349
|
+
_, _, final_cursor = getattr(buffer, _PLACEHOLDER_ATTR)
|
|
350
|
+
buffer.exit_selection()
|
|
351
|
+
buffer.cursor_position = final_cursor
|
|
352
|
+
delattr(buffer, _PLACEHOLDER_ATTR)
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _line_offsets(source: str) -> list[int]:
|
|
357
|
+
offsets = [0]
|
|
358
|
+
offsets.extend(match.end() for match in re.finditer("\n", source))
|
|
359
|
+
return offsets
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _absolute_token_position(
|
|
363
|
+
position: tuple[int, int], line_offsets: list[int], source_length: int
|
|
364
|
+
) -> int:
|
|
365
|
+
row, column = position
|
|
366
|
+
if 0 < row <= len(line_offsets):
|
|
367
|
+
return min(line_offsets[row - 1] + column, source_length)
|
|
368
|
+
return source_length
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _python_tokens(source: str) -> list[tuple[tokenize.TokenInfo, int, int]]:
|
|
372
|
+
line_offsets = _line_offsets(source)
|
|
373
|
+
tokens: list[tuple[tokenize.TokenInfo, int, int]] = []
|
|
374
|
+
generator = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
375
|
+
try:
|
|
376
|
+
for token in generator:
|
|
377
|
+
start = _absolute_token_position(token.start, line_offsets, len(source))
|
|
378
|
+
end = _absolute_token_position(token.end, line_offsets, len(source))
|
|
379
|
+
tokens.append((token, start, end))
|
|
380
|
+
except (IndentationError, SyntaxError, tokenize.TokenError):
|
|
381
|
+
# Incomplete interactive input is normal. Tokens emitted before the
|
|
382
|
+
# error still describe whether the adjacent closer is syntactic.
|
|
383
|
+
pass
|
|
384
|
+
return tokens
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _string_parts(value: str) -> tuple[str, str] | None:
|
|
388
|
+
match = _STRING_START_RE.match(value)
|
|
389
|
+
if match is None:
|
|
390
|
+
return None
|
|
391
|
+
return match.group("prefix"), match.group("quote")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _quoted_string_end(value: str, quote_start: int) -> int:
|
|
395
|
+
quote = value[quote_start : quote_start + 3]
|
|
396
|
+
delimiter = quote if quote in {"'''", '"""'} else value[quote_start]
|
|
397
|
+
index = quote_start + len(delimiter)
|
|
398
|
+
while index < len(value):
|
|
399
|
+
if value.startswith(delimiter, index):
|
|
400
|
+
backslashes = 0
|
|
401
|
+
before = index - 1
|
|
402
|
+
while before >= quote_start and value[before] == "\\":
|
|
403
|
+
backslashes += 1
|
|
404
|
+
before -= 1
|
|
405
|
+
if backslashes % 2 == 0:
|
|
406
|
+
return index + len(delimiter)
|
|
407
|
+
index += 1
|
|
408
|
+
return len(value)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _fstring_brace_is_closing(value: str, cursor: int) -> bool:
|
|
412
|
+
parts = _string_parts(value)
|
|
413
|
+
if parts is None:
|
|
414
|
+
return False
|
|
415
|
+
prefix, quote = parts
|
|
416
|
+
if "f" not in prefix.lower() or not value.endswith(quote):
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
content_start = len(prefix) + len(quote)
|
|
420
|
+
content_end = len(value) - len(quote)
|
|
421
|
+
if not (content_start <= cursor < content_end) or value[cursor] != "}":
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
# Each context is [kind, is_format_spec, bracket_stack]. Literal f-string
|
|
425
|
+
# text and Python replacement expressions follow different brace rules.
|
|
426
|
+
contexts: list[list[object]] = [["literal", False, []]]
|
|
427
|
+
index = content_start
|
|
428
|
+
while index <= cursor and contexts:
|
|
429
|
+
kind, is_format_spec, bracket_stack = contexts[-1]
|
|
430
|
+
character = value[index]
|
|
431
|
+
|
|
432
|
+
if kind == "literal":
|
|
433
|
+
if character == "{" and value.startswith("{{", index):
|
|
434
|
+
index += 2
|
|
435
|
+
continue
|
|
436
|
+
if character == "{":
|
|
437
|
+
contexts.append(["expression", False, []])
|
|
438
|
+
index += 1
|
|
439
|
+
continue
|
|
440
|
+
if character == "}" and is_format_spec:
|
|
441
|
+
if index == cursor:
|
|
442
|
+
return True
|
|
443
|
+
contexts.pop()
|
|
444
|
+
index += 1
|
|
445
|
+
continue
|
|
446
|
+
if character == "}" and value.startswith("}}", index):
|
|
447
|
+
index += 2
|
|
448
|
+
continue
|
|
449
|
+
if character == "}":
|
|
450
|
+
return False
|
|
451
|
+
index += 1
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
assert isinstance(bracket_stack, list)
|
|
455
|
+
if character in {"'", '"'}:
|
|
456
|
+
string_end = _quoted_string_end(value, index)
|
|
457
|
+
if index < cursor < string_end:
|
|
458
|
+
# A nested f-string can contain the candidate brace.
|
|
459
|
+
prefix_start = index
|
|
460
|
+
while prefix_start > content_start and value[prefix_start - 1] in (
|
|
461
|
+
"rRuUbBfF"
|
|
462
|
+
):
|
|
463
|
+
prefix_start -= 1
|
|
464
|
+
nested = value[prefix_start:string_end]
|
|
465
|
+
if "f" in value[prefix_start:index].lower():
|
|
466
|
+
return _fstring_brace_is_closing(nested, cursor - prefix_start)
|
|
467
|
+
return False
|
|
468
|
+
index = string_end
|
|
469
|
+
continue
|
|
470
|
+
if character == "#":
|
|
471
|
+
newline = value.find("\n", index)
|
|
472
|
+
if newline == -1 or cursor < newline:
|
|
473
|
+
return False
|
|
474
|
+
index = newline + 1
|
|
475
|
+
continue
|
|
476
|
+
if character in _OPENING_BRACKETS:
|
|
477
|
+
bracket_stack.append(character)
|
|
478
|
+
index += 1
|
|
479
|
+
continue
|
|
480
|
+
if character in _CLOSING_BRACKETS:
|
|
481
|
+
if bracket_stack:
|
|
482
|
+
if bracket_stack[-1] != _CLOSING_BRACKETS[character]:
|
|
483
|
+
return False
|
|
484
|
+
bracket_stack.pop()
|
|
485
|
+
index += 1
|
|
486
|
+
continue
|
|
487
|
+
if character == "}":
|
|
488
|
+
if index == cursor:
|
|
489
|
+
return True
|
|
490
|
+
contexts.pop()
|
|
491
|
+
index += 1
|
|
492
|
+
continue
|
|
493
|
+
return False
|
|
494
|
+
if character == ":" and not bracket_stack:
|
|
495
|
+
contexts[-1] = ["literal", True, []]
|
|
496
|
+
index += 1
|
|
497
|
+
continue
|
|
498
|
+
index += 1
|
|
499
|
+
return False
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _string_closer_width(
|
|
503
|
+
source: str,
|
|
504
|
+
cursor: int,
|
|
505
|
+
tokens: list[tuple[tokenize.TokenInfo, int, int]],
|
|
506
|
+
) -> int:
|
|
507
|
+
fstring_end_types = {
|
|
508
|
+
token_type
|
|
509
|
+
for token_type in (
|
|
510
|
+
getattr(tokenize, "FSTRING_END", None),
|
|
511
|
+
getattr(tokenize, "TSTRING_END", None),
|
|
512
|
+
)
|
|
513
|
+
if token_type is not None
|
|
514
|
+
}
|
|
515
|
+
for token, start, end in tokens:
|
|
516
|
+
if (
|
|
517
|
+
token.type in fstring_end_types
|
|
518
|
+
and start == cursor
|
|
519
|
+
and token.string in {"'", '"', "'''", '"""'}
|
|
520
|
+
):
|
|
521
|
+
return len(token.string)
|
|
522
|
+
if token.type != tokenize.STRING:
|
|
523
|
+
continue
|
|
524
|
+
parts = _string_parts(token.string)
|
|
525
|
+
if parts is None:
|
|
526
|
+
continue
|
|
527
|
+
_, quote = parts
|
|
528
|
+
if end - len(quote) == cursor and source.startswith(quote, cursor):
|
|
529
|
+
return len(quote)
|
|
530
|
+
return 0
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _bracket_closer_is_syntactic(
|
|
534
|
+
source: str,
|
|
535
|
+
cursor: int,
|
|
536
|
+
tokens: list[tuple[tokenize.TokenInfo, int, int]],
|
|
537
|
+
) -> bool:
|
|
538
|
+
closer = source[cursor]
|
|
539
|
+
stack: list[str] = []
|
|
540
|
+
for token, start, _ in tokens:
|
|
541
|
+
if start > cursor:
|
|
542
|
+
break
|
|
543
|
+
if token.type != tokenize.OP or token.string not in (
|
|
544
|
+
_OPENING_BRACKETS | _CLOSING_BRACKETS
|
|
545
|
+
):
|
|
546
|
+
continue
|
|
547
|
+
if start == cursor:
|
|
548
|
+
return (
|
|
549
|
+
token.string == closer
|
|
550
|
+
and bool(stack)
|
|
551
|
+
and (stack[-1] == _CLOSING_BRACKETS[closer])
|
|
552
|
+
)
|
|
553
|
+
if token.string in _OPENING_BRACKETS:
|
|
554
|
+
stack.append(token.string)
|
|
555
|
+
elif not stack or stack[-1] != _CLOSING_BRACKETS[token.string]:
|
|
556
|
+
return False
|
|
557
|
+
else:
|
|
558
|
+
stack.pop()
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _fstring_brace_fallback(
|
|
563
|
+
source: str,
|
|
564
|
+
cursor: int,
|
|
565
|
+
tokens: list[tuple[tokenize.TokenInfo, int, int]],
|
|
566
|
+
) -> bool:
|
|
567
|
+
for token, start, end in tokens:
|
|
568
|
+
if token.type == tokenize.STRING and start < cursor < end:
|
|
569
|
+
return _fstring_brace_is_closing(token.string, cursor - start)
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _smart_tab_jump_width(source: str, cursor: int) -> int:
|
|
574
|
+
if cursor >= len(source):
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
tokens = _python_tokens(source)
|
|
578
|
+
quote_width = _string_closer_width(source, cursor, tokens)
|
|
579
|
+
if quote_width:
|
|
580
|
+
return quote_width
|
|
581
|
+
|
|
582
|
+
if source[cursor] not in _CLOSING_BRACKETS:
|
|
583
|
+
return 0
|
|
584
|
+
line_start = source.rfind("\n", 0, cursor) + 1
|
|
585
|
+
if not source[line_start:cursor].strip():
|
|
586
|
+
return 0
|
|
587
|
+
if _bracket_closer_is_syntactic(source, cursor, tokens):
|
|
588
|
+
return 1
|
|
589
|
+
if source[cursor] == "}" and _fstring_brace_fallback(source, cursor, tokens):
|
|
590
|
+
return 1
|
|
591
|
+
return 0
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _smart_tab_jump_enabled(state: PostfixState | None = None) -> bool:
|
|
595
|
+
state = _state_or_default(state)
|
|
596
|
+
return state is None or state.config.smart_tab_jump
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _can_smart_tab_jump(buffer, state: PostfixState | None = None) -> bool:
|
|
600
|
+
if not _smart_tab_jump_enabled(state) or _has_active_placeholder(buffer):
|
|
601
|
+
return False
|
|
602
|
+
if _postfix_candidates(
|
|
603
|
+
buffer.document.current_line_before_cursor, exact=True, state=state
|
|
604
|
+
)[1]:
|
|
605
|
+
return False
|
|
606
|
+
return bool(_smart_tab_jump_width(buffer.text, buffer.cursor_position))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _jump_over_closer(buffer) -> bool:
|
|
610
|
+
width = _smart_tab_jump_width(buffer.text, buffer.cursor_position)
|
|
611
|
+
if not width:
|
|
612
|
+
return False
|
|
613
|
+
if buffer.complete_state is not None:
|
|
614
|
+
buffer.cancel_completion()
|
|
615
|
+
width = _smart_tab_jump_width(buffer.text, buffer.cursor_position)
|
|
616
|
+
if not width:
|
|
617
|
+
return False
|
|
618
|
+
buffer.cursor_position += width
|
|
291
619
|
return True
|
|
292
620
|
|
|
293
621
|
|
|
@@ -299,7 +627,7 @@ def _insert_trigger_and_maybe_complete(buffer) -> bool:
|
|
|
299
627
|
return True
|
|
300
628
|
|
|
301
629
|
|
|
302
|
-
def _make_key_bindings():
|
|
630
|
+
def _make_key_bindings(state: PostfixState | None = None):
|
|
303
631
|
from prompt_toolkit.application.current import get_app
|
|
304
632
|
from prompt_toolkit.filters import Condition
|
|
305
633
|
from prompt_toolkit.key_binding import KeyBindings
|
|
@@ -311,14 +639,36 @@ def _make_key_bindings():
|
|
|
311
639
|
buffer = get_app().current_buffer
|
|
312
640
|
return bool(
|
|
313
641
|
_postfix_candidates(
|
|
314
|
-
buffer.document.current_line_before_cursor,
|
|
642
|
+
buffer.document.current_line_before_cursor,
|
|
643
|
+
exact=True,
|
|
644
|
+
state=state,
|
|
315
645
|
)[1]
|
|
316
646
|
)
|
|
317
647
|
|
|
648
|
+
@Condition
|
|
649
|
+
def has_active_placeholder() -> bool:
|
|
650
|
+
return _has_active_placeholder(get_app().current_buffer)
|
|
651
|
+
|
|
652
|
+
@Condition
|
|
653
|
+
def can_smart_tab_jump() -> bool:
|
|
654
|
+
return _can_smart_tab_jump(get_app().current_buffer, state)
|
|
655
|
+
|
|
656
|
+
@key_bindings.add("tab", filter=has_active_placeholder)
|
|
657
|
+
def accept_placeholder(event) -> None:
|
|
658
|
+
_accept_placeholder(event.current_buffer)
|
|
659
|
+
|
|
660
|
+
@key_bindings.add("enter", filter=has_active_placeholder)
|
|
661
|
+
def accept_placeholder_with_enter(event) -> None:
|
|
662
|
+
_accept_placeholder(event.current_buffer)
|
|
663
|
+
|
|
318
664
|
@key_bindings.add("tab", filter=has_exact_postfix)
|
|
319
665
|
def expand_postfix(event) -> None:
|
|
320
666
|
_expand_buffer(event.current_buffer)
|
|
321
667
|
|
|
668
|
+
@key_bindings.add("tab", filter=can_smart_tab_jump)
|
|
669
|
+
def smart_tab_jump(event) -> None:
|
|
670
|
+
_jump_over_closer(event.current_buffer)
|
|
671
|
+
|
|
322
672
|
@key_bindings.add(".")
|
|
323
673
|
def insert_trigger(event) -> None:
|
|
324
674
|
_insert_trigger_and_maybe_complete(event.current_buffer)
|
|
@@ -400,10 +750,12 @@ def _postfix_template_magic(line: str) -> None:
|
|
|
400
750
|
|
|
401
751
|
def _list_postfix_templates(state: PostfixState) -> None:
|
|
402
752
|
effective = state.effective_templates()
|
|
403
|
-
names =
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
state.
|
|
753
|
+
names = (
|
|
754
|
+
set(DEFAULT_TEMPLATES)
|
|
755
|
+
| set(state.config.templates)
|
|
756
|
+
| set(state.runtime_templates or {})
|
|
757
|
+
| set(state.config.disabled_templates)
|
|
758
|
+
| set(state.runtime_disabled_templates or set())
|
|
407
759
|
)
|
|
408
760
|
for name in sorted(names):
|
|
409
761
|
origin = state.template_origin(name)
|
|
@@ -435,7 +787,7 @@ class PostfixPTCompleter(IPythonPTCompleter):
|
|
|
435
787
|
|
|
436
788
|
fragment = body[completion.start : completion.end]
|
|
437
789
|
line_start = body.rfind("\n", 0, completion.start) + 1
|
|
438
|
-
indent = body[line_start:completion.start]
|
|
790
|
+
indent = body[line_start : completion.start]
|
|
439
791
|
state = _state_or_default()
|
|
440
792
|
key = _template_name(fragment, completion.text, indent, state) or "postfix"
|
|
441
793
|
display = f".{key} -> {completion.text}"
|
|
@@ -484,7 +836,9 @@ def _install_key_binding(ip, state: PostfixState) -> None:
|
|
|
484
836
|
from prompt_toolkit.key_binding import merge_key_bindings
|
|
485
837
|
|
|
486
838
|
state.original_key_bindings = original
|
|
487
|
-
|
|
839
|
+
# prompt-toolkit resolves the last active binding. Keep extension bindings
|
|
840
|
+
# last so their filtered Tab cases win while native bindings remain fallback.
|
|
841
|
+
pt_app.key_bindings = merge_key_bindings([original, _make_key_bindings(state)])
|
|
488
842
|
|
|
489
843
|
|
|
490
844
|
def load_ipython_extension(ip) -> None:
|
{ipython_postfix_completion-0.1.1.dist-info → ipython_postfix_completion-0.2.0.dist-info}/METADATA
RENAMED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ipython-postfix-completion
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Configurable postfix completion extension for IPython.
|
|
5
5
|
Author: IPython Postfix Completion Contributors
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/fishandsheep/ipython-postfix-completion
|
|
8
|
+
Project-URL: Repository, https://github.com/fishandsheep/ipython-postfix-completion
|
|
9
|
+
Project-URL: Issues, https://github.com/fishandsheep/ipython-postfix-completion/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/fishandsheep/ipython-postfix-completion/blob/main/CHANGELOG.md
|
|
7
11
|
Keywords: ipython,completion,postfix,extension
|
|
8
12
|
Classifier: Framework :: IPython
|
|
9
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -12,16 +16,19 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
12
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
21
|
Requires-Python: >=3.11
|
|
17
22
|
Description-Content-Type: text/markdown
|
|
18
23
|
License-File: LICENSE
|
|
19
|
-
Requires-Dist: ipython
|
|
24
|
+
Requires-Dist: ipython<10,>=9.0
|
|
20
25
|
Requires-Dist: traitlets>=5.13
|
|
21
26
|
Provides-Extra: test
|
|
22
27
|
Requires-Dist: pytest>=7; extra == "test"
|
|
23
28
|
Provides-Extra: dev
|
|
24
29
|
Requires-Dist: build; extra == "dev"
|
|
30
|
+
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
25
32
|
Requires-Dist: twine; extra == "dev"
|
|
26
33
|
Requires-Dist: ipython-postfix-completion[test]; extra == "dev"
|
|
27
34
|
Dynamic: license-file
|
|
@@ -60,6 +67,10 @@ To load it automatically, add this to `ipython_config.py`:
|
|
|
60
67
|
c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
|
|
61
68
|
```
|
|
62
69
|
|
|
70
|
+
Smart Tab key bindings are supported in terminal IPython. Postfix matcher
|
|
71
|
+
completion can also work in other IPython frontends, but this package does not
|
|
72
|
+
promise frontend-specific Tab behavior outside the terminal.
|
|
73
|
+
|
|
63
74
|
## Quick Example: Add a `for` Template
|
|
64
75
|
|
|
65
76
|
Add a template for the current IPython session:
|
|
@@ -130,11 +141,59 @@ c.PostfixCompletionConfig.templates = {
|
|
|
130
141
|
c.PostfixCompletionConfig.disabled_templates = ["tuple"]
|
|
131
142
|
```
|
|
132
143
|
|
|
144
|
+
Smart Tab jump is enabled by default. Disable it while keeping postfix
|
|
145
|
+
completion with:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
c.PostfixCompletionConfig.smart_tab_jump = False
|
|
149
|
+
```
|
|
150
|
+
|
|
133
151
|
Template names must match `[A-Za-z_][A-Za-z0-9_]*`.
|
|
134
152
|
|
|
135
153
|
Templates must include `{expr}` and may also use `{indent}`. No other template
|
|
136
154
|
fields are allowed.
|
|
137
155
|
|
|
156
|
+
## `.var` Placeholder
|
|
157
|
+
|
|
158
|
+
The built-in `.var` template creates an assignment and selects `key` as an
|
|
159
|
+
editable placeholder:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
"hello".var<Tab> -> key = "hello"
|
|
163
|
+
^^^ selected
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
While `key` remains selected:
|
|
167
|
+
|
|
168
|
+
- Tab accepts `key` and moves cursor to the end of the assignment.
|
|
169
|
+
- Enter behaves like Tab for this selection only; press Enter again to submit.
|
|
170
|
+
- Any other typed text replaces `key` with a custom variable name.
|
|
171
|
+
|
|
172
|
+
In 0.1.x, `.var` produced `expr = ` with the cursor after the assignment
|
|
173
|
+
target. Version 0.2.0 changes this to `key = expr` with an editable
|
|
174
|
+
placeholder; use a custom template if you need the old behavior.
|
|
175
|
+
|
|
176
|
+
## Smart Tab Jump
|
|
177
|
+
|
|
178
|
+
When cursor is immediately before a valid Python closing token, Tab moves over
|
|
179
|
+
it without changing source text. Repeated Tab presses exit nested constructs:
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
"hello|" -> "hello"|
|
|
183
|
+
print("hello|") -> print("hello"|) -> print("hello")|
|
|
184
|
+
print(f"{name|}") -> print(f"{name}|") -> print(f"{name}"|) -> print(f"{name}")|
|
|
185
|
+
items[index|] -> items[index]|
|
|
186
|
+
list[dict[str, int|]] -> list[dict[str, int]|] -> list[dict[str, int]]|
|
|
187
|
+
{"name": value|} -> {"name": value}|
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`|` marks cursor and is not typed. Supported closers are single and triple
|
|
191
|
+
quotes plus `)`, `]`, and `}`. Detection follows Python tokens, including
|
|
192
|
+
multiline input, string prefixes, and f-string expressions. Tab still accepts
|
|
193
|
+
the `.var` name selection or an exact postfix template first; otherwise it
|
|
194
|
+
falls back to IPython completion or indentation. Ambiguous `< >`, colon, and
|
|
195
|
+
comma are intentionally excluded.
|
|
196
|
+
|
|
138
197
|
## Built-in Templates
|
|
139
198
|
|
|
140
199
|
Default templates:
|
|
@@ -145,7 +204,7 @@ Default templates:
|
|
|
145
204
|
| `len` | `len({expr})` |
|
|
146
205
|
| `not` | `not {expr}` |
|
|
147
206
|
| `par` | `({expr})` |
|
|
148
|
-
| `var` | `{expr}
|
|
207
|
+
| `var` | `key = {expr}`; selects `key`; Tab or Enter accepts it |
|
|
149
208
|
| `await` | `await {expr}` |
|
|
150
209
|
| `return` | `return {expr}` |
|
|
151
210
|
| `if` | `if {expr}:\n{indent} ` |
|
|
@@ -167,6 +226,9 @@ Run tests:
|
|
|
167
226
|
|
|
168
227
|
```bash
|
|
169
228
|
uv run --extra test pytest -q
|
|
229
|
+
uv run --extra dev ruff check .
|
|
230
|
+
uv run --extra dev ruff format --check .
|
|
231
|
+
uv run --isolated --no-project --with "ipython>=9,<10" --with "traitlets>=5.13" --with "pip-audit>=2.7" pip-audit --strict --local
|
|
170
232
|
```
|
|
171
233
|
|
|
172
234
|
Build and check release artifacts:
|
|
@@ -205,14 +267,16 @@ existing PyPI project once under **Manage > Publishing > Add a new publisher**:
|
|
|
205
267
|
| Environment | `pypi` |
|
|
206
268
|
|
|
207
269
|
For each release, update `project.version` in `pyproject.toml`, commit and push
|
|
208
|
-
the change, then create a matching `v` tag. For
|
|
209
|
-
version to `0.1.1`:
|
|
270
|
+
the change, then create a matching `v` tag. For this release:
|
|
210
271
|
|
|
211
272
|
```bash
|
|
212
|
-
git tag v0.
|
|
213
|
-
git push origin v0.
|
|
273
|
+
git tag v0.2.0
|
|
274
|
+
git push origin v0.2.0
|
|
214
275
|
```
|
|
215
276
|
|
|
216
277
|
The workflow verifies the tag against `project.version`, runs tests, builds and
|
|
217
278
|
checks both distributions, then publishes them to PyPI using a short-lived OIDC
|
|
218
|
-
credential.
|
|
279
|
+
credential. PyPI versions are immutable: never reuse a published version or tag;
|
|
280
|
+
fixes require the next version.
|
|
281
|
+
|
|
282
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes and migration guidance.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ipython_postfix_completion/__init__.py,sha256=K9fMAo6kLY3JeUe9SfEp83Om-KsYTcDrC6Fyhhy3VUQ,30361
|
|
2
|
+
ipython_postfix_completion-0.2.0.dist-info/licenses/LICENSE,sha256=omLR-7QW1K3l6AG7ih1N5L8Z58UY-ROrQL82ANN9PnM,1547
|
|
3
|
+
ipython_postfix_completion-0.2.0.dist-info/METADATA,sha256=CwKUKdYbwrxPvVFLnTEJ3n-_sD_E4CRvBoiM1pHb_ds,7767
|
|
4
|
+
ipython_postfix_completion-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
ipython_postfix_completion-0.2.0.dist-info/top_level.txt,sha256=W2sJCvsBheoCB2nbzJeD7-p5PR6lVDNicVLxNxmeraY,27
|
|
6
|
+
ipython_postfix_completion-0.2.0.dist-info/RECORD,,
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
ipython_postfix_completion/__init__.py,sha256=Ihs9zrkmwcmDydHIxRn4KU7yeRR_NWxHpB9WaidvyXs,18145
|
|
2
|
-
ipython_postfix_completion-0.1.1.dist-info/licenses/LICENSE,sha256=omLR-7QW1K3l6AG7ih1N5L8Z58UY-ROrQL82ANN9PnM,1547
|
|
3
|
-
ipython_postfix_completion-0.1.1.dist-info/METADATA,sha256=10PHgmlDTJQVOwdahbiqMKFEdkxQvdCEvWyO1jo3uQk,4960
|
|
4
|
-
ipython_postfix_completion-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
-
ipython_postfix_completion-0.1.1.dist-info/top_level.txt,sha256=W2sJCvsBheoCB2nbzJeD7-p5PR6lVDNicVLxNxmeraY,27
|
|
6
|
-
ipython_postfix_completion-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|