diff --git a/examples/action_factory_demo.py b/examples/action_factory_demo.py index f55cfcd..c1a8867 100644 --- a/examples/action_factory_demo.py +++ b/examples/action_factory_demo.py @@ -6,7 +6,7 @@ from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, Selecti # Selection of a post ID to fetch (just an example set) post_selector = SelectionAction( name="Pick Post ID", - selections=["1", "2", "3", "4", "5"], + selections=["15", "25", "35", "45", "55"], title="Choose a Post ID to submit", prompt_message="Post ID > ", show_table=True, @@ -14,7 +14,7 @@ post_selector = SelectionAction( # Factory that builds and executes the actual HTTP POST request -def build_post_action(post_id) -> HTTPAction: +async def build_post_action(post_id) -> HTTPAction: print(f"Building HTTPAction for Post ID: {post_id}") return HTTPAction( name=f"POST to /posts (id={post_id})", diff --git a/examples/menu_demo.py b/examples/menu_demo.py index a9d7a0a..702412d 100644 --- a/examples/menu_demo.py +++ b/examples/menu_demo.py @@ -2,8 +2,16 @@ import asyncio import time from falyx import Falyx -from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction +from falyx.action import ( + Action, + ActionGroup, + ChainedAction, + MenuAction, + ProcessAction, + PromptMenuAction, +) from falyx.menu import MenuOption, MenuOptionMap +from falyx.themes import OneColors # Basic coroutine for Action @@ -77,20 +85,28 @@ parallel = ActionGroup( process = ProcessAction(name="compute", action=heavy_computation) +menu_options = MenuOptionMap( + { + "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), + "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), + "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), + "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), + } +) + # Menu setup menu = MenuAction( name="main-menu", title="Choose a task to run", - menu_options=MenuOptionMap( - { - "1": MenuOption("Run basic Action", basic_action), - "2": MenuOption("Run ChainedAction", chained), - "3": MenuOption("Run ActionGroup (parallel)", parallel), - "4": MenuOption("Run ProcessAction (heavy task)", process), - } - ), + menu_options=menu_options, +) + + +prompt_menu = PromptMenuAction( + name="select-user", + menu_options=menu_options, ) flx = Falyx( @@ -108,6 +124,13 @@ flx.add_command( logging_hooks=True, ) +flx.add_command( + key="P", + description="Show Prompt Menu", + action=prompt_menu, + logging_hooks=True, +) + if __name__ == "__main__": asyncio.run(flx.run()) diff --git a/examples/selection_demo.py b/examples/selection_demo.py index 580910a..87a5cbb 100644 --- a/examples/selection_demo.py +++ b/examples/selection_demo.py @@ -2,6 +2,7 @@ import asyncio from falyx.action import SelectionAction from falyx.selection import SelectionOption +from falyx.signals import CancelSignal selections = { "1": SelectionOption( @@ -23,4 +24,7 @@ select = SelectionAction( show_table=True, ) -print(asyncio.run(select())) +try: + print(asyncio.run(select())) +except CancelSignal: + print("Selection was cancelled.") diff --git a/falyx/action/__init__.py b/falyx/action/__init__.py index d480608..b27cd72 100644 --- a/falyx/action/__init__.py +++ b/falyx/action/__init__.py @@ -18,6 +18,7 @@ from .action_factory import ActionFactoryAction from .http_action import HTTPAction from .io_action import BaseIOAction, ShellAction from .menu_action import MenuAction +from .prompt_menu_action import PromptMenuAction from .select_file_action import SelectFileAction from .selection_action import SelectionAction from .signal_action import SignalAction @@ -40,4 +41,5 @@ __all__ = [ "FallbackAction", "LiteralInputAction", "UserInputAction", + "PromptMenuAction", ] diff --git a/falyx/action/prompt_menu_action.py b/falyx/action/prompt_menu_action.py new file mode 100644 index 0000000..ab608e2 --- /dev/null +++ b/falyx/action/prompt_menu_action.py @@ -0,0 +1,134 @@ +# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed +"""prompt_menu_action.py""" +from typing import Any + +from prompt_toolkit import PromptSession +from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text +from rich.console import Console +from rich.tree import Tree + +from falyx.action.action import BaseAction +from falyx.context import ExecutionContext +from falyx.execution_registry import ExecutionRegistry as er +from falyx.hook_manager import HookType +from falyx.logger import logger +from falyx.menu import MenuOptionMap +from falyx.signals import BackSignal, QuitSignal +from falyx.themes import OneColors + + +class PromptMenuAction(BaseAction): + """PromptMenuAction class for creating prompt -> actions.""" + + def __init__( + self, + name: str, + menu_options: MenuOptionMap, + *, + prompt_message: str = "Select > ", + default_selection: str = "", + inject_last_result: bool = False, + inject_into: str = "last_result", + console: Console | None = None, + prompt_session: PromptSession | None = None, + never_prompt: bool = False, + include_reserved: bool = True, + ): + super().__init__( + name, + inject_last_result=inject_last_result, + inject_into=inject_into, + never_prompt=never_prompt, + ) + self.menu_options = menu_options + self.prompt_message = prompt_message + self.default_selection = default_selection + self.console = console or Console(color_system="auto") + self.prompt_session = prompt_session or PromptSession() + self.include_reserved = include_reserved + + def get_infer_target(self) -> tuple[None, None]: + return None, None + + async def _run(self, *args, **kwargs) -> Any: + kwargs = self._maybe_inject_last_result(kwargs) + context = ExecutionContext( + name=self.name, + args=args, + kwargs=kwargs, + action=self, + ) + + effective_default = self.default_selection + maybe_result = str(self.last_result) + if maybe_result in self.menu_options: + effective_default = maybe_result + elif self.inject_last_result: + logger.warning( + "[%s] Injected last result '%s' not found in menu options", + self.name, + maybe_result, + ) + + if self.never_prompt and not effective_default: + raise ValueError( + f"[{self.name}] 'never_prompt' is True but no valid default_selection" + " was provided." + ) + + context.start_timer() + try: + await self.hooks.trigger(HookType.BEFORE, context) + key = effective_default + if not self.never_prompt: + placeholder_formatted_text = [] + for index, (key, option) in enumerate(self.menu_options.items()): + placeholder_formatted_text.append(option.render_prompt(key)) + if index < len(self.menu_options) - 1: + placeholder_formatted_text.append( + FormattedText([(OneColors.WHITE, " | ")]) + ) + placeholder = merge_formatted_text(placeholder_formatted_text) + key = await self.prompt_session.prompt_async( + message=self.prompt_message, placeholder=placeholder + ) + option = self.menu_options[key] + result = await option.action(*args, **kwargs) + context.result = result + await self.hooks.trigger(HookType.ON_SUCCESS, context) + return result + + except BackSignal: + logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) + return None + except QuitSignal: + logger.debug("[%s][QuitSignal] ← Exiting application", self.name) + raise + except Exception as error: + context.exception = error + await self.hooks.trigger(HookType.ON_ERROR, context) + raise + finally: + context.stop_timer() + await self.hooks.trigger(HookType.AFTER, context) + await self.hooks.trigger(HookType.ON_TEARDOWN, context) + er.record(context) + + async def preview(self, parent: Tree | None = None): + label = f"[{OneColors.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'" + tree = parent.add(label) if parent else Tree(label) + for key, option in self.menu_options.items(): + tree.add( + f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]" + ) + await option.action.preview(parent=tree) + if not parent: + self.console.print(tree) + + def __str__(self) -> str: + return ( + f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, " + f"default_selection={self.default_selection!r}, " + f"include_reserved={self.include_reserved}, " + f"prompt={'off' if self.never_prompt else 'on'})" + ) diff --git a/falyx/action/selection_action.py b/falyx/action/selection_action.py index f156f8b..17aec41 100644 --- a/falyx/action/selection_action.py +++ b/falyx/action/selection_action.py @@ -1,5 +1,6 @@ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed """selection_action.py""" +from copy import copy from typing import Any from prompt_toolkit import PromptSession @@ -72,6 +73,7 @@ class SelectionAction(BaseAction): self.default_selection = default_selection self.prompt_message = prompt_message self.show_table = show_table + self.cancel_key = self._find_cancel_key() def _coerce_return_type( self, return_type: SelectionReturnType | str @@ -115,12 +117,40 @@ class SelectionAction(BaseAction): ) def _find_cancel_key(self) -> str: - """Return first numeric value not already used in the selection dict.""" - for index in range(len(self.selections)): - if str(index) not in self.selections: - return str(index) + """Find the cancel key in the selections.""" + if isinstance(self.selections, dict): + for index in range(len(self.selections) + 1): + if str(index) not in self.selections: + return str(index) return str(len(self.selections)) + @property + def cancel_key(self) -> str: + return self._cancel_key + + @cancel_key.setter + def cancel_key(self, value: str) -> None: + """Set the cancel key for the selection.""" + if not isinstance(value, str): + raise TypeError("Cancel key must be a string.") + if isinstance(self.selections, dict) and value in self.selections: + raise ValueError( + "Cancel key cannot be one of the selection keys. " + f"Current selections: {self.selections}" + ) + if isinstance(self.selections, list): + if not value.isdigit() or int(value) > len(self.selections): + raise ValueError( + "cancel_key must be a digit and not greater than the number of selections." + ) + self._cancel_key = value + + def cancel_formatter(self, index: int, selection: str) -> str: + """Format the cancel option for display.""" + if self.cancel_key == str(index): + return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]" + return f"[{index}] {selection}" + def get_infer_target(self) -> tuple[None, None]: return None, None @@ -164,16 +194,17 @@ class SelectionAction(BaseAction): context.start_timer() try: - cancel_key = self._find_cancel_key() + self.cancel_key = self._find_cancel_key() await self.hooks.trigger(HookType.BEFORE, context) if isinstance(self.selections, list): table = render_selection_indexed_table( title=self.title, selections=self.selections + ["Cancel"], columns=self.columns, + formatter=self.cancel_formatter, ) if not self.never_prompt: - index = await prompt_for_index( + index: int | str = await prompt_for_index( len(self.selections), table, default_selection=effective_default, @@ -184,12 +215,12 @@ class SelectionAction(BaseAction): ) else: index = effective_default - if index == cancel_key: + if int(index) == int(self.cancel_key): raise CancelSignal("User cancelled the selection.") result: Any = self.selections[int(index)] elif isinstance(self.selections, dict): cancel_option = { - cancel_key: SelectionOption( + self.cancel_key: SelectionOption( description="Cancel", value=CancelSignal, style=OneColors.DARK_RED ) } @@ -210,7 +241,7 @@ class SelectionAction(BaseAction): ) else: key = effective_default - if key == cancel_key: + if key == self.cancel_key: raise CancelSignal("User cancelled the selection.") if self.return_type == SelectionReturnType.KEY: result = key diff --git a/falyx/command.py b/falyx/command.py index 9ef1373..deb6bc5 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -139,7 +139,7 @@ class Command(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - def parse_args( + async def parse_args( self, raw_args: list[str] | str, from_validate: bool = False ) -> tuple[tuple, dict]: if callable(self.custom_parser): @@ -165,7 +165,9 @@ class Command(BaseModel): raw_args, ) return ((), {}) - return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate) + return await self.arg_parser.parse_args_split( + raw_args, from_validate=from_validate + ) @field_validator("action", mode="before") @classmethod diff --git a/falyx/falyx.py b/falyx/falyx.py index 2d3d814..c8db59c 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -83,8 +83,11 @@ class CommandValidator(Validator): self.error_message = error_message def validate(self, document) -> None: + pass + + async def validate_async(self, document) -> None: text = document.text - is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True) + is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) if is_preview: return None if not choice: @@ -188,7 +191,7 @@ class Falyx: self.cli_args: Namespace | None = cli_args self.render_menu: Callable[[Falyx], None] | None = render_menu self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table - self.hide_menu_table: bool = hide_menu_table + self._hide_menu_table: bool = hide_menu_table self.validate_options(cli_args, options) self._prompt_session: PromptSession | None = None self.mode = FalyxMode.MENU @@ -740,7 +743,7 @@ class Falyx: return True, input_str[1:].strip() return False, input_str.strip() - def get_command( + async def get_command( self, raw_choices: str, from_validate=False ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: """ @@ -773,7 +776,9 @@ class Falyx: if is_preview: return True, name_map[choice], args, kwargs try: - args, kwargs = name_map[choice].parse_args(input_args, from_validate) + args, kwargs = await name_map[choice].parse_args( + input_args, from_validate + ) except CommandArgumentError as error: if not from_validate: if not name_map[choice].show_help(): @@ -834,7 +839,7 @@ class Falyx: """Processes the action of the selected command.""" with patch_stdout(raw=True): choice = await self.prompt_session.prompt_async() - is_preview, selected_command, args, kwargs = self.get_command(choice) + is_preview, selected_command, args, kwargs = await self.get_command(choice) if not selected_command: logger.info("Invalid command '%s'.", choice) return True @@ -876,7 +881,7 @@ class Falyx: ) -> Any: """Run a command by key without displaying the menu (non-interactive mode).""" self.debug_hooks() - is_preview, selected_command, _, __ = self.get_command(command_key) + is_preview, selected_command, _, __ = await self.get_command(command_key) kwargs = kwargs or {} self.last_run_command = selected_command @@ -975,7 +980,7 @@ class Falyx: self.print_message(self.welcome_message) try: while True: - if not self.hide_menu_table: + if not self.options.get("hide_menu_table", self._hide_menu_table): if callable(self.render_menu): self.render_menu(self) else: @@ -1012,6 +1017,9 @@ class Falyx: if not self.options.get("force_confirm"): self.options.set("force_confirm", self._force_confirm) + if not self.options.get("hide_menu_table"): + self.options.set("hide_menu_table", self._hide_menu_table) + if self.cli_args.verbose: logging.getLogger("falyx").setLevel(logging.DEBUG) @@ -1029,7 +1037,7 @@ class Falyx: if self.cli_args.command == "preview": self.mode = FalyxMode.PREVIEW - _, command, args, kwargs = self.get_command(self.cli_args.name) + _, command, args, kwargs = await self.get_command(self.cli_args.name) if not command: self.console.print( f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." @@ -1043,7 +1051,7 @@ class Falyx: if self.cli_args.command == "run": self.mode = FalyxMode.RUN - is_preview, command, _, __ = self.get_command(self.cli_args.name) + is_preview, command, _, __ = await self.get_command(self.cli_args.name) if is_preview: if command is None: sys.exit(1) @@ -1054,7 +1062,7 @@ class Falyx: sys.exit(1) self._set_retry_policy(command) try: - args, kwargs = command.parse_args(self.cli_args.command_args) + args, kwargs = await command.parse_args(self.cli_args.command_args) except HelpSignal: sys.exit(0) try: diff --git a/falyx/menu.py b/falyx/menu.py index 4f61ee6..9e90002 100644 --- a/falyx/menu.py +++ b/falyx/menu.py @@ -2,6 +2,8 @@ from __future__ import annotations from dataclasses import dataclass +from prompt_toolkit.formatted_text import FormattedText + from falyx.action import BaseAction from falyx.signals import BackSignal, QuitSignal from falyx.themes import OneColors @@ -26,6 +28,12 @@ class MenuOption: """Render the menu option for display.""" return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" + def render_prompt(self, key: str) -> FormattedText: + """Render the menu option for prompt display.""" + return FormattedText( + [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] + ) + class MenuOptionMap(CaseInsensitiveDict): """ diff --git a/falyx/parsers/argparse.py b/falyx/parsers/argparse.py index 6b29f43..801f2f3 100644 --- a/falyx/parsers/argparse.py +++ b/falyx/parsers/argparse.py @@ -39,7 +39,7 @@ class ArgumentAction(Enum): class Argument: """Represents a command-line argument.""" - flags: list[str] + flags: tuple[str, ...] dest: str # Destination name for the argument action: ArgumentAction = ( ArgumentAction.STORE @@ -49,7 +49,7 @@ class Argument: choices: list[str] | None = None # List of valid choices for the argument required: bool = False # True if the argument is required help: str = "" # Help text for the argument - nargs: int | str = 1 # int, '?', '*', '+' + nargs: int | str | None = None # int, '?', '*', '+', None positional: bool = False # True if no leading - or -- in flags def get_positional_text(self) -> str: @@ -151,6 +151,7 @@ class CommandArgumentParser: aliases: list[str] | None = None, ) -> None: """Initialize the CommandArgumentParser.""" + self.console = Console(color_system="auto") self.command_key: str = command_key self.command_description: str = command_description self.command_style: str = command_style @@ -163,7 +164,6 @@ class CommandArgumentParser: self._flag_map: dict[str, Argument] = {} self._dest_set: set[str] = set() self._add_help() - self.console = Console(color_system="auto") def _add_help(self): """Add help argument to the parser.""" @@ -185,9 +185,7 @@ class CommandArgumentParser: raise CommandArgumentError("Positional arguments cannot have multiple flags") return positional - def _get_dest_from_flags( - self, flags: tuple[str, ...], dest: str | None - ) -> str | None: + def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: """Convert flags to a destination name.""" if dest: if not dest.replace("_", "").isalnum(): @@ -216,7 +214,7 @@ class CommandArgumentParser: return dest def _determine_required( - self, required: bool, positional: bool, nargs: int | str + self, required: bool, positional: bool, nargs: int | str | None ) -> bool: """Determine if the argument is required.""" if required: @@ -234,7 +232,22 @@ class CommandArgumentParser: return required - def _validate_nargs(self, nargs: int | str) -> int | str: + def _validate_nargs( + self, nargs: int | str | None, action: ArgumentAction + ) -> int | str | None: + if action in ( + ArgumentAction.STORE_FALSE, + ArgumentAction.STORE_TRUE, + ArgumentAction.COUNT, + ArgumentAction.HELP, + ): + if nargs is not None: + raise CommandArgumentError( + f"nargs cannot be specified for {action} actions" + ) + return None + if nargs is None: + nargs = 1 allowed_nargs = ("?", "*", "+") if isinstance(nargs, int): if nargs <= 0: @@ -246,7 +259,9 @@ class CommandArgumentParser: raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") return nargs - def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]: + def _normalize_choices( + self, choices: Iterable | None, expected_type: Any + ) -> list[Any]: if choices is not None: if isinstance(choices, dict): raise CommandArgumentError("choices cannot be a dict") @@ -293,8 +308,34 @@ class CommandArgumentParser: f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" ) + def _validate_action( + self, action: ArgumentAction | str, positional: bool + ) -> ArgumentAction: + if not isinstance(action, ArgumentAction): + try: + action = ArgumentAction(action) + except ValueError: + raise CommandArgumentError( + f"Invalid action '{action}' is not a valid ArgumentAction" + ) + if action in ( + ArgumentAction.STORE_TRUE, + ArgumentAction.STORE_FALSE, + ArgumentAction.COUNT, + ArgumentAction.HELP, + ): + if positional: + raise CommandArgumentError( + f"Action '{action}' cannot be used with positional arguments" + ) + + return action + def _resolve_default( - self, action: ArgumentAction, default: Any, nargs: str | int + self, + default: Any, + action: ArgumentAction, + nargs: str | int | None, ) -> Any: """Get the default value for the argument.""" if default is None: @@ -328,7 +369,18 @@ class CommandArgumentParser: f"Flag '{flag}' must be a single character or start with '--'" ) - def add_argument(self, *flags, **kwargs): + def add_argument( + self, + *flags, + action: str | ArgumentAction = "store", + nargs: int | str | None = None, + default: Any = None, + type: Any = str, + choices: Iterable | None = None, + required: bool = False, + help: str = "", + dest: str | None = None, + ) -> None: """Add an argument to the parser. Args: name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). @@ -341,9 +393,10 @@ class CommandArgumentParser: help: A brief description of the argument. dest: The name of the attribute to be added to the object returned by parse_args(). """ + expected_type = type self._validate_flags(flags) positional = self._is_positional(flags) - dest = self._get_dest_from_flags(flags, kwargs.get("dest")) + dest = self._get_dest_from_flags(flags, dest) if dest in self._dest_set: raise CommandArgumentError( f"Destination '{dest}' is already defined.\n" @@ -351,18 +404,9 @@ class CommandArgumentParser: "is not supported. Define a unique 'dest' for each argument." ) self._dest_set.add(dest) - action = kwargs.get("action", ArgumentAction.STORE) - if not isinstance(action, ArgumentAction): - try: - action = ArgumentAction(action) - except ValueError: - raise CommandArgumentError( - f"Invalid action '{action}' is not a valid ArgumentAction" - ) - flags = list(flags) - nargs = self._validate_nargs(kwargs.get("nargs", 1)) - default = self._resolve_default(action, kwargs.get("default"), nargs) - expected_type = kwargs.get("type", str) + action = self._validate_action(action, positional) + nargs = self._validate_nargs(nargs, action) + default = self._resolve_default(default, action, nargs) if ( action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) and default is not None @@ -371,14 +415,12 @@ class CommandArgumentParser: self._validate_default_list_type(default, expected_type, dest) else: self._validate_default_type(default, expected_type, dest) - choices = self._normalize_choices(kwargs.get("choices"), expected_type) + choices = self._normalize_choices(choices, expected_type) if default is not None and choices and default not in choices: raise CommandArgumentError( f"Default value '{default}' not in allowed choices: {choices}" ) - required = self._determine_required( - kwargs.get("required", False), positional, nargs - ) + required = self._determine_required(required, positional, nargs) argument = Argument( flags=flags, dest=dest, @@ -387,7 +429,7 @@ class CommandArgumentParser: default=default, choices=choices, required=required, - help=kwargs.get("help", ""), + help=help, nargs=nargs, positional=positional, ) @@ -430,11 +472,11 @@ class CommandArgumentParser: values = [] i = start if isinstance(spec.nargs, int): - # assert i + spec.nargs <= len( - # args - # ), "Not enough arguments provided: shouldn't happen" values = args[i : i + spec.nargs] return values, i + spec.nargs + elif spec.nargs is None: + values = [args[i]] + return values, i + 1 elif spec.nargs == "+": if i >= len(args): raise CommandArgumentError( @@ -479,6 +521,8 @@ class CommandArgumentParser: for next_spec in positional_args[j + 1 :]: if isinstance(next_spec.nargs, int): min_required += next_spec.nargs + elif next_spec.nargs is None: + min_required += 1 elif next_spec.nargs == "+": min_required += 1 elif next_spec.nargs == "?": @@ -521,7 +565,7 @@ class CommandArgumentParser: return i - def parse_args( + async def parse_args( self, args: list[str] | None = None, from_validate: bool = False ) -> dict[str, Any]: """Parse Falyx Command arguments.""" @@ -669,7 +713,7 @@ class CommandArgumentParser: result.pop("help", None) return result - def parse_args_split( + async def parse_args_split( self, args: list[str], from_validate: bool = False ) -> tuple[tuple[Any, ...], dict[str, Any]]: """ @@ -677,7 +721,7 @@ class CommandArgumentParser: tuple[args, kwargs] - Positional arguments in defined order, followed by keyword argument mapping. """ - parsed = self.parse_args(args, from_validate) + parsed = await self.parse_args(args, from_validate) args_list = [] kwargs_dict = {} for arg in self._arguments: diff --git a/falyx/parsers/signature.py b/falyx/parsers/signature.py index 5b49b83..e018dec 100644 --- a/falyx/parsers/signature.py +++ b/falyx/parsers/signature.py @@ -42,7 +42,7 @@ def infer_args_from_func( else: flags = [f"--{name.replace('_', '-')}"] action = "store" - nargs: int | str = 1 + nargs: int | str | None = None if arg_type is bool: if param.default is False: diff --git a/falyx/selection.py b/falyx/selection.py index 022379d..8f5f2b5 100644 --- a/falyx/selection.py +++ b/falyx/selection.py @@ -271,7 +271,7 @@ async def prompt_for_index( prompt_session: PromptSession | None = None, prompt_message: str = "Select an option > ", show_table: bool = True, -): +) -> int: prompt_session = prompt_session or PromptSession() console = console or Console(color_system="auto") diff --git a/falyx/version.py b/falyx/version.py index c2e5539..8ef886b 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.33" +__version__ = "0.1.34" diff --git a/pyproject.toml b/pyproject.toml index 32afb3a..b4a533f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.33" +version = "0.1.34" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_command.py b/tests/test_command.py index b722f88..b0e53c9 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -1,7 +1,7 @@ # test_command.py import pytest -from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction +from falyx.action import Action, BaseIOAction, ChainedAction from falyx.command import Command from falyx.execution_registry import ExecutionRegistry as er from falyx.retry import RetryPolicy diff --git a/tests/test_command_argument_parser.py b/tests/test_command_argument_parser.py index e07429c..82791d4 100644 --- a/tests/test_command_argument_parser.py +++ b/tests/test_command_argument_parser.py @@ -5,98 +5,109 @@ from falyx.parsers import ArgumentAction, CommandArgumentParser from falyx.signals import HelpSignal -def build_parser_and_parse(args, config): +async def build_parser_and_parse(args, config): cap = CommandArgumentParser() config(cap) - return cap.parse_args(args) + return await cap.parse_args(args) -def test_none(): +@pytest.mark.asyncio +async def test_none(): def config(parser): parser.add_argument("--foo", type=str) - parsed = build_parser_and_parse(None, config) + parsed = await build_parser_and_parse(None, config) assert parsed["foo"] is None -def test_append_multiple_flags(): +@pytest.mark.asyncio +async def test_append_multiple_flags(): def config(parser): parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) - parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config) + parsed = await build_parser_and_parse( + ["--tag", "a", "--tag", "b", "--tag", "c"], config + ) assert parsed["tag"] == ["a", "b", "c"] -def test_positional_nargs_plus_and_single(): +@pytest.mark.asyncio +async def test_positional_nargs_plus_and_single(): def config(parser): parser.add_argument("files", nargs="+", type=str) parser.add_argument("mode", nargs=1) - parsed = build_parser_and_parse(["a", "b", "c", "prod"], config) + parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) assert parsed["files"] == ["a", "b", "c"] assert parsed["mode"] == "prod" -def test_type_validation_failure(): +@pytest.mark.asyncio +async def test_type_validation_failure(): def config(parser): parser.add_argument("--count", type=int) with pytest.raises(CommandArgumentError): - build_parser_and_parse(["--count", "abc"], config) + await build_parser_and_parse(["--count", "abc"], config) -def test_required_field_missing(): +@pytest.mark.asyncio +async def test_required_field_missing(): def config(parser): parser.add_argument("--env", type=str, required=True) with pytest.raises(CommandArgumentError): - build_parser_and_parse([], config) + await build_parser_and_parse([], config) -def test_choices_enforced(): +@pytest.mark.asyncio +async def test_choices_enforced(): def config(parser): parser.add_argument("--mode", choices=["dev", "prod"]) with pytest.raises(CommandArgumentError): - build_parser_and_parse(["--mode", "staging"], config) + await build_parser_and_parse(["--mode", "staging"], config) -def test_boolean_flags(): +@pytest.mark.asyncio +async def test_boolean_flags(): def config(parser): parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) - parsed = build_parser_and_parse(["--debug", "--no-debug"], config) + parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) assert parsed["debug"] is True assert parsed["no_debug"] is False - parsed = build_parser_and_parse([], config) - print(parsed) + parsed = await build_parser_and_parse([], config) assert parsed["debug"] is False assert parsed["no_debug"] is True -def test_count_action(): +@pytest.mark.asyncio +async def test_count_action(): def config(parser): parser.add_argument("-v", action=ArgumentAction.COUNT) - parsed = build_parser_and_parse(["-v", "-v", "-v"], config) + parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) assert parsed["v"] == 3 -def test_nargs_star(): +@pytest.mark.asyncio +async def test_nargs_star(): def config(parser): parser.add_argument("args", nargs="*", type=str) - parsed = build_parser_and_parse(["one", "two", "three"], config) + parsed = await build_parser_and_parse(["one", "two", "three"], config) assert parsed["args"] == ["one", "two", "three"] -def test_flag_and_positional_mix(): +@pytest.mark.asyncio +async def test_flag_and_positional_mix(): def config(parser): parser.add_argument("--env", type=str) parser.add_argument("tasks", nargs="+") - parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config) + parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) assert parsed["env"] == "prod" assert parsed["tasks"] == ["build", "test"] @@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest(): parser.add_argument("-f", "--falyx") arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["-f", "--falyx"] + assert arg.flags == ("-f", "--falyx") def test_add_argument_flag_dest_conflict(): @@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest(): parser.add_argument("-f", "--falyx", "--test", dest="falyx") arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["-f", "--falyx", "--test"] + assert arg.flags == ("-f", "--falyx", "--test") def test_add_argument_multiple_flags_dest(): @@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest(): parser.add_argument("-f", "--falyx", "--test") arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["-f", "--falyx", "--test"] + assert arg.flags == ("-f", "--falyx", "--test") def test_add_argument_single_flag_dest(): @@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest(): parser.add_argument("-f") arg = parser._arguments[-1] assert arg.dest == "f" - assert arg.flags == ["-f"] + assert arg.flags == ("-f",) def test_add_argument_bad_dest(): @@ -257,7 +268,7 @@ def test_add_argument_default_value(): parser.add_argument("--falyx", default="default_value") arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["--falyx"] + assert arg.flags == ("--falyx",) assert arg.default == "default_value" @@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices(): parser.add_argument("--falyx", choices=["a", "b"], default="c") -def test_add_argument_choices(): +@pytest.mark.asyncio +async def test_add_argument_choices(): parser = CommandArgumentParser() # ✅ Choices provided parser.add_argument("--falyx", choices=["a", "b", "c"]) arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["--falyx"] + assert arg.flags == ("--falyx",) assert arg.choices == ["a", "b", "c"] - args = parser.parse_args(["--falyx", "a"]) + args = await parser.parse_args(["--falyx", "a"]) assert args["falyx"] == "a" with pytest.raises(CommandArgumentError): - parser.parse_args(["--falyx", "d"]) + await parser.parse_args(["--falyx", "d"]) def test_add_argument_choices_invalid(): @@ -352,7 +364,7 @@ def test_add_argument_nargs(): parser.add_argument("--falyx", nargs=2) arg = parser._arguments[-1] assert arg.dest == "falyx" - assert arg.flags == ["--falyx"] + assert arg.flags == ("--falyx",) assert arg.nargs == 2 @@ -377,56 +389,60 @@ def test_get_argument(): parser.add_argument("--falyx", type=str, default="default_value") arg = parser.get_argument("falyx") assert arg.dest == "falyx" - assert arg.flags == ["--falyx"] + assert arg.flags == ("--falyx",) assert arg.default == "default_value" -def test_parse_args_nargs(): +@pytest.mark.asyncio +async def test_parse_args_nargs(): parser = CommandArgumentParser() parser.add_argument("files", nargs="+", type=str) parser.add_argument("mode", nargs=1) - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b"] assert args["mode"] == "c" -def test_parse_args_nargs_plus(): +@pytest.mark.asyncio +async def test_parse_args_nargs_plus(): parser = CommandArgumentParser() parser.add_argument("files", nargs="+", type=str) - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] - args = parser.parse_args(["a"]) + args = await parser.parse_args(["a"]) assert args["files"] == ["a"] -def test_parse_args_flagged_nargs_plus(): +@pytest.mark.asyncio +async def test_parse_args_flagged_nargs_plus(): parser = CommandArgumentParser() parser.add_argument("--files", nargs="+", type=str) - args = parser.parse_args(["--files", "a", "b", "c"]) + args = await parser.parse_args(["--files", "a", "b", "c"]) assert args["files"] == ["a", "b", "c"] - args = parser.parse_args(["--files", "a"]) + args = await parser.parse_args(["--files", "a"]) print(args) assert args["files"] == ["a"] - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["files"] == [] -def test_parse_args_numbered_nargs(): +@pytest.mark.asyncio +async def test_parse_args_numbered_nargs(): parser = CommandArgumentParser() parser.add_argument("files", nargs=2, type=str) - args = parser.parse_args(["a", "b"]) + args = await parser.parse_args(["a", "b"]) assert args["files"] == ["a", "b"] with pytest.raises(CommandArgumentError): - args = parser.parse_args(["a"]) + args = await parser.parse_args(["a"]) print(args) @@ -436,48 +452,53 @@ def test_parse_args_nargs_zero(): parser.add_argument("files", nargs=0, type=str) -def test_parse_args_nargs_more_than_expected(): +@pytest.mark.asyncio +async def test_parse_args_nargs_more_than_expected(): parser = CommandArgumentParser() parser.add_argument("files", nargs=2, type=str) with pytest.raises(CommandArgumentError): - parser.parse_args(["a", "b", "c", "d"]) + await parser.parse_args(["a", "b", "c", "d"]) -def test_parse_args_nargs_one_or_none(): +@pytest.mark.asyncio +async def test_parse_args_nargs_one_or_none(): parser = CommandArgumentParser() parser.add_argument("files", nargs="?", type=str) - args = parser.parse_args(["a"]) + args = await parser.parse_args(["a"]) assert args["files"] == "a" - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["files"] is None -def test_parse_args_nargs_positional(): +@pytest.mark.asyncio +async def test_parse_args_nargs_positional(): parser = CommandArgumentParser() parser.add_argument("files", nargs="*", type=str) - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["files"] == [] -def test_parse_args_nargs_positional_plus(): +@pytest.mark.asyncio +async def test_parse_args_nargs_positional_plus(): parser = CommandArgumentParser() parser.add_argument("files", nargs="+", type=str) - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] with pytest.raises(CommandArgumentError): - args = parser.parse_args([]) + args = await parser.parse_args([]) -def test_parse_args_nargs_multiple_positional(): +@pytest.mark.asyncio +async def test_parse_args_nargs_multiple_positional(): parser = CommandArgumentParser() parser.add_argument("files", nargs="+", type=str) parser.add_argument("mode", nargs=1) @@ -485,7 +506,7 @@ def test_parse_args_nargs_multiple_positional(): parser.add_argument("target", nargs="*") parser.add_argument("extra", nargs="+") - args = parser.parse_args(["a", "b", "c", "d", "e"]) + args = await parser.parse_args(["a", "b", "c", "d", "e"]) assert args["files"] == ["a", "b", "c"] assert args["mode"] == "d" assert args["action"] == [] @@ -493,186 +514,209 @@ def test_parse_args_nargs_multiple_positional(): assert args["extra"] == ["e"] with pytest.raises(CommandArgumentError): - parser.parse_args([]) + await parser.parse_args([]) -def test_parse_args_nargs_invalid_positional_arguments(): +@pytest.mark.asyncio +async def test_parse_args_nargs_invalid_positional_arguments(): parser = CommandArgumentParser() parser.add_argument("numbers", nargs="*", type=int) parser.add_argument("mode", nargs=1) with pytest.raises(CommandArgumentError): - parser.parse_args(["1", "2", "c", "d"]) + await parser.parse_args(["1", "2", "c", "d"]) -def test_parse_args_append(): +@pytest.mark.asyncio +async def test_parse_args_append(): parser = CommandArgumentParser() parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) - args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) + args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) assert args["numbers"] == [1, 2, 3] - args = parser.parse_args(["--numbers", "1"]) + args = await parser.parse_args(["--numbers", "1"]) assert args["numbers"] == [1] - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["numbers"] == [] -def test_parse_args_nargs_append(): +@pytest.mark.asyncio +async def test_parse_args_nargs_append(): parser = CommandArgumentParser() parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") parser.add_argument("--mode") - args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) + args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) assert args["numbers"] == [[1, 2, 3], [4, 5]] - args = parser.parse_args(["1"]) + args = await parser.parse_args(["1"]) assert args["numbers"] == [[1]] - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["numbers"] == [] -def test_parse_args_append_flagged_invalid_type(): +@pytest.mark.asyncio +async def test_parse_args_append_flagged_invalid_type(): parser = CommandArgumentParser() parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) with pytest.raises(CommandArgumentError): - parser.parse_args(["--numbers", "a"]) + await parser.parse_args(["--numbers", "a"]) -def test_append_groups_nargs(): +@pytest.mark.asyncio +async def test_append_groups_nargs(): cap = CommandArgumentParser() cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) - parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) + parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) assert parsed["item"] == [["a", "b"], ["c", "d"]] -def test_extend_flattened(): +@pytest.mark.asyncio +async def test_extend_flattened(): cap = CommandArgumentParser() cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) - parsed = cap.parse_args(["--value", "x", "--value", "y"]) + parsed = await cap.parse_args(["--value", "x", "--value", "y"]) assert parsed["value"] == ["x", "y"] -def test_parse_args_split_order(): +@pytest.mark.asyncio +async def test_parse_args_split_order(): cap = CommandArgumentParser() cap.add_argument("a") cap.add_argument("--x") cap.add_argument("b", nargs="*") - args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"]) + args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) assert args == ("1", ["2"]) assert kwargs == {"x": "100"} -def test_help_signal_triggers(): +@pytest.mark.asyncio +async def test_help_signal_triggers(): parser = CommandArgumentParser() parser.add_argument("--foo") with pytest.raises(HelpSignal): - parser.parse_args(["--help"]) + await parser.parse_args(["--help"]) -def test_empty_parser_defaults(): +@pytest.mark.asyncio +async def test_empty_parser_defaults(): parser = CommandArgumentParser() with pytest.raises(HelpSignal): - parser.parse_args(["--help"]) + await parser.parse_args(["--help"]) -def test_extend_basic(): +@pytest.mark.asyncio +async def test_extend_basic(): parser = CommandArgumentParser() parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) - args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) + args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) assert args["tag"] == ["a", "b", "c"] -def test_extend_nargs_2(): +@pytest.mark.asyncio +async def test_extend_nargs_2(): parser = CommandArgumentParser() parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) - args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) + args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) assert args["pair"] == ["a", "b", "c", "d"] -def test_extend_nargs_star(): +@pytest.mark.asyncio +async def test_extend_nargs_star(): parser = CommandArgumentParser() parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") - args = parser.parse_args(["--files", "x", "y", "z"]) + args = await parser.parse_args(["--files", "x", "y", "z"]) assert args["files"] == ["x", "y", "z"] - args = parser.parse_args(["--files"]) + args = await parser.parse_args(["--files"]) assert args["files"] == [] -def test_extend_nargs_plus(): +@pytest.mark.asyncio +async def test_extend_nargs_plus(): parser = CommandArgumentParser() parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") - args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) + args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) assert args["inputs"] == [1, 2, 3, 4] -def test_extend_invalid_type(): +@pytest.mark.asyncio +async def test_extend_invalid_type(): parser = CommandArgumentParser() parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) with pytest.raises(CommandArgumentError): - parser.parse_args(["--nums", "a"]) + await parser.parse_args(["--nums", "a"]) -def test_greedy_invalid_type(): +@pytest.mark.asyncio +async def test_greedy_invalid_type(): parser = CommandArgumentParser() parser.add_argument("--nums", nargs="*", type=int) with pytest.raises(CommandArgumentError): - parser.parse_args(["--nums", "a"]) + await parser.parse_args(["--nums", "a"]) -def test_append_vs_extend_behavior(): +@pytest.mark.asyncio +async def test_append_vs_extend_behavior(): parser = CommandArgumentParser() parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) - args = parser.parse_args( + args = await parser.parse_args( ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] ) assert args["x"] == [["a", "b"], ["c", "d"]] assert args["y"] == ["1", "2", "3", "4"] -def test_append_vs_extend_behavior_error(): +@pytest.mark.asyncio +async def test_append_vs_extend_behavior_error(): parser = CommandArgumentParser() parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) # This should raise an error because the last argument is not a valid pair with pytest.raises(CommandArgumentError): - parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]) + await parser.parse_args( + ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] + ) with pytest.raises(CommandArgumentError): - parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]) + await parser.parse_args( + ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] + ) -def test_extend_positional(): +@pytest.mark.asyncio +async def test_extend_positional(): parser = CommandArgumentParser() parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] - args = parser.parse_args([]) + args = await parser.parse_args([]) assert args["files"] == [] -def test_extend_positional_nargs(): +@pytest.mark.asyncio +async def test_extend_positional_nargs(): parser = CommandArgumentParser() parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") - args = parser.parse_args(["a", "b", "c"]) + args = await parser.parse_args(["a", "b", "c"]) assert args["files"] == ["a", "b", "c"] with pytest.raises(CommandArgumentError): - parser.parse_args([]) + await parser.parse_args([])