falyx 0.1.33__tar.gz → 0.1.35__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 (54) hide show
  1. {falyx-0.1.33 → falyx-0.1.35}/PKG-INFO +1 -1
  2. falyx-0.1.35/falyx/.coverage +0 -0
  3. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/__init__.py +2 -0
  4. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/action.py +1 -1
  5. falyx-0.1.35/falyx/action/prompt_menu_action.py +134 -0
  6. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/selection_action.py +40 -9
  7. {falyx-0.1.33 → falyx-0.1.35}/falyx/command.py +4 -2
  8. {falyx-0.1.33 → falyx-0.1.35}/falyx/falyx.py +18 -10
  9. {falyx-0.1.33 → falyx-0.1.35}/falyx/menu.py +8 -0
  10. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/argparse.py +79 -35
  11. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/signature.py +1 -1
  12. {falyx-0.1.33 → falyx-0.1.35}/falyx/selection.py +1 -1
  13. falyx-0.1.35/falyx/version.py +1 -0
  14. {falyx-0.1.33 → falyx-0.1.35}/pyproject.toml +1 -1
  15. falyx-0.1.33/falyx/version.py +0 -1
  16. {falyx-0.1.33 → falyx-0.1.35}/LICENSE +0 -0
  17. {falyx-0.1.33 → falyx-0.1.35}/README.md +0 -0
  18. {falyx-0.1.33 → falyx-0.1.35}/falyx/.pytyped +0 -0
  19. {falyx-0.1.33 → falyx-0.1.35}/falyx/__init__.py +0 -0
  20. {falyx-0.1.33 → falyx-0.1.35}/falyx/__main__.py +0 -0
  21. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/.pytyped +0 -0
  22. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/action_factory.py +0 -0
  23. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/http_action.py +0 -0
  24. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/io_action.py +0 -0
  25. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/menu_action.py +0 -0
  26. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/select_file_action.py +0 -0
  27. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/signal_action.py +0 -0
  28. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/types.py +0 -0
  29. {falyx-0.1.33 → falyx-0.1.35}/falyx/action/user_input_action.py +0 -0
  30. {falyx-0.1.33 → falyx-0.1.35}/falyx/bottom_bar.py +0 -0
  31. {falyx-0.1.33 → falyx-0.1.35}/falyx/config.py +0 -0
  32. {falyx-0.1.33 → falyx-0.1.35}/falyx/context.py +0 -0
  33. {falyx-0.1.33 → falyx-0.1.35}/falyx/debug.py +0 -0
  34. {falyx-0.1.33 → falyx-0.1.35}/falyx/exceptions.py +0 -0
  35. {falyx-0.1.33 → falyx-0.1.35}/falyx/execution_registry.py +0 -0
  36. {falyx-0.1.33 → falyx-0.1.35}/falyx/hook_manager.py +0 -0
  37. {falyx-0.1.33 → falyx-0.1.35}/falyx/hooks.py +0 -0
  38. {falyx-0.1.33 → falyx-0.1.35}/falyx/init.py +0 -0
  39. {falyx-0.1.33 → falyx-0.1.35}/falyx/logger.py +0 -0
  40. {falyx-0.1.33 → falyx-0.1.35}/falyx/options_manager.py +0 -0
  41. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/.pytyped +0 -0
  42. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/__init__.py +0 -0
  43. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/parsers.py +0 -0
  44. {falyx-0.1.33 → falyx-0.1.35}/falyx/parsers/utils.py +0 -0
  45. {falyx-0.1.33 → falyx-0.1.35}/falyx/prompt_utils.py +0 -0
  46. {falyx-0.1.33 → falyx-0.1.35}/falyx/protocols.py +0 -0
  47. {falyx-0.1.33 → falyx-0.1.35}/falyx/retry.py +0 -0
  48. {falyx-0.1.33 → falyx-0.1.35}/falyx/retry_utils.py +0 -0
  49. {falyx-0.1.33 → falyx-0.1.35}/falyx/signals.py +0 -0
  50. {falyx-0.1.33 → falyx-0.1.35}/falyx/tagged_table.py +0 -0
  51. {falyx-0.1.33 → falyx-0.1.35}/falyx/themes/__init__.py +0 -0
  52. {falyx-0.1.33 → falyx-0.1.35}/falyx/themes/colors.py +0 -0
  53. {falyx-0.1.33 → falyx-0.1.35}/falyx/utils.py +0 -0
  54. {falyx-0.1.33 → falyx-0.1.35}/falyx/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.33
3
+ Version: 0.1.35
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
Binary file
@@ -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
  ]
@@ -726,7 +726,7 @@ class ActionGroup(BaseAction, ActionListMixin):
726
726
  if context.extra["errors"]:
727
727
  context.exception = Exception(
728
728
  f"{len(context.extra['errors'])} action(s) failed: "
729
- f"{' ,'.join(name for name, _ in context.extra["errors"])}"
729
+ f"{' ,'.join(name for name, _ in context.extra['errors'])}"
730
730
  )
731
731
  await self.hooks.trigger(HookType.ON_ERROR, context)
732
732
  raise context.exception
@@ -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:
@@ -151,6 +151,7 @@ class CommandArgumentParser:
151
151
  aliases: list[str] | None = None,
152
152
  ) -> None:
153
153
  """Initialize the CommandArgumentParser."""
154
+ self.console = Console(color_system="auto")
154
155
  self.command_key: str = command_key
155
156
  self.command_description: str = command_description
156
157
  self.command_style: str = command_style
@@ -163,7 +164,6 @@ class CommandArgumentParser:
163
164
  self._flag_map: dict[str, Argument] = {}
164
165
  self._dest_set: set[str] = set()
165
166
  self._add_help()
166
- self.console = Console(color_system="auto")
167
167
 
168
168
  def _add_help(self):
169
169
  """Add help argument to the parser."""
@@ -185,9 +185,7 @@ class CommandArgumentParser:
185
185
  raise CommandArgumentError("Positional arguments cannot have multiple flags")
186
186
  return positional
187
187
 
188
- def _get_dest_from_flags(
189
- self, flags: tuple[str, ...], dest: str | None
190
- ) -> str | None:
188
+ def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
191
189
  """Convert flags to a destination name."""
192
190
  if dest:
193
191
  if not dest.replace("_", "").isalnum():
@@ -216,7 +214,7 @@ class CommandArgumentParser:
216
214
  return dest
217
215
 
218
216
  def _determine_required(
219
- self, required: bool, positional: bool, nargs: int | str
217
+ self, required: bool, positional: bool, nargs: int | str | None
220
218
  ) -> bool:
221
219
  """Determine if the argument is required."""
222
220
  if required:
@@ -234,7 +232,22 @@ class CommandArgumentParser:
234
232
 
235
233
  return required
236
234
 
237
- 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
238
251
  allowed_nargs = ("?", "*", "+")
239
252
  if isinstance(nargs, int):
240
253
  if nargs <= 0:
@@ -246,7 +259,9 @@ class CommandArgumentParser:
246
259
  raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
247
260
  return nargs
248
261
 
249
- 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]:
250
265
  if choices is not None:
251
266
  if isinstance(choices, dict):
252
267
  raise CommandArgumentError("choices cannot be a dict")
@@ -293,8 +308,34 @@ class CommandArgumentParser:
293
308
  f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}"
294
309
  )
295
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
+
296
334
  def _resolve_default(
297
- self, action: ArgumentAction, default: Any, nargs: str | int
335
+ self,
336
+ default: Any,
337
+ action: ArgumentAction,
338
+ nargs: str | int | None,
298
339
  ) -> Any:
299
340
  """Get the default value for the argument."""
300
341
  if default is None:
@@ -328,7 +369,18 @@ class CommandArgumentParser:
328
369
  f"Flag '{flag}' must be a single character or start with '--'"
329
370
  )
330
371
 
331
- 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:
332
384
  """Add an argument to the parser.
333
385
  Args:
334
386
  name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
@@ -341,9 +393,10 @@ class CommandArgumentParser:
341
393
  help: A brief description of the argument.
342
394
  dest: The name of the attribute to be added to the object returned by parse_args().
343
395
  """
396
+ expected_type = type
344
397
  self._validate_flags(flags)
345
398
  positional = self._is_positional(flags)
346
- dest = self._get_dest_from_flags(flags, kwargs.get("dest"))
399
+ dest = self._get_dest_from_flags(flags, dest)
347
400
  if dest in self._dest_set:
348
401
  raise CommandArgumentError(
349
402
  f"Destination '{dest}' is already defined.\n"
@@ -351,18 +404,9 @@ class CommandArgumentParser:
351
404
  "is not supported. Define a unique 'dest' for each argument."
352
405
  )
353
406
  self._dest_set.add(dest)
354
- action = kwargs.get("action", ArgumentAction.STORE)
355
- if not isinstance(action, ArgumentAction):
356
- try:
357
- action = ArgumentAction(action)
358
- except ValueError:
359
- raise CommandArgumentError(
360
- f"Invalid action '{action}' is not a valid ArgumentAction"
361
- )
362
- flags = list(flags)
363
- nargs = self._validate_nargs(kwargs.get("nargs", 1))
364
- default = self._resolve_default(action, kwargs.get("default"), nargs)
365
- 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)
366
410
  if (
367
411
  action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
368
412
  and default is not None
@@ -371,14 +415,12 @@ class CommandArgumentParser:
371
415
  self._validate_default_list_type(default, expected_type, dest)
372
416
  else:
373
417
  self._validate_default_type(default, expected_type, dest)
374
- choices = self._normalize_choices(kwargs.get("choices"), expected_type)
418
+ choices = self._normalize_choices(choices, expected_type)
375
419
  if default is not None and choices and default not in choices:
376
420
  raise CommandArgumentError(
377
421
  f"Default value '{default}' not in allowed choices: {choices}"
378
422
  )
379
- required = self._determine_required(
380
- kwargs.get("required", False), positional, nargs
381
- )
423
+ required = self._determine_required(required, positional, nargs)
382
424
  argument = Argument(
383
425
  flags=flags,
384
426
  dest=dest,
@@ -387,7 +429,7 @@ class CommandArgumentParser:
387
429
  default=default,
388
430
  choices=choices,
389
431
  required=required,
390
- help=kwargs.get("help", ""),
432
+ help=help,
391
433
  nargs=nargs,
392
434
  positional=positional,
393
435
  )
@@ -430,11 +472,11 @@ class CommandArgumentParser:
430
472
  values = []
431
473
  i = start
432
474
  if isinstance(spec.nargs, int):
433
- # assert i + spec.nargs <= len(
434
- # args
435
- # ), "Not enough arguments provided: shouldn't happen"
436
475
  values = args[i : i + spec.nargs]
437
476
  return values, i + spec.nargs
477
+ elif spec.nargs is None:
478
+ values = [args[i]]
479
+ return values, i + 1
438
480
  elif spec.nargs == "+":
439
481
  if i >= len(args):
440
482
  raise CommandArgumentError(
@@ -479,6 +521,8 @@ class CommandArgumentParser:
479
521
  for next_spec in positional_args[j + 1 :]:
480
522
  if isinstance(next_spec.nargs, int):
481
523
  min_required += next_spec.nargs
524
+ elif next_spec.nargs is None:
525
+ min_required += 1
482
526
  elif next_spec.nargs == "+":
483
527
  min_required += 1
484
528
  elif next_spec.nargs == "?":
@@ -521,7 +565,7 @@ class CommandArgumentParser:
521
565
 
522
566
  return i
523
567
 
524
- def parse_args(
568
+ async def parse_args(
525
569
  self, args: list[str] | None = None, from_validate: bool = False
526
570
  ) -> dict[str, Any]:
527
571
  """Parse Falyx Command arguments."""
@@ -669,7 +713,7 @@ class CommandArgumentParser:
669
713
  result.pop("help", None)
670
714
  return result
671
715
 
672
- def parse_args_split(
716
+ async def parse_args_split(
673
717
  self, args: list[str], from_validate: bool = False
674
718
  ) -> tuple[tuple[Any, ...], dict[str, Any]]:
675
719
  """
@@ -677,7 +721,7 @@ class CommandArgumentParser:
677
721
  tuple[args, kwargs] - Positional arguments in defined order,
678
722
  followed by keyword argument mapping.
679
723
  """
680
- parsed = self.parse_args(args, from_validate)
724
+ parsed = await self.parse_args(args, from_validate)
681
725
  args_list = []
682
726
  kwargs_dict = {}
683
727
  for arg in self._arguments:
@@ -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.35"
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "falyx"
3
- version = "0.1.33"
3
+ version = "0.1.35"
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.33"
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