falyx 0.1.29__tar.gz → 0.1.31__tar.gz

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.
Files changed (52) hide show
  1. {falyx-0.1.29 → falyx-0.1.31}/PKG-INFO +1 -1
  2. {falyx-0.1.29 → falyx-0.1.31}/falyx/__init__.py +0 -1
  3. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/action.py +42 -14
  4. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/action_factory.py +8 -1
  5. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/io_action.py +16 -10
  6. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/menu_action.py +3 -0
  7. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/select_file_action.py +30 -9
  8. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/selection_action.py +83 -19
  9. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/types.py +15 -0
  10. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/user_input_action.py +3 -0
  11. {falyx-0.1.29 → falyx-0.1.31}/falyx/command.py +14 -61
  12. {falyx-0.1.29 → falyx-0.1.31}/falyx/config.py +0 -1
  13. {falyx-0.1.29 → falyx-0.1.31}/falyx/falyx.py +38 -46
  14. {falyx-0.1.29 → falyx-0.1.31}/falyx/hook_manager.py +8 -7
  15. {falyx-0.1.29 → falyx-0.1.31}/falyx/menu.py +20 -8
  16. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/__init__.py +0 -4
  17. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/argparse.py +11 -0
  18. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/signature.py +5 -2
  19. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/utils.py +5 -10
  20. {falyx-0.1.29 → falyx-0.1.31}/falyx/selection.py +57 -1
  21. falyx-0.1.31/falyx/version.py +1 -0
  22. {falyx-0.1.29 → falyx-0.1.31}/pyproject.toml +1 -1
  23. falyx-0.1.29/falyx/version.py +0 -1
  24. {falyx-0.1.29 → falyx-0.1.31}/LICENSE +0 -0
  25. {falyx-0.1.29 → falyx-0.1.31}/README.md +0 -0
  26. {falyx-0.1.29 → falyx-0.1.31}/falyx/.pytyped +0 -0
  27. {falyx-0.1.29 → falyx-0.1.31}/falyx/__main__.py +0 -0
  28. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/.pytyped +0 -0
  29. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/__init__.py +0 -0
  30. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/http_action.py +0 -0
  31. {falyx-0.1.29 → falyx-0.1.31}/falyx/action/signal_action.py +0 -0
  32. {falyx-0.1.29 → falyx-0.1.31}/falyx/bottom_bar.py +0 -0
  33. {falyx-0.1.29 → falyx-0.1.31}/falyx/context.py +0 -0
  34. {falyx-0.1.29 → falyx-0.1.31}/falyx/debug.py +0 -0
  35. {falyx-0.1.29 → falyx-0.1.31}/falyx/exceptions.py +0 -0
  36. {falyx-0.1.29 → falyx-0.1.31}/falyx/execution_registry.py +0 -0
  37. {falyx-0.1.29 → falyx-0.1.31}/falyx/hooks.py +0 -0
  38. {falyx-0.1.29 → falyx-0.1.31}/falyx/init.py +0 -0
  39. {falyx-0.1.29 → falyx-0.1.31}/falyx/logger.py +0 -0
  40. {falyx-0.1.29 → falyx-0.1.31}/falyx/options_manager.py +0 -0
  41. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/.pytyped +0 -0
  42. {falyx-0.1.29 → falyx-0.1.31}/falyx/parsers/parsers.py +0 -0
  43. {falyx-0.1.29 → falyx-0.1.31}/falyx/prompt_utils.py +0 -0
  44. {falyx-0.1.29 → falyx-0.1.31}/falyx/protocols.py +0 -0
  45. {falyx-0.1.29 → falyx-0.1.31}/falyx/retry.py +0 -0
  46. {falyx-0.1.29 → falyx-0.1.31}/falyx/retry_utils.py +0 -0
  47. {falyx-0.1.29 → falyx-0.1.31}/falyx/signals.py +0 -0
  48. {falyx-0.1.29 → falyx-0.1.31}/falyx/tagged_table.py +0 -0
  49. {falyx-0.1.29 → falyx-0.1.31}/falyx/themes/__init__.py +0 -0
  50. {falyx-0.1.29 → falyx-0.1.31}/falyx/themes/colors.py +0 -0
  51. {falyx-0.1.29 → falyx-0.1.31}/falyx/utils.py +0 -0
  52. {falyx-0.1.29 → falyx-0.1.31}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.29
3
+ Version: 0.1.31
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -12,7 +12,6 @@ from .command import Command
12
12
  from .context import ExecutionContext, SharedContext
13
13
  from .execution_registry import ExecutionRegistry
14
14
  from .falyx import Falyx
15
- from .hook_manager import HookType
16
15
 
17
16
  logger = logging.getLogger("falyx")
18
17
 
@@ -47,6 +47,7 @@ from falyx.execution_registry import ExecutionRegistry as er
47
47
  from falyx.hook_manager import Hook, HookManager, HookType
48
48
  from falyx.logger import logger
49
49
  from falyx.options_manager import OptionsManager
50
+ from falyx.parsers.utils import same_argument_definitions
50
51
  from falyx.retry import RetryHandler, RetryPolicy
51
52
  from falyx.themes import OneColors
52
53
  from falyx.utils import ensure_async
@@ -61,8 +62,7 @@ class BaseAction(ABC):
61
62
  inject_last_result (bool): Whether to inject the previous action's result
62
63
  into kwargs.
63
64
  inject_into (str): The name of the kwarg key to inject the result as
64
- (default: 'last_result').
65
- _requires_injection (bool): Whether the action requires input injection.
65
+ (default: 'last_result').
66
66
  """
67
67
 
68
68
  def __init__(
@@ -82,7 +82,6 @@ class BaseAction(ABC):
82
82
  self.inject_last_result: bool = inject_last_result
83
83
  self.inject_into: str = inject_into
84
84
  self._never_prompt: bool = never_prompt
85
- self._requires_injection: bool = False
86
85
  self._skip_in_chain: bool = False
87
86
  self.console = Console(color_system="auto")
88
87
  self.options_manager: OptionsManager | None = None
@@ -101,6 +100,14 @@ class BaseAction(ABC):
101
100
  async def preview(self, parent: Tree | None = None):
102
101
  raise NotImplementedError("preview must be implemented by subclasses")
103
102
 
103
+ @abstractmethod
104
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
105
+ """
106
+ Returns the callable to be used for argument inference.
107
+ By default, it returns None.
108
+ """
109
+ raise NotImplementedError("get_infer_target must be implemented by subclasses")
110
+
104
111
  def set_options_manager(self, options_manager: OptionsManager) -> None:
105
112
  self.options_manager = options_manager
106
113
 
@@ -154,10 +161,6 @@ class BaseAction(ABC):
154
161
  async def _write_stdout(self, data: str) -> None:
155
162
  """Override in subclasses that produce terminal output."""
156
163
 
157
- def requires_io_injection(self) -> bool:
158
- """Checks to see if the action requires input injection."""
159
- return self._requires_injection
160
-
161
164
  def __repr__(self) -> str:
162
165
  return str(self)
163
166
 
@@ -246,6 +249,13 @@ class Action(BaseAction):
246
249
  if policy.enabled:
247
250
  self.enable_retry()
248
251
 
252
+ def get_infer_target(self) -> tuple[Callable[..., Any], None]:
253
+ """
254
+ Returns the callable to be used for argument inference.
255
+ By default, it returns the action itself.
256
+ """
257
+ return self.action, None
258
+
249
259
  async def _run(self, *args, **kwargs) -> Any:
250
260
  combined_args = args + self.args
251
261
  combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
@@ -477,6 +487,14 @@ class ChainedAction(BaseAction, ActionListMixin):
477
487
  if hasattr(action, "register_teardown") and callable(action.register_teardown):
478
488
  action.register_teardown(self.hooks)
479
489
 
490
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
491
+ if self.actions:
492
+ return self.actions[0].get_infer_target()
493
+ return None, None
494
+
495
+ def _clear_args(self):
496
+ return (), {}
497
+
480
498
  async def _run(self, *args, **kwargs) -> list[Any]:
481
499
  if not self.actions:
482
500
  raise EmptyChainError(f"[{self.name}] No actions to execute.")
@@ -505,12 +523,8 @@ class ChainedAction(BaseAction, ActionListMixin):
505
523
  continue
506
524
  shared_context.current_index = index
507
525
  prepared = action.prepare(shared_context, self.options_manager)
508
- last_result = shared_context.last_result()
509
526
  try:
510
- if self.requires_io_injection() and last_result is not None:
511
- result = await prepared(**{prepared.inject_into: last_result})
512
- else:
513
- result = await prepared(*args, **updated_kwargs)
527
+ result = await prepared(*args, **updated_kwargs)
514
528
  except Exception as error:
515
529
  if index + 1 < len(self.actions) and isinstance(
516
530
  self.actions[index + 1], FallbackAction
@@ -529,6 +543,7 @@ class ChainedAction(BaseAction, ActionListMixin):
529
543
  fallback._skip_in_chain = True
530
544
  else:
531
545
  raise
546
+ args, updated_kwargs = self._clear_args()
532
547
  shared_context.add_result(result)
533
548
  context.extra["results"].append(result)
534
549
  context.extra["rollback_stack"].append(prepared)
@@ -669,6 +684,16 @@ class ActionGroup(BaseAction, ActionListMixin):
669
684
  if hasattr(action, "register_teardown") and callable(action.register_teardown):
670
685
  action.register_teardown(self.hooks)
671
686
 
687
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
688
+ arg_defs = same_argument_definitions(self.actions)
689
+ if arg_defs:
690
+ return self.actions[0].get_infer_target()
691
+ logger.debug(
692
+ "[%s] auto_args disabled: mismatched ActionGroup arguments",
693
+ self.name,
694
+ )
695
+ return None, None
696
+
672
697
  async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
673
698
  shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
674
699
  if self.shared_context:
@@ -787,8 +812,11 @@ class ProcessAction(BaseAction):
787
812
  self.executor = executor or ProcessPoolExecutor()
788
813
  self.is_retryable = True
789
814
 
790
- async def _run(self, *args, **kwargs):
791
- if self.inject_last_result:
815
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
816
+ return self.action, None
817
+
818
+ async def _run(self, *args, **kwargs) -> Any:
819
+ if self.inject_last_result and self.shared_context:
792
820
  last_result = self.shared_context.last_result()
793
821
  if not self._validate_pickleable(last_result):
794
822
  raise ValueError(
@@ -1,6 +1,6 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
2
  """action_factory.py"""
3
- from typing import Any
3
+ from typing import Any, Callable
4
4
 
5
5
  from rich.tree import Tree
6
6
 
@@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction):
35
35
  *,
36
36
  inject_last_result: bool = False,
37
37
  inject_into: str = "last_result",
38
+ args: tuple[Any, ...] = (),
39
+ kwargs: dict[str, Any] | None = None,
38
40
  preview_args: tuple[Any, ...] = (),
39
41
  preview_kwargs: dict[str, Any] | None = None,
40
42
  ):
@@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction):
44
46
  inject_into=inject_into,
45
47
  )
46
48
  self.factory = factory
49
+ self.args = args
50
+ self.kwargs = kwargs or {}
47
51
  self.preview_args = preview_args
48
52
  self.preview_kwargs = preview_kwargs or {}
49
53
 
@@ -55,6 +59,9 @@ class ActionFactoryAction(BaseAction):
55
59
  def factory(self, value: ActionFactoryProtocol):
56
60
  self._factory = ensure_async(value)
57
61
 
62
+ def get_infer_target(self) -> tuple[Callable[..., Any], None]:
63
+ return self.factory, None
64
+
58
65
  async def _run(self, *args, **kwargs) -> Any:
59
66
  updated_kwargs = self._maybe_inject_last_result(kwargs)
60
67
  context = ExecutionContext(
@@ -19,7 +19,7 @@ import asyncio
19
19
  import shlex
20
20
  import subprocess
21
21
  import sys
22
- from typing import Any
22
+ from typing import Any, Callable
23
23
 
24
24
  from rich.tree import Tree
25
25
 
@@ -73,7 +73,6 @@ class BaseIOAction(BaseAction):
73
73
  inject_last_result=inject_last_result,
74
74
  )
75
75
  self.mode = mode
76
- self._requires_injection = True
77
76
 
78
77
  def from_input(self, raw: str | bytes) -> Any:
79
78
  raise NotImplementedError
@@ -81,15 +80,15 @@ class BaseIOAction(BaseAction):
81
80
  def to_output(self, result: Any) -> str | bytes:
82
81
  raise NotImplementedError
83
82
 
84
- async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes:
85
- last_result = kwargs.pop(self.inject_into, None)
86
-
83
+ async def _resolve_input(
84
+ self, args: tuple[Any], kwargs: dict[str, Any]
85
+ ) -> str | bytes:
87
86
  data = await self._read_stdin()
88
87
  if data:
89
88
  return self.from_input(data)
90
89
 
91
- if last_result is not None:
92
- return last_result
90
+ if len(args) == 1:
91
+ return self.from_input(args[0])
93
92
 
94
93
  if self.inject_last_result and self.shared_context:
95
94
  return self.shared_context.last_result()
@@ -99,6 +98,9 @@ class BaseIOAction(BaseAction):
99
98
  )
100
99
  raise FalyxError("No input provided and no last result to inject.")
101
100
 
101
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
102
+ return None, None
103
+
102
104
  async def __call__(self, *args, **kwargs):
103
105
  context = ExecutionContext(
104
106
  name=self.name,
@@ -117,8 +119,8 @@ class BaseIOAction(BaseAction):
117
119
  pass
118
120
  result = getattr(self, "_last_result", None)
119
121
  else:
120
- parsed_input = await self._resolve_input(kwargs)
121
- result = await self._run(parsed_input, *args, **kwargs)
122
+ parsed_input = await self._resolve_input(args, kwargs)
123
+ result = await self._run(parsed_input)
122
124
  output = self.to_output(result)
123
125
  await self._write_stdout(output)
124
126
  context.result = result
@@ -195,7 +197,6 @@ class ShellAction(BaseIOAction):
195
197
  - Captures stdout and stderr from shell execution
196
198
  - Raises on non-zero exit codes with stderr as the error
197
199
  - Result is returned as trimmed stdout string
198
- - Compatible with ChainedAction and Command.requires_input detection
199
200
 
200
201
  Args:
201
202
  name (str): Name of the action.
@@ -220,6 +221,11 @@ class ShellAction(BaseIOAction):
220
221
  )
221
222
  return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
222
223
 
224
+ def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
225
+ if sys.stdin.isatty():
226
+ return self._run, {"parsed_input": {"help": self.command_template}}
227
+ return None, None
228
+
223
229
  async def _run(self, parsed_input: str) -> str:
224
230
  # Replace placeholder in template, or use raw input as full command
225
231
  command = self.command_template.format(parsed_input)
@@ -73,6 +73,9 @@ class MenuAction(BaseAction):
73
73
  table.add_row(*row)
74
74
  return table
75
75
 
76
+ def get_infer_target(self) -> tuple[None, None]:
77
+ return None, None
78
+
76
79
  async def _run(self, *args, **kwargs) -> Any:
77
80
  kwargs = self._maybe_inject_last_result(kwargs)
78
81
  context = ExecutionContext(
@@ -25,6 +25,7 @@ from falyx.selection import (
25
25
  prompt_for_selection,
26
26
  render_selection_dict_table,
27
27
  )
28
+ from falyx.signals import CancelSignal
28
29
  from falyx.themes import OneColors
29
30
 
30
31
 
@@ -121,6 +122,16 @@ class SelectFileAction(BaseAction):
121
122
  logger.warning("[ERROR] Failed to parse %s: %s", file.name, error)
122
123
  return options
123
124
 
125
+ def _find_cancel_key(self, options) -> str:
126
+ """Return first numeric value not already used in the selection dict."""
127
+ for index in range(len(options)):
128
+ if str(index) not in options:
129
+ return str(index)
130
+ return str(len(options))
131
+
132
+ def get_infer_target(self) -> tuple[None, None]:
133
+ return None, None
134
+
124
135
  async def _run(self, *args, **kwargs) -> Any:
125
136
  context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
126
137
  context.start_timer()
@@ -128,28 +139,38 @@ class SelectFileAction(BaseAction):
128
139
  await self.hooks.trigger(HookType.BEFORE, context)
129
140
 
130
141
  files = [
131
- f
132
- for f in self.directory.iterdir()
133
- if f.is_file()
134
- and (self.suffix_filter is None or f.suffix == self.suffix_filter)
142
+ file
143
+ for file in self.directory.iterdir()
144
+ if file.is_file()
145
+ and (self.suffix_filter is None or file.suffix == self.suffix_filter)
135
146
  ]
136
147
  if not files:
137
148
  raise FileNotFoundError("No files found in directory.")
138
149
 
139
150
  options = self.get_options(files)
140
151
 
152
+ cancel_key = self._find_cancel_key(options)
153
+ cancel_option = {
154
+ cancel_key: SelectionOption(
155
+ description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED
156
+ )
157
+ }
158
+
141
159
  table = render_selection_dict_table(
142
- title=self.title, selections=options, columns=self.columns
160
+ title=self.title, selections=options | cancel_option, columns=self.columns
143
161
  )
144
162
 
145
163
  key = await prompt_for_selection(
146
- options.keys(),
164
+ (options | cancel_option).keys(),
147
165
  table,
148
166
  console=self.console,
149
167
  prompt_session=self.prompt_session,
150
168
  prompt_message=self.prompt_message,
151
169
  )
152
170
 
171
+ if key == cancel_key:
172
+ raise CancelSignal("User canceled the selection.")
173
+
153
174
  result = options[key].value
154
175
  context.result = result
155
176
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -176,11 +197,11 @@ class SelectFileAction(BaseAction):
176
197
  try:
177
198
  files = list(self.directory.iterdir())
178
199
  if self.suffix_filter:
179
- files = [f for f in files if f.suffix == self.suffix_filter]
200
+ files = [file for file in files if file.suffix == self.suffix_filter]
180
201
  sample = files[:10]
181
202
  file_list = tree.add("[dim]Files:[/]")
182
- for f in sample:
183
- file_list.add(f"[dim]{f.name}[/]")
203
+ for file in sample:
204
+ file_list.add(f"[dim]{file.name}[/]")
184
205
  if len(files) > 10:
185
206
  file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
186
207
  except Exception as error:
@@ -7,19 +7,21 @@ from rich.console import Console
7
7
  from rich.tree import Tree
8
8
 
9
9
  from falyx.action.action import BaseAction
10
+ from falyx.action.types import SelectionReturnType
10
11
  from falyx.context import ExecutionContext
11
12
  from falyx.execution_registry import ExecutionRegistry as er
12
13
  from falyx.hook_manager import HookType
13
14
  from falyx.logger import logger
14
15
  from falyx.selection import (
15
16
  SelectionOption,
17
+ SelectionOptionMap,
16
18
  prompt_for_index,
17
19
  prompt_for_selection,
18
20
  render_selection_dict_table,
19
21
  render_selection_indexed_table,
20
22
  )
23
+ from falyx.signals import CancelSignal
21
24
  from falyx.themes import OneColors
22
- from falyx.utils import CaseInsensitiveDict
23
25
 
24
26
 
25
27
  class SelectionAction(BaseAction):
@@ -34,7 +36,13 @@ class SelectionAction(BaseAction):
34
36
  def __init__(
35
37
  self,
36
38
  name: str,
37
- selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption],
39
+ selections: (
40
+ list[str]
41
+ | set[str]
42
+ | tuple[str, ...]
43
+ | dict[str, SelectionOption]
44
+ | dict[str, Any]
45
+ ),
38
46
  *,
39
47
  title: str = "Select an option",
40
48
  columns: int = 5,
@@ -42,7 +50,7 @@ class SelectionAction(BaseAction):
42
50
  default_selection: str = "",
43
51
  inject_last_result: bool = False,
44
52
  inject_into: str = "last_result",
45
- return_key: bool = False,
53
+ return_type: SelectionReturnType | str = "value",
46
54
  console: Console | None = None,
47
55
  prompt_session: PromptSession | None = None,
48
56
  never_prompt: bool = False,
@@ -55,8 +63,8 @@ class SelectionAction(BaseAction):
55
63
  never_prompt=never_prompt,
56
64
  )
57
65
  # Setter normalizes to correct type, mypy can't infer that
58
- self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment]
59
- self.return_key = return_key
66
+ self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
67
+ self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
60
68
  self.title = title
61
69
  self.columns = columns
62
70
  self.console = console or Console(color_system="auto")
@@ -65,8 +73,15 @@ class SelectionAction(BaseAction):
65
73
  self.prompt_message = prompt_message
66
74
  self.show_table = show_table
67
75
 
76
+ def _coerce_return_type(
77
+ self, return_type: SelectionReturnType | str
78
+ ) -> SelectionReturnType:
79
+ if isinstance(return_type, SelectionReturnType):
80
+ return return_type
81
+ return SelectionReturnType(return_type)
82
+
68
83
  @property
69
- def selections(self) -> list[str] | CaseInsensitiveDict:
84
+ def selections(self) -> list[str] | SelectionOptionMap:
70
85
  return self._selections
71
86
 
72
87
  @selections.setter
@@ -74,17 +89,41 @@ class SelectionAction(BaseAction):
74
89
  self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
75
90
  ):
76
91
  if isinstance(value, (list, tuple, set)):
77
- self._selections: list[str] | CaseInsensitiveDict = list(value)
92
+ self._selections: list[str] | SelectionOptionMap = list(value)
78
93
  elif isinstance(value, dict):
79
- cid = CaseInsensitiveDict()
80
- cid.update(value)
81
- self._selections = cid
94
+ som = SelectionOptionMap()
95
+ if all(isinstance(key, str) for key in value) and all(
96
+ not isinstance(value[key], SelectionOption) for key in value
97
+ ):
98
+ som.update(
99
+ {
100
+ str(index): SelectionOption(key, option)
101
+ for index, (key, option) in enumerate(value.items())
102
+ }
103
+ )
104
+ elif all(isinstance(key, str) for key in value) and all(
105
+ isinstance(value[key], SelectionOption) for key in value
106
+ ):
107
+ som.update(value)
108
+ else:
109
+ raise ValueError("Invalid dictionary format. Keys must be strings")
110
+ self._selections = som
82
111
  else:
83
112
  raise TypeError(
84
113
  "'selections' must be a list[str] or dict[str, SelectionOption], "
85
114
  f"got {type(value).__name__}"
86
115
  )
87
116
 
117
+ def _find_cancel_key(self) -> str:
118
+ """Return first numeric value not already used in the selection dict."""
119
+ for index in range(len(self.selections)):
120
+ if str(index) not in self.selections:
121
+ return str(index)
122
+ return str(len(self.selections))
123
+
124
+ def get_infer_target(self) -> tuple[None, None]:
125
+ return None, None
126
+
88
127
  async def _run(self, *args, **kwargs) -> Any:
89
128
  kwargs = self._maybe_inject_last_result(kwargs)
90
129
  context = ExecutionContext(
@@ -125,16 +164,17 @@ class SelectionAction(BaseAction):
125
164
 
126
165
  context.start_timer()
127
166
  try:
167
+ cancel_key = self._find_cancel_key()
128
168
  await self.hooks.trigger(HookType.BEFORE, context)
129
169
  if isinstance(self.selections, list):
130
170
  table = render_selection_indexed_table(
131
171
  title=self.title,
132
- selections=self.selections,
172
+ selections=self.selections + ["Cancel"],
133
173
  columns=self.columns,
134
174
  )
135
175
  if not self.never_prompt:
136
176
  index = await prompt_for_index(
137
- len(self.selections) - 1,
177
+ len(self.selections),
138
178
  table,
139
179
  default_selection=effective_default,
140
180
  console=self.console,
@@ -144,14 +184,23 @@ class SelectionAction(BaseAction):
144
184
  )
145
185
  else:
146
186
  index = effective_default
147
- result = self.selections[int(index)]
187
+ if index == cancel_key:
188
+ raise CancelSignal("User cancelled the selection.")
189
+ result: Any = self.selections[int(index)]
148
190
  elif isinstance(self.selections, dict):
191
+ cancel_option = {
192
+ cancel_key: SelectionOption(
193
+ description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
194
+ )
195
+ }
149
196
  table = render_selection_dict_table(
150
- title=self.title, selections=self.selections, columns=self.columns
197
+ title=self.title,
198
+ selections=self.selections | cancel_option,
199
+ columns=self.columns,
151
200
  )
152
201
  if not self.never_prompt:
153
202
  key = await prompt_for_selection(
154
- self.selections.keys(),
203
+ (self.selections | cancel_option).keys(),
155
204
  table,
156
205
  default_selection=effective_default,
157
206
  console=self.console,
@@ -161,10 +210,25 @@ class SelectionAction(BaseAction):
161
210
  )
162
211
  else:
163
212
  key = effective_default
164
- result = key if self.return_key else self.selections[key].value
213
+ if key == cancel_key:
214
+ raise CancelSignal("User cancelled the selection.")
215
+ if self.return_type == SelectionReturnType.KEY:
216
+ result = key
217
+ elif self.return_type == SelectionReturnType.VALUE:
218
+ result = self.selections[key].value
219
+ elif self.return_type == SelectionReturnType.ITEMS:
220
+ result = {key: self.selections[key]}
221
+ elif self.return_type == SelectionReturnType.DESCRIPTION:
222
+ result = self.selections[key].description
223
+ elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
224
+ result = {
225
+ self.selections[key].description: self.selections[key].value
226
+ }
227
+ else:
228
+ raise ValueError(f"Unsupported return type: {self.return_type}")
165
229
  else:
166
230
  raise TypeError(
167
- "'selections' must be a list[str] or dict[str, tuple[str, Any]], "
231
+ "'selections' must be a list[str] or dict[str, Any], "
168
232
  f"got {type(self.selections).__name__}"
169
233
  )
170
234
  context.result = result
@@ -203,7 +267,7 @@ class SelectionAction(BaseAction):
203
267
  return
204
268
 
205
269
  tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
206
- tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}")
270
+ tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
207
271
  tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
208
272
 
209
273
  if not parent:
@@ -218,6 +282,6 @@ class SelectionAction(BaseAction):
218
282
  return (
219
283
  f"SelectionAction(name={self.name!r}, type={selection_type}, "
220
284
  f"default_selection={self.default_selection!r}, "
221
- f"return_key={self.return_key}, "
285
+ f"return_type={self.return_type!r}, "
222
286
  f"prompt={'off' if self.never_prompt else 'on'})"
223
287
  )
@@ -35,3 +35,18 @@ class FileReturnType(Enum):
35
35
  return member
36
36
  valid = ", ".join(member.value for member in cls)
37
37
  raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
38
+
39
+
40
+ class SelectionReturnType(Enum):
41
+ """Enum for dictionary return types."""
42
+
43
+ KEY = "key"
44
+ VALUE = "value"
45
+ DESCRIPTION = "description"
46
+ DESCRIPTION_VALUE = "description_value"
47
+ ITEMS = "items"
48
+
49
+ @classmethod
50
+ def _missing_(cls, value: object) -> SelectionReturnType:
51
+ valid = ", ".join(member.value for member in cls)
52
+ raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}")
@@ -43,6 +43,9 @@ class UserInputAction(BaseAction):
43
43
  self.console = console or Console(color_system="auto")
44
44
  self.prompt_session = prompt_session or PromptSession()
45
45
 
46
+ def get_infer_target(self) -> tuple[None, None]:
47
+ return None, None
48
+
46
49
  async def _run(self, *args, **kwargs) -> str:
47
50
  context = ExecutionContext(
48
51
  name=self.name,