falyx 0.1.28__py3-none-any.whl → 0.1.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
falyx/action/.pytyped ADDED
File without changes
falyx/action/io_action.py CHANGED
@@ -224,7 +224,10 @@ class ShellAction(BaseIOAction):
224
224
  # Replace placeholder in template, or use raw input as full command
225
225
  command = self.command_template.format(parsed_input)
226
226
  if self.safe_mode:
227
- args = shlex.split(command)
227
+ try:
228
+ args = shlex.split(command)
229
+ except ValueError as error:
230
+ raise FalyxError(f"Invalid command template: {error}")
228
231
  result = subprocess.run(args, capture_output=True, text=True, check=True)
229
232
  else:
230
233
  result = subprocess.run(
falyx/command.py CHANGED
@@ -27,15 +27,25 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
27
27
  from rich.console import Console
28
28
  from rich.tree import Tree
29
29
 
30
- from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction
30
+ from falyx.action.action import (
31
+ Action,
32
+ ActionGroup,
33
+ BaseAction,
34
+ ChainedAction,
35
+ ProcessAction,
36
+ )
31
37
  from falyx.action.io_action import BaseIOAction
32
- from falyx.argparse import CommandArgumentParser
33
38
  from falyx.context import ExecutionContext
34
39
  from falyx.debug import register_debug_hooks
35
40
  from falyx.execution_registry import ExecutionRegistry as er
36
41
  from falyx.hook_manager import HookManager, HookType
37
42
  from falyx.logger import logger
38
43
  from falyx.options_manager import OptionsManager
44
+ from falyx.parsers import (
45
+ CommandArgumentParser,
46
+ infer_args_from_func,
47
+ same_argument_definitions,
48
+ )
39
49
  from falyx.prompt_utils import confirm_async, should_prompt_user
40
50
  from falyx.protocols import ArgParserProtocol
41
51
  from falyx.retry import RetryPolicy
@@ -90,6 +100,11 @@ class Command(BaseModel):
90
100
  tags (list[str]): Organizational tags for the command.
91
101
  logging_hooks (bool): Whether to attach logging hooks automatically.
92
102
  requires_input (bool | None): Indicates if the action needs input.
103
+ options_manager (OptionsManager): Manages global command-line options.
104
+ arg_parser (CommandArgumentParser): Parses command arguments.
105
+ custom_parser (ArgParserProtocol | None): Custom argument parser.
106
+ custom_help (Callable[[], str | None] | None): Custom help message generator.
107
+ auto_args (bool): Automatically infer arguments from the action.
93
108
 
94
109
  Methods:
95
110
  __call__(): Executes the command, respecting hooks and retries.
@@ -101,12 +116,13 @@ class Command(BaseModel):
101
116
 
102
117
  key: str
103
118
  description: str
104
- action: BaseAction | Callable[[], Any]
119
+ action: BaseAction | Callable[[Any], Any]
105
120
  args: tuple = ()
106
121
  kwargs: dict[str, Any] = Field(default_factory=dict)
107
122
  hidden: bool = False
108
123
  aliases: list[str] = Field(default_factory=list)
109
124
  help_text: str = ""
125
+ help_epilogue: str = ""
110
126
  style: str = OneColors.WHITE
111
127
  confirm: bool = False
112
128
  confirm_message: str = "Are you sure?"
@@ -125,22 +141,44 @@ class Command(BaseModel):
125
141
  requires_input: bool | None = None
126
142
  options_manager: OptionsManager = Field(default_factory=OptionsManager)
127
143
  arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser)
144
+ arguments: list[dict[str, Any]] = Field(default_factory=list)
145
+ argument_config: Callable[[CommandArgumentParser], None] | None = None
128
146
  custom_parser: ArgParserProtocol | None = None
129
147
  custom_help: Callable[[], str | None] | None = None
148
+ auto_args: bool = False
149
+ arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
130
150
 
131
151
  _context: ExecutionContext | None = PrivateAttr(default=None)
132
152
 
133
153
  model_config = ConfigDict(arbitrary_types_allowed=True)
134
154
 
135
- def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]:
155
+ def parse_args(
156
+ self, raw_args: list[str] | str, from_validate: bool = False
157
+ ) -> tuple[tuple, dict]:
136
158
  if self.custom_parser:
137
159
  if isinstance(raw_args, str):
138
- raw_args = shlex.split(raw_args)
160
+ try:
161
+ raw_args = shlex.split(raw_args)
162
+ except ValueError:
163
+ logger.warning(
164
+ "[Command:%s] Failed to split arguments: %s",
165
+ self.key,
166
+ raw_args,
167
+ )
168
+ return ((), {})
139
169
  return self.custom_parser(raw_args)
140
170
 
141
171
  if isinstance(raw_args, str):
142
- raw_args = shlex.split(raw_args)
143
- return self.arg_parser.parse_args_split(raw_args)
172
+ try:
173
+ raw_args = shlex.split(raw_args)
174
+ except ValueError:
175
+ logger.warning(
176
+ "[Command:%s] Failed to split arguments: %s",
177
+ self.key,
178
+ raw_args,
179
+ )
180
+ return ((), {})
181
+ return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate)
144
182
 
145
183
  @field_validator("action", mode="before")
146
184
  @classmethod
@@ -151,11 +189,37 @@ class Command(BaseModel):
151
189
  return ensure_async(action)
152
190
  raise TypeError("Action must be a callable or an instance of BaseAction")
153
191
 
192
+ def get_argument_definitions(self) -> list[dict[str, Any]]:
193
+ if self.arguments:
194
+ return self.arguments
195
+ elif self.argument_config:
196
+ self.argument_config(self.arg_parser)
197
+ elif self.auto_args:
198
+ if isinstance(self.action, (Action, ProcessAction)):
199
+ return infer_args_from_func(self.action.action, self.arg_metadata)
200
+ elif isinstance(self.action, ChainedAction):
201
+ if self.action.actions:
202
+ action = self.action.actions[0]
203
+ if isinstance(action, Action):
204
+ return infer_args_from_func(action.action, self.arg_metadata)
205
+ elif callable(action):
206
+ return infer_args_from_func(action, self.arg_metadata)
207
+ elif isinstance(self.action, ActionGroup):
208
+ arg_defs = same_argument_definitions(
209
+ self.action.actions, self.arg_metadata
210
+ )
211
+ if arg_defs:
212
+ return arg_defs
213
+ logger.debug(
214
+ "[Command:%s] auto_args disabled: mismatched ActionGroup arguments",
215
+ self.key,
216
+ )
217
+ elif callable(self.action):
218
+ return infer_args_from_func(self.action, self.arg_metadata)
219
+ return []
220
+
154
221
  def model_post_init(self, _: Any) -> None:
155
222
  """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
-
159
223
  if self.retry and isinstance(self.action, Action):
160
224
  self.action.enable_retry()
161
225
  elif self.retry_policy and isinstance(self.action, Action):
@@ -183,6 +247,9 @@ class Command(BaseModel):
183
247
  elif self.requires_input is None:
184
248
  self.requires_input = False
185
249
 
250
+ for arg_def in self.get_argument_definitions():
251
+ self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
252
+
186
253
  @cached_property
187
254
  def detect_requires_input(self) -> bool:
188
255
  """Detect if the action requires input based on its type."""
falyx/falyx.py CHANGED
@@ -58,9 +58,10 @@ from falyx.execution_registry import ExecutionRegistry as er
58
58
  from falyx.hook_manager import Hook, HookManager, HookType
59
59
  from falyx.logger import logger
60
60
  from falyx.options_manager import OptionsManager
61
- from falyx.parsers import get_arg_parsers
61
+ from falyx.parsers import CommandArgumentParser, get_arg_parsers
62
+ from falyx.protocols import ArgParserProtocol
62
63
  from falyx.retry import RetryPolicy
63
- from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
64
+ from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal
64
65
  from falyx.themes import OneColors, get_nord_theme
65
66
  from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation
66
67
  from falyx.version import __version__
@@ -444,6 +445,7 @@ class Falyx:
444
445
  bottom_toolbar=self._get_bottom_bar_render(),
445
446
  key_bindings=self.key_bindings,
446
447
  validate_while_typing=False,
448
+ interrupt_exception=FlowSignal,
447
449
  )
448
450
  return self._prompt_session
449
451
 
@@ -524,7 +526,7 @@ class Falyx:
524
526
  key: str = "X",
525
527
  description: str = "Exit",
526
528
  aliases: list[str] | None = None,
527
- action: Callable[[], Any] | None = None,
529
+ action: Callable[[Any], Any] | None = None,
528
530
  style: str = OneColors.DARK_RED,
529
531
  confirm: bool = False,
530
532
  confirm_message: str = "Are you sure?",
@@ -578,13 +580,14 @@ class Falyx:
578
580
  self,
579
581
  key: str,
580
582
  description: str,
581
- action: BaseAction | Callable[[], Any],
583
+ action: BaseAction | Callable[[Any], Any],
582
584
  *,
583
585
  args: tuple = (),
584
586
  kwargs: dict[str, Any] | None = None,
585
587
  hidden: bool = False,
586
588
  aliases: list[str] | None = None,
587
589
  help_text: str = "",
590
+ help_epilogue: str = "",
588
591
  style: str = OneColors.WHITE,
589
592
  confirm: bool = False,
590
593
  confirm_message: str = "Are you sure?",
@@ -606,9 +609,33 @@ class Falyx:
606
609
  retry_all: bool = False,
607
610
  retry_policy: RetryPolicy | None = None,
608
611
  requires_input: bool | None = None,
612
+ arg_parser: CommandArgumentParser | None = None,
613
+ arguments: list[dict[str, Any]] | None = None,
614
+ argument_config: Callable[[CommandArgumentParser], None] | None = None,
615
+ custom_parser: ArgParserProtocol | None = None,
616
+ custom_help: Callable[[], str | None] | None = None,
617
+ auto_args: bool = False,
618
+ arg_metadata: dict[str, str | dict[str, Any]] | None = None,
609
619
  ) -> Command:
610
620
  """Adds an command to the menu, preventing duplicates."""
611
621
  self._validate_command_key(key)
622
+
623
+ if arg_parser:
624
+ if not isinstance(arg_parser, CommandArgumentParser):
625
+ raise NotAFalyxError(
626
+ "arg_parser must be an instance of CommandArgumentParser."
627
+ )
628
+ arg_parser = arg_parser
629
+ else:
630
+ arg_parser = CommandArgumentParser(
631
+ command_key=key,
632
+ command_description=description,
633
+ command_style=style,
634
+ help_text=help_text,
635
+ help_epilogue=help_epilogue,
636
+ aliases=aliases,
637
+ )
638
+
612
639
  command = Command(
613
640
  key=key,
614
641
  description=description,
@@ -618,6 +645,7 @@ class Falyx:
618
645
  hidden=hidden,
619
646
  aliases=aliases if aliases else [],
620
647
  help_text=help_text,
648
+ help_epilogue=help_epilogue,
621
649
  style=style,
622
650
  confirm=confirm,
623
651
  confirm_message=confirm_message,
@@ -634,6 +662,13 @@ class Falyx:
634
662
  retry_policy=retry_policy or RetryPolicy(),
635
663
  requires_input=requires_input,
636
664
  options_manager=self.options,
665
+ arg_parser=arg_parser,
666
+ arguments=arguments or [],
667
+ argument_config=argument_config,
668
+ custom_parser=custom_parser,
669
+ custom_help=custom_help,
670
+ auto_args=auto_args,
671
+ arg_metadata=arg_metadata or {},
637
672
  )
638
673
 
639
674
  if hooks:
@@ -715,7 +750,10 @@ class Falyx:
715
750
  """
716
751
  args = ()
717
752
  kwargs: dict[str, Any] = {}
718
- choice, *input_args = shlex.split(raw_choices)
753
+ try:
754
+ choice, *input_args = shlex.split(raw_choices)
755
+ except ValueError:
756
+ return False, None, args, kwargs
719
757
  is_preview, choice = self.parse_preview_command(choice)
720
758
  if is_preview and not choice and self.help_command:
721
759
  is_preview = False
@@ -735,7 +773,7 @@ class Falyx:
735
773
  logger.info("Command '%s' selected.", choice)
736
774
  if input_args and name_map[choice].arg_parser:
737
775
  try:
738
- args, kwargs = name_map[choice].parse_args(input_args)
776
+ args, kwargs = name_map[choice].parse_args(input_args, from_validate)
739
777
  except CommandArgumentError as error:
740
778
  if not from_validate:
741
779
  if not name_map[choice].show_help():
@@ -748,6 +786,8 @@ class Falyx:
748
786
  message=str(error), cursor_position=len(raw_choices)
749
787
  )
750
788
  return is_preview, None, args, kwargs
789
+ except HelpSignal:
790
+ return True, None, args, kwargs
751
791
  return is_preview, name_map[choice], args, kwargs
752
792
 
753
793
  prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)]
@@ -823,7 +863,6 @@ class Falyx:
823
863
  context.start_timer()
824
864
  try:
825
865
  await self.hooks.trigger(HookType.BEFORE, context)
826
- print(args, kwargs)
827
866
  result = await selected_command(*args, **kwargs)
828
867
  context.result = result
829
868
  await self.hooks.trigger(HookType.ON_SUCCESS, context)
@@ -964,8 +1003,6 @@ class Falyx:
964
1003
  logger.info("BackSignal received.")
965
1004
  except CancelSignal:
966
1005
  logger.info("CancelSignal received.")
967
- except HelpSignal:
968
- logger.info("HelpSignal received.")
969
1006
  finally:
970
1007
  logger.info("Exiting menu: %s", self.get_title())
971
1008
  if self.exit_message:
@@ -995,7 +1032,7 @@ class Falyx:
995
1032
  sys.exit(0)
996
1033
 
997
1034
  if self.cli_args.command == "version" or self.cli_args.version:
998
- self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]")
1035
+ self.console.print(f"[{OneColors.BLUE_b}]Falyx CLI v{__version__}[/]")
999
1036
  sys.exit(0)
1000
1037
 
1001
1038
  if self.cli_args.command == "preview":
falyx/parsers/.pytyped ADDED
File without changes
@@ -0,0 +1,21 @@
1
+ """
2
+ Falyx CLI Framework
3
+
4
+ Copyright (c) 2025 rtj.dev LLC.
5
+ Licensed under the MIT License. See LICENSE file for details.
6
+ """
7
+
8
+ from .argparse import Argument, ArgumentAction, CommandArgumentParser
9
+ from .parsers import FalyxParsers, get_arg_parsers
10
+ from .signature import infer_args_from_func
11
+ from .utils import same_argument_definitions
12
+
13
+ __all__ = [
14
+ "Argument",
15
+ "ArgumentAction",
16
+ "CommandArgumentParser",
17
+ "get_arg_parsers",
18
+ "FalyxParsers",
19
+ "infer_args_from_func",
20
+ "same_argument_definitions",
21
+ ]
@@ -5,7 +5,8 @@ from enum import Enum
5
5
  from typing import Any, Iterable
6
6
 
7
7
  from rich.console import Console
8
- from rich.table import Table
8
+ from rich.markup import escape
9
+ from rich.text import Text
9
10
 
10
11
  from falyx.exceptions import CommandArgumentError
11
12
  from falyx.signals import HelpSignal
@@ -40,6 +41,70 @@ class Argument:
40
41
  nargs: int | str = 1 # int, '?', '*', '+'
41
42
  positional: bool = False # True if no leading - or -- in flags
42
43
 
44
+ def get_positional_text(self) -> str:
45
+ """Get the positional text for the argument."""
46
+ text = ""
47
+ if self.positional:
48
+ if self.choices:
49
+ text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
50
+ else:
51
+ text = self.dest
52
+ return text
53
+
54
+ def get_choice_text(self) -> str:
55
+ """Get the choice text for the argument."""
56
+ choice_text = ""
57
+ if self.choices:
58
+ choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}"
59
+ elif (
60
+ self.action
61
+ in (
62
+ ArgumentAction.STORE,
63
+ ArgumentAction.APPEND,
64
+ ArgumentAction.EXTEND,
65
+ )
66
+ and not self.positional
67
+ ):
68
+ choice_text = self.dest.upper()
69
+ elif isinstance(self.nargs, str):
70
+ choice_text = self.dest
71
+
72
+ if self.nargs == "?":
73
+ choice_text = f"[{choice_text}]"
74
+ elif self.nargs == "*":
75
+ choice_text = f"[{choice_text} ...]"
76
+ elif self.nargs == "+":
77
+ choice_text = f"{choice_text} [{choice_text} ...]"
78
+ return choice_text
79
+
80
+ def __eq__(self, other: object) -> bool:
81
+ if not isinstance(other, Argument):
82
+ return False
83
+ return (
84
+ self.flags == other.flags
85
+ and self.dest == other.dest
86
+ and self.action == other.action
87
+ and self.type == other.type
88
+ and self.choices == other.choices
89
+ and self.required == other.required
90
+ and self.nargs == other.nargs
91
+ and self.positional == other.positional
92
+ )
93
+
94
+ def __hash__(self) -> int:
95
+ return hash(
96
+ (
97
+ tuple(self.flags),
98
+ self.dest,
99
+ self.action,
100
+ self.type,
101
+ tuple(self.choices or []),
102
+ self.required,
103
+ self.nargs,
104
+ self.positional,
105
+ )
106
+ )
107
+
43
108
 
44
109
  class CommandArgumentParser:
45
110
  """
@@ -61,10 +126,25 @@ class CommandArgumentParser:
61
126
  - Render Help using Rich library.
62
127
  """
63
128
 
64
- def __init__(self) -> None:
129
+ def __init__(
130
+ self,
131
+ command_key: str = "",
132
+ command_description: str = "",
133
+ command_style: str = "bold",
134
+ help_text: str = "",
135
+ help_epilogue: str = "",
136
+ aliases: list[str] | None = None,
137
+ ) -> None:
65
138
  """Initialize the CommandArgumentParser."""
66
- self.command_description: str = ""
139
+ self.command_key: str = command_key
140
+ self.command_description: str = command_description
141
+ self.command_style: str = command_style
142
+ self.help_text: str = help_text
143
+ self.help_epilogue: str = help_epilogue
144
+ self.aliases: list[str] = aliases or []
67
145
  self._arguments: list[Argument] = []
146
+ self._positional: list[Argument] = []
147
+ self._keyword: list[Argument] = []
68
148
  self._flag_map: dict[str, Argument] = {}
69
149
  self._dest_set: set[str] = set()
70
150
  self._add_help()
@@ -73,10 +153,10 @@ class CommandArgumentParser:
73
153
  def _add_help(self):
74
154
  """Add help argument to the parser."""
75
155
  self.add_argument(
76
- "--help",
77
156
  "-h",
157
+ "--help",
78
158
  action=ArgumentAction.HELP,
79
- help="Show this help message and exit.",
159
+ help="Show this help message.",
80
160
  dest="help",
81
161
  )
82
162
 
@@ -304,10 +384,31 @@ class CommandArgumentParser:
304
384
  )
305
385
  self._flag_map[flag] = argument
306
386
  self._arguments.append(argument)
387
+ if positional:
388
+ self._positional.append(argument)
389
+ else:
390
+ self._keyword.append(argument)
307
391
 
308
392
  def get_argument(self, dest: str) -> Argument | None:
309
393
  return next((a for a in self._arguments if a.dest == dest), None)
310
394
 
395
+ def to_definition_list(self) -> list[dict[str, Any]]:
396
+ defs = []
397
+ for arg in self._arguments:
398
+ defs.append(
399
+ {
400
+ "flags": arg.flags,
401
+ "dest": arg.dest,
402
+ "action": arg.action,
403
+ "type": arg.type,
404
+ "choices": arg.choices,
405
+ "required": arg.required,
406
+ "nargs": arg.nargs,
407
+ "positional": arg.positional,
408
+ }
409
+ )
410
+ return defs
411
+
311
412
  def _consume_nargs(
312
413
  self, args: list[str], start: int, spec: Argument
313
414
  ) -> tuple[list[str], int]:
@@ -405,7 +506,9 @@ class CommandArgumentParser:
405
506
 
406
507
  return i
407
508
 
408
- def parse_args(self, args: list[str] | None = None) -> dict[str, Any]:
509
+ def parse_args(
510
+ self, args: list[str] | None = None, from_validate: bool = False
511
+ ) -> dict[str, Any]:
409
512
  """Parse Falyx Command arguments."""
410
513
  if args is None:
411
514
  args = []
@@ -423,7 +526,8 @@ class CommandArgumentParser:
423
526
  action = spec.action
424
527
 
425
528
  if action == ArgumentAction.HELP:
426
- self.render_help()
529
+ if not from_validate:
530
+ self.render_help()
427
531
  raise HelpSignal()
428
532
  elif action == ArgumentAction.STORE_TRUE:
429
533
  result[spec.dest] = True
@@ -550,13 +654,15 @@ class CommandArgumentParser:
550
654
  result.pop("help", None)
551
655
  return result
552
656
 
553
- def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]:
657
+ def parse_args_split(
658
+ self, args: list[str], from_validate: bool = False
659
+ ) -> tuple[tuple[Any, ...], dict[str, Any]]:
554
660
  """
555
661
  Returns:
556
662
  tuple[args, kwargs] - Positional arguments in defined order,
557
663
  followed by keyword argument mapping.
558
664
  """
559
- parsed = self.parse_args(args)
665
+ parsed = self.parse_args(args, from_validate)
560
666
  args_list = []
561
667
  kwargs_dict = {}
562
668
  for arg in self._arguments:
@@ -568,20 +674,74 @@ class CommandArgumentParser:
568
674
  kwargs_dict[arg.dest] = parsed[arg.dest]
569
675
  return tuple(args_list), kwargs_dict
570
676
 
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)
677
+ def render_help(self) -> None:
678
+ # Options
679
+ # Add all keyword arguments to the options list
680
+ options_list = []
681
+ for arg in self._keyword:
682
+ choice_text = arg.get_choice_text()
683
+ if choice_text:
684
+ options_list.extend([f"[{arg.flags[0]} {choice_text}]"])
685
+ else:
686
+ options_list.extend([f"[{arg.flags[0]}]"])
687
+
688
+ # Add positional arguments to the options list
689
+ for arg in self._positional:
690
+ choice_text = arg.get_choice_text()
691
+ if isinstance(arg.nargs, int):
692
+ choice_text = " ".join([choice_text] * arg.nargs)
693
+ options_list.append(escape(choice_text))
694
+
695
+ options_text = " ".join(options_list)
696
+ command_keys = " | ".join(
697
+ [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"]
698
+ + [
699
+ f"[{self.command_style}]{alias}[/{self.command_style}]"
700
+ for alias in self.aliases
701
+ ]
702
+ )
703
+
704
+ usage = f"usage: {command_keys} {options_text}"
705
+ self.console.print(f"[bold]{usage}[/bold]\n")
706
+
707
+ # Description
708
+ if self.help_text:
709
+ self.console.print(self.help_text + "\n")
710
+
711
+ # Arguments
712
+ if self._arguments:
713
+ if self._positional:
714
+ self.console.print("[bold]positional:[/bold]")
715
+ for arg in self._positional:
716
+ flags = arg.get_positional_text()
717
+ arg_line = Text(f" {flags:<30} ")
718
+ help_text = arg.help or ""
719
+ arg_line.append(help_text)
720
+ self.console.print(arg_line)
721
+ self.console.print("[bold]options:[/bold]")
722
+ for arg in self._keyword:
723
+ flags = ", ".join(arg.flags)
724
+ flags_choice = f"{flags} {arg.get_choice_text()}"
725
+ arg_line = Text(f" {flags_choice:<30} ")
726
+ help_text = arg.help or ""
727
+ arg_line.append(help_text)
728
+ self.console.print(arg_line)
729
+
730
+ # Epilogue
731
+ if self.help_epilogue:
732
+ self.console.print("\n" + self.help_epilogue, style="dim")
733
+
734
+ def __eq__(self, other: object) -> bool:
735
+ if not isinstance(other, CommandArgumentParser):
736
+ return False
737
+
738
+ def sorted_args(parser):
739
+ return sorted(parser._arguments, key=lambda a: a.dest)
740
+
741
+ return sorted_args(self) == sorted_args(other)
742
+
743
+ def __hash__(self) -> int:
744
+ return hash(tuple(sorted(self._arguments, key=lambda a: a.dest)))
585
745
 
586
746
  def __str__(self) -> str:
587
747
  positional = sum(arg.positional for arg in self._arguments)
@@ -0,0 +1,71 @@
1
+ import inspect
2
+ from typing import Any, Callable
3
+
4
+ from falyx import logger
5
+
6
+
7
+ def infer_args_from_func(
8
+ func: Callable[[Any], Any],
9
+ arg_metadata: dict[str, str | dict[str, Any]] | None = None,
10
+ ) -> list[dict[str, Any]]:
11
+ """
12
+ Infer argument definitions from a callable's signature.
13
+ Returns a list of kwargs suitable for CommandArgumentParser.add_argument.
14
+ """
15
+ arg_metadata = arg_metadata or {}
16
+ signature = inspect.signature(func)
17
+ arg_defs = []
18
+
19
+ for name, param in signature.parameters.items():
20
+ raw_metadata = arg_metadata.get(name, {})
21
+ metadata = (
22
+ {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata
23
+ )
24
+
25
+ if param.kind not in (
26
+ inspect.Parameter.POSITIONAL_ONLY,
27
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
28
+ inspect.Parameter.KEYWORD_ONLY,
29
+ ):
30
+ continue
31
+
32
+ arg_type = (
33
+ param.annotation if param.annotation is not inspect.Parameter.empty else str
34
+ )
35
+ default = param.default if param.default is not inspect.Parameter.empty else None
36
+ is_required = param.default is inspect.Parameter.empty
37
+ if is_required:
38
+ flags = [f"{name.replace('_', '-')}"]
39
+ else:
40
+ flags = [f"--{name.replace('_', '-')}"]
41
+ action = "store"
42
+ nargs: int | str = 1
43
+
44
+ if arg_type is bool:
45
+ if param.default is False:
46
+ action = "store_true"
47
+ else:
48
+ action = "store_false"
49
+
50
+ if arg_type is list:
51
+ action = "append"
52
+ if is_required:
53
+ nargs = "+"
54
+ else:
55
+ nargs = "*"
56
+
57
+ arg_defs.append(
58
+ {
59
+ "flags": flags,
60
+ "dest": name,
61
+ "type": arg_type,
62
+ "default": default,
63
+ "required": is_required,
64
+ "nargs": nargs,
65
+ "action": action,
66
+ "help": metadata.get("help", ""),
67
+ "choices": metadata.get("choices"),
68
+ }
69
+ )
70
+
71
+ return arg_defs
falyx/parsers/utils.py ADDED
@@ -0,0 +1,33 @@
1
+ from typing import Any
2
+
3
+ from falyx import logger
4
+ from falyx.action.action import Action, ChainedAction, ProcessAction
5
+ from falyx.parsers.signature import infer_args_from_func
6
+
7
+
8
+ def same_argument_definitions(
9
+ actions: list[Any],
10
+ arg_metadata: dict[str, str | dict[str, Any]] | None = None,
11
+ ) -> list[dict[str, Any]] | None:
12
+ arg_sets = []
13
+ for action in actions:
14
+ if isinstance(action, (Action, ProcessAction)):
15
+ arg_defs = infer_args_from_func(action.action, arg_metadata)
16
+ elif isinstance(action, ChainedAction):
17
+ if action.actions:
18
+ action = action.actions[0]
19
+ if isinstance(action, Action):
20
+ arg_defs = infer_args_from_func(action.action, arg_metadata)
21
+ elif callable(action):
22
+ arg_defs = infer_args_from_func(action, arg_metadata)
23
+ elif callable(action):
24
+ arg_defs = infer_args_from_func(action, arg_metadata)
25
+ else:
26
+ logger.debug("Auto args unsupported for action: %s", action)
27
+ return None
28
+ arg_sets.append(arg_defs)
29
+
30
+ first = arg_sets[0]
31
+ if all(arg_set == first for arg_set in arg_sets[1:]):
32
+ return first
33
+ return None
falyx/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.28"
1
+ __version__ = "0.1.29"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: falyx
3
- Version: 0.1.28
3
+ Version: 0.1.29
4
4
  Summary: Reliable and introspectable async CLI action framework.
5
5
  License: MIT
6
6
  Author: Roland Thomas Jr
@@ -1,34 +1,38 @@
1
1
  falyx/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  falyx/__init__.py,sha256=L40665QyjAqHQxHdxxY2_yPeDa4p0LE7Nu_2dkm08Ls,650
3
3
  falyx/__main__.py,sha256=g_LwJieofK3DJzCYtpkAMEeOXhzSLQenb7pRVUqcf-Y,2152
4
+ falyx/action/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
5
  falyx/action/__init__.py,sha256=zpOK5g4DybydV8d3QI0Zq52aWaKFPYi-J6szAQTsQ2c,974
5
6
  falyx/action/action.py,sha256=CJB9eeeEqBGkZHjMpG24eXHRjouKSfESCI1zWzoE7JQ,32488
6
7
  falyx/action/action_factory.py,sha256=qNtEnsbKsNl-WijChbTQYfdI3k14fN-1bzDsGFx8yZI,4517
7
8
  falyx/action/http_action.py,sha256=aIieGHyZSkz1ZGay-fwgDYZ0QF17XypAWtKeVAYp5f4,5806
8
- falyx/action/io_action.py,sha256=zdDq07zSLlaShBQ3ztXTRC6aZL0JoERNZSmvHy1V22w,9718
9
+ falyx/action/io_action.py,sha256=Xy4k4Zx5qrrRs7Y7NZE5qCzAnHe6iJMkVrPZ8KpDD0k,9850
9
10
  falyx/action/menu_action.py,sha256=cboCpXyl0fZUxpFsvEPu0dGhFfr_vdfllceQnICA0gU,5683
10
11
  falyx/action/select_file_action.py,sha256=hHLhmTSacWaUXhRTeIIiXt8gR7zbjkXJ2MAkKQYCpp4,7799
11
12
  falyx/action/selection_action.py,sha256=22rF7UqRrQAMjGIheDqAbUizVMBg9aCl9e4VOLLZZJo,8811
12
13
  falyx/action/signal_action.py,sha256=5UMqvzy7fBnLANGwYUWoe1VRhrr7e-yOVeLdOnCBiJo,1350
13
14
  falyx/action/types.py,sha256=iVD-bHm1GRXOTIlHOeT_KcDBRZm4Hz5Xzl_BOalvEf4,961
14
15
  falyx/action/user_input_action.py,sha256=LSTzC_3TfsfXdz-qV3GlOIGpZWAOgO9J5DnNsHO7ee8,3398
15
- falyx/argparse.py,sha256=kI_tLYD6KOiWvXEHEvOi8bprx5sQm9KTrgens2_nWKU,24498
16
16
  falyx/bottom_bar.py,sha256=iWxgOKWgn5YmREeZBuGA50FzqzEfz1-Vnqm0V_fhldc,7383
17
- falyx/command.py,sha256=Vr5YHehZB6GavBbHe9diNbdeXNWfnWTkuDZ-NfymcFo,13437
17
+ falyx/command.py,sha256=YTkLFzVwKNmVkRRL237qbEMD8lpYycRaVtKVn4rt2OQ,16177
18
18
  falyx/config.py,sha256=8dkQfL-Ro-vWw1AcO2fD1PGZ92Cyfnwl885ZlpLkp4Y,9636
19
- falyx/config_schema.py,sha256=j5GQuHVlaU-VLxLF9t8idZRjqOP9MIKp1hyd9NhpAGU,3124
20
19
  falyx/context.py,sha256=FNF-IS7RMDxel2l3kskEqQImZ0mLO6zvGw_xC9cIzgI,10338
21
20
  falyx/debug.py,sha256=oWWTLOF8elrx_RGZ1G4pbzfFr46FjB0woFXpVU2wmjU,1567
22
21
  falyx/exceptions.py,sha256=kK9k1v7LVNjJSwYztRa9Krhr3ZOI-6Htq2ZjlYICPKg,922
23
22
  falyx/execution_registry.py,sha256=rctsz0mrIHPToLZqylblVjDdKWdq1x_JBc8GwMP5sJ8,4710
24
- falyx/falyx.py,sha256=3OrmQ0p7ZOpjLaH5mqTYVEZ96KKTygSLYlPqg3eH5eM,43061
23
+ falyx/falyx.py,sha256=TX446soM31kq3pNCsGWcrxDtZoQQlgtcfRCJ1OMq5XY,44595
25
24
  falyx/hook_manager.py,sha256=GuGxVVz9FXrU39Tk220QcsLsMXeut7ZDkGW3hU9GcwQ,2952
26
25
  falyx/hooks.py,sha256=IV2nbj5FjY2m3_L7x4mYBnaRDG45E8tWQU90i4butlw,2940
27
26
  falyx/init.py,sha256=abcSlPmxVeByLIHdUkNjqtO_tEkO3ApC6f9WbxsSEWg,3393
28
27
  falyx/logger.py,sha256=1Mfb_vJFJ1tQwziuyU2p-cSMi2Js8N2byniFEnI6vOQ,132
29
28
  falyx/menu.py,sha256=faxGgocqQYY6HtzVbenHaFj8YqsmycBEyziC8Ahzqjo,2870
30
29
  falyx/options_manager.py,sha256=dFAnQw543tQ6Xupvh1PwBrhiSWlSACHw8K-sHP_lUh4,2842
31
- falyx/parsers.py,sha256=KsDFEmJLM86d2X4Kh4SHA9mBbUk351NjLhhFYzQkaPk,5762
30
+ falyx/parsers/.pytyped,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
+ falyx/parsers/__init__.py,sha256=Z5HjwqVlf2Bp8STSz3XMMYWWkkerRS0MDGf_Y4KXGVA,520
32
+ falyx/parsers/argparse.py,sha256=ElWg4nl1ygkDY9Q4lTfjj4KMXI0FKDLVdc08eiGw4AQ,29889
33
+ falyx/parsers/parsers.py,sha256=KsDFEmJLM86d2X4Kh4SHA9mBbUk351NjLhhFYzQkaPk,5762
34
+ falyx/parsers/signature.py,sha256=mDMgg-H27OKqrORksIbQSETfEUoL_DaHKVnDviCLsCQ,2125
35
+ falyx/parsers/utils.py,sha256=3kjvrYBnvw90b1ALrAaRWUkQU7axLY9XqJOmf8W1BAQ,1230
32
36
  falyx/prompt_utils.py,sha256=qgk0bXs7mwzflqzWyFhEOTpKQ_ZtMIqGhKeg-ocwNnE,1542
33
37
  falyx/protocols.py,sha256=mesdq5CjPF_5Kyu7Evwr6qMT71tUHlw0SjjtmnggTZw,495
34
38
  falyx/retry.py,sha256=UUzY6FlKobr84Afw7yJO9rj3AIQepDk2fcWs6_1gi6Q,3788
@@ -40,9 +44,9 @@ falyx/themes/__init__.py,sha256=1CZhEUCin9cUk8IGYBUFkVvdHRNNJBEFXccHwpUKZCA,284
40
44
  falyx/themes/colors.py,sha256=4aaeAHJetmeNInI0Zytg4E3YqKfPFelpf04vtjSvsS8,19776
41
45
  falyx/utils.py,sha256=u3puR4Bh-unNBw9a0V9sw7PDTIzRaNLolap0oz5bVIk,6718
42
46
  falyx/validators.py,sha256=t5iyzVpY8tdC4rfhr4isEfWpD5gNTzjeX_Hbi_Uq6sA,1328
43
- falyx/version.py,sha256=MWZDdAHrdUZS0c3VlLqX4O1eaxPodI7irMtEvknKQ94,23
44
- falyx-0.1.28.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
45
- falyx-0.1.28.dist-info/METADATA,sha256=n-VK44OFgjFxyRn1-LdysM1ciDF8RKxlKfAy_0Yz_Zc,5521
46
- falyx-0.1.28.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
47
- falyx-0.1.28.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
48
- falyx-0.1.28.dist-info/RECORD,,
47
+ falyx/version.py,sha256=A-lFHZ4YpCrWZ6nw3tlt_yurFJ00mInm3gR6hz51Eww,23
48
+ falyx-0.1.29.dist-info/LICENSE,sha256=B0yqgaHuSdhN7T3OBmgQSiDTy8HqT5Oe_dLypRe4Ra4,1073
49
+ falyx-0.1.29.dist-info/METADATA,sha256=cUcRdSgrw8TPfn21pRyn7mS-WrvpvWChdwPd4ikSCQQ,5521
50
+ falyx-0.1.29.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
51
+ falyx-0.1.29.dist-info/entry_points.txt,sha256=j8owOSl2j1Ss8DtGMnKfgehKaolqnIPhVFHaUBLUnMs,45
52
+ falyx-0.1.29.dist-info/RECORD,,
falyx/config_schema.py DELETED
@@ -1,76 +0,0 @@
1
- FALYX_CONFIG_SCHEMA = {
2
- "$schema": "http://json-schema.org/draft-07/schema#",
3
- "title": "Falyx CLI Config",
4
- "type": "object",
5
- "properties": {
6
- "title": {"type": "string", "description": "Title shown at top of menu"},
7
- "prompt": {
8
- "oneOf": [
9
- {"type": "string"},
10
- {
11
- "type": "array",
12
- "items": {
13
- "type": "array",
14
- "prefixItems": [
15
- {
16
- "type": "string",
17
- "description": "Style string (e.g., 'bold #ff0000 italic')",
18
- },
19
- {"type": "string", "description": "Text content"},
20
- ],
21
- "minItems": 2,
22
- "maxItems": 2,
23
- },
24
- },
25
- ]
26
- },
27
- "columns": {
28
- "type": "integer",
29
- "minimum": 1,
30
- "description": "Number of menu columns",
31
- },
32
- "welcome_message": {"type": "string"},
33
- "exit_message": {"type": "string"},
34
- "commands": {
35
- "type": "array",
36
- "items": {
37
- "type": "object",
38
- "required": ["key", "description", "action"],
39
- "properties": {
40
- "key": {"type": "string", "minLength": 1},
41
- "description": {"type": "string"},
42
- "action": {
43
- "type": "string",
44
- "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$",
45
- "description": "Dotted import path (e.g., mymodule.task)",
46
- },
47
- "args": {"type": "array"},
48
- "kwargs": {"type": "object"},
49
- "aliases": {"type": "array", "items": {"type": "string"}},
50
- "tags": {"type": "array", "items": {"type": "string"}},
51
- "style": {"type": "string"},
52
- "confirm": {"type": "boolean"},
53
- "confirm_message": {"type": "string"},
54
- "preview_before_confirm": {"type": "boolean"},
55
- "spinner": {"type": "boolean"},
56
- "spinner_message": {"type": "string"},
57
- "spinner_type": {"type": "string"},
58
- "spinner_style": {"type": "string"},
59
- "logging_hooks": {"type": "boolean"},
60
- "retry": {"type": "boolean"},
61
- "retry_all": {"type": "boolean"},
62
- "retry_policy": {
63
- "type": "object",
64
- "properties": {
65
- "enabled": {"type": "boolean"},
66
- "max_retries": {"type": "integer"},
67
- "delay": {"type": "number"},
68
- "backoff": {"type": "number"},
69
- },
70
- },
71
- },
72
- },
73
- },
74
- },
75
- "required": ["commands"],
76
- }
File without changes
File without changes