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,538 @@
1
+ """
2
+ Action registry for managing commands and keyboard shortcuts.
3
+
4
+ The registry serves as the central hub for registering, organizing, and
5
+ executing actions that can be triggered through multiple interaction methods.
6
+ """
7
+
8
+ import importlib
9
+ from typing import Any, Callable, Dict, List, Optional, Union
10
+ from loguru import logger
11
+
12
+ from .shell import shell_command
13
+
14
+ from ..ptypes import ActionHandler, AsyncBackend
15
+ from .action import Action, ActionContext, ActionError, ActionValidationError, ActionExecutionError
16
+
17
+ class ActionRegistry(ActionHandler):
18
+ """
19
+ Registry for managing both command and keyboard shortcut actions.
20
+
21
+ The registry provides a single point of control for all user-triggered
22
+ actions, whether they originate from typed commands or keyboard shortcuts.
23
+ It handles action registration, validation, execution, and help generation.
24
+
25
+ Example:
26
+ registry = ActionRegistry()
27
+
28
+ # Register an action command and shortcut
29
+ registry.register_action(Action(
30
+ name="show_help",
31
+ description="Show help information",
32
+ category="General",
33
+ handler=help_handler,
34
+ command="/help",
35
+ command_usage="/help [command] - Show help",
36
+ keys="F1",
37
+ keys_description="Show help"
38
+ ))
39
+
40
+ # OR using convenience method
41
+
42
+ registry.register_action(
43
+ name="show_help",
44
+ description="Show help information",
45
+ category="General",
46
+ handler=help_handler,
47
+ command="/help",
48
+ command_usage="/help [command] - Show help",
49
+ keys="F1",
50
+ keys_description="Show help"
51
+ )
52
+
53
+ # Execute by command
54
+ registry.handle_command("/help")
55
+
56
+ # Execute by shortcut (in key binding)
57
+ registry.handle_shortcut("F1", event)
58
+ """
59
+
60
+ def __init__(self):
61
+ """Initialize the action registry with built-in actions."""
62
+ logger.trace("ActionRegistry.__init__() entry")
63
+ self.actions: Dict[str, Action] = {}
64
+ self.command_map: Dict[str, str] = {} # command -> action_name
65
+ self.key_map: Dict[str, str] = {} # key_combo -> action_name
66
+ self.handler_cache: Dict[str, Callable] = {}
67
+ self._backend = None
68
+ # Register built-in actions
69
+ self._register_builtin_actions()
70
+ logger.trace("ActionRegistry.__init__() exit")
71
+
72
+ @property
73
+ def backend(self):
74
+ """The backend property getter."""
75
+ logger.trace("ActionRegistry.backend getter")
76
+ return self._backend
77
+
78
+ @backend.setter
79
+ def backend(self, value):
80
+ """The backend property setter with validation."""
81
+ logger.trace("ActionRegistry.backend setter entry")
82
+ if not isinstance(value, AsyncBackend):
83
+ raise TypeError("Backend must implement AsyncBackend.") # pragma: no cover
84
+ self._backend = value # Set the actual private attribute
85
+ logger.trace("ActionRegistry.backend setter exit")
86
+
87
+
88
+ def _register_builtin_actions(self) -> None:
89
+ """Register essential built-in actions."""
90
+ logger.trace("ActionRegistry._register_builtin_actions() entry")
91
+
92
+ # Help action - Both command and shortcut
93
+ self.register_action(Action(
94
+ name="show_help",
95
+ description="Show help information for all actions or a specific action",
96
+ category="General",
97
+ handler=self._show_help,
98
+ command="/help",
99
+ command_usage="/help [action|command] - Show help for all actions or specific one",
100
+ keys="F1",
101
+ keys_description="Show help"
102
+ ))
103
+
104
+ # List shortcuts action - command only
105
+ self.register_action(Action(
106
+ name="list_shortcuts",
107
+ description="List all available keyboard shortcuts",
108
+ category="General",
109
+ handler=self._list_shortcuts,
110
+ command="/shortcuts",
111
+ command_usage="/shortcuts - List all keyboard shortcuts"
112
+ ))
113
+
114
+ # Exit actions - commands only (main loop handles these)
115
+ self.register_action(Action(
116
+ name="exit_repl",
117
+ description="Exit the REPL application",
118
+ category="Control",
119
+ handler=None, # Handled by main loop
120
+ command="/exit",
121
+ command_usage="/exit - Exit the application"
122
+ ))
123
+
124
+ self.register_action(Action(
125
+ name="quit_repl",
126
+ description="Quit the REPL application",
127
+ category="Control",
128
+ handler=None, # Handled by main loop
129
+ command="/quit",
130
+ command_usage="/quit - Quit the application"
131
+ ))
132
+
133
+ self.register_action(Action(
134
+ name="shell_command",
135
+ description="Drop to shell or execute shell command",
136
+ category="System",
137
+ handler=shell_command,
138
+ command="/shell",
139
+ command_usage="/shell [cmd [args [ ... ] ] ]"
140
+ ))
141
+
142
+ logger.trace("ActionRegistry._register_builtin_actions() exit")
143
+
144
+ def register_action(self, *args, **kwargs) -> None:
145
+ """
146
+ Register an action in the registry.
147
+
148
+ Args:
149
+ action: Action to register
150
+
151
+ Raises:
152
+ ActionValidationError: If action is invalid or conflicts exist
153
+ """
154
+ logger.trace("ActionRegistry.register_action() entry")
155
+
156
+ action = None
157
+ if args and isinstance(args[0], Action):
158
+ action = args[0]
159
+ if kwargs:
160
+ for k, v in kwargs.items():
161
+ setattr(action, k, v)
162
+ else:
163
+ action = Action(**kwargs)
164
+
165
+ # Validate action
166
+ if action.name in self.actions:
167
+ raise ActionValidationError(f"Action '{action.name}' already exists") # pragma: no cover
168
+
169
+ if action.command and action.command in self.command_map:
170
+ existing_action = self.command_map[action.command]
171
+ raise ActionValidationError( # pragma: no cover
172
+ f"Command '{action.command}' already bound to action '{existing_action}'"
173
+ )
174
+
175
+ # Check for key conflicts
176
+ for key_combo in action.get_keys_list():
177
+ if key_combo in self.key_map:
178
+ existing_action = self.key_map[key_combo]
179
+ raise ActionValidationError( # pragma: no cover
180
+ f"Key '{key_combo}' already bound to action '{existing_action}'"
181
+ )
182
+
183
+ # Register action
184
+ self.actions[action.name] = action
185
+
186
+ # Register command mapping
187
+ if action.command:
188
+ self.command_map[action.command] = action.name
189
+
190
+ # Register key mappings
191
+ for key_combo in action.get_keys_list():
192
+ self.key_map[key_combo] = action.name
193
+
194
+ logger.debug(f"Registered action '{action.name}' with command='{action.command}' keys={action.keys}")
195
+ logger.trace("ActionRegistry.register_action() exit")
196
+
197
+ def get_action(self, name: str) -> Optional[Action]:
198
+ """Get an action by name."""
199
+ logger.trace("ActionRegistry.get_action() entry")
200
+ result = self.actions.get(name)
201
+ logger.trace("ActionRegistry.get_action() exit")
202
+ return result
203
+
204
+ def get_action_by_command(self, command: str) -> Optional[Action]:
205
+ """Get an action by its command string."""
206
+ logger.trace("ActionRegistry.get_action_by_command() entry")
207
+ action_name = self.command_map.get(command)
208
+ result = self.actions.get(action_name) if action_name else None
209
+ logger.trace("ActionRegistry.get_action_by_command() exit")
210
+ return result
211
+
212
+ def get_action_by_keys(self, keys: str) -> Optional[Action]:
213
+ """Get an action by its key combination."""
214
+ logger.trace("ActionRegistry.get_action_by_keys() entry")
215
+ action_name = self.key_map.get(keys)
216
+ result = self.actions.get(action_name) if action_name else None
217
+ logger.trace("ActionRegistry.get_action_by_keys() exit")
218
+ return result
219
+
220
+ def _resolve_handler(self, action: Action) -> Optional[Callable]:
221
+ """
222
+ Resolve action handler to a callable function.
223
+
224
+ Args:
225
+ action: Action whose handler to resolve
226
+
227
+ Returns:
228
+ Callable handler function or None for main-loop actions
229
+ """
230
+ logger.trace("ActionRegistry._resolve_handler() entry")
231
+
232
+ if action.handler is None:
233
+ logger.trace("ActionRegistry._resolve_handler() exit - None handler")
234
+ return None
235
+
236
+ # Check cache first
237
+ cache_key = f"{action.name}:{action.handler}"
238
+ if cache_key in self.handler_cache:
239
+ logger.trace("ActionRegistry._resolve_handler() exit - cached")
240
+ return self.handler_cache[cache_key]
241
+
242
+ # If already callable, use it
243
+ if callable(action.handler):
244
+ self.handler_cache[cache_key] = action.handler
245
+ logger.trace("ActionRegistry._resolve_handler() exit - callable")
246
+ return action.handler
247
+
248
+ # If string, try to import
249
+ if isinstance(action.handler, str):
250
+ try:
251
+ module_path, func_name = action.handler.rsplit('.', 1)
252
+ module = importlib.import_module(module_path)
253
+ handler_func = getattr(module, func_name)
254
+ self.handler_cache[cache_key] = handler_func
255
+ logger.trace("ActionRegistry._resolve_handler() exit - imported")
256
+ return handler_func
257
+ except Exception as e: # pragma: no cover
258
+ logger.error(f"Failed to import handler '{action.handler}' for action '{action.name}': {e}") # pragma: no cover
259
+ raise ActionValidationError(f"Cannot resolve handler '{action.handler}'") # pragma: no cover
260
+
261
+ raise ActionValidationError(f"Invalid handler type for action '{action.name}': {type(action.handler)}") # pragma: no cover
262
+
263
+ def execute_action(self, action_name: str, context: ActionContext) -> None:
264
+ """
265
+ Execute an action by name.
266
+
267
+ Args:
268
+ action_name: Name of action to execute
269
+ context: Action context
270
+
271
+ Raises:
272
+ ActionError: If action is not found or execution fails
273
+ """
274
+ logger.trace("ActionRegistry.execute_action() entry")
275
+
276
+ action = self.get_action(action_name)
277
+ if not action:
278
+ raise ActionError(f"Action '{action_name}' not found") # pragma: no cover
279
+
280
+ if not action.enabled:
281
+ logger.debug(f"Action '{action_name}' is disabled")
282
+ logger.trace("ActionRegistry.execute_action() exit - disabled")
283
+ return
284
+
285
+ # Resolve handler
286
+ handler = self._resolve_handler(action)
287
+ if handler is None:
288
+ # Main loop actions (like exit/quit) return without execution
289
+ logger.debug(f"Action '{action_name}' handled by main loop")
290
+ logger.trace("ActionRegistry.execute_action() exit - main loop")
291
+ return
292
+
293
+ try:
294
+ logger.debug(f"Executing action '{action_name}' via {context.triggered_by}")
295
+
296
+ # Execute handler synchronously
297
+ # If handler needs async operations, it can handle them internally
298
+ handler(context)
299
+ logger.trace("ActionRegistry.execute_action() exit - success")
300
+
301
+ except Exception as e: # pragma: no cover
302
+ logger.error(f"Error executing action '{action_name}': {e}") # pragma: no cover
303
+ logger.trace("ActionRegistry.execute_action() exit - exception")
304
+ raise ActionExecutionError(f"Failed to execute action '{action_name}': {e}", action_name) # pragma: no cover
305
+
306
+ def handle_command(self, command_string: str, **kwargs) -> None:
307
+ """
308
+ Handle a command string by mapping to appropriate action.
309
+
310
+ Args:
311
+ command_string: Full command string (e.g., '/help topic')
312
+ """
313
+ logger.trace("ActionRegistry.handle_command() entry")
314
+ logger.debug(f"Handling command: {command_string}")
315
+
316
+ # Parse command and arguments
317
+ parts = command_string.strip().split()
318
+ if not parts:
319
+ logger.trace("ActionRegistry.handle_command() exit - no parts")
320
+ return
321
+
322
+ command = parts[0]
323
+ args = parts[1:]
324
+
325
+ # Ensure command starts with /
326
+ if not command.startswith('/'):
327
+ command = f'/{command}'
328
+
329
+ # Look up action
330
+ action = self.get_action_by_command(command)
331
+ if not action:
332
+ print(f"Unknown command: {command}")
333
+ print("Use /help to see available commands.")
334
+ logger.trace("ActionRegistry.handle_command() exit - unknown command")
335
+ return
336
+
337
+ # Create context and execute
338
+ context = ActionContext(
339
+ registry=self,
340
+ args=args,
341
+ backend=self.backend,
342
+ triggered_by="command",
343
+ user_input=command_string
344
+ )
345
+
346
+ vars(context).update(kwargs)
347
+
348
+ try:
349
+ self.execute_action(action.name, context)
350
+ logger.trace("ActionRegistry.handle_command() exit - success")
351
+ except ActionError as e: # pragma: no cover
352
+ print(f"Error: {e}")
353
+ logger.trace("ActionRegistry.handle_command() exit - action error")
354
+ except Exception as e: # pragma: no cover
355
+ logger.error(f"Unexpected error handling command '{command_string}': {e}") # pragma: no cover
356
+ print(f"An unexpected error occurred: {e}")
357
+ logger.trace("ActionRegistry.handle_command() exit - unexpected error")
358
+
359
+ def handle_shortcut(self, key_combo: str, event: Any, **kwargs) -> None:
360
+ """
361
+ Handle a keyboard shortcut by mapping to appropriate action.
362
+
363
+ Args:
364
+ key_combo: Key combination string
365
+ event: Key press event from prompt_toolkit
366
+ """
367
+ logger.trace("ActionRegistry.handle_shortcut() entry")
368
+ logger.debug(f"Handling shortcut: {key_combo}")
369
+
370
+ # Look up action
371
+ action = self.get_action_by_keys(key_combo)
372
+ if not action:
373
+ logger.debug(f"No action bound to key combination: {key_combo}")
374
+ logger.trace("ActionRegistry.handle_shortcut() exit - no action")
375
+ return
376
+
377
+ # Create context and execute
378
+ context = ActionContext(
379
+ registry=self,
380
+ backend=self.backend,
381
+ event=event,
382
+ triggered_by="shortcut"
383
+ )
384
+
385
+ vars(context).update(kwargs)
386
+
387
+ try:
388
+ self.execute_action(action.name, context)
389
+ logger.trace("ActionRegistry.handle_shortcut() exit - success")
390
+ except ActionError as e: # pragma: no cover
391
+ print(f"Error: {e}")
392
+ logger.trace("ActionRegistry.handle_shortcut() exit - action error")
393
+ except Exception as e: # pragma: no cover
394
+ logger.error(f"Unexpected error handling shortcut '{key_combo}': {e}") # pragma: no cover
395
+ print(f"An unexpected error occurred: {e}")
396
+ logger.trace("ActionRegistry.handle_shortcut() exit - unexpected error")
397
+
398
+ # ActionHandler protocol implementation
399
+ def validate_action(self, action_name: str) -> bool:
400
+ """Validate if an action is supported."""
401
+ logger.trace("ActionRegistry.validate_action() entry/exit")
402
+ return action_name in self.actions
403
+
404
+ def list_actions(self) -> List[str]:
405
+ """Return a list of all available action names."""
406
+ logger.trace("ActionRegistry.list_actions() entry/exit")
407
+ return list(self.actions.keys())
408
+
409
+ def list_commands(self) -> List[str]:
410
+ """Return a list of all available commands."""
411
+ logger.trace("ActionRegistry.list_commands() entry/exit")
412
+ return list(self.command_map.keys())
413
+
414
+ def list_shortcuts(self) -> List[str]:
415
+ """Return a list of all available keyboard shortcuts."""
416
+ logger.trace("ActionRegistry.list_shortcuts() entry/exit")
417
+ return list(self.key_map.keys())
418
+
419
+ def get_actions_by_category(self) -> Dict[str, List[Action]]:
420
+ """Get actions organized by category."""
421
+ logger.trace("ActionRegistry.get_actions_by_category() entry")
422
+ categories = {}
423
+ for action in self.actions.values():
424
+ if action.hidden:
425
+ continue
426
+ if action.category not in categories:
427
+ categories[action.category] = []
428
+ categories[action.category].append(action)
429
+ logger.trace("ActionRegistry.get_actions_by_category() exit")
430
+ return categories
431
+
432
+ # Built-in action handlers
433
+ def _show_help(self, context: ActionContext) -> None:
434
+ """Show help information."""
435
+ logger.trace("ActionRegistry._show_help() entry")
436
+
437
+ if context.args and len(context.args) > 0:
438
+ # Show help for specific action or command
439
+ target = context.args[0]
440
+
441
+ # Try as action name first
442
+ action = self.get_action(target)
443
+ if not action:
444
+ # Try as command (add / if missing)
445
+ if not target.startswith('/'):
446
+ target = f'/{target}'
447
+ action = self.get_action_by_command(target)
448
+
449
+ if action:
450
+ self._show_action_help(action)
451
+ else: # pragma: no cover
452
+ print(f"No help available for: {context.args[0]}")
453
+ else:
454
+ # Show general help
455
+ self._show_general_help()
456
+
457
+ logger.trace("ActionRegistry._show_help() exit")
458
+
459
+ def _show_action_help(self, action: Action) -> None:
460
+ """Show detailed help for a specific action."""
461
+ logger.trace("ActionRegistry._show_action_help() entry")
462
+ # pragma: no cover
463
+ print(f"\n{action.description}") # pragma: no cover
464
+ print(f"Category: {action.category}")
465
+
466
+ if action.command: # pragma: no cover
467
+ print(f"Command: {action.command_usage or action.command}")
468
+
469
+ if action.keys:
470
+ keys_str = ", ".join(action.get_keys_list())
471
+ desc = f" - {action.keys_description}" if action.keys_description else "" # pragma: no cover
472
+ print(f"Shortcut: {keys_str}{desc}")
473
+
474
+ if not action.enabled: # pragma: no cover
475
+ print("Status: Disabled") # pragma: no cover
476
+ print()
477
+
478
+ logger.trace("ActionRegistry._show_action_help() exit")
479
+
480
+ def _show_general_help(self) -> None:
481
+ """Show general help with all actions organized by category."""
482
+ logger.trace("ActionRegistry._show_general_help() entry")
483
+ # pragma: no cover
484
+ print("\nAvailable Actions:") # pragma: no cover
485
+ print("=" * 50)
486
+
487
+ categories = self.get_actions_by_category()
488
+
489
+ for category, actions in sorted(categories.items()): # pragma: no cover
490
+ print(f"\n{category}:")
491
+ for action in sorted(actions, key=lambda a: a.name):
492
+ # Format display line
493
+ parts = []
494
+
495
+ if action.command:
496
+ parts.append(f"{action.command:<20}")
497
+ else:
498
+ parts.append(" " * 20)
499
+
500
+ if action.keys:
501
+ keys_str = ", ".join(action.get_keys_list())
502
+ parts.append(f"{keys_str:<15}")
503
+ else:
504
+ parts.append(" " * 15)
505
+
506
+ parts.append(action.description)
507
+ # pragma: no cover
508
+ print(" " + "".join(parts))
509
+ # pragma: no cover
510
+ print(f"\nUse '/help <command>' for detailed information about a specific action.") # pragma: no cover
511
+ print(f"Use '/shortcuts' to see only keyboard shortcuts.") # pragma: no cover
512
+ print()
513
+
514
+ logger.trace("ActionRegistry._show_general_help() exit")
515
+
516
+ def _list_shortcuts(self, context: ActionContext) -> None:
517
+ """List all keyboard shortcuts."""
518
+ logger.trace("ActionRegistry._list_shortcuts() entry")
519
+ # pragma: no cover
520
+ print("\nKeyboard Shortcuts:") # pragma: no cover
521
+ print("=" * 50)
522
+
523
+ categories = self.get_actions_by_category()
524
+
525
+ for category, actions in sorted(categories.items()):
526
+ shortcuts_in_category = [a for a in actions if a.keys]
527
+ if not shortcuts_in_category:
528
+ continue
529
+ # pragma: no cover
530
+ print(f"\n{category}:")
531
+ for action in sorted(shortcuts_in_category, key=lambda a: a.name):
532
+ keys_str = ", ".join(action.get_keys_list())
533
+ desc = action.keys_description or action.description # pragma: no cover
534
+ print(f" {keys_str:<15} {desc}")
535
+ # pragma: no cover
536
+ print()
537
+
538
+ logger.trace("ActionRegistry._list_shortcuts() exit")
@@ -0,0 +1,62 @@
1
+ import os
2
+ import subprocess
3
+
4
+ from loguru import logger
5
+
6
+ from .action import ActionContext
7
+
8
+ def shell_command(context: ActionContext) -> None: # pragma: no cover
9
+ """Built-in shell command handler."""
10
+ logger.trace("shell_command() entry")
11
+
12
+ if not context.args:
13
+ # No arguments - drop to interactive shell
14
+ logger.debug("Dropping to interactive shell. Type 'exit' to return to REPL.")
15
+ shell = os.environ.get('SHELL', '/bin/sh' if os.name != 'nt' else 'cmd')
16
+ subprocess.run(shell, check=False)
17
+ logger.debug("Returned from shell.")
18
+ else:
19
+ # Execute shell command and add output to input buffer
20
+ command_args = context.args
21
+ command_str = ' '.join(command_args)
22
+
23
+ logger.debug(f"Executing: {command_str}")
24
+ try:
25
+ result = subprocess.run(
26
+ command_args,
27
+ capture_output=True,
28
+ text=True,
29
+ timeout=30
30
+ )
31
+
32
+ if result.stdout:
33
+ # Add output directly to prompt_toolkit input buffer
34
+ from prompt_toolkit.application import get_app
35
+ app = get_app()
36
+ current_buffer = app.current_buffer
37
+
38
+ logger.debug(f"Current buffer before adding output: {current_buffer.text!r}")
39
+
40
+ output_text = result.stdout.strip()
41
+ logger.debug(f"Output_text: {output_text!r}")
42
+ current_buffer.insert_text(f"{output_text}")
43
+
44
+ # Force the application to redraw to show the inserted text
45
+ app.invalidate()
46
+
47
+ logger.debug(f"Output added to input buffer and display invalidated")
48
+
49
+ if result.stderr:
50
+ print("Error output:", result.stderr)
51
+
52
+ if result.returncode != 0:
53
+ logger.warning(f"Command exited with code: {result.returncode}")
54
+
55
+ except subprocess.TimeoutExpired:
56
+ logger.warning("Command timed out after 30 seconds.")
57
+ except FileNotFoundError:
58
+ logger.warning(f"Command not found: {command_args[0]}")
59
+ except Exception as e:
60
+ logger.warning(f"Error executing command: {e}")
61
+
62
+ logger.trace("shell_command() exit")