ipython-postfix-completion 0.1.1__py3-none-any.whl → 0.2.1__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.
@@ -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,26 @@ _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: "PostfixState | None" = None
35
+ _ACTIVE_STATE: PostfixState | None = None
36
+ _VAR_TEMPLATE = "key = {expr}"
37
+ _VAR_PLACEHOLDER = "key"
38
+ _FOR_TEMPLATE = "for item in {expr}:\n{indent} pass"
39
+ _FORI_TEMPLATE = "for i, value in enumerate({expr}):\n{indent} pass"
40
+ _IF_TEMPLATE = "if {expr}:\n{indent} pass"
41
+ _EF_TEMPLATE = (
42
+ "if {expr}:\n{indent} pass\n{indent}elif cond:\n{indent} pass"
43
+ "\n{indent}else:\n{indent} pass"
44
+ )
45
+ _TEMPLATE_PLACEHOLDERS = {
46
+ "for": (_FOR_TEMPLATE, ("item", "pass")),
47
+ "fori": (_FORI_TEMPLATE, ("i", "value", "pass")),
48
+ "if": (_IF_TEMPLATE, ("pass",)),
49
+ "ef": (_EF_TEMPLATE, ("pass", "cond", "pass", "pass")),
50
+ }
51
+ _PLACEHOLDER_ATTR = "_postfix_completion_placeholder"
52
+ _OPENING_BRACKETS = {"(": ")", "[": "]", "{": "}"}
53
+ _CLOSING_BRACKETS = {closer: opener for opener, closer in _OPENING_BRACKETS.items()}
54
+ _STRING_START_RE = re.compile(r"(?i)^(?P<prefix>[rubf]*)(?P<quote>'''|\"\"\"|'|\")")
39
55
 
40
56
 
41
57
  class _TemplateFormatter(string.Formatter):
@@ -53,11 +69,14 @@ DEFAULT_TEMPLATES: dict[str, str] = {
53
69
  "len": "len({expr})",
54
70
  "not": "not {expr}",
55
71
  "par": "({expr})",
56
- "var": "{expr} = ",
72
+ "var": _VAR_TEMPLATE,
57
73
  "await": "await {expr}",
58
74
  "return": "return {expr}",
59
- "if": "if {expr}:\n{indent} ",
75
+ "if": _IF_TEMPLATE,
60
76
  "while": "while {expr}:\n{indent} ",
77
+ "for": _FOR_TEMPLATE,
78
+ "fori": _FORI_TEMPLATE,
79
+ "ef": _EF_TEMPLATE,
61
80
  "raise": "raise {expr}",
62
81
  "yield": "yield {expr}",
63
82
  "str": "str({expr})",
@@ -65,6 +84,8 @@ DEFAULT_TEMPLATES: dict[str, str] = {
65
84
  "set": "set({expr})",
66
85
  "dict": "dict({expr})",
67
86
  "tuple": "tuple({expr})",
87
+ "type": "type({expr})",
88
+ "range": "range({expr})",
68
89
  }
69
90
 
70
91
 
@@ -88,22 +109,35 @@ class PostfixCompletionConfig(Configurable):
88
109
  _SELECTED_STYLE,
89
110
  help="Prompt-toolkit selected style for postfix completion menu entries.",
90
111
  ).tag(config=True)
112
+ smart_tab_jump = Bool(
113
+ True,
114
+ help="Move Tab over adjacent Python string and bracket closing tokens.",
115
+ ).tag(config=True)
91
116
 
92
117
 
93
118
  def _validate_template_name(name: str) -> None:
94
119
  if not _TEMPLATE_NAME_RE.match(name):
95
120
  raise UsageError(
96
- f"Invalid postfix template name {name!r}: expected "
97
- "[A-Za-z_][A-Za-z0-9_]*"
121
+ f"Invalid postfix template name {name!r}: expected [A-Za-z_][A-Za-z0-9_]*"
98
122
  )
99
123
 
100
124
 
101
125
  def _validate_template(template: str) -> None:
102
- fields = {
103
- field_name
104
- for _, field_name, _, _ in _FORMATTER.parse(template)
105
- if field_name is not None
106
- }
126
+ try:
127
+ parsed = list(_FORMATTER.parse(template))
128
+ except ValueError as error:
129
+ raise UsageError(f"Invalid postfix template: {error}") from error
130
+
131
+ fields = set()
132
+ for _, field_name, format_spec, conversion in parsed:
133
+ if field_name is None:
134
+ continue
135
+ if format_spec or conversion is not None:
136
+ raise UsageError(
137
+ "Postfix template fields must use plain {expr} or {indent}."
138
+ )
139
+ fields.add(field_name)
140
+
107
141
  unknown = fields - _ALLOWED_TEMPLATE_FIELDS
108
142
  if unknown:
109
143
  names = ", ".join(sorted(f"{{{name}}}" for name in unknown))
@@ -127,6 +161,30 @@ def _render_template(template: str, expr: str, indent: str = "") -> str:
127
161
  return _FORMATTER.format(template, expr=expr, indent=indent)
128
162
 
129
163
 
164
+ def _is_integer_literal(expr: str) -> bool:
165
+ try:
166
+ node = ast.parse(expr, mode="eval").body
167
+ except SyntaxError:
168
+ return False
169
+ if isinstance(node, ast.Constant):
170
+ return type(node.value) is int
171
+ return (
172
+ isinstance(node, ast.UnaryOp)
173
+ and isinstance(node.op, (ast.UAdd, ast.USub))
174
+ and _is_integer_literal(ast.unparse(node.operand))
175
+ )
176
+
177
+
178
+ def _render_candidate(name: str, template: str, expr: str, indent: str = "") -> str:
179
+ wraps_integer = _is_integer_literal(expr) and (
180
+ (name == "for" and template == _FOR_TEMPLATE)
181
+ or (name == "fori" and template == _FORI_TEMPLATE)
182
+ )
183
+ if wraps_integer:
184
+ expr = f"range({expr})"
185
+ return _render_template(template, expr, indent)
186
+
187
+
130
188
  @dataclass
131
189
  class PostfixState:
132
190
  matcher: Callable[[CompletionContext], dict]
@@ -228,7 +286,7 @@ def _effective_templates(state: PostfixState | None = None) -> dict[str, str]:
228
286
  def _apply_template(
229
287
  name: str, expr: str, indent: str = "", state: PostfixState | None = None
230
288
  ) -> str:
231
- return _render_template(_effective_templates(state)[name], expr, indent)
289
+ return _render_candidate(name, _effective_templates(state)[name], expr, indent)
232
290
 
233
291
 
234
292
  def _postfix_candidates(
@@ -248,7 +306,7 @@ def _postfix_candidates(
248
306
  if (name == prefix if exact else name.startswith(prefix))
249
307
  ]
250
308
  return f"{expr}.{prefix}", [
251
- _render_template(templates[name], expr, indent) for name in names
309
+ _render_candidate(name, templates[name], expr, indent) for name in names
252
310
  ]
253
311
 
254
312
 
@@ -262,7 +320,7 @@ def _template_name(
262
320
  for name, template in _effective_templates(state).items():
263
321
  if not name.startswith(prefix):
264
322
  continue
265
- if _render_template(template, expr, indent) == expansion:
323
+ if _render_candidate(name, template, expr, indent) == expansion:
266
324
  return name
267
325
  return None
268
326
 
@@ -286,8 +344,399 @@ def _expand_buffer(buffer) -> bool:
286
344
  matched_fragment, candidates = _postfix_candidates(line, exact=True)
287
345
  if len(candidates) != 1:
288
346
  return False
347
+ parsed = _line_prefix(line)
289
348
  buffer.delete_before_cursor(len(matched_fragment))
349
+ expansion_start = buffer.cursor_position
290
350
  buffer.insert_text(candidates[0])
351
+ placeholder_config = (
352
+ _TEMPLATE_PLACEHOLDERS.get(parsed[2]) if parsed is not None else None
353
+ )
354
+ if (
355
+ parsed is not None
356
+ and placeholder_config is not None
357
+ and _effective_templates().get(parsed[2]) == placeholder_config[0]
358
+ ):
359
+ search_from = 0
360
+ ranges = []
361
+ for placeholder_text in placeholder_config[1]:
362
+ start = candidates[0].index(placeholder_text, search_from)
363
+ end = start + len(placeholder_text)
364
+ ranges.append((start, end))
365
+ search_from = end
366
+ _select_placeholders(
367
+ buffer,
368
+ expansion_start,
369
+ ranges,
370
+ buffer.cursor_position,
371
+ )
372
+ elif (
373
+ parsed is not None
374
+ and parsed[2] == "var"
375
+ and _effective_templates().get("var") == _VAR_TEMPLATE
376
+ ):
377
+ _select_placeholder(
378
+ buffer,
379
+ expansion_start,
380
+ expansion_start + len(_VAR_PLACEHOLDER),
381
+ buffer.cursor_position,
382
+ )
383
+ return True
384
+
385
+
386
+ def _select_placeholder(buffer, start: int, end: int, final_cursor: int) -> None:
387
+ _select_placeholders(buffer, start, [(start, end)], final_cursor, absolute=True)
388
+
389
+
390
+ def _select_placeholders(
391
+ buffer,
392
+ start: int,
393
+ ranges: list[tuple[int, int]],
394
+ final_cursor: int,
395
+ *,
396
+ absolute: bool = False,
397
+ index: int = 0,
398
+ ) -> None:
399
+ from prompt_toolkit.selection import SelectionState, SelectionType
400
+
401
+ if not absolute:
402
+ ranges = [(start + left, start + right) for left, right in ranges]
403
+ setattr(buffer, _PLACEHOLDER_ATTR, (ranges, index, final_cursor))
404
+ left, right = ranges[index]
405
+ buffer.cursor_position = right
406
+ buffer.selection_state = SelectionState(left, SelectionType.CHARACTERS)
407
+ buffer.selection_state.enter_shift_mode()
408
+
409
+
410
+ def _has_active_placeholder(buffer) -> bool:
411
+ placeholder = getattr(buffer, _PLACEHOLDER_ATTR, None)
412
+ selection = buffer.selection_state
413
+ if placeholder is None:
414
+ return False
415
+ ranges, index, _ = placeholder
416
+ start, end = ranges[index]
417
+ return (selection is None and len(ranges) > 1) or (
418
+ selection is not None
419
+ and selection.shift_mode
420
+ and selection.original_cursor_position == start
421
+ and buffer.cursor_position == end
422
+ and (len(ranges) > 1 or buffer.text[start:end] == _VAR_PLACEHOLDER)
423
+ )
424
+
425
+
426
+ def _accept_placeholder(buffer) -> bool:
427
+ if not _has_active_placeholder(buffer):
428
+ return False
429
+ ranges, index, final_cursor = _update_placeholder_after_edit(buffer)
430
+ if buffer.selection_state is not None:
431
+ buffer.exit_selection()
432
+ if index + 1 < len(ranges):
433
+ _select_placeholders(
434
+ buffer, 0, ranges, final_cursor, absolute=True, index=index + 1
435
+ )
436
+ else:
437
+ delattr(buffer, _PLACEHOLDER_ATTR)
438
+ buffer.cursor_position = final_cursor
439
+ return True
440
+
441
+
442
+ def _update_placeholder_after_edit(buffer):
443
+ ranges, index, final_cursor = getattr(buffer, _PLACEHOLDER_ATTR)
444
+ if buffer.selection_state is None:
445
+ start, old_end = ranges[index]
446
+ new_end = buffer.cursor_position
447
+ delta = new_end - old_end
448
+ final_cursor += delta
449
+ ranges = [
450
+ (start, new_end)
451
+ if stop == index
452
+ else (left + delta, right + delta)
453
+ if stop > index
454
+ else (left, right)
455
+ for stop, (left, right) in enumerate(ranges)
456
+ ]
457
+ setattr(buffer, _PLACEHOLDER_ATTR, (ranges, index, final_cursor))
458
+ return ranges, index, final_cursor
459
+
460
+
461
+ def _reverse_placeholder(buffer) -> bool:
462
+ if not _has_active_placeholder(buffer):
463
+ return False
464
+ ranges, index, final_cursor = _update_placeholder_after_edit(buffer)
465
+ previous_index = max(0, index - 1)
466
+ _select_placeholders(
467
+ buffer,
468
+ 0,
469
+ ranges,
470
+ final_cursor,
471
+ absolute=True,
472
+ index=previous_index,
473
+ )
474
+ return True
475
+
476
+
477
+ def _line_offsets(source: str) -> list[int]:
478
+ offsets = [0]
479
+ offsets.extend(match.end() for match in re.finditer("\n", source))
480
+ return offsets
481
+
482
+
483
+ def _absolute_token_position(
484
+ position: tuple[int, int], line_offsets: list[int], source_length: int
485
+ ) -> int:
486
+ row, column = position
487
+ if 0 < row <= len(line_offsets):
488
+ return min(line_offsets[row - 1] + column, source_length)
489
+ return source_length
490
+
491
+
492
+ def _python_tokens(source: str) -> list[tuple[tokenize.TokenInfo, int, int]]:
493
+ line_offsets = _line_offsets(source)
494
+ tokens: list[tuple[tokenize.TokenInfo, int, int]] = []
495
+ generator = tokenize.generate_tokens(io.StringIO(source).readline)
496
+ try:
497
+ for token in generator:
498
+ start = _absolute_token_position(token.start, line_offsets, len(source))
499
+ end = _absolute_token_position(token.end, line_offsets, len(source))
500
+ tokens.append((token, start, end))
501
+ except (IndentationError, SyntaxError, tokenize.TokenError):
502
+ # Incomplete interactive input is normal. Tokens emitted before the
503
+ # error still describe whether the adjacent closer is syntactic.
504
+ pass
505
+ return tokens
506
+
507
+
508
+ def _string_parts(value: str) -> tuple[str, str] | None:
509
+ match = _STRING_START_RE.match(value)
510
+ if match is None:
511
+ return None
512
+ return match.group("prefix"), match.group("quote")
513
+
514
+
515
+ def _quoted_string_end(value: str, quote_start: int) -> int:
516
+ quote = value[quote_start : quote_start + 3]
517
+ delimiter = quote if quote in {"'''", '"""'} else value[quote_start]
518
+ index = quote_start + len(delimiter)
519
+ while index < len(value):
520
+ if value.startswith(delimiter, index):
521
+ backslashes = 0
522
+ before = index - 1
523
+ while before >= quote_start and value[before] == "\\":
524
+ backslashes += 1
525
+ before -= 1
526
+ if backslashes % 2 == 0:
527
+ return index + len(delimiter)
528
+ index += 1
529
+ return len(value)
530
+
531
+
532
+ def _fstring_brace_is_closing(value: str, cursor: int) -> bool:
533
+ parts = _string_parts(value)
534
+ if parts is None:
535
+ return False
536
+ prefix, quote = parts
537
+ if "f" not in prefix.lower() or not value.endswith(quote):
538
+ return False
539
+
540
+ content_start = len(prefix) + len(quote)
541
+ content_end = len(value) - len(quote)
542
+ if not (content_start <= cursor < content_end) or value[cursor] != "}":
543
+ return False
544
+
545
+ # Each context is [kind, is_format_spec, bracket_stack]. Literal f-string
546
+ # text and Python replacement expressions follow different brace rules.
547
+ contexts: list[list[object]] = [["literal", False, []]]
548
+ index = content_start
549
+ while index <= cursor and contexts:
550
+ kind, is_format_spec, bracket_stack = contexts[-1]
551
+ character = value[index]
552
+
553
+ if kind == "literal":
554
+ if character == "{" and value.startswith("{{", index):
555
+ index += 2
556
+ continue
557
+ if character == "{":
558
+ contexts.append(["expression", False, []])
559
+ index += 1
560
+ continue
561
+ if character == "}" and is_format_spec:
562
+ if index == cursor:
563
+ return True
564
+ contexts.pop()
565
+ index += 1
566
+ continue
567
+ if character == "}" and value.startswith("}}", index):
568
+ index += 2
569
+ continue
570
+ if character == "}":
571
+ return False
572
+ index += 1
573
+ continue
574
+
575
+ assert isinstance(bracket_stack, list)
576
+ if character in {"'", '"'}:
577
+ string_end = _quoted_string_end(value, index)
578
+ if index < cursor < string_end:
579
+ # A nested f-string can contain the candidate brace.
580
+ prefix_start = index
581
+ while prefix_start > content_start and value[prefix_start - 1] in (
582
+ "rRuUbBfF"
583
+ ):
584
+ prefix_start -= 1
585
+ nested = value[prefix_start:string_end]
586
+ if "f" in value[prefix_start:index].lower():
587
+ return _fstring_brace_is_closing(nested, cursor - prefix_start)
588
+ return False
589
+ index = string_end
590
+ continue
591
+ if character == "#":
592
+ newline = value.find("\n", index)
593
+ if newline == -1 or cursor < newline:
594
+ return False
595
+ index = newline + 1
596
+ continue
597
+ if character in _OPENING_BRACKETS:
598
+ bracket_stack.append(character)
599
+ index += 1
600
+ continue
601
+ if character in _CLOSING_BRACKETS:
602
+ if bracket_stack:
603
+ if bracket_stack[-1] != _CLOSING_BRACKETS[character]:
604
+ return False
605
+ bracket_stack.pop()
606
+ index += 1
607
+ continue
608
+ if character == "}":
609
+ if index == cursor:
610
+ return True
611
+ contexts.pop()
612
+ index += 1
613
+ continue
614
+ return False
615
+ if character == ":" and not bracket_stack:
616
+ contexts[-1] = ["literal", True, []]
617
+ index += 1
618
+ continue
619
+ index += 1
620
+ return False
621
+
622
+
623
+ def _string_closer_width(
624
+ source: str,
625
+ cursor: int,
626
+ tokens: list[tuple[tokenize.TokenInfo, int, int]],
627
+ ) -> int:
628
+ fstring_end_types = {
629
+ token_type
630
+ for token_type in (
631
+ getattr(tokenize, "FSTRING_END", None),
632
+ getattr(tokenize, "TSTRING_END", None),
633
+ )
634
+ if token_type is not None
635
+ }
636
+ for token, start, end in tokens:
637
+ if (
638
+ token.type in fstring_end_types
639
+ and start == cursor
640
+ and token.string in {"'", '"', "'''", '"""'}
641
+ ):
642
+ return len(token.string)
643
+ if token.type != tokenize.STRING:
644
+ continue
645
+ parts = _string_parts(token.string)
646
+ if parts is None:
647
+ continue
648
+ _, quote = parts
649
+ if end - len(quote) == cursor and source.startswith(quote, cursor):
650
+ return len(quote)
651
+ return 0
652
+
653
+
654
+ def _bracket_closer_is_syntactic(
655
+ source: str,
656
+ cursor: int,
657
+ tokens: list[tuple[tokenize.TokenInfo, int, int]],
658
+ ) -> bool:
659
+ closer = source[cursor]
660
+ stack: list[str] = []
661
+ for token, start, _ in tokens:
662
+ if start > cursor:
663
+ break
664
+ if token.type != tokenize.OP or token.string not in (
665
+ _OPENING_BRACKETS | _CLOSING_BRACKETS
666
+ ):
667
+ continue
668
+ if start == cursor:
669
+ return (
670
+ token.string == closer
671
+ and bool(stack)
672
+ and (stack[-1] == _CLOSING_BRACKETS[closer])
673
+ )
674
+ if token.string in _OPENING_BRACKETS:
675
+ stack.append(token.string)
676
+ elif not stack or stack[-1] != _CLOSING_BRACKETS[token.string]:
677
+ return False
678
+ else:
679
+ stack.pop()
680
+ return False
681
+
682
+
683
+ def _fstring_brace_fallback(
684
+ source: str,
685
+ cursor: int,
686
+ tokens: list[tuple[tokenize.TokenInfo, int, int]],
687
+ ) -> bool:
688
+ for token, start, end in tokens:
689
+ if token.type == tokenize.STRING and start < cursor < end:
690
+ return _fstring_brace_is_closing(token.string, cursor - start)
691
+ return False
692
+
693
+
694
+ def _smart_tab_jump_width(source: str, cursor: int) -> int:
695
+ if cursor >= len(source):
696
+ return 0
697
+
698
+ tokens = _python_tokens(source)
699
+ quote_width = _string_closer_width(source, cursor, tokens)
700
+ if quote_width:
701
+ return quote_width
702
+
703
+ if source[cursor] not in _CLOSING_BRACKETS:
704
+ return 0
705
+ line_start = source.rfind("\n", 0, cursor) + 1
706
+ if not source[line_start:cursor].strip():
707
+ return 0
708
+ if _bracket_closer_is_syntactic(source, cursor, tokens):
709
+ return 1
710
+ if source[cursor] == "}" and _fstring_brace_fallback(source, cursor, tokens):
711
+ return 1
712
+ return 0
713
+
714
+
715
+ def _smart_tab_jump_enabled(state: PostfixState | None = None) -> bool:
716
+ state = _state_or_default(state)
717
+ return state is None or state.config.smart_tab_jump
718
+
719
+
720
+ def _can_smart_tab_jump(buffer, state: PostfixState | None = None) -> bool:
721
+ if not _smart_tab_jump_enabled(state) or _has_active_placeholder(buffer):
722
+ return False
723
+ if _postfix_candidates(
724
+ buffer.document.current_line_before_cursor, exact=True, state=state
725
+ )[1]:
726
+ return False
727
+ return bool(_smart_tab_jump_width(buffer.text, buffer.cursor_position))
728
+
729
+
730
+ def _jump_over_closer(buffer) -> bool:
731
+ width = _smart_tab_jump_width(buffer.text, buffer.cursor_position)
732
+ if not width:
733
+ return False
734
+ if buffer.complete_state is not None:
735
+ buffer.cancel_completion()
736
+ width = _smart_tab_jump_width(buffer.text, buffer.cursor_position)
737
+ if not width:
738
+ return False
739
+ buffer.cursor_position += width
291
740
  return True
292
741
 
293
742
 
@@ -299,7 +748,7 @@ def _insert_trigger_and_maybe_complete(buffer) -> bool:
299
748
  return True
300
749
 
301
750
 
302
- def _make_key_bindings():
751
+ def _make_key_bindings(state: PostfixState | None = None):
303
752
  from prompt_toolkit.application.current import get_app
304
753
  from prompt_toolkit.filters import Condition
305
754
  from prompt_toolkit.key_binding import KeyBindings
@@ -311,14 +760,40 @@ def _make_key_bindings():
311
760
  buffer = get_app().current_buffer
312
761
  return bool(
313
762
  _postfix_candidates(
314
- buffer.document.current_line_before_cursor, exact=True
763
+ buffer.document.current_line_before_cursor,
764
+ exact=True,
765
+ state=state,
315
766
  )[1]
316
767
  )
317
768
 
769
+ @Condition
770
+ def has_active_placeholder() -> bool:
771
+ return _has_active_placeholder(get_app().current_buffer)
772
+
773
+ @Condition
774
+ def can_smart_tab_jump() -> bool:
775
+ return _can_smart_tab_jump(get_app().current_buffer, state)
776
+
777
+ @key_bindings.add("tab", filter=has_active_placeholder)
778
+ def accept_placeholder(event) -> None:
779
+ _accept_placeholder(event.current_buffer)
780
+
781
+ @key_bindings.add("s-tab", filter=has_active_placeholder)
782
+ def reverse_placeholder(event) -> None:
783
+ _reverse_placeholder(event.current_buffer)
784
+
785
+ @key_bindings.add("enter", filter=has_active_placeholder)
786
+ def accept_placeholder_with_enter(event) -> None:
787
+ _accept_placeholder(event.current_buffer)
788
+
318
789
  @key_bindings.add("tab", filter=has_exact_postfix)
319
790
  def expand_postfix(event) -> None:
320
791
  _expand_buffer(event.current_buffer)
321
792
 
793
+ @key_bindings.add("tab", filter=can_smart_tab_jump)
794
+ def smart_tab_jump(event) -> None:
795
+ _jump_over_closer(event.current_buffer)
796
+
322
797
  @key_bindings.add(".")
323
798
  def insert_trigger(event) -> None:
324
799
  _insert_trigger_and_maybe_complete(event.current_buffer)
@@ -400,10 +875,12 @@ def _postfix_template_magic(line: str) -> None:
400
875
 
401
876
  def _list_postfix_templates(state: PostfixState) -> None:
402
877
  effective = state.effective_templates()
403
- names = set(DEFAULT_TEMPLATES) | set(state.config.templates) | set(
404
- state.runtime_templates or {}
405
- ) | set(state.config.disabled_templates) | set(
406
- state.runtime_disabled_templates or set()
878
+ names = (
879
+ set(DEFAULT_TEMPLATES)
880
+ | set(state.config.templates)
881
+ | set(state.runtime_templates or {})
882
+ | set(state.config.disabled_templates)
883
+ | set(state.runtime_disabled_templates or set())
407
884
  )
408
885
  for name in sorted(names):
409
886
  origin = state.template_origin(name)
@@ -435,7 +912,7 @@ class PostfixPTCompleter(IPythonPTCompleter):
435
912
 
436
913
  fragment = body[completion.start : completion.end]
437
914
  line_start = body.rfind("\n", 0, completion.start) + 1
438
- indent = body[line_start:completion.start]
915
+ indent = body[line_start : completion.start]
439
916
  state = _state_or_default()
440
917
  key = _template_name(fragment, completion.text, indent, state) or "postfix"
441
918
  display = f".{key} -> {completion.text}"
@@ -484,7 +961,9 @@ def _install_key_binding(ip, state: PostfixState) -> None:
484
961
  from prompt_toolkit.key_binding import merge_key_bindings
485
962
 
486
963
  state.original_key_bindings = original
487
- pt_app.key_bindings = merge_key_bindings([_make_key_bindings(), original])
964
+ # prompt-toolkit resolves the last active binding. Keep extension bindings
965
+ # last so their filtered Tab cases win while native bindings remain fallback.
966
+ pt_app.key_bindings = merge_key_bindings([original, _make_key_bindings(state)])
488
967
 
489
968
 
490
969
  def load_ipython_extension(ip) -> None:
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipython-postfix-completion
3
+ Version: 0.2.1
4
+ Summary: Configurable postfix completion extension for IPython.
5
+ Author: IPython Postfix Completion Contributors
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
11
+ Keywords: ipython,completion,postfix,extension
12
+ Classifier: Framework :: IPython
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: ipython<10,>=9.0
25
+ Requires-Dist: traitlets>=5.13
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=7; extra == "test"
28
+ Provides-Extra: dev
29
+ Requires-Dist: build; extra == "dev"
30
+ Requires-Dist: pip-audit>=2.7; extra == "dev"
31
+ Requires-Dist: ruff>=0.8; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
33
+ Requires-Dist: ipython-postfix-completion[test]; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # IPython Postfix Completion
37
+
38
+ Complete Python expressions with postfix templates in IPython: type an
39
+ expression, a dot, and a template name, then press Tab.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv tool install ipython --with ipython-postfix-completion
45
+ ```
46
+
47
+ Start IPython and load the extension:
48
+
49
+ ```python
50
+ %load_ext ipython_postfix_completion
51
+ ```
52
+
53
+ ## Common Templates
54
+
55
+ `items.for<Tab>` creates a loop. Tab selects `item`, then `pass`; type to
56
+ replace a selection.
57
+
58
+ ```python
59
+ for item in items:
60
+ pass
61
+ ```
62
+
63
+ An integer literal is wrapped in `range(...)`, so `10.for<Tab>` produces
64
+ `for item in range(10):`.
65
+
66
+ `items.fori<Tab>` creates an indexed loop. Tab selects `i`, `value`, then
67
+ `pass`. For an integer literal, it uses `enumerate(range(n))`.
68
+
69
+ `condition.if<Tab>` creates an `if` block with a selected `pass`:
70
+
71
+ ```python
72
+ if condition:
73
+ pass
74
+ ```
75
+
76
+ `condition.ef<Tab>` creates `if`/`elif`/`else` branches. Tab selects each
77
+ placeholder in order: `pass`, `cond`, `pass`, `pass`. The `cond` selection
78
+ does not include its colon.
79
+
80
+ Other built-ins: `print`, `len`, `not`, `par`, `var`, `await`, `return`,
81
+ `while`, `raise`, `yield`, `str`, `list`, `set`, `dict`, `tuple`, `type`, and
82
+ `range`. Use `%postfix_template list` to see their expansions.
83
+
84
+ Shift+Tab moves back to the previous placeholder. Custom Tab navigation is
85
+ supported in terminal IPython.
86
+
87
+ ## Load Automatically
88
+
89
+ The default configuration file is:
90
+
91
+ ```text
92
+ ~/.ipython/profile_default/ipython_config.py
93
+ ```
94
+
95
+ Create it with:
96
+
97
+ ```bash
98
+ ipython profile create
99
+ ```
100
+
101
+ Add the extension and any persistent templates there:
102
+
103
+ ```python
104
+ c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
105
+ c.PostfixCompletionConfig.templates = {
106
+ "debug": "print({expr}=)",
107
+ }
108
+ ```
109
+
110
+ `IPYTHONDIR` and `--ipython-dir` can change the configuration directory. Use
111
+ `ipython locate profile default` to find the active profile. See the [official
112
+ IPython configuration guide](https://ipython.readthedocs.io/en/stable/development/config.html).
113
+
114
+ For runtime template commands, detailed behavior, and contributor or release
115
+ steps, see [the extended guide](docs/REFERENCE.md).
@@ -0,0 +1,6 @@
1
+ ipython_postfix_completion/__init__.py,sha256=OeNt29f_YF-MT01GxZu233B28Il7xUuWLOVLiSdWEPk,34444
2
+ ipython_postfix_completion-0.2.1.dist-info/licenses/LICENSE,sha256=omLR-7QW1K3l6AG7ih1N5L8Z58UY-ROrQL82ANN9PnM,1547
3
+ ipython_postfix_completion-0.2.1.dist-info/METADATA,sha256=uLGeW00R_fljuKcFU9TGUX8UUaC6B9EreQvi_qmfVnE,3655
4
+ ipython_postfix_completion-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ ipython_postfix_completion-0.2.1.dist-info/top_level.txt,sha256=W2sJCvsBheoCB2nbzJeD7-p5PR6lVDNicVLxNxmeraY,27
6
+ ipython_postfix_completion-0.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,218 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: ipython-postfix-completion
3
- Version: 0.1.1
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
- Package on PyPI: `ipython-postfix-completion`
34
-
35
- ## Install
36
-
37
- Install into the current Python environment with `uv`:
38
-
39
- ```bash
40
- uvx --with ipython-postfix-completion ipython
41
- ```
42
-
43
- Install into the current ipython environment with `uv`:
44
-
45
- ```bash
46
- uv tool install ipython --with ipython-postfix-completion
47
- ```
48
-
49
- ## Load
50
-
51
- Inside IPython:
52
-
53
- ```python
54
- %load_ext ipython_postfix_completion
55
- ```
56
-
57
- To load it automatically, add this to `ipython_config.py`:
58
-
59
- ```python
60
- c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
61
- ```
62
-
63
- ## Quick Example: Add a `for` Template
64
-
65
- Add a template for the current IPython session:
66
-
67
- ```python
68
- %postfix_template add for "for item in {expr}:\n{indent} "
69
- ```
70
-
71
- Use it:
72
-
73
- ```python
74
- items.for<Tab>
75
- ```
76
-
77
- It expands to:
78
-
79
- ```python
80
- for item in items:
81
-
82
- ```
83
-
84
- Runtime templates only affect the current IPython session. Put templates in
85
- `ipython_config.py` if you want them to persist.
86
-
87
- ## Runtime Magic
88
-
89
- List effective templates:
90
-
91
- ```python
92
- %postfix_template list
93
- ```
94
-
95
- Add or override a template for the current session:
96
-
97
- ```python
98
- %postfix_template add debug "print({expr}=)"
99
- %postfix_template add forin "for item in {expr}:\n{indent} "
100
- ```
101
-
102
- Disable a template for the current session:
103
-
104
- ```python
105
- %postfix_template remove tuple
106
- ```
107
-
108
- Reset one runtime change:
109
-
110
- ```python
111
- %postfix_template reset forin
112
- ```
113
-
114
- Reset all runtime changes:
115
-
116
- ```python
117
- %postfix_template reset --all
118
- ```
119
-
120
- ## Persistent Config
121
-
122
- Add persistent templates in `ipython_config.py`:
123
-
124
- ```python
125
- c.PostfixCompletionConfig.templates = {
126
- "debug": "print({expr}=)",
127
- "forin": "for item in {expr}:\n{indent} ",
128
- }
129
-
130
- c.PostfixCompletionConfig.disabled_templates = ["tuple"]
131
- ```
132
-
133
- Template names must match `[A-Za-z_][A-Za-z0-9_]*`.
134
-
135
- Templates must include `{expr}` and may also use `{indent}`. No other template
136
- fields are allowed.
137
-
138
- ## Built-in Templates
139
-
140
- Default templates:
141
-
142
- | Name | Expansion |
143
- | --- | --- |
144
- | `print` | `print({expr})` |
145
- | `len` | `len({expr})` |
146
- | `not` | `not {expr}` |
147
- | `par` | `({expr})` |
148
- | `var` | `{expr} = ` |
149
- | `await` | `await {expr}` |
150
- | `return` | `return {expr}` |
151
- | `if` | `if {expr}:\n{indent} ` |
152
- | `while` | `while {expr}:\n{indent} ` |
153
- | `raise` | `raise {expr}` |
154
- | `yield` | `yield {expr}` |
155
- | `str` | `str({expr})` |
156
- | `list` | `list({expr})` |
157
- | `set` | `set({expr})` |
158
- | `dict` | `dict({expr})` |
159
- | `tuple` | `tuple({expr})` |
160
-
161
- Use `%postfix_template list` in IPython to see the exact effective set, including
162
- custom and disabled templates.
163
-
164
- ## Local Validation
165
-
166
- Run tests:
167
-
168
- ```bash
169
- uv run --extra test pytest -q
170
- ```
171
-
172
- Build and check release artifacts:
173
-
174
- ```bash
175
- uv run --extra dev python -m build
176
- uv run --extra dev python -m twine check dist/*
177
- ```
178
-
179
- Validate the wheel in a clean local virtual environment:
180
-
181
- ```bash
182
- uv venv .venv-check
183
- uv pip install --python .venv-check/bin/python dist/*.whl
184
- .venv-check/bin/ipython
185
- ```
186
-
187
- Then inside IPython:
188
-
189
- ```python
190
- %load_ext ipython_postfix_completion
191
- %postfix_template add for "for item in {expr}:\n{indent} "
192
- %postfix_template list
193
- ```
194
-
195
- ## Publish
196
-
197
- Publishing uses GitHub Actions and PyPI Trusted Publishing. Configure the
198
- existing PyPI project once under **Manage > Publishing > Add a new publisher**:
199
-
200
- | Setting | Value |
201
- | --- | --- |
202
- | Owner | `fishandsheep` |
203
- | Repository | `ipython-postfix-completion` |
204
- | Workflow | `publish.yml` |
205
- | Environment | `pypi` |
206
-
207
- For each release, update `project.version` in `pyproject.toml`, commit and push
208
- the change, then create a matching `v` tag. For example, after changing the
209
- version to `0.1.1`:
210
-
211
- ```bash
212
- git tag v0.1.1
213
- git push origin v0.1.1
214
- ```
215
-
216
- The workflow verifies the tag against `project.version`, runs tests, builds and
217
- checks both distributions, then publishes them to PyPI using a short-lived OIDC
218
- credential. The already-published `0.1.0` release cannot be uploaded again.
@@ -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,,