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.

repl_toolkit/ptypes.py ADDED
@@ -0,0 +1,119 @@
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 Protocol, runtime_checkable, Optional, List
9
+ from pathlib import Path
10
+
11
+
12
+ @runtime_checkable
13
+ class AsyncBackend(Protocol):
14
+ """
15
+ Protocol for async backends that process user input.
16
+
17
+ Backends are responsible for handling user input and generating responses
18
+ in an asynchronous manner, supporting cancellation and error handling.
19
+ """
20
+
21
+ async def handle_input(self, user_input: str) -> bool:
22
+ """
23
+ Handle user input asynchronously.
24
+
25
+ Args:
26
+ user_input: The input string from the user
27
+
28
+ Returns:
29
+ bool: True if processing was successful, False if there was an error
30
+
31
+ Note:
32
+ This method should handle its own error reporting to the user.
33
+ The return value indicates success/failure for flow control.
34
+ """
35
+ ...
36
+
37
+ @runtime_checkable
38
+ class ActionHandler(Protocol):
39
+ """
40
+ Protocol for action handlers in the action system.
41
+
42
+ ActionHandler defines the interface for handling both command-based
43
+ and keyboard shortcut-based actions in a coherent manner.
44
+ """
45
+
46
+ def execute_action(self, action_name: str, context: "ActionContext") -> None:
47
+ """
48
+ Execute an action by name.
49
+
50
+ Args:
51
+ action_name: Name of the action to execute
52
+ context: Action context containing relevant information
53
+
54
+ Raises:
55
+ ActionError: If action execution fails
56
+ """
57
+ ...
58
+
59
+ def handle_command(self, command_string: str, **kwargs) -> None:
60
+ """
61
+ Handle a command string by mapping to appropriate action.
62
+
63
+ Args:
64
+ command_string: Full command string (e.g., '/help arg1 arg2')
65
+ **kwargs: Additional context parameters (e.g., headless_mode, buffer)
66
+
67
+ Note:
68
+ This method parses the command and maps it to the appropriate
69
+ action execution with proper context.
70
+ """
71
+ ...
72
+
73
+ def validate_action(self, action_name: str) -> bool:
74
+ """
75
+ Validate if an action is supported.
76
+
77
+ Args:
78
+ action_name: Action name to validate
79
+
80
+ Returns:
81
+ bool: True if action is supported, False otherwise
82
+ """
83
+ ...
84
+
85
+ def list_actions(self) -> List[str]:
86
+ """
87
+ Return a list of all available action names.
88
+
89
+ Returns:
90
+ List of action names
91
+ """
92
+ ...
93
+
94
+
95
+ @runtime_checkable
96
+ class Completer(Protocol):
97
+ """
98
+ Protocol for auto-completion providers.
99
+
100
+ Completers provide tab-completion suggestions for user input,
101
+ supporting both command completion and context-aware suggestions.
102
+ """
103
+
104
+ def get_completions(self, document, complete_event):
105
+ """
106
+ Get completions for the current input.
107
+
108
+ Args:
109
+ document: Current document state from prompt_toolkit
110
+ complete_event: Completion event from prompt_toolkit
111
+
112
+ Yields:
113
+ Completion: Individual completion suggestions
114
+
115
+ Note:
116
+ This follows the prompt_toolkit Completer interface for
117
+ compatibility with the underlying prompt_toolkit system.
118
+ """
119
+ ...
@@ -0,0 +1,5 @@
1
+ """
2
+ Test suite for repl_toolkit.
3
+
4
+ Comprehensive tests for the action system and REPL functionality.
5
+ """
@@ -0,0 +1,453 @@
1
+ """
2
+ Tests for the action system.
3
+ """
4
+
5
+ import pytest
6
+ from unittest.mock import Mock
7
+
8
+ from repl_toolkit.actions import Action, ActionContext, ActionRegistry
9
+ from repl_toolkit.actions import ActionError, ActionValidationError, ActionExecutionError
10
+
11
+
12
+ class TestAction:
13
+ """Test Action dataclass functionality."""
14
+
15
+ def test_action_creation_minimal(self):
16
+ """Test creating action with minimal required parameters."""
17
+ action = Action(
18
+ name="test_action",
19
+ description="Test action",
20
+ category="Test",
21
+ handler=lambda ctx: None,
22
+ command="/test"
23
+ )
24
+
25
+ assert action.name == "test_action"
26
+ assert action.description == "Test action"
27
+ assert action.category == "Test"
28
+ assert action.command == "/test"
29
+ assert action.has_command
30
+ assert not action.has_shortcut
31
+
32
+ def test_action_creation_full(self):
33
+ """Test creating action with all parameters."""
34
+ handler = lambda ctx: None
35
+
36
+ action = Action(
37
+ name="full_action",
38
+ description="Full test action",
39
+ category="Test",
40
+ handler=handler,
41
+ command="/full",
42
+ command_usage="/full [args] - Full test command",
43
+ keys="F2",
44
+ keys_description="Full test shortcut",
45
+ enabled=True,
46
+ context="test_context"
47
+ )
48
+
49
+ assert action.name == "full_action"
50
+ assert action.has_command
51
+ assert action.has_shortcut
52
+ assert action.enabled
53
+ assert action.context == "test_context"
54
+
55
+ def test_action_validation_errors(self):
56
+ """Test action validation failures."""
57
+ # Empty name
58
+ with pytest.raises(ValueError, match="Action name cannot be empty"):
59
+ Action(name="", description="Test", category="Test", handler=lambda ctx: None, command="/test")
60
+
61
+ # Empty description
62
+ with pytest.raises(ValueError, match="Action description cannot be empty"):
63
+ Action(name="test", description="", category="Test", handler=lambda ctx: None, command="/test")
64
+
65
+ # No command or keys
66
+ with pytest.raises(ValueError, match="Action must have either command or keys binding"):
67
+ Action(name="test", description="Test", category="Test", handler=lambda ctx: None)
68
+
69
+ # Invalid command format
70
+ with pytest.raises(ValueError, match="Command 'test' must start with '/'"):
71
+ Action(name="test", description="Test", category="Test", handler=lambda ctx: None, command="test")
72
+
73
+ def test_keys_list_handling(self):
74
+ """Test handling of keys as string vs list."""
75
+ # Single key as string
76
+ action1 = Action(
77
+ name="test1", description="Test", category="Test",
78
+ handler=lambda ctx: None, keys="F1"
79
+ )
80
+ assert action1.get_keys_list() == ["F1"]
81
+
82
+ # Multiple keys as list
83
+ action2 = Action(
84
+ name="test2", description="Test", category="Test",
85
+ handler=lambda ctx: None, keys=["F1", "ctrl-h"]
86
+ )
87
+ assert action2.get_keys_list() == ["F1", "ctrl-h"]
88
+
89
+ # No keys
90
+ action3 = Action(
91
+ name="test3", description="Test", category="Test",
92
+ handler=lambda ctx: None, command="/test"
93
+ )
94
+ assert action3.get_keys_list() == []
95
+
96
+
97
+ class TestActionContext:
98
+ """Test ActionContext functionality."""
99
+
100
+ def test_context_creation(self):
101
+ """Test action context creation."""
102
+ registry = Mock()
103
+ backend = Mock()
104
+
105
+ context = ActionContext(
106
+ registry=registry,
107
+ backend=backend,
108
+ args=["arg1", "arg2"],
109
+ triggered_by="command"
110
+ )
111
+
112
+ assert context.registry is registry
113
+ assert context.backend is backend
114
+ assert context.args == ["arg1", "arg2"]
115
+ assert context.triggered_by == "command"
116
+
117
+ def test_context_triggered_by_detection(self):
118
+ """Test automatic triggered_by detection."""
119
+ registry = Mock()
120
+
121
+ # Should detect shortcut from event
122
+ context1 = ActionContext(registry=registry, event=Mock())
123
+ assert context1.triggered_by == "shortcut"
124
+
125
+ # Should detect command from args
126
+ context2 = ActionContext(registry=registry, args=["arg"])
127
+ assert context2.triggered_by == "command"
128
+
129
+ # Should detect command from user_input
130
+ context3 = ActionContext(registry=registry, user_input="/test")
131
+ assert context3.triggered_by == "command"
132
+
133
+ # Should default to programmatic
134
+ context4 = ActionContext(registry=registry)
135
+ assert context4.triggered_by == "programmatic"
136
+
137
+
138
+ class TestActionRegistry:
139
+ """Test ActionRegistry functionality."""
140
+
141
+ def setup_method(self):
142
+ """Set up test registry."""
143
+ self.registry = ActionRegistry()
144
+
145
+ def test_registry_initialization(self):
146
+ """Test registry initializes with built-in actions."""
147
+ assert len(self.registry.actions) > 0
148
+ assert "show_help" in self.registry.actions
149
+ assert "/help" in self.registry.command_map
150
+ assert "F1" in self.registry.key_map
151
+
152
+ def test_register_action(self):
153
+ """Test action registration."""
154
+ action = Action(
155
+ name="test_action",
156
+ description="Test action",
157
+ category="Test",
158
+ handler=lambda ctx: None,
159
+ command="/test",
160
+ keys="F10"
161
+ )
162
+
163
+ self.registry.register_action(action)
164
+
165
+ assert "test_action" in self.registry.actions
166
+ assert "/test" in self.registry.command_map
167
+ assert "F10" in self.registry.key_map
168
+ assert self.registry.command_map["/test"] == "test_action"
169
+ assert self.registry.key_map["F10"] == "test_action"
170
+
171
+ def test_register_action_conflicts(self):
172
+ """Test action registration conflict detection."""
173
+ action1 = Action(
174
+ name="action1", description="Test", category="Test",
175
+ handler=lambda ctx: None, command="/test"
176
+ )
177
+ action2 = Action(
178
+ name="action2", description="Test", category="Test",
179
+ handler=lambda ctx: None, command="/test" # Same command
180
+ )
181
+
182
+ self.registry.register_action(action1)
183
+
184
+ with pytest.raises(ActionValidationError, match="Command '/test' already bound"):
185
+ self.registry.register_action(action2)
186
+
187
+ def test_convenience_registration_methods(self):
188
+ """Test convenience registration methods."""
189
+ # Test action registration with both command and keys
190
+ self.registry.register_action(
191
+ name="both_test",
192
+ description="Both test",
193
+ category="Test",
194
+ handler=lambda ctx: None,
195
+ command="/both",
196
+ keys="F11"
197
+ )
198
+
199
+ assert "both_test" in self.registry.actions
200
+ assert "/both" in self.registry.command_map
201
+ assert "F11" in self.registry.key_map
202
+
203
+ # Test command-only registration
204
+ self.registry.register_action(
205
+ name="cmd_test",
206
+ command="/cmdonly",
207
+ description="Command only",
208
+ category="Test",
209
+ handler=lambda ctx: None
210
+ )
211
+
212
+ action = self.registry.get_action("cmd_test")
213
+ assert action.has_command
214
+ assert not action.has_shortcut
215
+
216
+ # Test shortcut-only registration
217
+ self.registry.register_action(
218
+ name="key_test",
219
+ keys="F12",
220
+ description="Key only",
221
+ category="Test",
222
+ handler=lambda ctx: None
223
+ )
224
+
225
+ action = self.registry.get_action("key_test")
226
+ assert not action.has_command
227
+ assert action.has_shortcut
228
+
229
+ def test_action_lookup_methods(self):
230
+ """Test action lookup methods."""
231
+ action = Action(
232
+ name="lookup_test",
233
+ description="Lookup test",
234
+ category="Test",
235
+ handler=lambda ctx: None,
236
+ command="/lookup",
237
+ keys="ctrl-l"
238
+ )
239
+
240
+ self.registry.register_action(action)
241
+
242
+ # Test lookup by name
243
+ found = self.registry.get_action("lookup_test")
244
+ assert found is action
245
+
246
+ # Test lookup by command
247
+ found = self.registry.get_action_by_command("/lookup")
248
+ assert found is action
249
+
250
+ # Test lookup by keys
251
+ found = self.registry.get_action_by_keys("ctrl-l")
252
+ assert found is action
253
+
254
+ # Test not found cases
255
+ assert self.registry.get_action("nonexistent") is None
256
+ assert self.registry.get_action_by_command("/nonexistent") is None
257
+ assert self.registry.get_action_by_keys("nonexistent") is None
258
+
259
+ def test_execute_action(self):
260
+ """Test action execution."""
261
+ executed = []
262
+
263
+ def test_handler(context):
264
+ executed.append(context.triggered_by)
265
+
266
+ action = Action(
267
+ name="exec_test",
268
+ description="Execution test",
269
+ category="Test",
270
+ handler=test_handler,
271
+ command="/exec"
272
+ )
273
+
274
+ self.registry.register_action(action)
275
+
276
+ context = ActionContext(
277
+ registry=self.registry,
278
+ triggered_by="test"
279
+ )
280
+
281
+ self.registry.execute_action("exec_test", context)
282
+ assert executed == ["test"]
283
+
284
+ def test_execute_nonexistent_action(self):
285
+ """Test executing nonexistent action."""
286
+ context = ActionContext(registry=self.registry)
287
+
288
+ with pytest.raises(ActionError, match="Action 'nonexistent' not found"):
289
+ self.registry.execute_action("nonexistent", context)
290
+
291
+ def test_execute_disabled_action(self):
292
+ """Test executing disabled action."""
293
+ action = Action(
294
+ name="disabled_test",
295
+ description="Disabled test",
296
+ category="Test",
297
+ handler=lambda ctx: None,
298
+ command="/disabled",
299
+ enabled=False
300
+ )
301
+
302
+ self.registry.register_action(action)
303
+ context = ActionContext(registry=self.registry)
304
+
305
+ # Should not raise error, but should not execute
306
+ self.registry.execute_action("disabled_test", context)
307
+
308
+ def test_handle_command(self):
309
+ """Test command handling."""
310
+ executed = []
311
+
312
+ def test_handler(context):
313
+ executed.append(context.args)
314
+
315
+ action = Action(
316
+ name="cmd_test",
317
+ description="Command test",
318
+ category="Test",
319
+ handler=test_handler,
320
+ command="/cmdtest"
321
+ )
322
+
323
+ self.registry.register_action(action)
324
+
325
+ self.registry.handle_command("/cmdtest arg1 arg2")
326
+ assert executed == [["arg1", "arg2"]]
327
+
328
+ def test_handle_unknown_command(self):
329
+ """Test handling unknown command."""
330
+ # Should not raise error, just print message
331
+ self.registry.handle_command("/unknown")
332
+
333
+ def test_handle_shortcut(self):
334
+ """Test shortcut handling."""
335
+ executed = []
336
+
337
+ def test_handler(context):
338
+ executed.append(context.event)
339
+
340
+ action = Action(
341
+ name="key_test",
342
+ description="Key test",
343
+ category="Test",
344
+ handler=test_handler,
345
+ keys="F5"
346
+ )
347
+
348
+ self.registry.register_action(action)
349
+
350
+ mock_event = Mock()
351
+ self.registry.handle_shortcut("F5", mock_event)
352
+ assert executed == [mock_event]
353
+
354
+ def test_handle_unknown_shortcut(self):
355
+ """Test handling unknown shortcut."""
356
+ # Should not raise error, just log
357
+ self.registry.handle_shortcut("unknown", Mock())
358
+
359
+ def test_list_methods(self):
360
+ """Test list methods."""
361
+ actions = self.registry.list_actions()
362
+ assert isinstance(actions, list)
363
+ assert "show_help" in actions
364
+
365
+ commands = self.registry.list_commands()
366
+ assert isinstance(commands, list)
367
+ assert "/help" in commands
368
+
369
+ shortcuts = self.registry.list_shortcuts()
370
+ assert isinstance(shortcuts, list)
371
+ assert "F1" in shortcuts
372
+
373
+ def test_categories(self):
374
+ """Test category organization."""
375
+ categories = self.registry.get_actions_by_category()
376
+ assert isinstance(categories, dict)
377
+ assert "General" in categories
378
+ assert len(categories["General"]) > 0
379
+
380
+ def test_builtin_help_action(self):
381
+ """Test built-in help action."""
382
+ # Test general help
383
+ context = ActionContext(registry=self.registry, args=[])
384
+ self.registry.execute_action("show_help", context)
385
+
386
+ # Test specific help
387
+ context = ActionContext(registry=self.registry, args=["show_help"])
388
+ self.registry.execute_action("show_help", context)
389
+
390
+ # Test help for nonexistent action
391
+ context = ActionContext(registry=self.registry, args=["nonexistent"])
392
+ self.registry.execute_action("show_help", context)
393
+
394
+ def test_builtin_shortcuts_action(self):
395
+ """Test built-in shortcuts listing action."""
396
+ context = ActionContext(registry=self.registry, args=[])
397
+ self.registry.execute_action("list_shortcuts", context)
398
+
399
+
400
+ class TestActionHandlerProtocol:
401
+ """Test ActionHandler protocol compliance."""
402
+
403
+ def test_protocol_compliance(self):
404
+ """Test that ActionRegistry implements ActionHandler protocol."""
405
+ from repl_toolkit.ptypes import ActionHandler
406
+
407
+ registry = ActionRegistry()
408
+ assert isinstance(registry, ActionHandler)
409
+
410
+ # Test protocol methods
411
+ assert hasattr(registry, 'execute_action')
412
+ assert hasattr(registry, 'handle_command')
413
+ assert hasattr(registry, 'validate_action')
414
+ assert hasattr(registry, 'list_actions')
415
+
416
+ def test_validate_action(self):
417
+ """Test action validation."""
418
+ registry = ActionRegistry()
419
+
420
+ assert registry.validate_action("show_help") # Built-in action
421
+ assert not registry.validate_action("nonexistent")
422
+
423
+ def test_list_actions(self):
424
+ """Test action listing."""
425
+ registry = ActionRegistry()
426
+ actions = registry.list_actions()
427
+
428
+ assert isinstance(actions, list)
429
+ assert len(actions) > 0
430
+ assert "show_help" in actions
431
+
432
+ class TestActionValidationExtended:
433
+ """Additional validation tests for Action."""
434
+
435
+ def test_empty_category_validation(self):
436
+ """Test that empty category raises ValueError."""
437
+ with pytest.raises(ValueError, match="category cannot be empty"):
438
+ Action(
439
+ name="test",
440
+ description="Test",
441
+ category="", # Empty category
442
+ handler=lambda ctx: None
443
+ )
444
+
445
+ def test_empty_string_handler_validation(self):
446
+ """Test that empty string handler raises ValueError."""
447
+ with pytest.raises(ValueError, match="handler cannot be empty string"):
448
+ Action(
449
+ name="test",
450
+ description="Test",
451
+ category="Test",
452
+ handler="" # Empty string handler
453
+ )