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.
@@ -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,7 +56,7 @@ DEFAULT_TEMPLATES: dict[str, str] = {
53
56
  "len": "len({expr})",
54
57
  "not": "not {expr}",
55
58
  "par": "({expr})",
56
- "var": "{expr} = ",
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
- fields = {
103
- field_name
104
- for _, field_name, _, _ in _FORMATTER.parse(template)
105
- if field_name is not None
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, exact=True
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 = 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()
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
- 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)])
488
842
 
489
843
 
490
844
  def load_ipython_extension(ip) -> None:
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipython-postfix-completion
3
- Version: 0.1.1
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>=9.0
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 example, after changing the
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.1.1
213
- git push origin v0.1.1
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. The already-published `0.1.0` release cannot be uploaded again.
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,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,,