falyx 0.1.27__tar.gz → 0.1.28__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.
- {falyx-0.1.27 → falyx-0.1.28}/PKG-INFO +1 -1
- falyx-0.1.28/falyx/argparse.py +596 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/command.py +31 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/exceptions.py +4 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/falyx.py +82 -34
- {falyx-0.1.27 → falyx-0.1.28}/falyx/parsers.py +7 -1
- {falyx-0.1.27 → falyx-0.1.28}/falyx/protocols.py +7 -1
- {falyx-0.1.27 → falyx-0.1.28}/falyx/signals.py +7 -0
- falyx-0.1.28/falyx/version.py +1 -0
- {falyx-0.1.27 → falyx-0.1.28}/pyproject.toml +1 -1
- falyx-0.1.27/falyx/version.py +0 -1
- {falyx-0.1.27 → falyx-0.1.28}/LICENSE +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/README.md +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/.pytyped +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/__init__.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/__main__.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/__init__.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/action_factory.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/http_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/io_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/menu_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/select_file_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/selection_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/signal_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/types.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/action/user_input_action.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/bottom_bar.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/config.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/config_schema.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/context.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/debug.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/execution_registry.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/hook_manager.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/hooks.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/init.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/logger.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/menu.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/options_manager.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/prompt_utils.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/retry.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/retry_utils.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/selection.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/tagged_table.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/themes/__init__.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/themes/colors.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/utils.py +0 -0
- {falyx-0.1.27 → falyx-0.1.28}/falyx/validators.py +0 -0
@@ -0,0 +1,596 @@
|
|
1
|
+
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
2
|
+
from copy import deepcopy
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from enum import Enum
|
5
|
+
from typing import Any, Iterable
|
6
|
+
|
7
|
+
from rich.console import Console
|
8
|
+
from rich.table import Table
|
9
|
+
|
10
|
+
from falyx.exceptions import CommandArgumentError
|
11
|
+
from falyx.signals import HelpSignal
|
12
|
+
|
13
|
+
|
14
|
+
class ArgumentAction(Enum):
|
15
|
+
"""Defines the action to be taken when the argument is encountered."""
|
16
|
+
|
17
|
+
STORE = "store"
|
18
|
+
STORE_TRUE = "store_true"
|
19
|
+
STORE_FALSE = "store_false"
|
20
|
+
APPEND = "append"
|
21
|
+
EXTEND = "extend"
|
22
|
+
COUNT = "count"
|
23
|
+
HELP = "help"
|
24
|
+
|
25
|
+
|
26
|
+
@dataclass
|
27
|
+
class Argument:
|
28
|
+
"""Represents a command-line argument."""
|
29
|
+
|
30
|
+
flags: list[str]
|
31
|
+
dest: str # Destination name for the argument
|
32
|
+
action: ArgumentAction = (
|
33
|
+
ArgumentAction.STORE
|
34
|
+
) # Action to be taken when the argument is encountered
|
35
|
+
type: Any = str # Type of the argument (e.g., str, int, float) or callable
|
36
|
+
default: Any = None # Default value if the argument is not provided
|
37
|
+
choices: list[str] | None = None # List of valid choices for the argument
|
38
|
+
required: bool = False # True if the argument is required
|
39
|
+
help: str = "" # Help text for the argument
|
40
|
+
nargs: int | str = 1 # int, '?', '*', '+'
|
41
|
+
positional: bool = False # True if no leading - or -- in flags
|
42
|
+
|
43
|
+
|
44
|
+
class CommandArgumentParser:
|
45
|
+
"""
|
46
|
+
Custom argument parser for Falyx Commands.
|
47
|
+
It is used to create a command-line interface for Falyx
|
48
|
+
commands, allowing users to specify options and arguments
|
49
|
+
when executing commands.
|
50
|
+
It is not intended to be a full-featured replacement for
|
51
|
+
argparse, but rather a lightweight alternative for specific use
|
52
|
+
cases within the Falyx framework.
|
53
|
+
|
54
|
+
Features:
|
55
|
+
- Customizable argument parsing.
|
56
|
+
- Type coercion for arguments.
|
57
|
+
- Support for positional and keyword arguments.
|
58
|
+
- Support for default values.
|
59
|
+
- Support for boolean flags.
|
60
|
+
- Exception handling for invalid arguments.
|
61
|
+
- Render Help using Rich library.
|
62
|
+
"""
|
63
|
+
|
64
|
+
def __init__(self) -> None:
|
65
|
+
"""Initialize the CommandArgumentParser."""
|
66
|
+
self.command_description: str = ""
|
67
|
+
self._arguments: list[Argument] = []
|
68
|
+
self._flag_map: dict[str, Argument] = {}
|
69
|
+
self._dest_set: set[str] = set()
|
70
|
+
self._add_help()
|
71
|
+
self.console = Console(color_system="auto")
|
72
|
+
|
73
|
+
def _add_help(self):
|
74
|
+
"""Add help argument to the parser."""
|
75
|
+
self.add_argument(
|
76
|
+
"--help",
|
77
|
+
"-h",
|
78
|
+
action=ArgumentAction.HELP,
|
79
|
+
help="Show this help message and exit.",
|
80
|
+
dest="help",
|
81
|
+
)
|
82
|
+
|
83
|
+
def _is_positional(self, flags: tuple[str, ...]) -> bool:
|
84
|
+
"""Check if the flags are positional."""
|
85
|
+
positional = False
|
86
|
+
if any(not flag.startswith("-") for flag in flags):
|
87
|
+
positional = True
|
88
|
+
|
89
|
+
if positional and len(flags) > 1:
|
90
|
+
raise CommandArgumentError("Positional arguments cannot have multiple flags")
|
91
|
+
return positional
|
92
|
+
|
93
|
+
def _get_dest_from_flags(
|
94
|
+
self, flags: tuple[str, ...], dest: str | None
|
95
|
+
) -> str | None:
|
96
|
+
"""Convert flags to a destination name."""
|
97
|
+
if dest:
|
98
|
+
if not dest.replace("_", "").isalnum():
|
99
|
+
raise CommandArgumentError(
|
100
|
+
"dest must be a valid identifier (letters, digits, and underscores only)"
|
101
|
+
)
|
102
|
+
if dest[0].isdigit():
|
103
|
+
raise CommandArgumentError("dest must not start with a digit")
|
104
|
+
return dest
|
105
|
+
dest = None
|
106
|
+
for flag in flags:
|
107
|
+
if flag.startswith("--"):
|
108
|
+
dest = flag.lstrip("-").replace("-", "_").lower()
|
109
|
+
break
|
110
|
+
elif flag.startswith("-"):
|
111
|
+
dest = flag.lstrip("-").replace("-", "_").lower()
|
112
|
+
else:
|
113
|
+
dest = flag.replace("-", "_").lower()
|
114
|
+
assert dest is not None, "dest should not be None"
|
115
|
+
if not dest.replace("_", "").isalnum():
|
116
|
+
raise CommandArgumentError(
|
117
|
+
"dest must be a valid identifier (letters, digits, and underscores only)"
|
118
|
+
)
|
119
|
+
if dest[0].isdigit():
|
120
|
+
raise CommandArgumentError("dest must not start with a digit")
|
121
|
+
return dest
|
122
|
+
|
123
|
+
def _determine_required(
|
124
|
+
self, required: bool, positional: bool, nargs: int | str
|
125
|
+
) -> bool:
|
126
|
+
"""Determine if the argument is required."""
|
127
|
+
if required:
|
128
|
+
return True
|
129
|
+
if positional:
|
130
|
+
if isinstance(nargs, int):
|
131
|
+
return nargs > 0
|
132
|
+
elif isinstance(nargs, str):
|
133
|
+
if nargs in ("+"):
|
134
|
+
return True
|
135
|
+
elif nargs in ("*", "?"):
|
136
|
+
return False
|
137
|
+
else:
|
138
|
+
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
139
|
+
|
140
|
+
return required
|
141
|
+
|
142
|
+
def _validate_nargs(self, nargs: int | str) -> int | str:
|
143
|
+
allowed_nargs = ("?", "*", "+")
|
144
|
+
if isinstance(nargs, int):
|
145
|
+
if nargs <= 0:
|
146
|
+
raise CommandArgumentError("nargs must be a positive integer")
|
147
|
+
elif isinstance(nargs, str):
|
148
|
+
if nargs not in allowed_nargs:
|
149
|
+
raise CommandArgumentError(f"Invalid nargs value: {nargs}")
|
150
|
+
else:
|
151
|
+
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
|
152
|
+
return nargs
|
153
|
+
|
154
|
+
def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]:
|
155
|
+
if choices is not None:
|
156
|
+
if isinstance(choices, dict):
|
157
|
+
raise CommandArgumentError("choices cannot be a dict")
|
158
|
+
try:
|
159
|
+
choices = list(choices)
|
160
|
+
except TypeError:
|
161
|
+
raise CommandArgumentError(
|
162
|
+
"choices must be iterable (like list, tuple, or set)"
|
163
|
+
)
|
164
|
+
else:
|
165
|
+
choices = []
|
166
|
+
for choice in choices:
|
167
|
+
if not isinstance(choice, expected_type):
|
168
|
+
try:
|
169
|
+
expected_type(choice)
|
170
|
+
except Exception:
|
171
|
+
raise CommandArgumentError(
|
172
|
+
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}"
|
173
|
+
)
|
174
|
+
return choices
|
175
|
+
|
176
|
+
def _validate_default_type(
|
177
|
+
self, default: Any, expected_type: type, dest: str
|
178
|
+
) -> None:
|
179
|
+
"""Validate the default value type."""
|
180
|
+
if default is not None and not isinstance(default, expected_type):
|
181
|
+
try:
|
182
|
+
expected_type(default)
|
183
|
+
except Exception:
|
184
|
+
raise CommandArgumentError(
|
185
|
+
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
186
|
+
)
|
187
|
+
|
188
|
+
def _validate_default_list_type(
|
189
|
+
self, default: list[Any], expected_type: type, dest: str
|
190
|
+
) -> None:
|
191
|
+
if isinstance(default, list):
|
192
|
+
for item in default:
|
193
|
+
if not isinstance(item, expected_type):
|
194
|
+
try:
|
195
|
+
expected_type(item)
|
196
|
+
except Exception:
|
197
|
+
raise CommandArgumentError(
|
198
|
+
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
|
199
|
+
)
|
200
|
+
|
201
|
+
def _resolve_default(
|
202
|
+
self, action: ArgumentAction, default: Any, nargs: str | int
|
203
|
+
) -> Any:
|
204
|
+
"""Get the default value for the argument."""
|
205
|
+
if default is None:
|
206
|
+
if action == ArgumentAction.STORE_TRUE:
|
207
|
+
return False
|
208
|
+
elif action == ArgumentAction.STORE_FALSE:
|
209
|
+
return True
|
210
|
+
elif action == ArgumentAction.COUNT:
|
211
|
+
return 0
|
212
|
+
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
|
213
|
+
return []
|
214
|
+
elif nargs in ("+", "*"):
|
215
|
+
return []
|
216
|
+
else:
|
217
|
+
return None
|
218
|
+
return default
|
219
|
+
|
220
|
+
def _validate_flags(self, flags: tuple[str, ...]) -> None:
|
221
|
+
"""Validate the flags provided for the argument."""
|
222
|
+
if not flags:
|
223
|
+
raise CommandArgumentError("No flags provided")
|
224
|
+
for flag in flags:
|
225
|
+
if not isinstance(flag, str):
|
226
|
+
raise CommandArgumentError(f"Flag '{flag}' must be a string")
|
227
|
+
if flag.startswith("--") and len(flag) < 3:
|
228
|
+
raise CommandArgumentError(
|
229
|
+
f"Flag '{flag}' must be at least 3 characters long"
|
230
|
+
)
|
231
|
+
if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2:
|
232
|
+
raise CommandArgumentError(
|
233
|
+
f"Flag '{flag}' must be a single character or start with '--'"
|
234
|
+
)
|
235
|
+
|
236
|
+
def add_argument(self, *flags, **kwargs):
|
237
|
+
"""Add an argument to the parser.
|
238
|
+
Args:
|
239
|
+
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
|
240
|
+
action: The action to be taken when the argument is encountered.
|
241
|
+
nargs: The number of arguments expected.
|
242
|
+
default: The default value if the argument is not provided.
|
243
|
+
type: The type to which the command-line argument should be converted.
|
244
|
+
choices: A container of the allowable values for the argument.
|
245
|
+
required: Whether or not the argument is required.
|
246
|
+
help: A brief description of the argument.
|
247
|
+
dest: The name of the attribute to be added to the object returned by parse_args().
|
248
|
+
"""
|
249
|
+
self._validate_flags(flags)
|
250
|
+
positional = self._is_positional(flags)
|
251
|
+
dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
|
252
|
+
if dest in self._dest_set:
|
253
|
+
raise CommandArgumentError(
|
254
|
+
f"Destination '{dest}' is already defined.\n"
|
255
|
+
"Merging multiple arguments into the same dest (e.g. positional + flagged) "
|
256
|
+
"is not supported. Define a unique 'dest' for each argument."
|
257
|
+
)
|
258
|
+
self._dest_set.add(dest)
|
259
|
+
action = kwargs.get("action", ArgumentAction.STORE)
|
260
|
+
if not isinstance(action, ArgumentAction):
|
261
|
+
try:
|
262
|
+
action = ArgumentAction(action)
|
263
|
+
except ValueError:
|
264
|
+
raise CommandArgumentError(
|
265
|
+
f"Invalid action '{action}' is not a valid ArgumentAction"
|
266
|
+
)
|
267
|
+
flags = list(flags)
|
268
|
+
nargs = self._validate_nargs(kwargs.get("nargs", 1))
|
269
|
+
default = self._resolve_default(action, kwargs.get("default"), nargs)
|
270
|
+
expected_type = kwargs.get("type", str)
|
271
|
+
if (
|
272
|
+
action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
|
273
|
+
and default is not None
|
274
|
+
):
|
275
|
+
if isinstance(default, list):
|
276
|
+
self._validate_default_list_type(default, expected_type, dest)
|
277
|
+
else:
|
278
|
+
self._validate_default_type(default, expected_type, dest)
|
279
|
+
choices = self._normalize_choices(kwargs.get("choices"), expected_type)
|
280
|
+
if default is not None and choices and default not in choices:
|
281
|
+
raise CommandArgumentError(
|
282
|
+
f"Default value '{default}' not in allowed choices: {choices}"
|
283
|
+
)
|
284
|
+
required = self._determine_required(
|
285
|
+
kwargs.get("required", False), positional, nargs
|
286
|
+
)
|
287
|
+
argument = Argument(
|
288
|
+
flags=flags,
|
289
|
+
dest=dest,
|
290
|
+
action=action,
|
291
|
+
type=expected_type,
|
292
|
+
default=default,
|
293
|
+
choices=choices,
|
294
|
+
required=required,
|
295
|
+
help=kwargs.get("help", ""),
|
296
|
+
nargs=nargs,
|
297
|
+
positional=positional,
|
298
|
+
)
|
299
|
+
for flag in flags:
|
300
|
+
if flag in self._flag_map:
|
301
|
+
existing = self._flag_map[flag]
|
302
|
+
raise CommandArgumentError(
|
303
|
+
f"Flag '{flag}' is already used by argument '{existing.dest}'"
|
304
|
+
)
|
305
|
+
self._flag_map[flag] = argument
|
306
|
+
self._arguments.append(argument)
|
307
|
+
|
308
|
+
def get_argument(self, dest: str) -> Argument | None:
|
309
|
+
return next((a for a in self._arguments if a.dest == dest), None)
|
310
|
+
|
311
|
+
def _consume_nargs(
|
312
|
+
self, args: list[str], start: int, spec: Argument
|
313
|
+
) -> tuple[list[str], int]:
|
314
|
+
values = []
|
315
|
+
i = start
|
316
|
+
if isinstance(spec.nargs, int):
|
317
|
+
# assert i + spec.nargs <= len(
|
318
|
+
# args
|
319
|
+
# ), "Not enough arguments provided: shouldn't happen"
|
320
|
+
values = args[i : i + spec.nargs]
|
321
|
+
return values, i + spec.nargs
|
322
|
+
elif spec.nargs == "+":
|
323
|
+
if i >= len(args):
|
324
|
+
raise CommandArgumentError(
|
325
|
+
f"Expected at least one value for '{spec.dest}'"
|
326
|
+
)
|
327
|
+
while i < len(args) and not args[i].startswith("-"):
|
328
|
+
values.append(args[i])
|
329
|
+
i += 1
|
330
|
+
assert values, "Expected at least one value for '+' nargs: shouldn't happen"
|
331
|
+
return values, i
|
332
|
+
elif spec.nargs == "*":
|
333
|
+
while i < len(args) and not args[i].startswith("-"):
|
334
|
+
values.append(args[i])
|
335
|
+
i += 1
|
336
|
+
return values, i
|
337
|
+
elif spec.nargs == "?":
|
338
|
+
if i < len(args) and not args[i].startswith("-"):
|
339
|
+
return [args[i]], i + 1
|
340
|
+
return [], i
|
341
|
+
else:
|
342
|
+
assert False, "Invalid nargs value: shouldn't happen"
|
343
|
+
|
344
|
+
def _consume_all_positional_args(
|
345
|
+
self,
|
346
|
+
args: list[str],
|
347
|
+
result: dict[str, Any],
|
348
|
+
positional_args: list[Argument],
|
349
|
+
consumed_positional_indicies: set[int],
|
350
|
+
) -> int:
|
351
|
+
remaining_positional_args = [
|
352
|
+
(j, spec)
|
353
|
+
for j, spec in enumerate(positional_args)
|
354
|
+
if j not in consumed_positional_indicies
|
355
|
+
]
|
356
|
+
i = 0
|
357
|
+
|
358
|
+
for j, spec in remaining_positional_args:
|
359
|
+
# estimate how many args the remaining specs might need
|
360
|
+
is_last = j == len(positional_args) - 1
|
361
|
+
remaining = len(args) - i
|
362
|
+
min_required = 0
|
363
|
+
for next_spec in positional_args[j + 1 :]:
|
364
|
+
if isinstance(next_spec.nargs, int):
|
365
|
+
min_required += next_spec.nargs
|
366
|
+
elif next_spec.nargs == "+":
|
367
|
+
min_required += 1
|
368
|
+
elif next_spec.nargs == "?":
|
369
|
+
min_required += 0
|
370
|
+
elif next_spec.nargs == "*":
|
371
|
+
min_required += 0
|
372
|
+
else:
|
373
|
+
assert False, "Invalid nargs value: shouldn't happen"
|
374
|
+
|
375
|
+
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
|
376
|
+
values, new_i = self._consume_nargs(slice_args, 0, spec)
|
377
|
+
i += new_i
|
378
|
+
|
379
|
+
try:
|
380
|
+
typed = [spec.type(v) for v in values]
|
381
|
+
except Exception:
|
382
|
+
raise CommandArgumentError(
|
383
|
+
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
384
|
+
)
|
385
|
+
|
386
|
+
if spec.action == ArgumentAction.APPEND:
|
387
|
+
assert result.get(spec.dest) is not None, "dest should not be None"
|
388
|
+
if spec.nargs in (None, 1):
|
389
|
+
result[spec.dest].append(typed[0])
|
390
|
+
else:
|
391
|
+
result[spec.dest].append(typed)
|
392
|
+
elif spec.action == ArgumentAction.EXTEND:
|
393
|
+
assert result.get(spec.dest) is not None, "dest should not be None"
|
394
|
+
result[spec.dest].extend(typed)
|
395
|
+
elif spec.nargs in (None, 1, "?"):
|
396
|
+
result[spec.dest] = typed[0] if len(typed) == 1 else typed
|
397
|
+
else:
|
398
|
+
result[spec.dest] = typed
|
399
|
+
|
400
|
+
if spec.nargs not in ("*", "+"):
|
401
|
+
consumed_positional_indicies.add(j)
|
402
|
+
|
403
|
+
if i < len(args):
|
404
|
+
raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}")
|
405
|
+
|
406
|
+
return i
|
407
|
+
|
408
|
+
def parse_args(self, args: list[str] | None = None) -> dict[str, Any]:
|
409
|
+
"""Parse Falyx Command arguments."""
|
410
|
+
if args is None:
|
411
|
+
args = []
|
412
|
+
|
413
|
+
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
|
414
|
+
positional_args = [arg for arg in self._arguments if arg.positional]
|
415
|
+
consumed_positional_indices: set[int] = set()
|
416
|
+
|
417
|
+
consumed_indices: set[int] = set()
|
418
|
+
i = 0
|
419
|
+
while i < len(args):
|
420
|
+
token = args[i]
|
421
|
+
if token in self._flag_map:
|
422
|
+
spec = self._flag_map[token]
|
423
|
+
action = spec.action
|
424
|
+
|
425
|
+
if action == ArgumentAction.HELP:
|
426
|
+
self.render_help()
|
427
|
+
raise HelpSignal()
|
428
|
+
elif action == ArgumentAction.STORE_TRUE:
|
429
|
+
result[spec.dest] = True
|
430
|
+
consumed_indices.add(i)
|
431
|
+
i += 1
|
432
|
+
elif action == ArgumentAction.STORE_FALSE:
|
433
|
+
result[spec.dest] = False
|
434
|
+
consumed_indices.add(i)
|
435
|
+
i += 1
|
436
|
+
elif action == ArgumentAction.COUNT:
|
437
|
+
result[spec.dest] = result.get(spec.dest, 0) + 1
|
438
|
+
consumed_indices.add(i)
|
439
|
+
i += 1
|
440
|
+
elif action == ArgumentAction.APPEND:
|
441
|
+
assert result.get(spec.dest) is not None, "dest should not be None"
|
442
|
+
values, new_i = self._consume_nargs(args, i + 1, spec)
|
443
|
+
try:
|
444
|
+
typed_values = [spec.type(value) for value in values]
|
445
|
+
except ValueError:
|
446
|
+
raise CommandArgumentError(
|
447
|
+
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
448
|
+
)
|
449
|
+
if spec.nargs in (None, 1):
|
450
|
+
try:
|
451
|
+
result[spec.dest].append(spec.type(values[0]))
|
452
|
+
except ValueError:
|
453
|
+
raise CommandArgumentError(
|
454
|
+
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
455
|
+
)
|
456
|
+
else:
|
457
|
+
result[spec.dest].append(typed_values)
|
458
|
+
consumed_indices.update(range(i, new_i))
|
459
|
+
i = new_i
|
460
|
+
elif action == ArgumentAction.EXTEND:
|
461
|
+
assert result.get(spec.dest) is not None, "dest should not be None"
|
462
|
+
values, new_i = self._consume_nargs(args, i + 1, spec)
|
463
|
+
try:
|
464
|
+
typed_values = [spec.type(value) for value in values]
|
465
|
+
except ValueError:
|
466
|
+
raise CommandArgumentError(
|
467
|
+
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
468
|
+
)
|
469
|
+
result[spec.dest].extend(typed_values)
|
470
|
+
consumed_indices.update(range(i, new_i))
|
471
|
+
i = new_i
|
472
|
+
else:
|
473
|
+
values, new_i = self._consume_nargs(args, i + 1, spec)
|
474
|
+
try:
|
475
|
+
typed_values = [spec.type(v) for v in values]
|
476
|
+
except ValueError:
|
477
|
+
raise CommandArgumentError(
|
478
|
+
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
|
479
|
+
)
|
480
|
+
if (
|
481
|
+
spec.nargs in (None, 1, "?")
|
482
|
+
and spec.action != ArgumentAction.APPEND
|
483
|
+
):
|
484
|
+
result[spec.dest] = (
|
485
|
+
typed_values[0] if len(typed_values) == 1 else typed_values
|
486
|
+
)
|
487
|
+
else:
|
488
|
+
result[spec.dest] = typed_values
|
489
|
+
consumed_indices.update(range(i, new_i))
|
490
|
+
i = new_i
|
491
|
+
else:
|
492
|
+
# Get the next flagged argument index if it exists
|
493
|
+
next_flagged_index = -1
|
494
|
+
for index, arg in enumerate(args[i:], start=i):
|
495
|
+
if arg.startswith("-"):
|
496
|
+
next_flagged_index = index
|
497
|
+
break
|
498
|
+
if next_flagged_index == -1:
|
499
|
+
next_flagged_index = len(args)
|
500
|
+
|
501
|
+
args_consumed = self._consume_all_positional_args(
|
502
|
+
args[i:next_flagged_index],
|
503
|
+
result,
|
504
|
+
positional_args,
|
505
|
+
consumed_positional_indices,
|
506
|
+
)
|
507
|
+
i += args_consumed
|
508
|
+
|
509
|
+
# Required validation
|
510
|
+
for spec in self._arguments:
|
511
|
+
if spec.dest == "help":
|
512
|
+
continue
|
513
|
+
if spec.required and not result.get(spec.dest):
|
514
|
+
raise CommandArgumentError(f"Missing required argument: {spec.dest}")
|
515
|
+
|
516
|
+
if spec.choices and result.get(spec.dest) not in spec.choices:
|
517
|
+
raise CommandArgumentError(
|
518
|
+
f"Invalid value for {spec.dest}: must be one of {spec.choices}"
|
519
|
+
)
|
520
|
+
|
521
|
+
if isinstance(spec.nargs, int) and spec.nargs > 1:
|
522
|
+
if not isinstance(result.get(spec.dest), list):
|
523
|
+
raise CommandArgumentError(
|
524
|
+
f"Invalid value for {spec.dest}: expected a list"
|
525
|
+
)
|
526
|
+
if spec.action == ArgumentAction.APPEND:
|
527
|
+
if not isinstance(result[spec.dest], list):
|
528
|
+
raise CommandArgumentError(
|
529
|
+
f"Invalid value for {spec.dest}: expected a list"
|
530
|
+
)
|
531
|
+
for group in result[spec.dest]:
|
532
|
+
if len(group) % spec.nargs != 0:
|
533
|
+
raise CommandArgumentError(
|
534
|
+
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
535
|
+
)
|
536
|
+
elif spec.action == ArgumentAction.EXTEND:
|
537
|
+
if not isinstance(result[spec.dest], list):
|
538
|
+
raise CommandArgumentError(
|
539
|
+
f"Invalid value for {spec.dest}: expected a list"
|
540
|
+
)
|
541
|
+
if len(result[spec.dest]) % spec.nargs != 0:
|
542
|
+
raise CommandArgumentError(
|
543
|
+
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
|
544
|
+
)
|
545
|
+
elif len(result[spec.dest]) != spec.nargs:
|
546
|
+
raise CommandArgumentError(
|
547
|
+
f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}"
|
548
|
+
)
|
549
|
+
|
550
|
+
result.pop("help", None)
|
551
|
+
return result
|
552
|
+
|
553
|
+
def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
554
|
+
"""
|
555
|
+
Returns:
|
556
|
+
tuple[args, kwargs] - Positional arguments in defined order,
|
557
|
+
followed by keyword argument mapping.
|
558
|
+
"""
|
559
|
+
parsed = self.parse_args(args)
|
560
|
+
args_list = []
|
561
|
+
kwargs_dict = {}
|
562
|
+
for arg in self._arguments:
|
563
|
+
if arg.dest == "help":
|
564
|
+
continue
|
565
|
+
if arg.positional:
|
566
|
+
args_list.append(parsed[arg.dest])
|
567
|
+
else:
|
568
|
+
kwargs_dict[arg.dest] = parsed[arg.dest]
|
569
|
+
return tuple(args_list), kwargs_dict
|
570
|
+
|
571
|
+
def render_help(self):
|
572
|
+
table = Table(title=f"{self.command_description} Help")
|
573
|
+
table.add_column("Flags")
|
574
|
+
table.add_column("Help")
|
575
|
+
for arg in self._arguments:
|
576
|
+
if arg.dest == "help":
|
577
|
+
continue
|
578
|
+
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
|
579
|
+
table.add_row(flag_str, arg.help or "")
|
580
|
+
table.add_section()
|
581
|
+
arg = self.get_argument("help")
|
582
|
+
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest
|
583
|
+
table.add_row(flag_str, arg.help or "")
|
584
|
+
self.console.print(table)
|
585
|
+
|
586
|
+
def __str__(self) -> str:
|
587
|
+
positional = sum(arg.positional for arg in self._arguments)
|
588
|
+
required = sum(arg.required for arg in self._arguments)
|
589
|
+
return (
|
590
|
+
f"CommandArgumentParser(args={len(self._arguments)}, "
|
591
|
+
f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, "
|
592
|
+
f"required={required}, positional={positional})"
|
593
|
+
)
|
594
|
+
|
595
|
+
def __repr__(self) -> str:
|
596
|
+
return str(self)
|
@@ -18,6 +18,7 @@ in building robust interactive menus.
|
|
18
18
|
"""
|
19
19
|
from __future__ import annotations
|
20
20
|
|
21
|
+
import shlex
|
21
22
|
from functools import cached_property
|
22
23
|
from typing import Any, Callable
|
23
24
|
|
@@ -28,6 +29,7 @@ from rich.tree import Tree
|
|
28
29
|
|
29
30
|
from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
|
30
31
|
from falyx.action.io_action import BaseIOAction
|
32
|
+
from falyx.argparse import CommandArgumentParser
|
31
33
|
from falyx.context import ExecutionContext
|
32
34
|
from falyx.debug import register_debug_hooks
|
33
35
|
from falyx.execution_registry import ExecutionRegistry as er
|
@@ -35,6 +37,7 @@ from falyx.hook_manager import HookManager, HookType
|
|
35
37
|
from falyx.logger import logger
|
36
38
|
from falyx.options_manager import OptionsManager
|
37
39
|
from falyx.prompt_utils import confirm_async, should_prompt_user
|
40
|
+
from falyx.protocols import ArgParserProtocol
|
38
41
|
from falyx.retry import RetryPolicy
|
39
42
|
from falyx.retry_utils import enable_retries_recursively
|
40
43
|
from falyx.signals import CancelSignal
|
@@ -121,11 +124,24 @@ class Command(BaseModel):
|
|
121
124
|
logging_hooks: bool = False
|
122
125
|
requires_input: bool | None = None
|
123
126
|
options_manager: OptionsManager = Field(default_factory=OptionsManager)
|
127
|
+
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
|
128
|
+
custom_parser: ArgParserProtocol | None = None
|
129
|
+
custom_help: Callable[[], str | None] | None = None
|
124
130
|
|
125
131
|
_context: ExecutionContext | None = PrivateAttr(default=None)
|
126
132
|
|
127
133
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
128
134
|
|
135
|
+
def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]:
|
136
|
+
if self.custom_parser:
|
137
|
+
if isinstance(raw_args, str):
|
138
|
+
raw_args = shlex.split(raw_args)
|
139
|
+
return self.custom_parser(raw_args)
|
140
|
+
|
141
|
+
if isinstance(raw_args, str):
|
142
|
+
raw_args = shlex.split(raw_args)
|
143
|
+
return self.arg_parser.parse_args_split(raw_args)
|
144
|
+
|
129
145
|
@field_validator("action", mode="before")
|
130
146
|
@classmethod
|
131
147
|
def wrap_callable_as_async(cls, action: Any) -> Any:
|
@@ -137,6 +153,9 @@ class Command(BaseModel):
|
|
137
153
|
|
138
154
|
def model_post_init(self, _: Any) -> None:
|
139
155
|
"""Post-initialization to set up the action and hooks."""
|
156
|
+
if isinstance(self.arg_parser, CommandArgumentParser):
|
157
|
+
self.arg_parser.command_description = self.description
|
158
|
+
|
140
159
|
if self.retry and isinstance(self.action, Action):
|
141
160
|
self.action.enable_retry()
|
142
161
|
elif self.retry_policy and isinstance(self.action, Action):
|
@@ -269,6 +288,18 @@ class Command(BaseModel):
|
|
269
288
|
if self._context:
|
270
289
|
self._context.log_summary()
|
271
290
|
|
291
|
+
def show_help(self) -> bool:
|
292
|
+
"""Display the help message for the command."""
|
293
|
+
if self.custom_help:
|
294
|
+
output = self.custom_help()
|
295
|
+
if output:
|
296
|
+
console.print(output)
|
297
|
+
return True
|
298
|
+
if isinstance(self.arg_parser, CommandArgumentParser):
|
299
|
+
self.arg_parser.render_help()
|
300
|
+
return True
|
301
|
+
return False
|
302
|
+
|
272
303
|
async def preview(self) -> None:
|
273
304
|
label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}"
|
274
305
|
|
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|
23
23
|
|
24
24
|
import asyncio
|
25
25
|
import logging
|
26
|
+
import shlex
|
26
27
|
import sys
|
27
28
|
from argparse import Namespace
|
28
29
|
from difflib import get_close_matches
|
@@ -34,7 +35,8 @@ from prompt_toolkit import PromptSession
|
|
34
35
|
from prompt_toolkit.completion import WordCompleter
|
35
36
|
from prompt_toolkit.formatted_text import AnyFormattedText
|
36
37
|
from prompt_toolkit.key_binding import KeyBindings
|
37
|
-
from prompt_toolkit.
|
38
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
39
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
38
40
|
from rich import box
|
39
41
|
from rich.console import Console
|
40
42
|
from rich.markdown import Markdown
|
@@ -47,6 +49,7 @@ from falyx.context import ExecutionContext
|
|
47
49
|
from falyx.debug import log_after, log_before, log_error, log_success
|
48
50
|
from falyx.exceptions import (
|
49
51
|
CommandAlreadyExistsError,
|
52
|
+
CommandArgumentError,
|
50
53
|
FalyxError,
|
51
54
|
InvalidActionError,
|
52
55
|
NotAFalyxError,
|
@@ -57,19 +60,39 @@ from falyx.logger import logger
|
|
57
60
|
from falyx.options_manager import OptionsManager
|
58
61
|
from falyx.parsers import get_arg_parsers
|
59
62
|
from falyx.retry import RetryPolicy
|
60
|
-
from falyx.signals import BackSignal, CancelSignal, QuitSignal
|
63
|
+
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
|
61
64
|
from falyx.themes import OneColors, get_nord_theme
|
62
65
|
from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
|
63
66
|
from falyx.version import __version__
|
64
67
|
|
65
68
|
|
66
|
-
class FalyxMode(
|
69
|
+
class FalyxMode(Enum):
|
67
70
|
MENU = "menu"
|
68
71
|
RUN = "run"
|
69
72
|
PREVIEW = "preview"
|
70
73
|
RUN_ALL = "run-all"
|
71
74
|
|
72
75
|
|
76
|
+
class CommandValidator(Validator):
|
77
|
+
"""Validator to check if the input is a valid command or toggle key."""
|
78
|
+
|
79
|
+
def __init__(self, falyx: Falyx, error_message: str) -> None:
|
80
|
+
super().__init__()
|
81
|
+
self.falyx = falyx
|
82
|
+
self.error_message = error_message
|
83
|
+
|
84
|
+
def validate(self, document) -> None:
|
85
|
+
text = document.text
|
86
|
+
is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True)
|
87
|
+
if is_preview:
|
88
|
+
return None
|
89
|
+
if not choice:
|
90
|
+
raise ValidationError(
|
91
|
+
message=self.error_message,
|
92
|
+
cursor_position=document.get_end_of_document_position(),
|
93
|
+
)
|
94
|
+
|
95
|
+
|
73
96
|
class Falyx:
|
74
97
|
"""
|
75
98
|
Main menu controller for Falyx CLI applications.
|
@@ -325,7 +348,7 @@ class Falyx:
|
|
325
348
|
keys.extend(cmd.aliases)
|
326
349
|
return WordCompleter(keys, ignore_case=True)
|
327
350
|
|
328
|
-
def
|
351
|
+
def _get_validator_error_message(self) -> str:
|
329
352
|
"""Validator to check if the input is a valid command or toggle key."""
|
330
353
|
keys = {self.exit_command.key.upper()}
|
331
354
|
keys.update({alias.upper() for alias in self.exit_command.aliases})
|
@@ -354,18 +377,7 @@ class Falyx:
|
|
354
377
|
if toggle_keys:
|
355
378
|
message_lines.append(f" Toggles: {toggles_str}")
|
356
379
|
error_message = " ".join(message_lines)
|
357
|
-
|
358
|
-
def validator(text):
|
359
|
-
is_preview, choice = self.get_command(text, from_validate=True)
|
360
|
-
if is_preview and choice is None:
|
361
|
-
return True
|
362
|
-
return bool(choice)
|
363
|
-
|
364
|
-
return Validator.from_callable(
|
365
|
-
validator,
|
366
|
-
error_message=error_message,
|
367
|
-
move_cursor_to_end=True,
|
368
|
-
)
|
380
|
+
return error_message
|
369
381
|
|
370
382
|
def _invalidate_prompt_session_cache(self):
|
371
383
|
"""Forces the prompt session to be recreated on the next access."""
|
@@ -428,9 +440,10 @@ class Falyx:
|
|
428
440
|
multiline=False,
|
429
441
|
completer=self._get_completer(),
|
430
442
|
reserve_space_for_menu=1,
|
431
|
-
validator=self.
|
443
|
+
validator=CommandValidator(self, self._get_validator_error_message()),
|
432
444
|
bottom_toolbar=self._get_bottom_bar_render(),
|
433
445
|
key_bindings=self.key_bindings,
|
446
|
+
validate_while_typing=False,
|
434
447
|
)
|
435
448
|
return self._prompt_session
|
436
449
|
|
@@ -694,32 +707,52 @@ class Falyx:
|
|
694
707
|
return False, input_str.strip()
|
695
708
|
|
696
709
|
def get_command(
|
697
|
-
self,
|
698
|
-
) -> tuple[bool, Command | None]:
|
710
|
+
self, raw_choices: str, from_validate=False
|
711
|
+
) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
|
699
712
|
"""
|
700
713
|
Returns the selected command based on user input.
|
701
714
|
Supports keys, aliases, and abbreviations.
|
702
715
|
"""
|
716
|
+
args = ()
|
717
|
+
kwargs: dict[str, Any] = {}
|
718
|
+
choice, *input_args = shlex.split(raw_choices)
|
703
719
|
is_preview, choice = self.parse_preview_command(choice)
|
704
720
|
if is_preview and not choice and self.help_command:
|
705
721
|
is_preview = False
|
706
722
|
choice = "?"
|
707
723
|
elif is_preview and not choice:
|
724
|
+
# No help command enabled
|
708
725
|
if not from_validate:
|
709
726
|
self.console.print(
|
710
727
|
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
|
711
728
|
)
|
712
|
-
return is_preview, None
|
729
|
+
return is_preview, None, args, kwargs
|
713
730
|
|
714
731
|
choice = choice.upper()
|
715
732
|
name_map = self._name_map
|
716
|
-
|
717
733
|
if choice in name_map:
|
718
|
-
|
734
|
+
if not from_validate:
|
735
|
+
logger.info("Command '%s' selected.", choice)
|
736
|
+
if input_args and name_map[choice].arg_parser:
|
737
|
+
try:
|
738
|
+
args, kwargs = name_map[choice].parse_args(input_args)
|
739
|
+
except CommandArgumentError as error:
|
740
|
+
if not from_validate:
|
741
|
+
if not name_map[choice].show_help():
|
742
|
+
self.console.print(
|
743
|
+
f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
|
744
|
+
)
|
745
|
+
else:
|
746
|
+
name_map[choice].show_help()
|
747
|
+
raise ValidationError(
|
748
|
+
message=str(error), cursor_position=len(raw_choices)
|
749
|
+
)
|
750
|
+
return is_preview, None, args, kwargs
|
751
|
+
return is_preview, name_map[choice], args, kwargs
|
719
752
|
|
720
753
|
prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
|
721
754
|
if len(prefix_matches) == 1:
|
722
|
-
return is_preview, prefix_matches[0]
|
755
|
+
return is_preview, prefix_matches[0], args, kwargs
|
723
756
|
|
724
757
|
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
|
725
758
|
if fuzzy_matches:
|
@@ -736,7 +769,7 @@ class Falyx:
|
|
736
769
|
self.console.print(
|
737
770
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
|
738
771
|
)
|
739
|
-
return is_preview, None
|
772
|
+
return is_preview, None, args, kwargs
|
740
773
|
|
741
774
|
def _create_context(self, selected_command: Command) -> ExecutionContext:
|
742
775
|
"""Creates a context dictionary for the selected command."""
|
@@ -759,8 +792,9 @@ class Falyx:
|
|
759
792
|
|
760
793
|
async def process_command(self) -> bool:
|
761
794
|
"""Processes the action of the selected command."""
|
762
|
-
|
763
|
-
|
795
|
+
with patch_stdout(raw=True):
|
796
|
+
choice = await self.prompt_session.prompt_async()
|
797
|
+
is_preview, selected_command, args, kwargs = self.get_command(choice)
|
764
798
|
if not selected_command:
|
765
799
|
logger.info("Invalid command '%s'.", choice)
|
766
800
|
return True
|
@@ -789,8 +823,8 @@ class Falyx:
|
|
789
823
|
context.start_timer()
|
790
824
|
try:
|
791
825
|
await self.hooks.trigger(HookType.BEFORE, context)
|
792
|
-
|
793
|
-
result = await selected_command()
|
826
|
+
print(args, kwargs)
|
827
|
+
result = await selected_command(*args, **kwargs)
|
794
828
|
context.result = result
|
795
829
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
796
830
|
except Exception as error:
|
@@ -803,10 +837,18 @@ class Falyx:
|
|
803
837
|
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
|
804
838
|
return True
|
805
839
|
|
806
|
-
async def run_key(
|
840
|
+
async def run_key(
|
841
|
+
self,
|
842
|
+
command_key: str,
|
843
|
+
return_context: bool = False,
|
844
|
+
args: tuple = (),
|
845
|
+
kwargs: dict[str, Any] | None = None,
|
846
|
+
) -> Any:
|
807
847
|
"""Run a command by key without displaying the menu (non-interactive mode)."""
|
808
848
|
self.debug_hooks()
|
809
|
-
is_preview, selected_command = self.get_command(command_key)
|
849
|
+
is_preview, selected_command, _, __ = self.get_command(command_key)
|
850
|
+
kwargs = kwargs or {}
|
851
|
+
|
810
852
|
self.last_run_command = selected_command
|
811
853
|
|
812
854
|
if not selected_command:
|
@@ -827,7 +869,7 @@ class Falyx:
|
|
827
869
|
context.start_timer()
|
828
870
|
try:
|
829
871
|
await self.hooks.trigger(HookType.BEFORE, context)
|
830
|
-
result = await selected_command()
|
872
|
+
result = await selected_command(*args, **kwargs)
|
831
873
|
context.result = result
|
832
874
|
|
833
875
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
@@ -922,6 +964,8 @@ class Falyx:
|
|
922
964
|
logger.info("BackSignal received.")
|
923
965
|
except CancelSignal:
|
924
966
|
logger.info("CancelSignal received.")
|
967
|
+
except HelpSignal:
|
968
|
+
logger.info("HelpSignal received.")
|
925
969
|
finally:
|
926
970
|
logger.info("Exiting menu: %s", self.get_title())
|
927
971
|
if self.exit_message:
|
@@ -956,7 +1000,7 @@ class Falyx:
|
|
956
1000
|
|
957
1001
|
if self.cli_args.command == "preview":
|
958
1002
|
self.mode = FalyxMode.PREVIEW
|
959
|
-
_, command = self.get_command(self.cli_args.name)
|
1003
|
+
_, command, args, kwargs = self.get_command(self.cli_args.name)
|
960
1004
|
if not command:
|
961
1005
|
self.console.print(
|
962
1006
|
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
|
@@ -970,7 +1014,7 @@ class Falyx:
|
|
970
1014
|
|
971
1015
|
if self.cli_args.command == "run":
|
972
1016
|
self.mode = FalyxMode.RUN
|
973
|
-
is_preview, command = self.get_command(self.cli_args.name)
|
1017
|
+
is_preview, command, _, __ = self.get_command(self.cli_args.name)
|
974
1018
|
if is_preview:
|
975
1019
|
if command is None:
|
976
1020
|
sys.exit(1)
|
@@ -981,7 +1025,11 @@ class Falyx:
|
|
981
1025
|
sys.exit(1)
|
982
1026
|
self._set_retry_policy(command)
|
983
1027
|
try:
|
984
|
-
|
1028
|
+
args, kwargs = command.parse_args(self.cli_args.command_args)
|
1029
|
+
except HelpSignal:
|
1030
|
+
sys.exit(0)
|
1031
|
+
try:
|
1032
|
+
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
|
985
1033
|
except FalyxError as error:
|
986
1034
|
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
|
987
1035
|
sys.exit(1)
|
@@ -2,7 +2,7 @@
|
|
2
2
|
"""parsers.py
|
3
3
|
This module contains the argument parsers used for the Falyx CLI.
|
4
4
|
"""
|
5
|
-
from argparse import ArgumentParser, Namespace, _SubParsersAction
|
5
|
+
from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction
|
6
6
|
from dataclasses import asdict, dataclass
|
7
7
|
from typing import Any, Sequence
|
8
8
|
|
@@ -114,6 +114,12 @@ def get_arg_parsers(
|
|
114
114
|
help="Skip confirmation prompts",
|
115
115
|
)
|
116
116
|
|
117
|
+
run_group.add_argument(
|
118
|
+
"command_args",
|
119
|
+
nargs=REMAINDER,
|
120
|
+
help="Arguments to pass to the command (if applicable)",
|
121
|
+
)
|
122
|
+
|
117
123
|
run_all_parser = subparsers.add_parser(
|
118
124
|
"run-all", help="Run all commands with a given tag"
|
119
125
|
)
|
@@ -2,10 +2,16 @@
|
|
2
2
|
"""protocols.py"""
|
3
3
|
from __future__ import annotations
|
4
4
|
|
5
|
-
from typing import Any, Awaitable, Protocol
|
5
|
+
from typing import Any, Awaitable, Protocol, runtime_checkable
|
6
6
|
|
7
7
|
from falyx.action.action import BaseAction
|
8
8
|
|
9
9
|
|
10
|
+
@runtime_checkable
|
10
11
|
class ActionFactoryProtocol(Protocol):
|
11
12
|
async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ...
|
13
|
+
|
14
|
+
|
15
|
+
@runtime_checkable
|
16
|
+
class ArgParserProtocol(Protocol):
|
17
|
+
def __call__(self, args: list[str]) -> tuple[tuple, dict]: ...
|
@@ -29,3 +29,10 @@ class CancelSignal(FlowSignal):
|
|
29
29
|
|
30
30
|
def __init__(self, message: str = "Cancel signal received."):
|
31
31
|
super().__init__(message)
|
32
|
+
|
33
|
+
|
34
|
+
class HelpSignal(FlowSignal):
|
35
|
+
"""Raised to display help information."""
|
36
|
+
|
37
|
+
def __init__(self, message: str = "Help signal received."):
|
38
|
+
super().__init__(message)
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = "0.1.28"
|
falyx-0.1.27/falyx/version.py
DELETED
@@ -1 +0,0 @@
|
|
1
|
-
__version__ = "0.1.27"
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|