ipython-postfix-completion 0.1.0__py3-none-any.whl

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