Add PromptMenuAction, add cancel button to SelectionAction, make get_command async, add Action validation and defauilt nargs to None.

This commit is contained in:
Roland Thomas Jr 2025-05-24 12:29:16 -04:00
parent b0c0e7dc16
commit ddb78bd5a7
Signed by: roland
GPG Key ID: 7C3C2B085A4C2872
16 changed files with 480 additions and 180 deletions

View File

@ -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})",

View File

@ -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())

View File

@ -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,
)
try:
print(asyncio.run(select()))
except CancelSignal:
print("Selection was cancelled.")

View File

@ -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",
]

View File

@ -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'})"
)

View File

@ -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)):
"""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

View File

@ -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

View File

@ -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:

View File

@ -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):
"""

View File

@ -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:

View File

@ -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:

View File

@ -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")

View File

@ -1 +1 @@
__version__ = "0.1.33"
__version__ = "0.1.34"

View File

@ -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 <roland@rtj.dev>"]
license = "MIT"

View File

@ -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

View File

@ -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([])