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