repl-toolkit 1.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.
@@ -0,0 +1,374 @@
1
+ """
2
+ Async REPL interface with action support for repl_toolkit.
3
+
4
+ Provides an interactive chat interface with full UI features including
5
+ history, action handling (commands + keyboard shortcuts), and
6
+ robust cancellation of long-running tasks.
7
+ """
8
+
9
+ import asyncio
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from loguru import logger
15
+ from prompt_toolkit import HTML, PromptSession
16
+ from prompt_toolkit import print_formatted_text as print
17
+ from prompt_toolkit.application import Application
18
+ from prompt_toolkit.history import FileHistory
19
+ from prompt_toolkit.input import create_input
20
+ from prompt_toolkit.key_binding import KeyBindings
21
+ from prompt_toolkit.keys import Keys
22
+ from prompt_toolkit.output import DummyOutput
23
+
24
+ from .actions import ActionRegistry
25
+ from .ptypes import ActionHandler, AsyncBackend, Completer
26
+
27
+ THINKING = HTML("<i><grey>Thinking... (Press Alt+C to cancel)</grey></i>")
28
+
29
+
30
+ class AsyncREPL:
31
+ """
32
+ Manages an interactive async REPL session with action support.
33
+
34
+ Provides user input handling, action processing (commands and shortcuts),
35
+ and robust cancellation of long-running tasks with a clean, extensible interface.
36
+
37
+ The AsyncREPL supports late backend binding, allowing initialization without
38
+ a backend for scenarios where the backend is only available within a resource
39
+ context block.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ action_registry: Optional[ActionHandler] = None,
45
+ completer: Optional[Completer] = None,
46
+ prompt_string: Optional[str] = None,
47
+ history_path: Optional[Path] = None,
48
+ **kwargs,
49
+ ):
50
+ """
51
+ Initialize the async REPL interface.
52
+
53
+ Args:
54
+ action_registry: Action registry for commands and shortcuts (optional)
55
+ completer: Optional tab-completion provider
56
+ prompt_string: Custom prompt string (default: "User: ")
57
+ history_path: Optional path for command history storage
58
+
59
+ Note:
60
+ Backend is provided later via the run() method to support scenarios
61
+ where the backend is only available within a resource context.
62
+ """
63
+ logger.trace("AsyncREPL.__init__() entry")
64
+
65
+ self.prompt_string = HTML(prompt_string or "User: ")
66
+ self.action_registry = action_registry or ActionRegistry()
67
+ self.session = PromptSession( # type: ignore[var-annotated]
68
+ message=self.prompt_string,
69
+ history=self._create_history(history_path),
70
+ key_bindings=self._create_key_bindings(),
71
+ multiline=True,
72
+ completer=completer, # type: ignore[arg-type]
73
+ **kwargs,
74
+ )
75
+ self.main_app = self.session.app
76
+
77
+ logger.trace("AsyncREPL.__init__() exit")
78
+
79
+ def _create_history(self, path: Optional[Path]) -> Optional[FileHistory]:
80
+ """
81
+ Create file history if path is provided.
82
+
83
+ Args:
84
+ path: Optional path to history file
85
+
86
+ Returns:
87
+ FileHistory instance or None
88
+ """
89
+ logger.trace("AsyncREPL._create_history() entry")
90
+
91
+ if path: # pragma: no cover
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+ result = FileHistory(str(path))
94
+ logger.trace("AsyncREPL._create_history() exit - with history")
95
+ return result
96
+
97
+ logger.trace("AsyncREPL._create_history() exit - no history")
98
+ return None
99
+
100
+ def _create_key_bindings(self) -> KeyBindings:
101
+ """
102
+ Create key bindings for the REPL session.
103
+
104
+ This method creates both built-in key bindings and dynamic bindings
105
+ from the action registry, providing a shortcut system.
106
+
107
+ Built-in Key Bindings:
108
+ - Enter: Add new line
109
+ - Alt+Enter: Send message
110
+ - Alt+C: Cancel operation (during processing)
111
+
112
+ Dynamic bindings are loaded from the action registry.
113
+
114
+ Returns:
115
+ KeyBindings instance with configured shortcuts
116
+ """
117
+ logger.trace("AsyncREPL._create_key_bindings() entry")
118
+
119
+ bindings = KeyBindings()
120
+
121
+ # Built-in bindings for core REPL functionality
122
+ @bindings.add("enter") # pragma: no cover
123
+ def _(event):
124
+ """Handle Enter key - add new line."""
125
+ event.app.current_buffer.insert_text("\n")
126
+
127
+ @bindings.add(Keys.Escape, "enter") # pragma: no cover
128
+ def _(event):
129
+ """Handle Alt+Enter - send message."""
130
+ event.app.current_buffer.validate_and_handle()
131
+
132
+ # Register dynamic key bindings from action registry
133
+ self._register_action_shortcuts(bindings)
134
+
135
+ logger.trace("AsyncREPL._create_key_bindings() exit")
136
+ return bindings
137
+
138
+ def _register_action_shortcuts(self, bindings: KeyBindings) -> None:
139
+ """
140
+ Register keyboard shortcuts from the action registry.
141
+
142
+ Args:
143
+ bindings: KeyBindings instance to add shortcuts to
144
+ """
145
+ logger.trace("AsyncREPL._register_action_shortcuts() entry")
146
+
147
+ if not hasattr(self.action_registry, "key_map"):
148
+ logger.trace("AsyncREPL._register_action_shortcuts() exit - no key_map")
149
+ return
150
+
151
+ for key_combo, action_name in self.action_registry.key_map.items():
152
+ self._register_shortcut(bindings, key_combo, action_name)
153
+
154
+ logger.trace("AsyncREPL._register_action_shortcuts() exit")
155
+
156
+ def _register_shortcut(self, bindings: KeyBindings, key_combo: str, action_name: str) -> None:
157
+ """
158
+ Register a single keyboard shortcut.
159
+
160
+ Args:
161
+ bindings: KeyBindings instance
162
+ key_combo: Key combination string (e.g., "F1", "ctrl-s")
163
+ action_name: Name of action to execute
164
+ """
165
+ logger.trace("AsyncREPL._register_shortcut() entry")
166
+
167
+ try:
168
+ # Parse key combination - handle common formats
169
+ keys = self._parse_key_combination(key_combo)
170
+
171
+ @bindings.add(*keys) # pragma: no cover
172
+ def _(event):
173
+ # Execute action synchronously
174
+ try:
175
+ self.action_registry.handle_shortcut(key_combo, event)
176
+ except Exception as e:
177
+ logger.error(f"Error executing shortcut '{key_combo}': {e}")
178
+ print(f"Error: {e}")
179
+
180
+ logger.debug(f"Registered shortcut '{key_combo}' -> '{action_name}'")
181
+ logger.trace("AsyncREPL._register_shortcut() exit - success")
182
+
183
+ except Exception as e: # pragma: no cover
184
+ logger.error(
185
+ f"Failed to register shortcut '{key_combo}' for action '{action_name}': {e}"
186
+ )
187
+ logger.trace("AsyncREPL._register_shortcut() exit - error")
188
+
189
+ def _parse_key_combination(self, key_combo: str) -> tuple:
190
+ """
191
+ Parse key combination string into prompt_toolkit format.
192
+
193
+ Args:
194
+ key_combo: Key combination (e.g., "F1", "ctrl-s", "alt-enter")
195
+
196
+ Returns:
197
+ Tuple of keys for prompt_toolkit
198
+ """
199
+ logger.trace("AsyncREPL._parse_key_combination() entry")
200
+
201
+ # Handle common key formats
202
+ key_combo = key_combo.lower().strip()
203
+
204
+ # Single function keys
205
+ if key_combo.startswith("f") and key_combo[1:].isdigit():
206
+ logger.trace("AsyncREPL._parse_key_combination() exit - function key")
207
+ return (key_combo,)
208
+
209
+ # Handle modifier combinations
210
+ if "-" in key_combo:
211
+ parts = key_combo.split("-")
212
+ if len(parts) == 2:
213
+ modifier, key = parts
214
+
215
+ # Map common modifiers
216
+ if modifier == "ctrl":
217
+ logger.trace("AsyncREPL._parse_key_combination() exit - ctrl combo")
218
+ return ("c-" + key,)
219
+ elif modifier == "alt":
220
+ logger.trace("AsyncREPL._parse_key_combination() exit - alt combo")
221
+ return (Keys.Escape, key)
222
+ elif modifier == "shift": # pragma: no cover
223
+ logger.trace("AsyncREPL._parse_key_combination() exit - shift combo")
224
+ return ("s-" + key,)
225
+
226
+ # Single keys
227
+ logger.trace("AsyncREPL._parse_key_combination() exit - single key")
228
+ return (key_combo,)
229
+
230
+ async def run(
231
+ self, backend: AsyncBackend, initial_message: Optional[str] = None
232
+ ): # pragma: no cover
233
+ """
234
+ Run the async REPL session with the provided backend.
235
+
236
+ This method accepts the backend at runtime, supporting scenarios where
237
+ the backend is only available within a resource context block.
238
+
239
+ Args:
240
+ backend: Backend responsible for processing user input
241
+ initial_message: Optional message to process before starting loop
242
+ """
243
+ logger.trace("AsyncREPL.run() entry")
244
+
245
+ # Set backend in action registry for action handlers to access
246
+ self.action_registry.backend = backend # type: ignore[attr-defined]
247
+
248
+ if initial_message:
249
+ print(self.prompt_string, end="")
250
+ print(initial_message)
251
+ await self._process_input(initial_message, backend)
252
+ print()
253
+
254
+ while True:
255
+ try:
256
+ user_input = await self.session.prompt_async()
257
+ if self._should_exit(user_input):
258
+ break
259
+ if not user_input.strip():
260
+ continue
261
+ if user_input.strip().startswith("/"):
262
+ # Handle commands synchronously
263
+ self.action_registry.handle_command(user_input.strip())
264
+ continue
265
+
266
+ logger.debug(f"Processing user input: {user_input}")
267
+ await self._process_input(user_input, backend)
268
+
269
+ except (KeyboardInterrupt, EOFError):
270
+ print()
271
+ break
272
+ except Exception as e:
273
+ logger.error(f"Error in REPL loop: {e}")
274
+ print(f"An error occurred: {e}", file=sys.stderr)
275
+
276
+ logger.trace("AsyncREPL.run() exit")
277
+
278
+ def _should_exit(self, user_input: str) -> bool:
279
+ """Check if input is an exit command."""
280
+ logger.trace("AsyncREPL._should_exit() entry/exit")
281
+ return user_input.strip().lower() in ["/exit", "/quit"]
282
+
283
+ async def _process_input(self, user_input: str, backend: AsyncBackend): # pragma: no cover
284
+ """
285
+ Process user input with cancellation support.
286
+
287
+ Runs the backend processing task concurrently with a cancellation
288
+ listener, allowing users to cancel long-running operations.
289
+
290
+ Args:
291
+ user_input: Input string to process
292
+ backend: Backend to process the input
293
+ """
294
+ logger.trace("AsyncREPL._process_input() entry")
295
+
296
+ cancel_future = asyncio.Future() # type: ignore[var-annotated]
297
+
298
+ kb = KeyBindings()
299
+
300
+ @kb.add("escape", "c")
301
+ def _(event):
302
+ if not cancel_future.done():
303
+ cancel_future.set_result(None)
304
+ event.app.exit()
305
+
306
+ cancel_app = Application(key_bindings=kb, output=DummyOutput(), input=create_input()) # type: ignore[var-annotated]
307
+
308
+ backend_task = asyncio.create_task(backend.handle_input(user_input))
309
+ listener_task = asyncio.create_task(cancel_app.run_async())
310
+ print(THINKING)
311
+
312
+ try:
313
+ done, pending = await asyncio.wait(
314
+ [backend_task, cancel_future],
315
+ return_when=asyncio.FIRST_COMPLETED,
316
+ )
317
+
318
+ if cancel_future in done:
319
+ print("\nOperation cancelled by user.")
320
+ backend_task.cancel()
321
+ else:
322
+ success = backend_task.result()
323
+ if not success:
324
+ print("Operation failed.")
325
+
326
+ except Exception as e:
327
+ print(f"\nAn error occurred: {e}")
328
+ if not backend_task.done():
329
+ backend_task.cancel()
330
+
331
+ finally:
332
+ # Cleanup
333
+ if not cancel_app.is_done:
334
+ cancel_app.exit()
335
+
336
+ await listener_task
337
+
338
+ self.main_app.renderer.reset()
339
+ self.main_app.invalidate()
340
+ await asyncio.sleep(0)
341
+
342
+ logger.trace("AsyncREPL._process_input() exit")
343
+
344
+
345
+ # Convenience function
346
+ async def run_async_repl( # pragma: no cover
347
+ backend: AsyncBackend,
348
+ action_registry: Optional[ActionHandler] = None,
349
+ completer: Optional[Completer] = None,
350
+ initial_message: Optional[str] = None,
351
+ prompt_string: Optional[str] = None,
352
+ history_path: Optional[Path] = None,
353
+ **kwargs,
354
+ ):
355
+ """
356
+ Convenience function to create and run an AsyncREPL with action support.
357
+
358
+ This function creates an AsyncREPL instance and runs it with the provided
359
+ backend, supporting the late backend binding pattern.
360
+
361
+ Args:
362
+ backend: Backend for processing input
363
+ action_registry: Action registry for commands and shortcuts (optional)
364
+ completer: Optional completer
365
+ initial_message: Optional initial message
366
+ prompt_string: Optional custom prompt
367
+ history_path: Optional history file path
368
+ """
369
+ logger.trace("run_async_repl() entry")
370
+
371
+ repl = AsyncREPL(action_registry, completer, prompt_string, history_path, **kwargs)
372
+ await repl.run(backend, initial_message)
373
+
374
+ logger.trace("run_async_repl() exit")
@@ -0,0 +1,15 @@
1
+ """
2
+ Completion utilities for REPL toolkit.
3
+
4
+ This module provides completers that can be used with prompt_toolkit:
5
+ - ShellExpansionCompleter: Environment variable and shell command expansion
6
+ - PrefixCompleter: Static string matching with optional prefix character
7
+ """
8
+
9
+ from .prefix import PrefixCompleter
10
+ from .shell_expansion import ShellExpansionCompleter
11
+
12
+ __all__ = [
13
+ "ShellExpansionCompleter",
14
+ "PrefixCompleter",
15
+ ]
@@ -0,0 +1,109 @@
1
+ """
2
+ Prefix-based string completer.
3
+ """
4
+
5
+ import re
6
+ from typing import Iterable, List, Optional
7
+
8
+ from prompt_toolkit.completion import Completer, Completion
9
+ from prompt_toolkit.document import Document
10
+
11
+
12
+ class PrefixCompleter(Completer):
13
+ """
14
+ Completer for static string matching with optional prefix character.
15
+
16
+ This completer matches strings from a predefined list. If a prefix is specified,
17
+ it only completes after that prefix character at word boundaries, avoiding false
18
+ positives in paths or other contexts.
19
+
20
+ Args:
21
+ words: List of words to complete
22
+ prefix: Optional prefix character (e.g., '/', '@', '#')
23
+ If provided, only completes after this prefix at word boundaries.
24
+ If None, completes anywhere (standard word completion).
25
+ ignore_case: Case-insensitive matching (default: True)
26
+
27
+ Examples:
28
+ >>> # Slash commands
29
+ >>> completer = PrefixCompleter(['/help', '/exit', '/quit'], prefix='/')
30
+ >>> # Or let it add the prefix
31
+ >>> completer = PrefixCompleter(['help', 'exit', 'quit'], prefix='/')
32
+
33
+ >>> # At-mentions
34
+ >>> completer = PrefixCompleter(['alice', 'bob', 'charlie'], prefix='@')
35
+
36
+ >>> # Hashtags
37
+ >>> completer = PrefixCompleter(['python', 'coding', 'opensource'], prefix='#')
38
+
39
+ >>> # SQL keywords (no prefix)
40
+ >>> completer = PrefixCompleter(['SELECT', 'FROM', 'WHERE'], prefix=None)
41
+ """
42
+
43
+ def __init__(self, words: List[str], prefix: Optional[str] = None, ignore_case: bool = True):
44
+ """Initialize the completer."""
45
+ self.prefix = prefix
46
+ self.ignore_case = ignore_case
47
+
48
+ # Normalize words: ensure they have the prefix if specified
49
+ if prefix:
50
+ self.words = [word if word.startswith(prefix) else f"{prefix}{word}" for word in words]
51
+ else:
52
+ self.words = words
53
+
54
+ # Build pattern for matching
55
+ if prefix:
56
+ # Match prefix at start of line or after whitespace/newline
57
+ # This prevents matching "/" in paths like "path/to/file"
58
+ escaped_prefix = re.escape(prefix)
59
+ self.pattern = re.compile(rf"(?:^|[\s\n])({escaped_prefix}\S*)$")
60
+ else:
61
+ # No prefix - match word at cursor
62
+ self.pattern = re.compile(r"(\S*)$")
63
+
64
+ def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
65
+ """
66
+ Get completions for words matching the prefix pattern.
67
+
68
+ Args:
69
+ document: Current document
70
+ complete_event: Completion event
71
+
72
+ Yields:
73
+ Completion objects for matching words
74
+ """
75
+ # Get text before cursor
76
+ text_before_cursor = document.text_before_cursor
77
+
78
+ # Try to match the pattern
79
+ match = self.pattern.search(text_before_cursor)
80
+
81
+ if not match:
82
+ return
83
+
84
+ # Get the partial word being typed
85
+ partial = match.group(1)
86
+
87
+ # Find matching words
88
+ for word in self.words:
89
+ if self._matches(word, partial):
90
+ # Calculate start position (negative, relative to cursor)
91
+ start_pos = -len(partial)
92
+
93
+ yield Completion(text=word, start_position=start_pos, display=word)
94
+
95
+ def _matches(self, word: str, partial: str) -> bool:
96
+ """
97
+ Check if word matches partial string.
98
+
99
+ Args:
100
+ word: Complete word from word list
101
+ partial: Partial string typed by user
102
+
103
+ Returns:
104
+ True if word matches partial
105
+ """
106
+ if self.ignore_case:
107
+ return word.lower().startswith(partial.lower())
108
+ else:
109
+ return word.startswith(partial)