falyx 0.1.29__py3-none-any.whl → 0.1.31__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.
- falyx/__init__.py +0 -1
- falyx/action/action.py +42 -14
- falyx/action/action_factory.py +8 -1
- falyx/action/io_action.py +16 -10
- falyx/action/menu_action.py +3 -0
- falyx/action/select_file_action.py +30 -9
- falyx/action/selection_action.py +83 -19
- falyx/action/types.py +15 -0
- falyx/action/user_input_action.py +3 -0
- falyx/command.py +14 -61
- falyx/config.py +0 -1
- falyx/falyx.py +38 -46
- falyx/hook_manager.py +8 -7
- falyx/menu.py +20 -8
- falyx/parsers/__init__.py +0 -4
- falyx/parsers/argparse.py +11 -0
- falyx/parsers/signature.py +5 -2
- falyx/parsers/utils.py +5 -10
- falyx/selection.py +57 -1
- falyx/version.py +1 -1
- {falyx-0.1.29.dist-info → falyx-0.1.31.dist-info}/METADATA +1 -1
- {falyx-0.1.29.dist-info → falyx-0.1.31.dist-info}/RECORD +25 -25
- {falyx-0.1.29.dist-info → falyx-0.1.31.dist-info}/LICENSE +0 -0
- {falyx-0.1.29.dist-info → falyx-0.1.31.dist-info}/WHEEL +0 -0
- {falyx-0.1.29.dist-info → falyx-0.1.31.dist-info}/entry_points.txt +0 -0
falyx/__init__.py
CHANGED
falyx/action/action.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
791
|
-
|
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(
|
falyx/action/action_factory.py
CHANGED
@@ -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(
|
falyx/action/io_action.py
CHANGED
@@ -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(
|
85
|
-
|
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
|
92
|
-
return
|
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
|
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)
|
falyx/action/menu_action.py
CHANGED
@@ -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
|
-
|
132
|
-
for
|
133
|
-
if
|
134
|
-
and (self.suffix_filter is None or
|
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 = [
|
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
|
183
|
-
file_list.add(f"[dim]{
|
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:
|
falyx/action/selection_action.py
CHANGED
@@ -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:
|
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
|
-
|
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] |
|
59
|
-
self.
|
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] |
|
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] |
|
92
|
+
self._selections: list[str] | SelectionOptionMap = list(value)
|
78
93
|
elif isinstance(value, dict):
|
79
|
-
|
80
|
-
|
81
|
-
|
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)
|
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
|
-
|
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,
|
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
|
-
|
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,
|
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:[/] {
|
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"
|
285
|
+
f"return_type={self.return_type!r}, "
|
222
286
|
f"prompt={'off' if self.never_prompt else 'on'})"
|
223
287
|
)
|
falyx/action/types.py
CHANGED
@@ -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,
|