From afa47b0bace2d052384f9173ed138bb0bec52c61 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Sun, 18 May 2025 22:24:44 -0400 Subject: [PATCH] Add auto_args --- examples/auto_args_group.py | 40 +++++ examples/auto_parse_demo.py | 32 ++++ falyx/action/.pytyped | 0 falyx/action/io_action.py | 5 +- falyx/command.py | 87 +++++++++-- falyx/falyx.py | 57 +++++-- falyx/parsers/.pytyped | 0 falyx/parsers/__init__.py | 21 +++ falyx/{ => parsers}/argparse.py | 206 +++++++++++++++++++++++--- falyx/{ => parsers}/parsers.py | 0 falyx/parsers/signature.py | 71 +++++++++ falyx/parsers/utils.py | 33 +++++ falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_command_argument_parser.py | 2 +- 15 files changed, 511 insertions(+), 47 deletions(-) create mode 100644 examples/auto_args_group.py create mode 100644 examples/auto_parse_demo.py create mode 100644 falyx/action/.pytyped create mode 100644 falyx/parsers/.pytyped create mode 100644 falyx/parsers/__init__.py rename falyx/{ => parsers}/argparse.py (78%) rename falyx/{ => parsers}/parsers.py (100%) create mode 100644 falyx/parsers/signature.py create mode 100644 falyx/parsers/utils.py diff --git a/examples/auto_args_group.py b/examples/auto_args_group.py new file mode 100644 index 0000000..2950ddf --- /dev/null +++ b/examples/auto_args_group.py @@ -0,0 +1,40 @@ +import asyncio + +from falyx import Action, ActionGroup, Command, Falyx + + +# Define a shared async function +async def say_hello(name: str, excited: bool = False): + if excited: + print(f"Hello, {name}!!!") + else: + print(f"Hello, {name}.") + + +# Wrap the same callable in multiple Actions +action1 = Action("say_hello_1", action=say_hello) +action2 = Action("say_hello_2", action=say_hello) +action3 = Action("say_hello_3", action=say_hello) + +# Combine into an ActionGroup +group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) + +# Create the Command with auto_args=True +cmd = Command( + key="G", + description="Greet someone with multiple variations.", + action=group, + auto_args=True, + arg_metadata={ + "name": { + "help": "The name of the person to greet.", + }, + "excited": { + "help": "Whether to greet excitedly.", + }, + }, +) + +flx = Falyx("Test Group") +flx.add_command_from_command(cmd) +asyncio.run(flx.run()) diff --git a/examples/auto_parse_demo.py b/examples/auto_parse_demo.py new file mode 100644 index 0000000..5d4a06c --- /dev/null +++ b/examples/auto_parse_demo.py @@ -0,0 +1,32 @@ +import asyncio + +from falyx import Action, Falyx + + +async def deploy(service: str, region: str = "us-east-1", verbose: bool = False): + if verbose: + print(f"Deploying {service} to {region}...") + await asyncio.sleep(2) + if verbose: + print(f"{service} deployed successfully!") + + +flx = Falyx("Deployment CLI") + +flx.add_command( + key="D", + aliases=["deploy"], + description="Deploy a service to a specified region.", + action=Action( + name="deploy_service", + action=deploy, + ), + auto_args=True, + arg_metadata={ + "service": "Service name", + "region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]}, + "verbose": {"help": "Enable verbose mode"}, + }, +) + +asyncio.run(flx.run()) diff --git a/falyx/action/.pytyped b/falyx/action/.pytyped new file mode 100644 index 0000000..e69de29 diff --git a/falyx/action/io_action.py b/falyx/action/io_action.py index dee53ba..0bb6e44 100644 --- a/falyx/action/io_action.py +++ b/falyx/action/io_action.py @@ -224,7 +224,10 @@ class ShellAction(BaseIOAction): # Replace placeholder in template, or use raw input as full command command = self.command_template.format(parsed_input) if self.safe_mode: - args = shlex.split(command) + try: + args = shlex.split(command) + except ValueError as error: + raise FalyxError(f"Invalid command template: {error}") result = subprocess.run(args, capture_output=True, text=True, check=True) else: result = subprocess.run( diff --git a/falyx/command.py b/falyx/command.py index 44d5759..fd697a7 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -27,15 +27,25 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator from rich.console import Console from rich.tree import Tree -from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction +from falyx.action.action import ( + Action, + ActionGroup, + BaseAction, + ChainedAction, + ProcessAction, +) from falyx.action.io_action import BaseIOAction -from falyx.argparse import CommandArgumentParser from falyx.context import ExecutionContext from falyx.debug import register_debug_hooks from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType from falyx.logger import logger from falyx.options_manager import OptionsManager +from falyx.parsers import ( + CommandArgumentParser, + infer_args_from_func, + same_argument_definitions, +) from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy @@ -90,6 +100,11 @@ class Command(BaseModel): tags (list[str]): Organizational tags for the command. logging_hooks (bool): Whether to attach logging hooks automatically. requires_input (bool | None): Indicates if the action needs input. + options_manager (OptionsManager): Manages global command-line options. + arg_parser (CommandArgumentParser): Parses command arguments. + custom_parser (ArgParserProtocol | None): Custom argument parser. + custom_help (Callable[[], str | None] | None): Custom help message generator. + auto_args (bool): Automatically infer arguments from the action. Methods: __call__(): Executes the command, respecting hooks and retries. @@ -101,12 +116,13 @@ class Command(BaseModel): key: str description: str - action: BaseAction | Callable[[], Any] + action: BaseAction | Callable[[Any], Any] args: tuple = () kwargs: dict[str, Any] = Field(default_factory=dict) hidden: bool = False aliases: list[str] = Field(default_factory=list) help_text: str = "" + help_epilogue: str = "" style: str = OneColors.WHITE confirm: bool = False confirm_message: str = "Are you sure?" @@ -125,22 +141,44 @@ class Command(BaseModel): requires_input: bool | None = None options_manager: OptionsManager = Field(default_factory=OptionsManager) arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser) + arguments: list[dict[str, Any]] = Field(default_factory=list) + argument_config: Callable[[CommandArgumentParser], None] | None = None custom_parser: ArgParserProtocol | None = None custom_help: Callable[[], str | None] | None = None + auto_args: bool = False + arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) _context: ExecutionContext | None = PrivateAttr(default=None) model_config = ConfigDict(arbitrary_types_allowed=True) - def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]: + def parse_args( + self, raw_args: list[str] | str, from_validate: bool = False + ) -> tuple[tuple, dict]: if self.custom_parser: if isinstance(raw_args, str): - raw_args = shlex.split(raw_args) + try: + raw_args = shlex.split(raw_args) + except ValueError: + logger.warning( + "[Command:%s] Failed to split arguments: %s", + self.key, + raw_args, + ) + return ((), {}) return self.custom_parser(raw_args) if isinstance(raw_args, str): - raw_args = shlex.split(raw_args) - return self.arg_parser.parse_args_split(raw_args) + try: + raw_args = shlex.split(raw_args) + except ValueError: + logger.warning( + "[Command:%s] Failed to split arguments: %s", + self.key, + raw_args, + ) + return ((), {}) + return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate) @field_validator("action", mode="before") @classmethod @@ -151,11 +189,37 @@ class Command(BaseModel): return ensure_async(action) raise TypeError("Action must be a callable or an instance of BaseAction") + def get_argument_definitions(self) -> list[dict[str, Any]]: + if self.arguments: + return self.arguments + elif self.argument_config: + self.argument_config(self.arg_parser) + elif self.auto_args: + if isinstance(self.action, (Action, ProcessAction)): + return infer_args_from_func(self.action.action, self.arg_metadata) + elif isinstance(self.action, ChainedAction): + if self.action.actions: + action = self.action.actions[0] + if isinstance(action, Action): + return infer_args_from_func(action.action, self.arg_metadata) + elif callable(action): + return infer_args_from_func(action, self.arg_metadata) + elif isinstance(self.action, ActionGroup): + arg_defs = same_argument_definitions( + self.action.actions, self.arg_metadata + ) + if arg_defs: + return arg_defs + logger.debug( + "[Command:%s] auto_args disabled: mismatched ActionGroup arguments", + self.key, + ) + elif callable(self.action): + return infer_args_from_func(self.action, self.arg_metadata) + return [] + def model_post_init(self, _: Any) -> None: """Post-initialization to set up the action and hooks.""" - if isinstance(self.arg_parser, CommandArgumentParser): - self.arg_parser.command_description = self.description - if self.retry and isinstance(self.action, Action): self.action.enable_retry() elif self.retry_policy and isinstance(self.action, Action): @@ -183,6 +247,9 @@ class Command(BaseModel): elif self.requires_input is None: self.requires_input = False + for arg_def in self.get_argument_definitions(): + self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) + @cached_property def detect_requires_input(self) -> bool: """Detect if the action requires input based on its type.""" diff --git a/falyx/falyx.py b/falyx/falyx.py index 88da6d3..3928a9d 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -58,9 +58,10 @@ from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType from falyx.logger import logger from falyx.options_manager import OptionsManager -from falyx.parsers import get_arg_parsers +from falyx.parsers import CommandArgumentParser, get_arg_parsers +from falyx.protocols import ArgParserProtocol from falyx.retry import RetryPolicy -from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal +from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal from falyx.themes import OneColors, get_nord_theme from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation from falyx.version import __version__ @@ -444,6 +445,7 @@ class Falyx: bottom_toolbar=self._get_bottom_bar_render(), key_bindings=self.key_bindings, validate_while_typing=False, + interrupt_exception=FlowSignal, ) return self._prompt_session @@ -524,7 +526,7 @@ class Falyx: key: str = "X", description: str = "Exit", aliases: list[str] | None = None, - action: Callable[[], Any] | None = None, + action: Callable[[Any], Any] | None = None, style: str = OneColors.DARK_RED, confirm: bool = False, confirm_message: str = "Are you sure?", @@ -578,13 +580,14 @@ class Falyx: self, key: str, description: str, - action: BaseAction | Callable[[], Any], + action: BaseAction | Callable[[Any], Any], *, args: tuple = (), kwargs: dict[str, Any] | None = None, hidden: bool = False, aliases: list[str] | None = None, help_text: str = "", + help_epilogue: str = "", style: str = OneColors.WHITE, confirm: bool = False, confirm_message: str = "Are you sure?", @@ -606,9 +609,33 @@ class Falyx: retry_all: bool = False, retry_policy: RetryPolicy | None = None, requires_input: bool | None = None, + arg_parser: CommandArgumentParser | None = None, + arguments: list[dict[str, Any]] | None = None, + argument_config: Callable[[CommandArgumentParser], None] | None = None, + custom_parser: ArgParserProtocol | None = None, + custom_help: Callable[[], str | None] | None = None, + auto_args: bool = False, + arg_metadata: dict[str, str | dict[str, Any]] | None = None, ) -> Command: """Adds an command to the menu, preventing duplicates.""" self._validate_command_key(key) + + if arg_parser: + if not isinstance(arg_parser, CommandArgumentParser): + raise NotAFalyxError( + "arg_parser must be an instance of CommandArgumentParser." + ) + arg_parser = arg_parser + else: + arg_parser = CommandArgumentParser( + command_key=key, + command_description=description, + command_style=style, + help_text=help_text, + help_epilogue=help_epilogue, + aliases=aliases, + ) + command = Command( key=key, description=description, @@ -618,6 +645,7 @@ class Falyx: hidden=hidden, aliases=aliases if aliases else [], help_text=help_text, + help_epilogue=help_epilogue, style=style, confirm=confirm, confirm_message=confirm_message, @@ -634,6 +662,13 @@ class Falyx: retry_policy=retry_policy or RetryPolicy(), requires_input=requires_input, options_manager=self.options, + arg_parser=arg_parser, + arguments=arguments or [], + argument_config=argument_config, + custom_parser=custom_parser, + custom_help=custom_help, + auto_args=auto_args, + arg_metadata=arg_metadata or {}, ) if hooks: @@ -715,7 +750,10 @@ class Falyx: """ args = () kwargs: dict[str, Any] = {} - choice, *input_args = shlex.split(raw_choices) + try: + choice, *input_args = shlex.split(raw_choices) + except ValueError: + return False, None, args, kwargs is_preview, choice = self.parse_preview_command(choice) if is_preview and not choice and self.help_command: is_preview = False @@ -735,7 +773,7 @@ class Falyx: logger.info("Command '%s' selected.", choice) if input_args and name_map[choice].arg_parser: try: - args, kwargs = name_map[choice].parse_args(input_args) + args, kwargs = name_map[choice].parse_args(input_args, from_validate) except CommandArgumentError as error: if not from_validate: if not name_map[choice].show_help(): @@ -748,6 +786,8 @@ class Falyx: message=str(error), cursor_position=len(raw_choices) ) return is_preview, None, args, kwargs + except HelpSignal: + return True, None, args, kwargs return is_preview, name_map[choice], args, kwargs prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)] @@ -823,7 +863,6 @@ class Falyx: context.start_timer() try: await self.hooks.trigger(HookType.BEFORE, context) - print(args, kwargs) result = await selected_command(*args, **kwargs) context.result = result await self.hooks.trigger(HookType.ON_SUCCESS, context) @@ -964,8 +1003,6 @@ class Falyx: logger.info("BackSignal received.") except CancelSignal: logger.info("CancelSignal received.") - except HelpSignal: - logger.info("HelpSignal received.") finally: logger.info("Exiting menu: %s", self.get_title()) if self.exit_message: @@ -995,7 +1032,7 @@ class Falyx: sys.exit(0) if self.cli_args.command == "version" or self.cli_args.version: - self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]") + self.console.print(f"[{OneColors.BLUE_b}]Falyx CLI v{__version__}[/]") sys.exit(0) if self.cli_args.command == "preview": diff --git a/falyx/parsers/.pytyped b/falyx/parsers/.pytyped new file mode 100644 index 0000000..e69de29 diff --git a/falyx/parsers/__init__.py b/falyx/parsers/__init__.py new file mode 100644 index 0000000..8b0b71f --- /dev/null +++ b/falyx/parsers/__init__.py @@ -0,0 +1,21 @@ +""" +Falyx CLI Framework + +Copyright (c) 2025 rtj.dev LLC. +Licensed under the MIT License. See LICENSE file for details. +""" + +from .argparse import Argument, ArgumentAction, CommandArgumentParser +from .parsers import FalyxParsers, get_arg_parsers +from .signature import infer_args_from_func +from .utils import same_argument_definitions + +__all__ = [ + "Argument", + "ArgumentAction", + "CommandArgumentParser", + "get_arg_parsers", + "FalyxParsers", + "infer_args_from_func", + "same_argument_definitions", +] diff --git a/falyx/argparse.py b/falyx/parsers/argparse.py similarity index 78% rename from falyx/argparse.py rename to falyx/parsers/argparse.py index 29b0bd4..db32991 100644 --- a/falyx/argparse.py +++ b/falyx/parsers/argparse.py @@ -5,7 +5,8 @@ from enum import Enum from typing import Any, Iterable from rich.console import Console -from rich.table import Table +from rich.markup import escape +from rich.text import Text from falyx.exceptions import CommandArgumentError from falyx.signals import HelpSignal @@ -40,6 +41,70 @@ class Argument: nargs: int | str = 1 # int, '?', '*', '+' positional: bool = False # True if no leading - or -- in flags + def get_positional_text(self) -> str: + """Get the positional text for the argument.""" + text = "" + if self.positional: + if self.choices: + text = f"{{{','.join([str(choice) for choice in self.choices])}}}" + else: + text = self.dest + return text + + def get_choice_text(self) -> str: + """Get the choice text for the argument.""" + choice_text = "" + if self.choices: + choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}" + elif ( + self.action + in ( + ArgumentAction.STORE, + ArgumentAction.APPEND, + ArgumentAction.EXTEND, + ) + and not self.positional + ): + choice_text = self.dest.upper() + elif isinstance(self.nargs, str): + choice_text = self.dest + + if self.nargs == "?": + choice_text = f"[{choice_text}]" + elif self.nargs == "*": + choice_text = f"[{choice_text} ...]" + elif self.nargs == "+": + choice_text = f"{choice_text} [{choice_text} ...]" + return choice_text + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Argument): + return False + return ( + self.flags == other.flags + and self.dest == other.dest + and self.action == other.action + and self.type == other.type + and self.choices == other.choices + and self.required == other.required + and self.nargs == other.nargs + and self.positional == other.positional + ) + + def __hash__(self) -> int: + return hash( + ( + tuple(self.flags), + self.dest, + self.action, + self.type, + tuple(self.choices or []), + self.required, + self.nargs, + self.positional, + ) + ) + class CommandArgumentParser: """ @@ -61,10 +126,25 @@ class CommandArgumentParser: - Render Help using Rich library. """ - def __init__(self) -> None: + def __init__( + self, + command_key: str = "", + command_description: str = "", + command_style: str = "bold", + help_text: str = "", + help_epilogue: str = "", + aliases: list[str] | None = None, + ) -> None: """Initialize the CommandArgumentParser.""" - self.command_description: str = "" + self.command_key: str = command_key + self.command_description: str = command_description + self.command_style: str = command_style + self.help_text: str = help_text + self.help_epilogue: str = help_epilogue + self.aliases: list[str] = aliases or [] self._arguments: list[Argument] = [] + self._positional: list[Argument] = [] + self._keyword: list[Argument] = [] self._flag_map: dict[str, Argument] = {} self._dest_set: set[str] = set() self._add_help() @@ -73,10 +153,10 @@ class CommandArgumentParser: def _add_help(self): """Add help argument to the parser.""" self.add_argument( - "--help", "-h", + "--help", action=ArgumentAction.HELP, - help="Show this help message and exit.", + help="Show this help message.", dest="help", ) @@ -304,10 +384,31 @@ class CommandArgumentParser: ) self._flag_map[flag] = argument self._arguments.append(argument) + if positional: + self._positional.append(argument) + else: + self._keyword.append(argument) def get_argument(self, dest: str) -> Argument | None: return next((a for a in self._arguments if a.dest == dest), None) + def to_definition_list(self) -> list[dict[str, Any]]: + defs = [] + for arg in self._arguments: + defs.append( + { + "flags": arg.flags, + "dest": arg.dest, + "action": arg.action, + "type": arg.type, + "choices": arg.choices, + "required": arg.required, + "nargs": arg.nargs, + "positional": arg.positional, + } + ) + return defs + def _consume_nargs( self, args: list[str], start: int, spec: Argument ) -> tuple[list[str], int]: @@ -405,7 +506,9 @@ class CommandArgumentParser: return i - def parse_args(self, args: list[str] | None = None) -> dict[str, Any]: + def parse_args( + self, args: list[str] | None = None, from_validate: bool = False + ) -> dict[str, Any]: """Parse Falyx Command arguments.""" if args is None: args = [] @@ -423,7 +526,8 @@ class CommandArgumentParser: action = spec.action if action == ArgumentAction.HELP: - self.render_help() + if not from_validate: + self.render_help() raise HelpSignal() elif action == ArgumentAction.STORE_TRUE: result[spec.dest] = True @@ -550,13 +654,15 @@ class CommandArgumentParser: result.pop("help", None) return result - def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]: + def parse_args_split( + self, args: list[str], from_validate: bool = False + ) -> tuple[tuple[Any, ...], dict[str, Any]]: """ Returns: tuple[args, kwargs] - Positional arguments in defined order, followed by keyword argument mapping. """ - parsed = self.parse_args(args) + parsed = self.parse_args(args, from_validate) args_list = [] kwargs_dict = {} for arg in self._arguments: @@ -568,20 +674,74 @@ class CommandArgumentParser: kwargs_dict[arg.dest] = parsed[arg.dest] return tuple(args_list), kwargs_dict - def render_help(self): - table = Table(title=f"{self.command_description} Help") - table.add_column("Flags") - table.add_column("Help") - for arg in self._arguments: - if arg.dest == "help": - continue - flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest - table.add_row(flag_str, arg.help or "") - table.add_section() - arg = self.get_argument("help") - flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest - table.add_row(flag_str, arg.help or "") - self.console.print(table) + def render_help(self) -> None: + # Options + # Add all keyword arguments to the options list + options_list = [] + for arg in self._keyword: + choice_text = arg.get_choice_text() + if choice_text: + options_list.extend([f"[{arg.flags[0]} {choice_text}]"]) + else: + options_list.extend([f"[{arg.flags[0]}]"]) + + # Add positional arguments to the options list + for arg in self._positional: + choice_text = arg.get_choice_text() + if isinstance(arg.nargs, int): + choice_text = " ".join([choice_text] * arg.nargs) + options_list.append(escape(choice_text)) + + options_text = " ".join(options_list) + command_keys = " | ".join( + [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"] + + [ + f"[{self.command_style}]{alias}[/{self.command_style}]" + for alias in self.aliases + ] + ) + + usage = f"usage: {command_keys} {options_text}" + self.console.print(f"[bold]{usage}[/bold]\n") + + # Description + if self.help_text: + self.console.print(self.help_text + "\n") + + # Arguments + if self._arguments: + if self._positional: + self.console.print("[bold]positional:[/bold]") + for arg in self._positional: + flags = arg.get_positional_text() + arg_line = Text(f" {flags:<30} ") + help_text = arg.help or "" + arg_line.append(help_text) + self.console.print(arg_line) + self.console.print("[bold]options:[/bold]") + for arg in self._keyword: + flags = ", ".join(arg.flags) + flags_choice = f"{flags} {arg.get_choice_text()}" + arg_line = Text(f" {flags_choice:<30} ") + help_text = arg.help or "" + arg_line.append(help_text) + self.console.print(arg_line) + + # Epilogue + if self.help_epilogue: + self.console.print("\n" + self.help_epilogue, style="dim") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CommandArgumentParser): + return False + + def sorted_args(parser): + return sorted(parser._arguments, key=lambda a: a.dest) + + return sorted_args(self) == sorted_args(other) + + def __hash__(self) -> int: + return hash(tuple(sorted(self._arguments, key=lambda a: a.dest))) def __str__(self) -> str: positional = sum(arg.positional for arg in self._arguments) diff --git a/falyx/parsers.py b/falyx/parsers/parsers.py similarity index 100% rename from falyx/parsers.py rename to falyx/parsers/parsers.py diff --git a/falyx/parsers/signature.py b/falyx/parsers/signature.py new file mode 100644 index 0000000..0c4c0ff --- /dev/null +++ b/falyx/parsers/signature.py @@ -0,0 +1,71 @@ +import inspect +from typing import Any, Callable + +from falyx import logger + + +def infer_args_from_func( + func: Callable[[Any], Any], + arg_metadata: dict[str, str | dict[str, Any]] | None = None, +) -> list[dict[str, Any]]: + """ + Infer argument definitions from a callable's signature. + Returns a list of kwargs suitable for CommandArgumentParser.add_argument. + """ + arg_metadata = arg_metadata or {} + signature = inspect.signature(func) + arg_defs = [] + + for name, param in signature.parameters.items(): + raw_metadata = arg_metadata.get(name, {}) + metadata = ( + {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata + ) + + if param.kind not in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + continue + + arg_type = ( + param.annotation if param.annotation is not inspect.Parameter.empty else str + ) + default = param.default if param.default is not inspect.Parameter.empty else None + is_required = param.default is inspect.Parameter.empty + if is_required: + flags = [f"{name.replace('_', '-')}"] + else: + flags = [f"--{name.replace('_', '-')}"] + action = "store" + nargs: int | str = 1 + + if arg_type is bool: + if param.default is False: + action = "store_true" + else: + action = "store_false" + + if arg_type is list: + action = "append" + if is_required: + nargs = "+" + else: + nargs = "*" + + arg_defs.append( + { + "flags": flags, + "dest": name, + "type": arg_type, + "default": default, + "required": is_required, + "nargs": nargs, + "action": action, + "help": metadata.get("help", ""), + "choices": metadata.get("choices"), + } + ) + + return arg_defs diff --git a/falyx/parsers/utils.py b/falyx/parsers/utils.py new file mode 100644 index 0000000..77163cf --- /dev/null +++ b/falyx/parsers/utils.py @@ -0,0 +1,33 @@ +from typing import Any + +from falyx import logger +from falyx.action.action import Action, ChainedAction, ProcessAction +from falyx.parsers.signature import infer_args_from_func + + +def same_argument_definitions( + actions: list[Any], + arg_metadata: dict[str, str | dict[str, Any]] | None = None, +) -> list[dict[str, Any]] | None: + arg_sets = [] + for action in actions: + if isinstance(action, (Action, ProcessAction)): + arg_defs = infer_args_from_func(action.action, arg_metadata) + elif isinstance(action, ChainedAction): + if action.actions: + action = action.actions[0] + if isinstance(action, Action): + arg_defs = infer_args_from_func(action.action, arg_metadata) + elif callable(action): + arg_defs = infer_args_from_func(action, arg_metadata) + elif callable(action): + arg_defs = infer_args_from_func(action, arg_metadata) + else: + logger.debug("Auto args unsupported for action: %s", action) + return None + arg_sets.append(arg_defs) + + first = arg_sets[0] + if all(arg_set == first for arg_set in arg_sets[1:]): + return first + return None diff --git a/falyx/version.py b/falyx/version.py index f7ee773..a5f3762 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.28" +__version__ = "0.1.29" diff --git a/pyproject.toml b/pyproject.toml index fa69aa3..7a10ec5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.28" +version = "0.1.29" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_command_argument_parser.py b/tests/test_command_argument_parser.py index 31b516c..e07429c 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_command_argument_parser.py @@ -1,7 +1,7 @@ import pytest -from falyx.argparse import ArgumentAction, CommandArgumentParser from falyx.exceptions import CommandArgumentError +from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.signals import HelpSignal