falyx 0.1.32__tar.gz → 0.1.34__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 (53) hide show
  1. {falyx-0.1.32 → falyx-0.1.34}/PKG-INFO +1 -1
  2. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/__init__.py +2 -0
  3. falyx-0.1.34/falyx/action/prompt_menu_action.py +134 -0
  4. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/selection_action.py +40 -9
  5. {falyx-0.1.32 → falyx-0.1.34}/falyx/command.py +4 -2
  6. {falyx-0.1.32 → falyx-0.1.34}/falyx/falyx.py +18 -10
  7. {falyx-0.1.32 → falyx-0.1.34}/falyx/menu.py +8 -0
  8. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/argparse.py +84 -36
  9. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/parsers.py +1 -1
  10. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/signature.py +1 -1
  11. {falyx-0.1.32 → falyx-0.1.34}/falyx/selection.py +1 -1
  12. falyx-0.1.34/falyx/version.py +1 -0
  13. {falyx-0.1.32 → falyx-0.1.34}/pyproject.toml +1 -1
  14. falyx-0.1.32/falyx/version.py +0 -1
  15. {falyx-0.1.32 → falyx-0.1.34}/LICENSE +0 -0
  16. {falyx-0.1.32 → falyx-0.1.34}/README.md +0 -0
  17. {falyx-0.1.32 → falyx-0.1.34}/falyx/.pytyped +0 -0
  18. {falyx-0.1.32 → falyx-0.1.34}/falyx/__init__.py +0 -0
  19. {falyx-0.1.32 → falyx-0.1.34}/falyx/__main__.py +0 -0
  20. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/.pytyped +0 -0
  21. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/action.py +0 -0
  22. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/action_factory.py +0 -0
  23. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/http_action.py +0 -0
  24. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/io_action.py +0 -0
  25. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/menu_action.py +0 -0
  26. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/select_file_action.py +0 -0
  27. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/signal_action.py +0 -0
  28. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/types.py +0 -0
  29. {falyx-0.1.32 → falyx-0.1.34}/falyx/action/user_input_action.py +0 -0
  30. {falyx-0.1.32 → falyx-0.1.34}/falyx/bottom_bar.py +0 -0
  31. {falyx-0.1.32 → falyx-0.1.34}/falyx/config.py +0 -0
  32. {falyx-0.1.32 → falyx-0.1.34}/falyx/context.py +0 -0
  33. {falyx-0.1.32 → falyx-0.1.34}/falyx/debug.py +0 -0
  34. {falyx-0.1.32 → falyx-0.1.34}/falyx/exceptions.py +0 -0
  35. {falyx-0.1.32 → falyx-0.1.34}/falyx/execution_registry.py +0 -0
  36. {falyx-0.1.32 → falyx-0.1.34}/falyx/hook_manager.py +0 -0
  37. {falyx-0.1.32 → falyx-0.1.34}/falyx/hooks.py +0 -0
  38. {falyx-0.1.32 → falyx-0.1.34}/falyx/init.py +0 -0
  39. {falyx-0.1.32 → falyx-0.1.34}/falyx/logger.py +0 -0
  40. {falyx-0.1.32 → falyx-0.1.34}/falyx/options_manager.py +0 -0
  41. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/.pytyped +0 -0
  42. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/__init__.py +0 -0
  43. {falyx-0.1.32 → falyx-0.1.34}/falyx/parsers/utils.py +0 -0
  44. {falyx-0.1.32 → falyx-0.1.34}/falyx/prompt_utils.py +0 -0
  45. {falyx-0.1.32 → falyx-0.1.34}/falyx/protocols.py +0 -0
  46. {falyx-0.1.32 → falyx-0.1.34}/falyx/retry.py +0 -0
  47. {falyx-0.1.32 → falyx-0.1.34}/falyx/retry_utils.py +0 -0
  48. {falyx-0.1.32 → falyx-0.1.34}/falyx/signals.py +0 -0
  49. {falyx-0.1.32 → falyx-0.1.34}/falyx/tagged_table.py +0 -0
  50. {falyx-0.1.32 → falyx-0.1.34}/falyx/themes/__init__.py +0 -0
  51. {falyx-0.1.32 → falyx-0.1.34}/falyx/themes/colors.py +0 -0
  52. {falyx-0.1.32 → falyx-0.1.34}/falyx/utils.py +0 -0
  53. {falyx-0.1.32 → falyx-0.1.34}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.32
3
+ Version: 0.1.34
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -18,6 +18,7 @@ from .action_factory import ActionFactoryAction
18
18
  from .http_action import HTTPAction
19
19
  from .io_action import BaseIOAction, ShellAction
20
20
  from .menu_action import MenuAction
21
+ from .prompt_menu_action import PromptMenuAction
21
22
  from .select_file_action import SelectFileAction
22
23
  from .selection_action import SelectionAction
23
24
  from .signal_action import SignalAction
@@ -40,4 +41,5 @@ __all__ = [
40
41
  "FallbackAction",
41
42
  "LiteralInputAction",
42
43
  "UserInputAction",
44
+ "PromptMenuAction",
43
45
  ]
@@ -0,0 +1,134 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
+ """prompt_menu_action.py"""
3
+ from typing import Any
4
+
5
+ from prompt_toolkit import PromptSession
6
+ from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text
7
+ from rich.console import Console
8
+ from rich.tree import Tree
9
+
10
+ from falyx.action.action import BaseAction
11
+ from falyx.context import ExecutionContext
12
+ from falyx.execution_registry import ExecutionRegistry as er
13
+ from falyx.hook_manager import HookType
14
+ from falyx.logger import logger
15
+ from falyx.menu import MenuOptionMap
16
+ from falyx.signals import BackSignal, QuitSignal
17
+ from falyx.themes import OneColors
18
+
19
+
20
+ class PromptMenuAction(BaseAction):
21
+ """PromptMenuAction class for creating prompt -> actions."""
22
+
23
+ def __init__(
24
+ self,
25
+ name: str,
26
+ menu_options: MenuOptionMap,
27
+ *,
28
+ prompt_message: str = "Select > ",
29
+ default_selection: str = "",
30
+ inject_last_result: bool = False,
31
+ inject_into: str = "last_result",
32
+ console: Console | None = None,
33
+ prompt_session: PromptSession | None = None,
34
+ never_prompt: bool = False,
35
+ include_reserved: bool = True,
36
+ ):
37
+ super().__init__(
38
+ name,
39
+ inject_last_result=inject_last_result,
40
+ inject_into=inject_into,
41
+ never_prompt=never_prompt,
42
+ )
43
+ self.menu_options = menu_options
44
+ self.prompt_message = prompt_message
45
+ self.default_selection = default_selection
46
+ self.console = console or Console(color_system="auto")
47
+ self.prompt_session = prompt_session or PromptSession()
48
+ self.include_reserved = include_reserved
49
+
50
+ def get_infer_target(self) -> tuple[None, None]:
51
+ return None, None
52
+
53
+ async def _run(self, *args, **kwargs) -> Any:
54
+ kwargs = self._maybe_inject_last_result(kwargs)
55
+ context = ExecutionContext(
56
+ name=self.name,
57
+ args=args,
58
+ kwargs=kwargs,
59
+ action=self,
60
+ )
61
+
62
+ effective_default = self.default_selection
63
+ maybe_result = str(self.last_result)
64
+ if maybe_result in self.menu_options:
65
+ effective_default = maybe_result
66
+ elif self.inject_last_result:
67
+ logger.warning(
68
+ "[%s] Injected last result '%s' not found in menu options",
69
+ self.name,
70
+ maybe_result,
71
+ )
72
+
73
+ if self.never_prompt and not effective_default:
74
+ raise ValueError(
75
+ f"[{self.name}] 'never_prompt' is True but no valid default_selection"
76
+ " was provided."
77
+ )
78
+
79
+ context.start_timer()
80
+ try:
81
+ await self.hooks.trigger(HookType.BEFORE, context)
82
+ key = effective_default
83
+ if not self.never_prompt:
84
+ placeholder_formatted_text = []
85
+ for index, (key, option) in enumerate(self.menu_options.items()):
86
+ placeholder_formatted_text.append(option.render_prompt(key))
87
+ if index < len(self.menu_options) - 1:
88
+ placeholder_formatted_text.append(
89
+ FormattedText([(OneColors.WHITE, " | ")])
90
+ )
91
+ placeholder = merge_formatted_text(placeholder_formatted_text)
92
+ key = await self.prompt_session.prompt_async(
93
+ message=self.prompt_message, placeholder=placeholder
94
+ )
95
+ option = self.menu_options[key]
96
+ result = await option.action(*args, **kwargs)
97
+ context.result = result
98
+ await self.hooks.trigger(HookType.ON_SUCCESS, context)
99
+ return result
100
+
101
+ except BackSignal:
102
+ logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name)
103
+ return None
104
+ except QuitSignal:
105
+ logger.debug("[%s][QuitSignal] ← Exiting application", self.name)
106
+ raise
107
+ except Exception as error:
108
+ context.exception = error
109
+ await self.hooks.trigger(HookType.ON_ERROR, context)
110
+ raise
111
+ finally:
112
+ context.stop_timer()
113
+ await self.hooks.trigger(HookType.AFTER, context)
114
+ await self.hooks.trigger(HookType.ON_TEARDOWN, context)
115
+ er.record(context)
116
+
117
+ async def preview(self, parent: Tree | None = None):
118
+ label = f"[{OneColors.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'"
119
+ tree = parent.add(label) if parent else Tree(label)
120
+ for key, option in self.menu_options.items():
121
+ tree.add(
122
+ f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]"
123
+ )
124
+ await option.action.preview(parent=tree)
125
+ if not parent:
126
+ self.console.print(tree)
127
+
128
+ def __str__(self) -> str:
129
+ return (
130
+ f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, "
131
+ f"default_selection={self.default_selection!r}, "
132
+ f"include_reserved={self.include_reserved}, "
133
+ f"prompt={'off' if self.never_prompt else 'on'})"
134
+ )
@@ -1,5 +1,6 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
2
  """selection_action.py"""
3
+ from copy import copy
3
4
  from typing import Any
4
5
 
5
6
  from prompt_toolkit import PromptSession
@@ -72,6 +73,7 @@ class SelectionAction(BaseAction):
72
73
  self.default_selection = default_selection
73
74
  self.prompt_message = prompt_message
74
75
  self.show_table = show_table
76
+ self.cancel_key = self._find_cancel_key()
75
77
 
76
78
  def _coerce_return_type(
77
79
  self, return_type: SelectionReturnType | str
@@ -115,12 +117,40 @@ class SelectionAction(BaseAction):
115
117
  )
116
118
 
117
119
  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)
120
+ """Find the cancel key in the selections."""
121
+ if isinstance(self.selections, dict):
122
+ for index in range(len(self.selections) + 1):
123
+ if str(index) not in self.selections:
124
+ return str(index)
122
125
  return str(len(self.selections))
123
126
 
127
+ @property
128
+ def cancel_key(self) -> str:
129
+ return self._cancel_key
130
+
131
+ @cancel_key.setter
132
+ def cancel_key(self, value: str) -> None:
133
+ """Set the cancel key for the selection."""
134
+ if not isinstance(value, str):
135
+ raise TypeError("Cancel key must be a string.")
136
+ if isinstance(self.selections, dict) and value in self.selections:
137
+ raise ValueError(
138
+ "Cancel key cannot be one of the selection keys. "
139
+ f"Current selections: {self.selections}"
140
+ )
141
+ if isinstance(self.selections, list):
142
+ if not value.isdigit() or int(value) > len(self.selections):
143
+ raise ValueError(
144
+ "cancel_key must be a digit and not greater than the number of selections."
145
+ )
146
+ self._cancel_key = value
147
+
148
+ def cancel_formatter(self, index: int, selection: str) -> str:
149
+ """Format the cancel option for display."""
150
+ if self.cancel_key == str(index):
151
+ return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]"
152
+ return f"[{index}] {selection}"
153
+
124
154
  def get_infer_target(self) -> tuple[None, None]:
125
155
  return None, None
126
156
 
@@ -164,16 +194,17 @@ class SelectionAction(BaseAction):
164
194
 
165
195
  context.start_timer()
166
196
  try:
167
- cancel_key = self._find_cancel_key()
197
+ self.cancel_key = self._find_cancel_key()
168
198
  await self.hooks.trigger(HookType.BEFORE, context)
169
199
  if isinstance(self.selections, list):
170
200
  table = render_selection_indexed_table(
171
201
  title=self.title,
172
202
  selections=self.selections + ["Cancel"],
173
203
  columns=self.columns,
204
+ formatter=self.cancel_formatter,
174
205
  )
175
206
  if not self.never_prompt:
176
- index = await prompt_for_index(
207
+ index: int | str = await prompt_for_index(
177
208
  len(self.selections),
178
209
  table,
179
210
  default_selection=effective_default,
@@ -184,12 +215,12 @@ class SelectionAction(BaseAction):
184
215
  )
185
216
  else:
186
217
  index = effective_default
187
- if index == cancel_key:
218
+ if int(index) == int(self.cancel_key):
188
219
  raise CancelSignal("User cancelled the selection.")
189
220
  result: Any = self.selections[int(index)]
190
221
  elif isinstance(self.selections, dict):
191
222
  cancel_option = {
192
- cancel_key: SelectionOption(
223
+ self.cancel_key: SelectionOption(
193
224
  description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
194
225
  )
195
226
  }
@@ -210,7 +241,7 @@ class SelectionAction(BaseAction):
210
241
  )
211
242
  else:
212
243
  key = effective_default
213
- if key == cancel_key:
244
+ if key == self.cancel_key:
214
245
  raise CancelSignal("User cancelled the selection.")
215
246
  if self.return_type == SelectionReturnType.KEY:
216
247
  result = key
@@ -139,7 +139,7 @@ class Command(BaseModel):
139
139
 
140
140
  model_config = ConfigDict(arbitrary_types_allowed=True)
141
141
 
142
- def parse_args(
142
+ async def parse_args(
143
143
  self, raw_args: list[str] | str, from_validate: bool = False
144
144
  ) -> tuple[tuple, dict]:
145
145
  if callable(self.custom_parser):
@@ -165,7 +165,9 @@ class Command(BaseModel):
165
165
  raw_args,
166
166
  )
167
167
  return ((), {})
168
- return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate)
168
+ return await self.arg_parser.parse_args_split(
169
+ raw_args, from_validate=from_validate
170
+ )
169
171
 
170
172
  @field_validator("action", mode="before")
171
173
  @classmethod
@@ -83,8 +83,11 @@ class CommandValidator(Validator):
83
83
  self.error_message = error_message
84
84
 
85
85
  def validate(self, document) -> None:
86
+ pass
87
+
88
+ async def validate_async(self, document) -> None:
86
89
  text = document.text
87
- is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True)
90
+ is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
88
91
  if is_preview:
89
92
  return None
90
93
  if not choice:
@@ -188,7 +191,7 @@ class Falyx:
188
191
  self.cli_args: Namespace | None = cli_args
189
192
  self.render_menu: Callable[[Falyx], None] | None = render_menu
190
193
  self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
191
- self.hide_menu_table: bool = hide_menu_table
194
+ self._hide_menu_table: bool = hide_menu_table
192
195
  self.validate_options(cli_args, options)
193
196
  self._prompt_session: PromptSession | None = None
194
197
  self.mode = FalyxMode.MENU
@@ -740,7 +743,7 @@ class Falyx:
740
743
  return True, input_str[1:].strip()
741
744
  return False, input_str.strip()
742
745
 
743
- def get_command(
746
+ async def get_command(
744
747
  self, raw_choices: str, from_validate=False
745
748
  ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
746
749
  """
@@ -773,7 +776,9 @@ class Falyx:
773
776
  if is_preview:
774
777
  return True, name_map[choice], args, kwargs
775
778
  try:
776
- args, kwargs = name_map[choice].parse_args(input_args, from_validate)
779
+ args, kwargs = await name_map[choice].parse_args(
780
+ input_args, from_validate
781
+ )
777
782
  except CommandArgumentError as error:
778
783
  if not from_validate:
779
784
  if not name_map[choice].show_help():
@@ -834,7 +839,7 @@ class Falyx:
834
839
  """Processes the action of the selected command."""
835
840
  with patch_stdout(raw=True):
836
841
  choice = await self.prompt_session.prompt_async()
837
- is_preview, selected_command, args, kwargs = self.get_command(choice)
842
+ is_preview, selected_command, args, kwargs = await self.get_command(choice)
838
843
  if not selected_command:
839
844
  logger.info("Invalid command '%s'.", choice)
840
845
  return True
@@ -876,7 +881,7 @@ class Falyx:
876
881
  ) -> Any:
877
882
  """Run a command by key without displaying the menu (non-interactive mode)."""
878
883
  self.debug_hooks()
879
- is_preview, selected_command, _, __ = self.get_command(command_key)
884
+ is_preview, selected_command, _, __ = await self.get_command(command_key)
880
885
  kwargs = kwargs or {}
881
886
 
882
887
  self.last_run_command = selected_command
@@ -975,7 +980,7 @@ class Falyx:
975
980
  self.print_message(self.welcome_message)
976
981
  try:
977
982
  while True:
978
- if not self.hide_menu_table:
983
+ if not self.options.get("hide_menu_table", self._hide_menu_table):
979
984
  if callable(self.render_menu):
980
985
  self.render_menu(self)
981
986
  else:
@@ -1012,6 +1017,9 @@ class Falyx:
1012
1017
  if not self.options.get("force_confirm"):
1013
1018
  self.options.set("force_confirm", self._force_confirm)
1014
1019
 
1020
+ if not self.options.get("hide_menu_table"):
1021
+ self.options.set("hide_menu_table", self._hide_menu_table)
1022
+
1015
1023
  if self.cli_args.verbose:
1016
1024
  logging.getLogger("falyx").setLevel(logging.DEBUG)
1017
1025
 
@@ -1029,7 +1037,7 @@ class Falyx:
1029
1037
 
1030
1038
  if self.cli_args.command == "preview":
1031
1039
  self.mode = FalyxMode.PREVIEW
1032
- _, command, args, kwargs = self.get_command(self.cli_args.name)
1040
+ _, command, args, kwargs = await self.get_command(self.cli_args.name)
1033
1041
  if not command:
1034
1042
  self.console.print(
1035
1043
  f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
@@ -1043,7 +1051,7 @@ class Falyx:
1043
1051
 
1044
1052
  if self.cli_args.command == "run":
1045
1053
  self.mode = FalyxMode.RUN
1046
- is_preview, command, _, __ = self.get_command(self.cli_args.name)
1054
+ is_preview, command, _, __ = await self.get_command(self.cli_args.name)
1047
1055
  if is_preview:
1048
1056
  if command is None:
1049
1057
  sys.exit(1)
@@ -1054,7 +1062,7 @@ class Falyx:
1054
1062
  sys.exit(1)
1055
1063
  self._set_retry_policy(command)
1056
1064
  try:
1057
- args, kwargs = command.parse_args(self.cli_args.command_args)
1065
+ args, kwargs = await command.parse_args(self.cli_args.command_args)
1058
1066
  except HelpSignal:
1059
1067
  sys.exit(0)
1060
1068
  try:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
4
 
5
+ from prompt_toolkit.formatted_text import FormattedText
6
+
5
7
  from falyx.action import BaseAction
6
8
  from falyx.signals import BackSignal, QuitSignal
7
9
  from falyx.themes import OneColors
@@ -26,6 +28,12 @@ class MenuOption:
26
28
  """Render the menu option for display."""
27
29
  return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]"
28
30
 
31
+ def render_prompt(self, key: str) -> FormattedText:
32
+ """Render the menu option for prompt display."""
33
+ return FormattedText(
34
+ [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)]
35
+ )
36
+
29
37
 
30
38
  class MenuOptionMap(CaseInsensitiveDict):
31
39
  """
@@ -39,7 +39,7 @@ class ArgumentAction(Enum):
39
39
  class Argument:
40
40
  """Represents a command-line argument."""
41
41
 
42
- flags: list[str]
42
+ flags: tuple[str, ...]
43
43
  dest: str # Destination name for the argument
44
44
  action: ArgumentAction = (
45
45
  ArgumentAction.STORE
@@ -49,7 +49,7 @@ class Argument:
49
49
  choices: list[str] | None = None # List of valid choices for the argument
50
50
  required: bool = False # True if the argument is required
51
51
  help: str = "" # Help text for the argument
52
- nargs: int | str = 1 # int, '?', '*', '+'
52
+ nargs: int | str | None = None # int, '?', '*', '+', None
53
53
  positional: bool = False # True if no leading - or -- in flags
54
54
 
55
55
  def get_positional_text(self) -> str:
@@ -77,7 +77,11 @@ class Argument:
77
77
  and not self.positional
78
78
  ):
79
79
  choice_text = self.dest.upper()
80
- elif isinstance(self.nargs, str):
80
+ elif self.action in (
81
+ ArgumentAction.STORE,
82
+ ArgumentAction.APPEND,
83
+ ArgumentAction.EXTEND,
84
+ ) or isinstance(self.nargs, str):
81
85
  choice_text = self.dest
82
86
 
83
87
  if self.nargs == "?":
@@ -147,6 +151,7 @@ class CommandArgumentParser:
147
151
  aliases: list[str] | None = None,
148
152
  ) -> None:
149
153
  """Initialize the CommandArgumentParser."""
154
+ self.console = Console(color_system="auto")
150
155
  self.command_key: str = command_key
151
156
  self.command_description: str = command_description
152
157
  self.command_style: str = command_style
@@ -159,7 +164,6 @@ class CommandArgumentParser:
159
164
  self._flag_map: dict[str, Argument] = {}
160
165
  self._dest_set: set[str] = set()
161
166
  self._add_help()
162
- self.console = Console(color_system="auto")
163
167
 
164
168
  def _add_help(self):
165
169
  """Add help argument to the parser."""
@@ -181,9 +185,7 @@ class CommandArgumentParser:
181
185
  raise CommandArgumentError("Positional arguments cannot have multiple flags")
182
186
  return positional
183
187
 
184
- def _get_dest_from_flags(
185
- self, flags: tuple[str, ...], dest: str | None
186
- ) -> str | None:
188
+ def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
187
189
  """Convert flags to a destination name."""
188
190
  if dest:
189
191
  if not dest.replace("_", "").isalnum():
@@ -212,7 +214,7 @@ class CommandArgumentParser:
212
214
  return dest
213
215
 
214
216
  def _determine_required(
215
- self, required: bool, positional: bool, nargs: int | str
217
+ self, required: bool, positional: bool, nargs: int | str | None
216
218
  ) -> bool:
217
219
  """Determine if the argument is required."""
218
220
  if required:
@@ -230,7 +232,22 @@ class CommandArgumentParser:
230
232
 
231
233
  return required
232
234
 
233
- def _validate_nargs(self, nargs: int | str) -> int | str:
235
+ def _validate_nargs(
236
+ self, nargs: int | str | None, action: ArgumentAction
237
+ ) -> int | str | None:
238
+ if action in (
239
+ ArgumentAction.STORE_FALSE,
240
+ ArgumentAction.STORE_TRUE,
241
+ ArgumentAction.COUNT,
242
+ ArgumentAction.HELP,
243
+ ):
244
+ if nargs is not None:
245
+ raise CommandArgumentError(
246
+ f"nargs cannot be specified for {action} actions"
247
+ )
248
+ return None
249
+ if nargs is None:
250
+ nargs = 1
234
251
  allowed_nargs = ("?", "*", "+")
235
252
  if isinstance(nargs, int):
236
253
  if nargs <= 0:
@@ -242,7 +259,9 @@ class CommandArgumentParser:
242
259
  raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
243
260
  return nargs
244
261
 
245
- def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]:
262
+ def _normalize_choices(
263
+ self, choices: Iterable | None, expected_type: Any
264
+ ) -> list[Any]:
246
265
  if choices is not None:
247
266
  if isinstance(choices, dict):
248
267
  raise CommandArgumentError("choices cannot be a dict")
@@ -289,8 +308,34 @@ class CommandArgumentParser:
289
308
  f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
290
309
  )
291
310
 
311
+ def _validate_action(
312
+ self, action: ArgumentAction | str, positional: bool
313
+ ) -> ArgumentAction:
314
+ if not isinstance(action, ArgumentAction):
315
+ try:
316
+ action = ArgumentAction(action)
317
+ except ValueError:
318
+ raise CommandArgumentError(
319
+ f"Invalid action '{action}' is not a valid ArgumentAction"
320
+ )
321
+ if action in (
322
+ ArgumentAction.STORE_TRUE,
323
+ ArgumentAction.STORE_FALSE,
324
+ ArgumentAction.COUNT,
325
+ ArgumentAction.HELP,
326
+ ):
327
+ if positional:
328
+ raise CommandArgumentError(
329
+ f"Action '{action}' cannot be used with positional arguments"
330
+ )
331
+
332
+ return action
333
+
292
334
  def _resolve_default(
293
- self, action: ArgumentAction, default: Any, nargs: str | int
335
+ self,
336
+ default: Any,
337
+ action: ArgumentAction,
338
+ nargs: str | int | None,
294
339
  ) -> Any:
295
340
  """Get the default value for the argument."""
296
341
  if default is None:
@@ -324,7 +369,18 @@ class CommandArgumentParser:
324
369
  f"Flag '{flag}' must be a single character or start with '--'"
325
370
  )
326
371
 
327
- def add_argument(self, *flags, **kwargs):
372
+ def add_argument(
373
+ self,
374
+ *flags,
375
+ action: str | ArgumentAction = "store",
376
+ nargs: int | str | None = None,
377
+ default: Any = None,
378
+ type: Any = str,
379
+ choices: Iterable | None = None,
380
+ required: bool = False,
381
+ help: str = "",
382
+ dest: str | None = None,
383
+ ) -> None:
328
384
  """Add an argument to the parser.
329
385
  Args:
330
386
  name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
@@ -337,9 +393,10 @@ class CommandArgumentParser:
337
393
  help: A brief description of the argument.
338
394
  dest: The name of the attribute to be added to the object returned by parse_args().
339
395
  """
396
+ expected_type = type
340
397
  self._validate_flags(flags)
341
398
  positional = self._is_positional(flags)
342
- dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
399
+ dest = self._get_dest_from_flags(flags, dest)
343
400
  if dest in self._dest_set:
344
401
  raise CommandArgumentError(
345
402
  f"Destination '{dest}' is already defined.\n"
@@ -347,18 +404,9 @@ class CommandArgumentParser:
347
404
  "is not supported. Define a unique 'dest' for each argument."
348
405
  )
349
406
  self._dest_set.add(dest)
350
- action = kwargs.get("action", ArgumentAction.STORE)
351
- if not isinstance(action, ArgumentAction):
352
- try:
353
- action = ArgumentAction(action)
354
- except ValueError:
355
- raise CommandArgumentError(
356
- f"Invalid action '{action}' is not a valid ArgumentAction"
357
- )
358
- flags = list(flags)
359
- nargs = self._validate_nargs(kwargs.get("nargs", 1))
360
- default = self._resolve_default(action, kwargs.get("default"), nargs)
361
- expected_type = kwargs.get("type", str)
407
+ action = self._validate_action(action, positional)
408
+ nargs = self._validate_nargs(nargs, action)
409
+ default = self._resolve_default(default, action, nargs)
362
410
  if (
363
411
  action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
364
412
  and default is not None
@@ -367,14 +415,12 @@ class CommandArgumentParser:
367
415
  self._validate_default_list_type(default, expected_type, dest)
368
416
  else:
369
417
  self._validate_default_type(default, expected_type, dest)
370
- choices = self._normalize_choices(kwargs.get("choices"), expected_type)
418
+ choices = self._normalize_choices(choices, expected_type)
371
419
  if default is not None and choices and default not in choices:
372
420
  raise CommandArgumentError(
373
421
  f"Default value '{default}' not in allowed choices: {choices}"
374
422
  )
375
- required = self._determine_required(
376
- kwargs.get("required", False), positional, nargs
377
- )
423
+ required = self._determine_required(required, positional, nargs)
378
424
  argument = Argument(
379
425
  flags=flags,
380
426
  dest=dest,
@@ -383,7 +429,7 @@ class CommandArgumentParser:
383
429
  default=default,
384
430
  choices=choices,
385
431
  required=required,
386
- help=kwargs.get("help", ""),
432
+ help=help,
387
433
  nargs=nargs,
388
434
  positional=positional,
389
435
  )
@@ -426,11 +472,11 @@ class CommandArgumentParser:
426
472
  values = []
427
473
  i = start
428
474
  if isinstance(spec.nargs, int):
429
- # assert i + spec.nargs <= len(
430
- # args
431
- # ), "Not enough arguments provided: shouldn't happen"
432
475
  values = args[i : i + spec.nargs]
433
476
  return values, i + spec.nargs
477
+ elif spec.nargs is None:
478
+ values = [args[i]]
479
+ return values, i + 1
434
480
  elif spec.nargs == "+":
435
481
  if i >= len(args):
436
482
  raise CommandArgumentError(
@@ -475,6 +521,8 @@ class CommandArgumentParser:
475
521
  for next_spec in positional_args[j + 1 :]:
476
522
  if isinstance(next_spec.nargs, int):
477
523
  min_required += next_spec.nargs
524
+ elif next_spec.nargs is None:
525
+ min_required += 1
478
526
  elif next_spec.nargs == "+":
479
527
  min_required += 1
480
528
  elif next_spec.nargs == "?":
@@ -517,7 +565,7 @@ class CommandArgumentParser:
517
565
 
518
566
  return i
519
567
 
520
- def parse_args(
568
+ async def parse_args(
521
569
  self, args: list[str] | None = None, from_validate: bool = False
522
570
  ) -> dict[str, Any]:
523
571
  """Parse Falyx Command arguments."""
@@ -665,7 +713,7 @@ class CommandArgumentParser:
665
713
  result.pop("help", None)
666
714
  return result
667
715
 
668
- def parse_args_split(
716
+ async def parse_args_split(
669
717
  self, args: list[str], from_validate: bool = False
670
718
  ) -> tuple[tuple[Any, ...], dict[str, Any]]:
671
719
  """
@@ -673,7 +721,7 @@ class CommandArgumentParser:
673
721
  tuple[args, kwargs] - Positional arguments in defined order,
674
722
  followed by keyword argument mapping.
675
723
  """
676
- parsed = self.parse_args(args, from_validate)
724
+ parsed = await self.parse_args(args, from_validate)
677
725
  args_list = []
678
726
  kwargs_dict = {}
679
727
  for arg in self._arguments:
@@ -114,7 +114,7 @@ def get_arg_parsers(
114
114
  help="Skip confirmation prompts",
115
115
  )
116
116
 
117
- run_group.add_argument(
117
+ run_parser.add_argument(
118
118
  "command_args",
119
119
  nargs=REMAINDER,
120
120
  help="Arguments to pass to the command (if applicable)",
@@ -42,7 +42,7 @@ def infer_args_from_func(
42
42
  else:
43
43
  flags = [f"--{name.replace('_', '-')}"]
44
44
  action = "store"
45
- nargs: int | str = 1
45
+ nargs: int | str | None = None
46
46
 
47
47
  if arg_type is bool:
48
48
  if param.default is False:
@@ -271,7 +271,7 @@ async def prompt_for_index(
271
271
  prompt_session: PromptSession | None = None,
272
272
  prompt_message: str = "Select an option > ",
273
273
  show_table: bool = True,
274
- ):
274
+ ) -> int:
275
275
  prompt_session = prompt_session or PromptSession()
276
276
  console = console or Console(color_system="auto")
277
277
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.34"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.32"
3
+ version = "0.1.34"
4
4
  description = "Reliable and introspectable async CLI action framework."
5
5
  authors = ["Roland Thomas Jr <roland@rtj.dev>"]
6
6
  license = "MIT"
@@ -1 +0,0 @@
1
- __version__ = "0.1.32"
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