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.
- repl_toolkit/__init__.py +70 -0
- repl_toolkit/actions/__init__.py +24 -0
- repl_toolkit/actions/action.py +223 -0
- repl_toolkit/actions/registry.py +564 -0
- repl_toolkit/async_repl.py +374 -0
- repl_toolkit/completion/__init__.py +15 -0
- repl_toolkit/completion/prefix.py +109 -0
- repl_toolkit/completion/shell_expansion.py +453 -0
- repl_toolkit/formatting.py +152 -0
- repl_toolkit/headless_repl.py +251 -0
- repl_toolkit/ptypes.py +122 -0
- repl_toolkit/tests/__init__.py +5 -0
- repl_toolkit/tests/conftest.py +79 -0
- repl_toolkit/tests/test_actions.py +578 -0
- repl_toolkit/tests/test_async_repl.py +381 -0
- repl_toolkit/tests/test_completion.py +656 -0
- repl_toolkit/tests/test_formatting.py +232 -0
- repl_toolkit/tests/test_headless.py +677 -0
- repl_toolkit/tests/test_types.py +174 -0
- repl_toolkit-1.2.0.dist-info/METADATA +761 -0
- repl_toolkit-1.2.0.dist-info/RECORD +24 -0
- repl_toolkit-1.2.0.dist-info/WHEEL +5 -0
- repl_toolkit-1.2.0.dist-info/licenses/LICENSE +21 -0
- repl_toolkit-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
from .actions.registry import ActionRegistry
|
|
6
|
+
from .ptypes import ActionHandler, AsyncBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HeadlessREPL:
|
|
10
|
+
"""
|
|
11
|
+
Headless REPL that reads from stdin and processes through action framework.
|
|
12
|
+
|
|
13
|
+
Similar to interactive mode but:
|
|
14
|
+
- Reads from stdin instead of prompt_toolkit
|
|
15
|
+
- Accumulates content in buffer until /send
|
|
16
|
+
- Processes commands through action system
|
|
17
|
+
- Supports multiple /send cycles
|
|
18
|
+
- Auto-sends remaining buffer on EOF
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, action_registry: Optional[ActionHandler] = None):
|
|
22
|
+
"""Initialize headless REPL."""
|
|
23
|
+
logger.trace("HeadlessREPL.__init__() entry")
|
|
24
|
+
|
|
25
|
+
# Simple string buffer for content accumulation
|
|
26
|
+
self.buffer = ""
|
|
27
|
+
self.action_registry = action_registry or ActionRegistry()
|
|
28
|
+
|
|
29
|
+
# State tracking
|
|
30
|
+
self.send_count = 0
|
|
31
|
+
self.total_success = True
|
|
32
|
+
self.running = True
|
|
33
|
+
|
|
34
|
+
logger.trace("HeadlessREPL.__init__() exit")
|
|
35
|
+
|
|
36
|
+
async def run(self, backend: AsyncBackend, initial_message: Optional[str] = None) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Run headless mode with stdin processing.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
backend: Backend for processing input
|
|
42
|
+
initial_message: Optional message to process before stdin loop
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
bool: True if all operations succeeded
|
|
46
|
+
"""
|
|
47
|
+
logger.trace("HeadlessREPL.run() entry")
|
|
48
|
+
|
|
49
|
+
# Set backend in action registry
|
|
50
|
+
self.action_registry.backend = backend # type: ignore[attr-defined]
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Process initial message if provided
|
|
54
|
+
if initial_message:
|
|
55
|
+
logger.info(f"Processing initial message: {initial_message}")
|
|
56
|
+
success = await backend.handle_input(initial_message)
|
|
57
|
+
if not success:
|
|
58
|
+
logger.warning("Initial message processing failed")
|
|
59
|
+
self.total_success = False
|
|
60
|
+
|
|
61
|
+
# Enter stdin processing loop
|
|
62
|
+
await self._stdin_loop(backend)
|
|
63
|
+
|
|
64
|
+
logger.trace("HeadlessREPL.run() exit - success")
|
|
65
|
+
return self.total_success
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error(f"Error in headless processing: {e}")
|
|
69
|
+
logger.trace("HeadlessREPL.run() exit - exception")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
async def _stdin_loop(self, backend: AsyncBackend):
|
|
73
|
+
"""
|
|
74
|
+
Process stdin with synchronous reading, async backend calls.
|
|
75
|
+
|
|
76
|
+
Reads lines from stdin synchronously (blocking), processes commands
|
|
77
|
+
through the action system, and accumulates content in buffer until
|
|
78
|
+
/send commands trigger backend processing.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
backend: Backend for processing accumulated content
|
|
82
|
+
"""
|
|
83
|
+
import sys
|
|
84
|
+
|
|
85
|
+
line_num = 0
|
|
86
|
+
|
|
87
|
+
while True:
|
|
88
|
+
# Simple synchronous readline - blocks until line available
|
|
89
|
+
# This is perfectly fine! We want to wait for the next line.
|
|
90
|
+
line = sys.stdin.readline()
|
|
91
|
+
|
|
92
|
+
if not line: # EOF
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
line_num += 1
|
|
96
|
+
line = line.rstrip("\n\r")
|
|
97
|
+
|
|
98
|
+
if line.startswith("/"):
|
|
99
|
+
if line == "/send":
|
|
100
|
+
await self._execute_send(backend, f"line {line_num}")
|
|
101
|
+
else:
|
|
102
|
+
# Synchronous command processing
|
|
103
|
+
self._execute_command(line)
|
|
104
|
+
else:
|
|
105
|
+
# Synchronous buffer addition
|
|
106
|
+
self._add_to_buffer(line)
|
|
107
|
+
|
|
108
|
+
await self._handle_eof(backend)
|
|
109
|
+
|
|
110
|
+
def _add_to_buffer(self, line: str):
|
|
111
|
+
"""
|
|
112
|
+
Add a line to the buffer.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
line: Line of text to add to the buffer
|
|
116
|
+
"""
|
|
117
|
+
logger.trace("HeadlessREPL._add_to_buffer() entry")
|
|
118
|
+
|
|
119
|
+
if self.buffer:
|
|
120
|
+
self.buffer += "\n" + line
|
|
121
|
+
else:
|
|
122
|
+
self.buffer = line
|
|
123
|
+
|
|
124
|
+
logger.debug(f"Added line to buffer, total length: {len(self.buffer)}")
|
|
125
|
+
logger.trace("HeadlessREPL._add_to_buffer() exit")
|
|
126
|
+
|
|
127
|
+
async def _execute_send(self, backend: AsyncBackend, context_info: str):
|
|
128
|
+
"""
|
|
129
|
+
Execute /send command - send buffer to backend and wait.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
backend: Backend to send buffer content to
|
|
133
|
+
context_info: Context information for logging (e.g., "line 5", "EOF")
|
|
134
|
+
"""
|
|
135
|
+
logger.trace("HeadlessREPL._execute_send() entry")
|
|
136
|
+
|
|
137
|
+
buffer_content = self.buffer.strip()
|
|
138
|
+
|
|
139
|
+
if not buffer_content:
|
|
140
|
+
logger.debug(f"Send #{self.send_count + 1} at {context_info}: empty buffer, skipping")
|
|
141
|
+
logger.trace("HeadlessREPL._execute_send() exit - empty buffer")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
self.send_count += 1
|
|
145
|
+
logger.info(
|
|
146
|
+
f"Send #{self.send_count} at {context_info}: sending {len(buffer_content)} characters"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
# Send to backend and wait for completion
|
|
151
|
+
success = await backend.handle_input(buffer_content)
|
|
152
|
+
|
|
153
|
+
if success:
|
|
154
|
+
logger.info(f"Send #{self.send_count} completed successfully")
|
|
155
|
+
else:
|
|
156
|
+
logger.warning(f"Send #{self.send_count} completed with backend reporting failure")
|
|
157
|
+
self.total_success = False
|
|
158
|
+
|
|
159
|
+
# Clear buffer after send (successful or not)
|
|
160
|
+
self.buffer = ""
|
|
161
|
+
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"Send #{self.send_count} failed with exception: {e}")
|
|
164
|
+
self.total_success = False
|
|
165
|
+
# Clear buffer even on exception to continue processing
|
|
166
|
+
self.buffer = ""
|
|
167
|
+
|
|
168
|
+
logger.trace("HeadlessREPL._execute_send() exit")
|
|
169
|
+
|
|
170
|
+
def _execute_command(self, command: str):
|
|
171
|
+
"""
|
|
172
|
+
Execute a command through the action system.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
command: Command string to execute (e.g., "/help", "/shortcuts")
|
|
176
|
+
"""
|
|
177
|
+
logger.trace("HeadlessREPL._execute_command() entry")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
self.action_registry.handle_command(command, headless_mode=True, buffer=self.buffer)
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error(f"Error executing command '{command}': {e}")
|
|
184
|
+
# Don't fail entire process for command errors
|
|
185
|
+
|
|
186
|
+
logger.trace("HeadlessREPL._execute_command() exit")
|
|
187
|
+
|
|
188
|
+
async def _handle_eof(self, backend: AsyncBackend):
|
|
189
|
+
"""
|
|
190
|
+
Handle end of stdin - send remaining buffer if not empty.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
backend: Backend to send remaining buffer content to
|
|
194
|
+
"""
|
|
195
|
+
logger.trace("HeadlessREPL._handle_eof() entry")
|
|
196
|
+
|
|
197
|
+
buffer_content = self.buffer.strip()
|
|
198
|
+
|
|
199
|
+
if buffer_content:
|
|
200
|
+
logger.info("EOF reached with non-empty buffer, triggering final send")
|
|
201
|
+
await self._execute_send(backend, "EOF")
|
|
202
|
+
else:
|
|
203
|
+
logger.debug("EOF reached with empty buffer")
|
|
204
|
+
|
|
205
|
+
logger.trace("HeadlessREPL._handle_eof() exit")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def run_headless_mode(
|
|
209
|
+
backend: AsyncBackend,
|
|
210
|
+
action_registry: Optional[ActionHandler] = None,
|
|
211
|
+
initial_message: Optional[str] = None,
|
|
212
|
+
) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Run headless mode reading from stdin with action framework support.
|
|
215
|
+
|
|
216
|
+
Processes an optional initial message, then reads from stdin line by line.
|
|
217
|
+
Content lines are accumulated in a buffer, commands are processed through
|
|
218
|
+
the action system, and /send commands trigger backend processing.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
backend: Backend for processing input
|
|
222
|
+
action_registry: Optional action registry for command processing
|
|
223
|
+
initial_message: Optional message to process before stdin loop
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
bool: True if all send operations succeeded
|
|
227
|
+
|
|
228
|
+
Example:
|
|
229
|
+
# stdin input:
|
|
230
|
+
# Message part 1
|
|
231
|
+
# Message part 2
|
|
232
|
+
# /send # Send parts 1-2, wait for completion
|
|
233
|
+
# Message part 3
|
|
234
|
+
# /help # Process command
|
|
235
|
+
# Message part 4
|
|
236
|
+
# /send # Send parts 3-4, wait for completion
|
|
237
|
+
# Final message
|
|
238
|
+
# ^D # EOF triggers final send
|
|
239
|
+
|
|
240
|
+
success = await run_headless_mode(
|
|
241
|
+
backend=my_backend,
|
|
242
|
+
initial_message="Starting headless session"
|
|
243
|
+
)
|
|
244
|
+
"""
|
|
245
|
+
logger.trace("run_headless_mode() entry")
|
|
246
|
+
|
|
247
|
+
headless_repl = HeadlessREPL(action_registry)
|
|
248
|
+
result = await headless_repl.run(backend, initial_message)
|
|
249
|
+
|
|
250
|
+
logger.trace("run_headless_mode() exit")
|
|
251
|
+
return result
|
repl_toolkit/ptypes.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol types for repl_toolkit.
|
|
3
|
+
|
|
4
|
+
Defines the interface contracts that backends and handlers must implement
|
|
5
|
+
for compatibility with the REPL toolkit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, List, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .actions.action import ActionContext # Avoid circular import
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class AsyncBackend(Protocol):
|
|
16
|
+
"""
|
|
17
|
+
Protocol for async backends that process user input.
|
|
18
|
+
|
|
19
|
+
Backends are responsible for handling user input and generating responses
|
|
20
|
+
in an asynchronous manner, supporting cancellation and error handling.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def handle_input(self, user_input: str) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Handle user input asynchronously.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
user_input: The input string from the user
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
bool: True if processing was successful, False if there was an error
|
|
32
|
+
|
|
33
|
+
Note:
|
|
34
|
+
This method should handle its own error reporting to the user.
|
|
35
|
+
The return value indicates success/failure for flow control.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class ActionHandler(Protocol):
|
|
42
|
+
"""
|
|
43
|
+
Protocol for action handlers in the action system.
|
|
44
|
+
|
|
45
|
+
ActionHandler defines the interface for handling both command-based
|
|
46
|
+
and keyboard shortcut-based actions in a coherent manner.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def execute_action(self, action_name: str, context: "ActionContext") -> None:
|
|
50
|
+
"""
|
|
51
|
+
Execute an action by name.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
action_name: Name of the action to execute
|
|
55
|
+
context: Action context containing relevant information
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ActionError: If action execution fails
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def handle_command(self, command_string: str, **kwargs) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Handle a command string by mapping to appropriate action.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
command_string: Full command string (e.g., '/help arg1 arg2')
|
|
68
|
+
**kwargs: Additional context parameters (e.g., headless_mode, buffer)
|
|
69
|
+
|
|
70
|
+
Note:
|
|
71
|
+
This method parses the command and maps it to the appropriate
|
|
72
|
+
action execution with proper context.
|
|
73
|
+
"""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def validate_action(self, action_name: str) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Validate if an action is supported.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
action_name: Action name to validate
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
bool: True if action is supported, False otherwise
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
def list_actions(self) -> List[str]:
|
|
89
|
+
"""
|
|
90
|
+
Return a list of all available action names.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of action names
|
|
94
|
+
"""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@runtime_checkable
|
|
99
|
+
class Completer(Protocol):
|
|
100
|
+
"""
|
|
101
|
+
Protocol for auto-completion providers.
|
|
102
|
+
|
|
103
|
+
Completers provide tab-completion suggestions for user input,
|
|
104
|
+
supporting both command completion and context-aware suggestions.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def get_completions(self, document, complete_event):
|
|
108
|
+
"""
|
|
109
|
+
Get completions for the current input.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
document: Current document state from prompt_toolkit
|
|
113
|
+
complete_event: Completion event from prompt_toolkit
|
|
114
|
+
|
|
115
|
+
Yields:
|
|
116
|
+
Completion: Individual completion suggestions
|
|
117
|
+
|
|
118
|
+
Note:
|
|
119
|
+
This follows the prompt_toolkit Completer interface for
|
|
120
|
+
compatibility with the underlying prompt_toolkit system.
|
|
121
|
+
"""
|
|
122
|
+
...
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pytest configuration and fixtures for repl-toolkit tests.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from unittest.mock import Mock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from prompt_toolkit.input import DummyInput
|
|
10
|
+
from prompt_toolkit.output import DummyOutput
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_terminal_for_repl(monkeypatch):
|
|
15
|
+
"""
|
|
16
|
+
Mock terminal I/O specifically for REPL tests that need it.
|
|
17
|
+
|
|
18
|
+
This fixture should be explicitly requested by tests that create AsyncREPL or
|
|
19
|
+
similar objects that require terminal access.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
def test_something(mock_terminal_for_repl):
|
|
23
|
+
# Terminal is now mocked
|
|
24
|
+
repl = AsyncREPL()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Mock prompt_toolkit's create_output to return DummyOutput
|
|
28
|
+
def mock_create_output(*args, **kwargs):
|
|
29
|
+
return DummyOutput()
|
|
30
|
+
|
|
31
|
+
# Mock prompt_toolkit's create_input to return DummyInput
|
|
32
|
+
def mock_create_input(*args, **kwargs):
|
|
33
|
+
return DummyInput()
|
|
34
|
+
|
|
35
|
+
# Patch the output creation functions
|
|
36
|
+
monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", mock_create_output)
|
|
37
|
+
|
|
38
|
+
# Patch the input creation functions
|
|
39
|
+
monkeypatch.setattr("prompt_toolkit.input.defaults.create_input", mock_create_input)
|
|
40
|
+
|
|
41
|
+
# Also patch platform-specific output classes to prevent initialization errors
|
|
42
|
+
if sys.platform == "win32":
|
|
43
|
+
# Mock Windows-specific components
|
|
44
|
+
monkeypatch.setattr(
|
|
45
|
+
"prompt_toolkit.output.windows10.Windows10_Output.__init__",
|
|
46
|
+
lambda self, *args, **kwargs: None,
|
|
47
|
+
)
|
|
48
|
+
monkeypatch.setattr(
|
|
49
|
+
"prompt_toolkit.output.win32.Win32Output.__init__", lambda self, *args, **kwargs: None
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def dummy_input():
|
|
55
|
+
"""Provide a DummyInput for tests that need to simulate input."""
|
|
56
|
+
return DummyInput()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def dummy_output():
|
|
61
|
+
"""Provide a DummyOutput for tests that need to capture output."""
|
|
62
|
+
return DummyOutput()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.fixture
|
|
66
|
+
def mock_asyncio_event_loop():
|
|
67
|
+
"""Provide a mock event loop for testing async code."""
|
|
68
|
+
import asyncio
|
|
69
|
+
|
|
70
|
+
loop = asyncio.new_event_loop()
|
|
71
|
+
asyncio.set_event_loop(loop)
|
|
72
|
+
|
|
73
|
+
yield loop
|
|
74
|
+
|
|
75
|
+
# Cleanup
|
|
76
|
+
try:
|
|
77
|
+
loop.close()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|