repl-toolkit 1.0.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.

Potentially problematic release.


This version of repl-toolkit might be problematic. Click here for more details.

@@ -0,0 +1,367 @@
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
+ from pathlib import Path
11
+ import sys
12
+ from typing import Optional
13
+
14
+ from loguru import logger
15
+ from prompt_toolkit import HTML, PromptSession, print_formatted_text as print
16
+ from prompt_toolkit.application import Application
17
+ from prompt_toolkit.input import create_input
18
+ from prompt_toolkit.key_binding import KeyBindings
19
+ from prompt_toolkit.output import DummyOutput
20
+ from prompt_toolkit.completion import Completer
21
+ from prompt_toolkit.keys import Keys
22
+ from prompt_toolkit.history import FileHistory
23
+
24
+ from .ptypes import AsyncBackend, ActionHandler, Completer
25
+ from .actions import ActionRegistry, ActionContext
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
+ ):
49
+ """
50
+ Initialize the async REPL interface.
51
+
52
+ Args:
53
+ action_registry: Action registry for commands and shortcuts (optional)
54
+ completer: Optional tab-completion provider
55
+ prompt_string: Custom prompt string (default: "User: ")
56
+ history_path: Optional path for command history storage
57
+
58
+ Note:
59
+ Backend is provided later via the run() method to support scenarios
60
+ where the backend is only available within a resource context.
61
+ """
62
+ logger.trace("AsyncREPL.__init__() entry")
63
+
64
+ self.prompt_string = HTML(prompt_string or "User: ")
65
+ self.action_registry = action_registry or ActionRegistry()
66
+ self.session = PromptSession(
67
+ history=self._create_history(history_path),
68
+ key_bindings=self._create_key_bindings(),
69
+ multiline=True,
70
+ completer=completer,
71
+ )
72
+ self.main_app = self.session.app
73
+
74
+ logger.trace("AsyncREPL.__init__() exit")
75
+
76
+ def _create_history(self, path: Optional[Path]) -> Optional[FileHistory]:
77
+ """
78
+ Create file history if path is provided.
79
+
80
+ Args:
81
+ path: Optional path to history file
82
+
83
+ Returns:
84
+ FileHistory instance or None
85
+ """
86
+ logger.trace("AsyncREPL._create_history() entry")
87
+
88
+ if path: # pragma: no cover
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ result = FileHistory(str(path))
91
+ logger.trace("AsyncREPL._create_history() exit - with history")
92
+ return result
93
+
94
+ logger.trace("AsyncREPL._create_history() exit - no history")
95
+ return None
96
+
97
+ def _create_key_bindings(self) -> KeyBindings:
98
+ """
99
+ Create key bindings for the REPL session.
100
+
101
+ This method creates both built-in key bindings and dynamic bindings
102
+ from the action registry, providing a shortcut system.
103
+
104
+ Built-in Key Bindings:
105
+ - Enter: Add new line
106
+ - Alt+Enter: Send message
107
+ - Alt+C: Cancel operation (during processing)
108
+
109
+ Dynamic bindings are loaded from the action registry.
110
+
111
+ Returns:
112
+ KeyBindings instance with configured shortcuts
113
+ """
114
+ logger.trace("AsyncREPL._create_key_bindings() entry")
115
+
116
+ bindings = KeyBindings()
117
+
118
+ # Built-in bindings for core REPL functionality
119
+ @bindings.add("enter") # pragma: no cover
120
+ def _(event):
121
+ """Handle Enter key - add new line."""
122
+ event.app.current_buffer.insert_text("\n")
123
+
124
+ @bindings.add(Keys.Escape, "enter") # pragma: no cover
125
+ def _(event):
126
+ """Handle Alt+Enter - send message."""
127
+ event.app.current_buffer.validate_and_handle()
128
+
129
+ # Register dynamic key bindings from action registry
130
+ self._register_action_shortcuts(bindings)
131
+
132
+ logger.trace("AsyncREPL._create_key_bindings() exit")
133
+ return bindings
134
+
135
+ def _register_action_shortcuts(self, bindings: KeyBindings) -> None:
136
+ """
137
+ Register keyboard shortcuts from the action registry.
138
+
139
+ Args:
140
+ bindings: KeyBindings instance to add shortcuts to
141
+ """
142
+ logger.trace("AsyncREPL._register_action_shortcuts() entry")
143
+
144
+ if not hasattr(self.action_registry, 'key_map'):
145
+ logger.trace("AsyncREPL._register_action_shortcuts() exit - no key_map")
146
+ return
147
+
148
+ for key_combo, action_name in self.action_registry.key_map.items():
149
+ self._register_shortcut(bindings, key_combo, action_name)
150
+
151
+ logger.trace("AsyncREPL._register_action_shortcuts() exit")
152
+
153
+ def _register_shortcut(self, bindings: KeyBindings, key_combo: str, action_name: str) -> None:
154
+ """
155
+ Register a single keyboard shortcut.
156
+
157
+ Args:
158
+ bindings: KeyBindings instance
159
+ key_combo: Key combination string (e.g., "F1", "ctrl-s")
160
+ action_name: Name of action to execute
161
+ """
162
+ logger.trace("AsyncREPL._register_shortcut() entry")
163
+
164
+ try:
165
+ # Parse key combination - handle common formats
166
+ keys = self._parse_key_combination(key_combo)
167
+
168
+ @bindings.add(*keys) # pragma: no cover
169
+ def _(event):
170
+ # Execute action synchronously
171
+ try:
172
+ self.action_registry.handle_shortcut(key_combo, event)
173
+ except Exception as e:
174
+ logger.error(f"Error executing shortcut '{key_combo}': {e}")
175
+ print(f"Error: {e}")
176
+
177
+ logger.debug(f"Registered shortcut '{key_combo}' -> '{action_name}'")
178
+ logger.trace("AsyncREPL._register_shortcut() exit - success")
179
+
180
+ except Exception as e: # pragma: no cover
181
+ logger.error(f"Failed to register shortcut '{key_combo}' for action '{action_name}': {e}")
182
+ logger.trace("AsyncREPL._register_shortcut() exit - error")
183
+
184
+ def _parse_key_combination(self, key_combo: str) -> tuple:
185
+ """
186
+ Parse key combination string into prompt_toolkit format.
187
+
188
+ Args:
189
+ key_combo: Key combination (e.g., "F1", "ctrl-s", "alt-enter")
190
+
191
+ Returns:
192
+ Tuple of keys for prompt_toolkit
193
+ """
194
+ logger.trace("AsyncREPL._parse_key_combination() entry")
195
+
196
+ # Handle common key formats
197
+ key_combo = key_combo.lower().strip()
198
+
199
+ # Single function keys
200
+ if key_combo.startswith('f') and key_combo[1:].isdigit():
201
+ logger.trace("AsyncREPL._parse_key_combination() exit - function key")
202
+ return (key_combo,)
203
+
204
+ # Handle modifier combinations
205
+ if '-' in key_combo:
206
+ parts = key_combo.split('-')
207
+ if len(parts) == 2:
208
+ modifier, key = parts
209
+
210
+ # Map common modifiers
211
+ if modifier == 'ctrl':
212
+ logger.trace("AsyncREPL._parse_key_combination() exit - ctrl combo")
213
+ return ('c-' + key,)
214
+ elif modifier == 'alt':
215
+ logger.trace("AsyncREPL._parse_key_combination() exit - alt combo")
216
+ return (Keys.Escape, key)
217
+ elif modifier == 'shift': # pragma: no cover
218
+ logger.trace("AsyncREPL._parse_key_combination() exit - shift combo")
219
+ return ('s-' + key,)
220
+
221
+ # Single keys
222
+ logger.trace("AsyncREPL._parse_key_combination() exit - single key")
223
+ return (key_combo,)
224
+
225
+ async def run(self, backend: AsyncBackend, initial_message: Optional[str] = None): # pragma: no cover
226
+ """
227
+ Run the async REPL session with the provided backend.
228
+
229
+ This method accepts the backend at runtime, supporting scenarios where
230
+ the backend is only available within a resource context block.
231
+
232
+ Args:
233
+ backend: Backend responsible for processing user input
234
+ initial_message: Optional message to process before starting loop
235
+ """
236
+ logger.trace("AsyncREPL.run() entry")
237
+
238
+ # Set backend in action registry for action handlers to access
239
+ self.action_registry.backend = backend
240
+
241
+ if initial_message:
242
+ print(self.prompt_string, end="")
243
+ print(initial_message)
244
+ await self._process_input(initial_message, backend)
245
+ print()
246
+
247
+ while True:
248
+ try:
249
+ user_input = await self.session.prompt_async(self.prompt_string)
250
+ if self._should_exit(user_input):
251
+ break
252
+ if not user_input.strip():
253
+ continue
254
+ if user_input.strip().startswith("/"):
255
+ # Handle commands synchronously
256
+ self.action_registry.handle_command(user_input.strip())
257
+ continue
258
+
259
+ logger.debug(f"Processing user input: {user_input}")
260
+ await self._process_input(user_input, backend)
261
+
262
+ except (KeyboardInterrupt, EOFError):
263
+ print()
264
+ break
265
+ except Exception as e:
266
+ logger.error(f"Error in REPL loop: {e}")
267
+ print(f"An error occurred: {e}", file=sys.stderr)
268
+
269
+ logger.trace("AsyncREPL.run() exit")
270
+
271
+ def _should_exit(self, user_input: str) -> bool:
272
+ """Check if input is an exit command."""
273
+ logger.trace("AsyncREPL._should_exit() entry/exit")
274
+ return user_input.strip().lower() in ["/exit", "/quit"]
275
+
276
+ async def _process_input(self, user_input: str, backend: AsyncBackend): # pragma: no cover
277
+ """
278
+ Process user input with cancellation support.
279
+
280
+ Runs the backend processing task concurrently with a cancellation
281
+ listener, allowing users to cancel long-running operations.
282
+
283
+ Args:
284
+ user_input: Input string to process
285
+ backend: Backend to process the input
286
+ """
287
+ logger.trace("AsyncREPL._process_input() entry")
288
+
289
+ cancel_future = asyncio.Future()
290
+
291
+ kb = KeyBindings()
292
+ @kb.add("escape", "c")
293
+ def _(event):
294
+ if not cancel_future.done():
295
+ cancel_future.set_result(None)
296
+ event.app.exit()
297
+
298
+ cancel_app = Application(
299
+ key_bindings=kb, output=DummyOutput(), input=create_input()
300
+ )
301
+
302
+ backend_task = asyncio.create_task(backend.handle_input(user_input))
303
+ listener_task = asyncio.create_task(cancel_app.run_async())
304
+ print(THINKING)
305
+
306
+ try:
307
+ done, pending = await asyncio.wait(
308
+ [backend_task, cancel_future],
309
+ return_when=asyncio.FIRST_COMPLETED,
310
+ )
311
+
312
+ if cancel_future in done:
313
+ print("\nOperation cancelled by user.")
314
+ backend_task.cancel()
315
+ else:
316
+ success = backend_task.result()
317
+ if not success:
318
+ print("Operation failed.")
319
+
320
+ except Exception as e:
321
+ print(f"\nAn error occurred: {e}")
322
+ if not backend_task.done():
323
+ backend_task.cancel()
324
+
325
+ finally:
326
+ # Cleanup
327
+ if not cancel_app.is_done:
328
+ cancel_app.exit()
329
+
330
+ await listener_task
331
+
332
+ self.main_app.renderer.reset()
333
+ self.main_app.invalidate()
334
+ await asyncio.sleep(0)
335
+
336
+ logger.trace("AsyncREPL._process_input() exit")
337
+
338
+
339
+ # Convenience function
340
+ async def run_async_repl( # pragma: no cover
341
+ backend: AsyncBackend,
342
+ action_registry: Optional[ActionHandler] = None,
343
+ completer: Optional[Completer] = None,
344
+ initial_message: Optional[str] = None,
345
+ prompt_string: Optional[str] = None,
346
+ history_path: Optional[Path] = None,
347
+ ):
348
+ """
349
+ Convenience function to create and run an AsyncREPL with action support.
350
+
351
+ This function creates an AsyncREPL instance and runs it with the provided
352
+ backend, supporting the late backend binding pattern.
353
+
354
+ Args:
355
+ backend: Backend for processing input
356
+ action_registry: Action registry for commands and shortcuts (optional)
357
+ completer: Optional completer
358
+ initial_message: Optional initial message
359
+ prompt_string: Optional custom prompt
360
+ history_path: Optional history file path
361
+ """
362
+ logger.trace("run_async_repl() entry")
363
+
364
+ repl = AsyncREPL(action_registry, completer, prompt_string, history_path)
365
+ await repl.run(backend, initial_message)
366
+
367
+ logger.trace("run_async_repl() exit")
@@ -0,0 +1,247 @@
1
+ from typing import Optional
2
+ from loguru import logger
3
+
4
+ from .ptypes import AsyncBackend
5
+ from .ptypes import ActionHandler
6
+ from .actions.registry import ActionRegistry
7
+
8
+ class HeadlessREPL:
9
+ """
10
+ Headless REPL that reads from stdin and processes through action framework.
11
+
12
+ Similar to interactive mode but:
13
+ - Reads from stdin instead of prompt_toolkit
14
+ - Accumulates content in buffer until /send
15
+ - Processes commands through action system
16
+ - Supports multiple /send cycles
17
+ - Auto-sends remaining buffer on EOF
18
+ """
19
+
20
+ def __init__(self, action_registry: Optional[ActionHandler] = None):
21
+ """Initialize headless REPL."""
22
+ logger.trace("HeadlessREPL.__init__() entry")
23
+
24
+ # Simple string buffer for content accumulation
25
+ self.buffer = ""
26
+ self.action_registry = action_registry or ActionRegistry()
27
+
28
+ # State tracking
29
+ self.send_count = 0
30
+ self.total_success = True
31
+ self.running = True
32
+
33
+ logger.trace("HeadlessREPL.__init__() exit")
34
+
35
+ async def run(self, backend: AsyncBackend, initial_message: Optional[str] = None) -> bool:
36
+ """
37
+ Run headless mode with stdin processing.
38
+
39
+ Args:
40
+ backend: Backend for processing input
41
+ initial_message: Optional message to process before stdin loop
42
+
43
+ Returns:
44
+ bool: True if all operations succeeded
45
+ """
46
+ logger.trace("HeadlessREPL.run() entry")
47
+
48
+ # Set backend in action registry
49
+ self.action_registry.backend = backend
50
+
51
+ try:
52
+ # Process initial message if provided
53
+ if initial_message:
54
+ logger.info(f"Processing initial message: {initial_message}")
55
+ success = await backend.handle_input(initial_message)
56
+ if not success:
57
+ logger.warning("Initial message processing failed")
58
+ self.total_success = False
59
+
60
+ # Enter stdin processing loop
61
+ await self._stdin_loop(backend)
62
+
63
+ logger.trace("HeadlessREPL.run() exit - success")
64
+ return self.total_success
65
+
66
+ except Exception as e:
67
+ logger.error(f"Error in headless processing: {e}")
68
+ logger.trace("HeadlessREPL.run() exit - exception")
69
+ return False
70
+
71
+ async def _stdin_loop(self, backend: AsyncBackend):
72
+ """
73
+ Process stdin with synchronous reading, async backend calls.
74
+
75
+ Reads lines from stdin synchronously (blocking), processes commands
76
+ through the action system, and accumulates content in buffer until
77
+ /send commands trigger backend processing.
78
+
79
+ Args:
80
+ backend: Backend for processing accumulated content
81
+ """
82
+ import sys
83
+
84
+ line_num = 0
85
+
86
+ while True:
87
+ # Simple synchronous readline - blocks until line available
88
+ # This is perfectly fine! We want to wait for the next line.
89
+ line = sys.stdin.readline()
90
+
91
+ if not line: # EOF
92
+ break
93
+
94
+ line_num += 1
95
+ line = line.rstrip('\n\r')
96
+
97
+ if line.startswith('/'):
98
+ if line == '/send':
99
+ await self._execute_send(backend, f"line {line_num}")
100
+ else:
101
+ # Synchronous command processing
102
+ self._execute_command(line)
103
+ else:
104
+ # Synchronous buffer addition
105
+ self._add_to_buffer(line)
106
+
107
+ await self._handle_eof(backend)
108
+
109
+ def _add_to_buffer(self, line: str):
110
+ """
111
+ Add a line to the buffer.
112
+
113
+ Args:
114
+ line: Line of text to add to the buffer
115
+ """
116
+ logger.trace("HeadlessREPL._add_to_buffer() entry")
117
+
118
+ if self.buffer:
119
+ self.buffer += "\n" + line
120
+ else:
121
+ self.buffer = line
122
+
123
+ logger.debug(f"Added line to buffer, total length: {len(self.buffer)}")
124
+ logger.trace("HeadlessREPL._add_to_buffer() exit")
125
+
126
+ async def _execute_send(self, backend: AsyncBackend, context_info: str):
127
+ """
128
+ Execute /send command - send buffer to backend and wait.
129
+
130
+ Args:
131
+ backend: Backend to send buffer content to
132
+ context_info: Context information for logging (e.g., "line 5", "EOF")
133
+ """
134
+ logger.trace("HeadlessREPL._execute_send() entry")
135
+
136
+ buffer_content = self.buffer.strip()
137
+
138
+ if not buffer_content:
139
+ logger.debug(f"Send #{self.send_count + 1} at {context_info}: empty buffer, skipping")
140
+ logger.trace("HeadlessREPL._execute_send() exit - empty buffer")
141
+ return
142
+
143
+ self.send_count += 1
144
+ logger.info(f"Send #{self.send_count} at {context_info}: sending {len(buffer_content)} characters")
145
+
146
+ try:
147
+ # Send to backend and wait for completion
148
+ success = await backend.handle_input(buffer_content)
149
+
150
+ if success:
151
+ logger.info(f"Send #{self.send_count} completed successfully")
152
+ else:
153
+ logger.warning(f"Send #{self.send_count} completed with backend reporting failure")
154
+ self.total_success = False
155
+
156
+ # Clear buffer after send (successful or not)
157
+ self.buffer = ""
158
+
159
+ except Exception as e:
160
+ logger.error(f"Send #{self.send_count} failed with exception: {e}")
161
+ self.total_success = False
162
+ # Clear buffer even on exception to continue processing
163
+ self.buffer = ""
164
+
165
+ logger.trace("HeadlessREPL._execute_send() exit")
166
+
167
+ def _execute_command(self, command: str):
168
+ """
169
+ Execute a command through the action system.
170
+
171
+ Args:
172
+ command: Command string to execute (e.g., "/help", "/shell ls")
173
+ """
174
+ logger.trace("HeadlessREPL._execute_command() entry")
175
+
176
+ try:
177
+ self.action_registry.handle_command(command, headless_mode=True, buffer=self.buffer)
178
+
179
+ except Exception as e:
180
+ logger.error(f"Error executing command '{command}': {e}")
181
+ # Don't fail entire process for command errors
182
+
183
+ logger.trace("HeadlessREPL._execute_command() exit")
184
+
185
+ async def _handle_eof(self, backend: AsyncBackend):
186
+ """
187
+ Handle end of stdin - send remaining buffer if not empty.
188
+
189
+ Args:
190
+ backend: Backend to send remaining buffer content to
191
+ """
192
+ logger.trace("HeadlessREPL._handle_eof() entry")
193
+
194
+ buffer_content = self.buffer.strip()
195
+
196
+ if buffer_content:
197
+ logger.info("EOF reached with non-empty buffer, triggering final send")
198
+ await self._execute_send(backend, "EOF")
199
+ else:
200
+ logger.debug("EOF reached with empty buffer")
201
+
202
+ logger.trace("HeadlessREPL._handle_eof() exit")
203
+
204
+ async def run_headless_mode(
205
+ backend: AsyncBackend,
206
+ action_registry: Optional[ActionHandler] = None,
207
+ initial_message: Optional[str] = None,
208
+ ) -> bool:
209
+ """
210
+ Run headless mode reading from stdin with action framework support.
211
+
212
+ Processes an optional initial message, then reads from stdin line by line.
213
+ Content lines are accumulated in a buffer, commands are processed through
214
+ the action system, and /send commands trigger backend processing.
215
+
216
+ Args:
217
+ backend: Backend for processing input
218
+ action_registry: Optional action registry for command processing
219
+ initial_message: Optional message to process before stdin loop
220
+
221
+ Returns:
222
+ bool: True if all send operations succeeded
223
+
224
+ Example:
225
+ # stdin input:
226
+ # Message part 1
227
+ # Message part 2
228
+ # /send # Send parts 1-2, wait for completion
229
+ # Message part 3
230
+ # /help # Process command
231
+ # Message part 4
232
+ # /send # Send parts 3-4, wait for completion
233
+ # Final message
234
+ # ^D # EOF triggers final send
235
+
236
+ success = await run_headless_mode(
237
+ backend=my_backend,
238
+ initial_message="Starting headless session"
239
+ )
240
+ """
241
+ logger.trace("run_headless_mode() entry")
242
+
243
+ headless_repl = HeadlessREPL(action_registry)
244
+ result = await headless_repl.run(backend, initial_message)
245
+
246
+ logger.trace("run_headless_mode() exit")
247
+ return result