ipython-postfix-completion 0.1.0__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.
@@ -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: "PostfixState | None" = None
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,6 +56,8 @@ DEFAULT_TEMPLATES: dict[str, str] = {
53
56
  "len": "len({expr})",
54
57
  "not": "not {expr}",
55
58
  "par": "({expr})",
59
+ "var": _VAR_TEMPLATE,
60
+ "await": "await {expr}",
56
61
  "return": "return {expr}",
57
62
  "if": "if {expr}:\n{indent} ",
58
63
  "while": "while {expr}:\n{indent} ",
@@ -86,22 +91,35 @@ class PostfixCompletionConfig(Configurable):
86
91
  _SELECTED_STYLE,
87
92
  help="Prompt-toolkit selected style for postfix completion menu entries.",
88
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)
89
98
 
90
99
 
91
100
  def _validate_template_name(name: str) -> None:
92
101
  if not _TEMPLATE_NAME_RE.match(name):
93
102
  raise UsageError(
94
- f"Invalid postfix template name {name!r}: expected "
95
- "[A-Za-z_][A-Za-z0-9_]*"
103
+ f"Invalid postfix template name {name!r}: expected [A-Za-z_][A-Za-z0-9_]*"
96
104
  )
97
105
 
98
106
 
99
107
  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
- }
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
+
105
123
  unknown = fields - _ALLOWED_TEMPLATE_FIELDS
106
124
  if unknown:
107
125
  names = ", ".join(sorted(f"{{{name}}}" for name in unknown))
@@ -284,8 +302,320 @@ def _expand_buffer(buffer) -> bool:
284
302
  matched_fragment, candidates = _postfix_candidates(line, exact=True)
285
303
  if len(candidates) != 1:
286
304
  return False
305
+ parsed = _line_prefix(line)
287
306
  buffer.delete_before_cursor(len(matched_fragment))
307
+ expansion_start = buffer.cursor_position
288
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
289
619
  return True
290
620
 
291
621
 
@@ -297,7 +627,7 @@ def _insert_trigger_and_maybe_complete(buffer) -> bool:
297
627
  return True
298
628
 
299
629
 
300
- def _make_key_bindings():
630
+ def _make_key_bindings(state: PostfixState | None = None):
301
631
  from prompt_toolkit.application.current import get_app
302
632
  from prompt_toolkit.filters import Condition
303
633
  from prompt_toolkit.key_binding import KeyBindings
@@ -309,14 +639,36 @@ def _make_key_bindings():
309
639
  buffer = get_app().current_buffer
310
640
  return bool(
311
641
  _postfix_candidates(
312
- buffer.document.current_line_before_cursor, exact=True
642
+ buffer.document.current_line_before_cursor,
643
+ exact=True,
644
+ state=state,
313
645
  )[1]
314
646
  )
315
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
+
316
664
  @key_bindings.add("tab", filter=has_exact_postfix)
317
665
  def expand_postfix(event) -> None:
318
666
  _expand_buffer(event.current_buffer)
319
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
+
320
672
  @key_bindings.add(".")
321
673
  def insert_trigger(event) -> None:
322
674
  _insert_trigger_and_maybe_complete(event.current_buffer)
@@ -398,10 +750,12 @@ def _postfix_template_magic(line: str) -> None:
398
750
 
399
751
  def _list_postfix_templates(state: PostfixState) -> None:
400
752
  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()
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())
405
759
  )
406
760
  for name in sorted(names):
407
761
  origin = state.template_origin(name)
@@ -433,7 +787,7 @@ class PostfixPTCompleter(IPythonPTCompleter):
433
787
 
434
788
  fragment = body[completion.start : completion.end]
435
789
  line_start = body.rfind("\n", 0, completion.start) + 1
436
- indent = body[line_start:completion.start]
790
+ indent = body[line_start : completion.start]
437
791
  state = _state_or_default()
438
792
  key = _template_name(fragment, completion.text, indent, state) or "postfix"
439
793
  display = f".{key} -> {completion.text}"
@@ -482,7 +836,9 @@ def _install_key_binding(ip, state: PostfixState) -> None:
482
836
  from prompt_toolkit.key_binding import merge_key_bindings
483
837
 
484
838
  state.original_key_bindings = original
485
- pt_app.key_bindings = merge_key_bindings([_make_key_bindings(), original])
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)])
486
842
 
487
843
 
488
844
  def load_ipython_extension(ip) -> None:
@@ -0,0 +1,282 @@
1
+ Metadata-Version: 2.4
2
+ Name: ipython-postfix-completion
3
+ Version: 0.2.0
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
+ Configurable postfix completion extension for IPython.
39
+
40
+ Package on PyPI: `ipython-postfix-completion`
41
+
42
+ ## Install
43
+
44
+ Install into the current Python environment with `uv`:
45
+
46
+ ```bash
47
+ uvx --with ipython-postfix-completion ipython
48
+ ```
49
+
50
+ Install into the current ipython environment with `uv`:
51
+
52
+ ```bash
53
+ uv tool install ipython --with ipython-postfix-completion
54
+ ```
55
+
56
+ ## Load
57
+
58
+ Inside IPython:
59
+
60
+ ```python
61
+ %load_ext ipython_postfix_completion
62
+ ```
63
+
64
+ To load it automatically, add this to `ipython_config.py`:
65
+
66
+ ```python
67
+ c.InteractiveShellApp.extensions = ["ipython_postfix_completion"]
68
+ ```
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
+
74
+ ## Quick Example: Add a `for` Template
75
+
76
+ Add a template for the current IPython session:
77
+
78
+ ```python
79
+ %postfix_template add for "for item in {expr}:\n{indent} "
80
+ ```
81
+
82
+ Use it:
83
+
84
+ ```python
85
+ items.for<Tab>
86
+ ```
87
+
88
+ It expands to:
89
+
90
+ ```python
91
+ for item in items:
92
+
93
+ ```
94
+
95
+ Runtime templates only affect the current IPython session. Put templates in
96
+ `ipython_config.py` if you want them to persist.
97
+
98
+ ## Runtime Magic
99
+
100
+ List effective templates:
101
+
102
+ ```python
103
+ %postfix_template list
104
+ ```
105
+
106
+ Add or override a template for the current session:
107
+
108
+ ```python
109
+ %postfix_template add debug "print({expr}=)"
110
+ %postfix_template add forin "for item in {expr}:\n{indent} "
111
+ ```
112
+
113
+ Disable a template for the current session:
114
+
115
+ ```python
116
+ %postfix_template remove tuple
117
+ ```
118
+
119
+ Reset one runtime change:
120
+
121
+ ```python
122
+ %postfix_template reset forin
123
+ ```
124
+
125
+ Reset all runtime changes:
126
+
127
+ ```python
128
+ %postfix_template reset --all
129
+ ```
130
+
131
+ ## Persistent Config
132
+
133
+ Add persistent templates in `ipython_config.py`:
134
+
135
+ ```python
136
+ c.PostfixCompletionConfig.templates = {
137
+ "debug": "print({expr}=)",
138
+ "forin": "for item in {expr}:\n{indent} ",
139
+ }
140
+
141
+ c.PostfixCompletionConfig.disabled_templates = ["tuple"]
142
+ ```
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
+
151
+ Template names must match `[A-Za-z_][A-Za-z0-9_]*`.
152
+
153
+ Templates must include `{expr}` and may also use `{indent}`. No other template
154
+ fields are allowed.
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
+
197
+ ## Built-in Templates
198
+
199
+ Default templates:
200
+
201
+ | Name | Expansion |
202
+ | --- | --- |
203
+ | `print` | `print({expr})` |
204
+ | `len` | `len({expr})` |
205
+ | `not` | `not {expr}` |
206
+ | `par` | `({expr})` |
207
+ | `var` | `key = {expr}`; selects `key`; Tab or Enter accepts it |
208
+ | `await` | `await {expr}` |
209
+ | `return` | `return {expr}` |
210
+ | `if` | `if {expr}:\n{indent} ` |
211
+ | `while` | `while {expr}:\n{indent} ` |
212
+ | `raise` | `raise {expr}` |
213
+ | `yield` | `yield {expr}` |
214
+ | `str` | `str({expr})` |
215
+ | `list` | `list({expr})` |
216
+ | `set` | `set({expr})` |
217
+ | `dict` | `dict({expr})` |
218
+ | `tuple` | `tuple({expr})` |
219
+
220
+ Use `%postfix_template list` in IPython to see the exact effective set, including
221
+ custom and disabled templates.
222
+
223
+ ## Local Validation
224
+
225
+ Run tests:
226
+
227
+ ```bash
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
232
+ ```
233
+
234
+ Build and check release artifacts:
235
+
236
+ ```bash
237
+ uv run --extra dev python -m build
238
+ uv run --extra dev python -m twine check dist/*
239
+ ```
240
+
241
+ Validate the wheel in a clean local virtual environment:
242
+
243
+ ```bash
244
+ uv venv .venv-check
245
+ uv pip install --python .venv-check/bin/python dist/*.whl
246
+ .venv-check/bin/ipython
247
+ ```
248
+
249
+ Then inside IPython:
250
+
251
+ ```python
252
+ %load_ext ipython_postfix_completion
253
+ %postfix_template add for "for item in {expr}:\n{indent} "
254
+ %postfix_template list
255
+ ```
256
+
257
+ ## Publish
258
+
259
+ Publishing uses GitHub Actions and PyPI Trusted Publishing. Configure the
260
+ existing PyPI project once under **Manage > Publishing > Add a new publisher**:
261
+
262
+ | Setting | Value |
263
+ | --- | --- |
264
+ | Owner | `fishandsheep` |
265
+ | Repository | `ipython-postfix-completion` |
266
+ | Workflow | `publish.yml` |
267
+ | Environment | `pypi` |
268
+
269
+ For each release, update `project.version` in `pyproject.toml`, commit and push
270
+ the change, then create a matching `v` tag. For this release:
271
+
272
+ ```bash
273
+ git tag v0.2.0
274
+ git push origin v0.2.0
275
+ ```
276
+
277
+ The workflow verifies the tag against `project.version`, runs tests, builds and
278
+ checks both distributions, then publishes them to PyPI using a short-lived OIDC
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,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,150 +0,0 @@
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
- ```
@@ -1,6 +0,0 @@
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,,