Compare commits

...

24 Commits

Author SHA1 Message Date
Roland Thomas Jr b24079ea7e
Add ExecutionContext.signature, fix partial command matching with arguments, fix passing args to Falyx._create_context helper 2025-06-05 17:23:27 -04:00
Roland Thomas Jr ac82076511
Add filtering and options for History Command 2025-06-03 23:07:50 -04:00
Roland Thomas Jr 09eeb90dc6
Bubble up errors from CAP, catch a broader exception when parsing arguments, add type parsing to arg_metadata 2025-06-02 23:45:37 -04:00
Roland Thomas Jr e3ebc1b17b
Fix validation for empty input 2025-06-01 23:12:53 -04:00
Roland Thomas Jr 079bc0ee77
Normalize epilogue -> epilog, allow version to be modifiable, don't allow empty input in repl 2025-06-01 23:02:35 -04:00
Roland Thomas Jr 1c97857cb8
Centralize CAP creation in Command, Add better default type coercion 2025-06-01 17:38:48 -04:00
Roland Thomas Jr 21af003bc7
Update help formatting, allow help to be filtered by tag 2025-05-31 21:51:08 -04:00
Roland Thomas Jr 1585098513
Add init init-global to subparsers 2025-05-31 09:29:24 -04:00
Roland Thomas Jr 3d3a706784
Formatting of help text 2025-05-30 21:52:29 -04:00
Roland Thomas Jr c2eb854e5a
Add help_text for commands to argparse run subcommand, change the way Falyx.run works and you can only pass FalyxParsers 2025-05-30 00:36:55 -04:00
Roland Thomas Jr 8a3c1d6cc8
Fix global-init imports, passing args from command line to required commands 2025-05-28 17:11:26 -04:00
Roland Thomas Jr f196e38c57
Add ProcessPoolAction, update CAP to look only at keywords correctly 2025-05-28 00:58:50 -04:00
Roland Thomas Jr fb1ffbe9f6
Add ArgumentAction.ACTION, support POSIX bundling in CAP, Move all Actions to their own file 2025-05-25 19:25:32 -04:00
Roland Thomas Jr 429b434566
Remove emojis from logging statements 2025-05-24 17:53:34 -04:00
Roland Thomas Jr 4f3632bc6b
Remove emojis from logging statements 2025-05-24 15:09:39 -04:00
Roland Thomas Jr ba562168aa
hotfix syntax error < python3.12 2025-05-24 13:46:07 -04:00
Roland Thomas Jr ddb78bd5a7
Add PromptMenuAction, add cancel button to SelectionAction, make get_command async, add Action validation and defauilt nargs to None. 2025-05-24 12:29:16 -04:00
Roland Thomas Jr b0c0e7dc16
Fix run_group parser for REMAINDER, fix render_help formatting 2025-05-22 14:59:16 -04:00
Roland Thomas Jr 0a1ba22a3d
Remove args/kwargs being passed to generated Action from FactoryAction 2025-05-21 23:35:57 -04:00
Roland Thomas Jr b51ba87999
Add cancel for SelectionActions, Add args/kwargs to ActionFactoryAction, remove requires_input detection, add return types to SelectionAction, add option to hide_menu_table 2025-05-21 23:18:45 -04:00
Roland Thomas Jr 3c0a81359c
Make auto_args default fallback, integrate io_actions with argument parsing 2025-05-19 20:03:04 -04:00
Roland Thomas Jr 4fa6e3bf1f Merge pull request 'Add auto_args' (#3) from argparse-integration into main
Reviewed-on: #3
2025-05-18 22:27:27 -04:00
Roland Thomas Jr afa47b0bac
Add auto_args 2025-05-18 22:24:44 -04:00
Roland Thomas Jr 70a527358d Merge pull request 'Add CommandArgumentParser and integrate argument parsing from cli and menu prompt' (#2) from command-arg-parser into main
Reviewed-on: #2
2025-05-17 21:14:28 -04:00
75 changed files with 4147 additions and 1529 deletions

View File

@ -52,7 +52,8 @@ poetry install
import asyncio import asyncio
import random import random
from falyx import Falyx, Action, ChainedAction from falyx import Falyx
from falyx.action import Action, ChainedAction
# A flaky async step that fails randomly # A flaky async step that fails randomly
async def flaky_step(): async def flaky_step():
@ -62,8 +63,8 @@ async def flaky_step():
return "ok" return "ok"
# Create the actions # Create the actions
step1 = Action(name="step_1", action=flaky_step, retry=True) step1 = Action(name="step_1", action=flaky_step)
step2 = Action(name="step_2", action=flaky_step, retry=True) step2 = Action(name="step_2", action=flaky_step)
# Chain the actions # Chain the actions
chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) chain = ChainedAction(name="my_pipeline", actions=[step1, step2])
@ -74,9 +75,9 @@ falyx.add_command(
key="R", key="R",
description="Run My Pipeline", description="Run My Pipeline",
action=chain, action=chain,
logging_hooks=True,
preview_before_confirm=True, preview_before_confirm=True,
confirm=True, confirm=True,
retry_all=True,
) )
# Entry point # Entry point

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
from falyx import Action, ActionGroup, ChainedAction from falyx.action import Action, ActionGroup, ChainedAction
# Actions can be defined as synchronous functions # Actions can be defined as synchronous functions

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) # Selection of a post ID to fetch (just an example set)
post_selector = SelectionAction( post_selector = SelectionAction(
name="Pick Post ID", name="Pick Post ID",
selections=["1", "2", "3", "4", "5"], selections=["15", "25", "35", "45", "55"],
title="Choose a Post ID to submit", title="Choose a Post ID to submit",
prompt_message="Post ID > ", prompt_message="Post ID > ",
show_table=True, show_table=True,
@ -14,7 +14,7 @@ post_selector = SelectionAction(
# Factory that builds and executes the actual HTTP POST request # 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}") print(f"Building HTTPAction for Post ID: {post_id}")
return HTTPAction( return HTTPAction(
name=f"POST to /posts (id={post_id})", name=f"POST to /posts (id={post_id})",

View File

@ -0,0 +1,38 @@
import asyncio
from falyx import Falyx
from falyx.action import Action, ActionGroup
# 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])
flx = Falyx("Test Group")
flx.add_command(
key="G",
description="Greet someone with multiple variations.",
aliases=["greet", "hello"],
action=group,
arg_metadata={
"name": {
"help": "The name of the person to greet.",
},
"excited": {
"help": "Whether to greet excitedly.",
},
},
)
asyncio.run(flx.run())

View File

@ -0,0 +1,59 @@
import asyncio
from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging
setup_logging()
async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str:
if verbose:
print(f"Deploying {service} to {region}...")
await asyncio.sleep(2)
if verbose:
print(f"{service} deployed successfully!")
return f"{service} deployed to {region}"
flx = Falyx("Deployment CLI")
flx.add_command(
key="D",
aliases=["deploy"],
description="Deploy",
help_text="Deploy a service to a specified region.",
action=Action(
name="deploy_service",
action=deploy,
),
arg_metadata={
"service": "Service name",
"region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]},
"verbose": {"help": "Enable verbose mode"},
},
tags=["deployment", "service"],
)
deploy_chain = ChainedAction(
name="DeployChain",
actions=[
Action(name="deploy_service", action=deploy),
Action(
name="notify",
action=lambda last_result: print(f"Notification: {last_result}"),
),
],
auto_inject=True,
)
flx.add_command(
key="N",
aliases=["notify"],
description="Deploy and Notify",
help_text="Deploy a service and notify.",
action=deploy_chain,
tags=["deployment", "service", "notification"],
)
asyncio.run(flx.run())

View File

@ -3,7 +3,7 @@ commands:
description: Pipeline Demo description: Pipeline Demo
action: pipeline_demo.pipeline action: pipeline_demo.pipeline
tags: [pipeline, demo] tags: [pipeline, demo]
help_text: Run Demployment Pipeline with retries. help_text: Run Deployment Pipeline with retries.
- key: G - key: G
description: Run HTTP Action Group description: Run HTTP Action Group

View File

@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details.
import asyncio import asyncio
import random import random
from argparse import Namespace
from falyx.action import Action, ActionGroup, ChainedAction from falyx.action import Action, ActionGroup, ChainedAction
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers
from falyx.version import __version__ from falyx.version import __version__
@ -74,17 +72,10 @@ class Foo:
await self.flx.run() await self.flx.run()
def parse_args() -> Namespace:
parsers: FalyxParsers = get_arg_parsers()
return parsers.parse_args()
async def main() -> None: async def main() -> None:
"""Build and return a Falyx instance with all your commands.""" """Build and return a Falyx instance with all your commands."""
args = parse_args()
flx = Falyx( flx = Falyx(
title="🚀 Falyx CLI", title="🚀 Falyx CLI",
cli_args=args,
columns=5, columns=5,
welcome_message="Welcome to Falyx CLI!", welcome_message="Welcome to Falyx CLI!",
exit_message="Goodbye!", exit_message="Goodbye!",

View File

@ -2,9 +2,8 @@ import asyncio
from rich.console import Console from rich.console import Console
from falyx import ActionGroup, Falyx from falyx import Falyx
from falyx.action import HTTPAction from falyx.action import ActionGroup, HTTPAction
from falyx.hook_manager import HookType
from falyx.hooks import ResultReporter from falyx.hooks import ResultReporter
console = Console() console = Console()
@ -49,7 +48,7 @@ action_group = ActionGroup(
reporter = ResultReporter() reporter = ResultReporter()
action_group.hooks.register( action_group.hooks.register(
HookType.ON_SUCCESS, "on_success",
reporter.report, reporter.report,
) )

View File

@ -2,8 +2,16 @@ import asyncio
import time import time
from falyx import Falyx 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.menu import MenuOption, MenuOptionMap
from falyx.themes import OneColors
# Basic coroutine for Action # Basic coroutine for Action
@ -77,20 +85,28 @@ parallel = ActionGroup(
process = ProcessAction(name="compute", action=heavy_computation) 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 setup
menu = MenuAction( menu = MenuAction(
name="main-menu", name="main-menu",
title="Choose a task to run", title="Choose a task to run",
menu_options=MenuOptionMap( menu_options=menu_options,
{ )
"1": MenuOption("Run basic Action", basic_action),
"2": MenuOption("Run ChainedAction", chained),
"3": MenuOption("Run ActionGroup (parallel)", parallel), prompt_menu = PromptMenuAction(
"4": MenuOption("Run ProcessAction (heavy task)", process), name="select-user",
} menu_options=menu_options,
),
) )
flx = Falyx( flx = Falyx(
@ -108,6 +124,13 @@ flx.add_command(
logging_hooks=True, logging_hooks=True,
) )
flx.add_command(
key="P",
description="Show Prompt Menu",
action=prompt_menu,
logging_hooks=True,
)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(flx.run()) asyncio.run(flx.run())

View File

@ -1,9 +1,7 @@
import asyncio import asyncio
from falyx import Action, ActionGroup, ChainedAction
from falyx import ExecutionRegistry as er from falyx import ExecutionRegistry as er
from falyx import ProcessAction from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction
from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
@ -47,7 +45,7 @@ def build_pipeline():
checkout = Action("Checkout", checkout_code) checkout = Action("Checkout", checkout_code)
analysis = ProcessAction("Static Analysis", run_static_analysis) analysis = ProcessAction("Static Analysis", run_static_analysis)
tests = Action("Run Tests", flaky_tests) tests = Action("Run Tests", flaky_tests)
tests.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error) tests.hooks.register("on_error", retry_handler.retry_on_error)
# Parallel deploys # Parallel deploys
deploy_group = ActionGroup( deploy_group = ActionGroup(

View File

@ -1,25 +1,36 @@
from rich.console import Console from rich.console import Console
from falyx import Falyx, ProcessAction from falyx import Falyx
from falyx.action import ProcessPoolAction
from falyx.action.process_pool_action import ProcessTask
from falyx.execution_registry import ExecutionRegistry as er
from falyx.themes import NordColors as nc from falyx.themes import NordColors as nc
console = Console() console = Console()
falyx = Falyx(title="🚀 Process Pool Demo") falyx = Falyx(title="🚀 Process Pool Demo")
def generate_primes(n): def generate_primes(start: int = 2, end: int = 100_000) -> list[int]:
primes = [] primes: list[int] = []
for num in range(2, n): console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW)
for num in range(start, end):
if all(num % p != 0 for p in primes): if all(num % p != 0 for p in primes):
primes.append(num) primes.append(num)
console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) console.print(
f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN
)
return primes return primes
# Will not block the event loop actions = [ProcessTask(task=generate_primes)]
heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,))
falyx.add_command("R", "Generate Primes", heavy_action, spinner=True) # Will not block the event loop
heavy_action = ProcessPoolAction(
name="Prime Generator",
actions=actions,
)
falyx.add_command("R", "Generate Primes", heavy_action)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
from falyx import Action, Falyx from falyx import Falyx
from falyx.action import Action
async def main(): async def main():

View File

@ -1,22 +1,30 @@
import asyncio import asyncio
from falyx.selection import ( from falyx.action import SelectionAction
SelectionOption, from falyx.selection import SelectionOption
prompt_for_selection, from falyx.signals import CancelSignal
render_selection_dict_table,
)
menu = { selections = {
"A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")), "1": SelectionOption(
"B": SelectionOption("Deploy to staging", lambda: print("Deploying...")), description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac"
),
"2": SelectionOption(
description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac"
),
} }
table = render_selection_dict_table(
title="Main Menu", select = SelectionAction(
selections=menu, name="Select Deployment",
selections=selections,
title="Select a Deployment",
columns=2,
prompt_message="> ",
return_type="value",
show_table=True,
) )
key = asyncio.run(prompt_for_selection(menu.keys(), table)) try:
print(f"You selected: {key}") print(asyncio.run(select()))
except CancelSignal:
menu[key.upper()].value() print("Selection was cancelled.")

View File

@ -1,9 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
import asyncio import asyncio
from falyx import Action, ChainedAction, Falyx from falyx import Falyx
from falyx.action import ShellAction from falyx.action import Action, ChainedAction, ShellAction
from falyx.hook_manager import HookType
from falyx.hooks import ResultReporter from falyx.hooks import ResultReporter
from falyx.utils import setup_logging from falyx.utils import setup_logging
@ -42,12 +41,12 @@ reporter = ResultReporter()
a1 = Action("a1", a1, inject_last_result=True) a1 = Action("a1", a1, inject_last_result=True)
a1.hooks.register( a1.hooks.register(
HookType.ON_SUCCESS, "on_success",
reporter.report, reporter.report,
) )
a2 = Action("a2", a2, inject_last_result=True) a2 = Action("a2", a2, inject_last_result=True)
a2.hooks.register( a2.hooks.register(
HookType.ON_SUCCESS, "on_success",
reporter.report, reporter.report,
) )

View File

@ -1,7 +1,8 @@
import asyncio import asyncio
import random import random
from falyx import Action, ChainedAction, Falyx from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging from falyx.utils import setup_logging
setup_logging() setup_logging()

View File

@ -1,7 +1,8 @@
import asyncio import asyncio
import random import random
from falyx import Action, ChainedAction, Falyx from falyx import Falyx
from falyx.action import Action, ChainedAction
from falyx.utils import setup_logging from falyx.utils import setup_logging
setup_logging() setup_logging()

100
examples/type_validation.py Normal file
View File

@ -0,0 +1,100 @@
import asyncio
from uuid import UUID, uuid4
from falyx import Falyx
from falyx.parsers import CommandArgumentParser
flx = Falyx("Test Type Validation")
def uuid_val(value: str) -> str:
"""Custom validator to ensure a string is a valid UUID."""
UUID(value)
return value
async def print_uuid(uuid: str) -> str:
"""Prints the UUID if valid."""
print(f"Valid UUID: {uuid}")
return uuid
flx.add_command(
"U",
"Print a valid UUID (arguemnts)",
print_uuid,
arguments=[
{
"flags": ["uuid"],
"type": uuid_val,
"help": "A valid UUID string",
}
],
)
def uuid_parser(parser: CommandArgumentParser) -> None:
"""Custom parser to ensure the UUID argument is valid."""
parser.add_argument(
"uuid",
type=uuid_val,
help="A valid UUID string",
)
flx.add_command(
"I",
"Print a valid UUID (argument_config)",
print_uuid,
argument_config=uuid_parser,
)
flx.add_command(
"D",
"Print a valid UUID (arg_metadata)",
print_uuid,
arg_metadata={
"uuid": {
"type": uuid_val,
"help": "A valid UUID string",
}
},
)
def custom_parser(arguments: list[str]) -> tuple[tuple, dict]:
"""Custom parser to ensure the UUID argument is valid."""
if len(arguments) != 1:
raise ValueError("Exactly one argument is required")
uuid_val(arguments[0])
return (arguments[0],), {}
flx.add_command(
"C",
"Print a valid UUID (custom_parser)",
print_uuid,
custom_parser=custom_parser,
)
async def generate_uuid() -> str:
"""Generates a new UUID."""
new_uuid = uuid4()
print(f"Generated UUID: {new_uuid}")
return new_uuid
flx.add_command(
"G",
"Generate a new UUID",
lambda: print(uuid4()),
)
async def main() -> None:
await flx.run()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -7,24 +7,12 @@ Licensed under the MIT License. See LICENSE file for details.
import logging import logging
from .action.action import Action, ActionGroup, ChainedAction, ProcessAction
from .command import Command
from .context import ExecutionContext, SharedContext
from .execution_registry import ExecutionRegistry from .execution_registry import ExecutionRegistry
from .falyx import Falyx from .falyx import Falyx
from .hook_manager import HookType
logger = logging.getLogger("falyx") logger = logging.getLogger("falyx")
__all__ = [ __all__ = [
"Action",
"ChainedAction",
"ActionGroup",
"ProcessAction",
"Falyx", "Falyx",
"Command",
"ExecutionContext",
"SharedContext",
"ExecutionRegistry", "ExecutionRegistry",
"HookType",
] ]

View File

@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details.
import asyncio import asyncio
import os import os
import sys import sys
from argparse import Namespace from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from falyx.config import loader from falyx.config import loader
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.parsers import FalyxParsers, get_arg_parsers from falyx.parsers import CommandArgumentParser, get_root_parser, get_subparsers
def find_falyx_config() -> Path | None: def find_falyx_config() -> Path | None:
@ -39,44 +39,81 @@ def bootstrap() -> Path | None:
return config_path return config_path
def get_falyx_parsers() -> FalyxParsers: def init_config(parser: CommandArgumentParser) -> None:
falyx_parsers: FalyxParsers = get_arg_parsers() parser.add_argument(
init_parser = falyx_parsers.subparsers.add_parser( "name",
"init", help="Create a new Falyx CLI project" type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
) )
init_parser.add_argument("name", nargs="?", default=".", help="Project directory")
falyx_parsers.subparsers.add_parser(
"init-global", help="Set up ~/.config/falyx with example tasks"
)
return falyx_parsers
def run(args: Namespace) -> Any: def init_callback(args: Namespace) -> None:
"""Callback for the init command."""
if args.command == "init": if args.command == "init":
from falyx.init import init_project from falyx.init import init_project
init_project(args.name) init_project(args.name)
return elif args.command == "init_global":
if args.command == "init-global":
from falyx.init import init_global from falyx.init import init_global
init_global() init_global()
return
def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]:
root_parser: ArgumentParser = get_root_parser()
subparsers = get_subparsers(root_parser)
init_parser = subparsers.add_parser(
"init",
help="Initialize a new Falyx project",
description="Create a new Falyx project with mock configuration files.",
epilog="If no name is provided, the current directory will be used.",
)
init_parser.add_argument(
"name",
type=str,
help="Name of the new Falyx project",
default=".",
nargs="?",
)
subparsers.add_parser(
"init-global",
help="Initialize Falyx global configuration",
description="Create a global Falyx configuration at ~/.config/falyx/.",
)
return root_parser, subparsers
def main() -> Any:
bootstrap_path = bootstrap() bootstrap_path = bootstrap()
if not bootstrap_path: if not bootstrap_path:
print("No Falyx config file found. Exiting.") from falyx.init import init_global, init_project
return None
flx: Falyx = loader(bootstrap_path) flx: Falyx = Falyx()
return asyncio.run(flx.run()) flx.add_command(
"I",
"Initialize a new Falyx project",
init_project,
aliases=["init"],
argument_config=init_config,
help_epilog="If no name is provided, the current directory will be used.",
)
flx.add_command(
"G",
"Initialize Falyx global configuration",
init_global,
aliases=["init-global"],
help_text="Create a global Falyx configuration at ~/.config/falyx/.",
)
else:
flx = loader(bootstrap_path)
root_parser, subparsers = get_parsers()
def main(): return asyncio.run(
parsers = get_falyx_parsers() flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback)
args = parsers.parse_args() )
run(args)
if __name__ == "__main__": if __name__ == "__main__":

0
falyx/action/.pytyped Normal file
View File

View File

@ -5,19 +5,19 @@ Copyright (c) 2025 rtj.dev LLC.
Licensed under the MIT License. See LICENSE file for details. Licensed under the MIT License. See LICENSE file for details.
""" """
from .action import ( from .action import Action
Action,
ActionGroup,
BaseAction,
ChainedAction,
FallbackAction,
LiteralInputAction,
ProcessAction,
)
from .action_factory import ActionFactoryAction from .action_factory import ActionFactoryAction
from .action_group import ActionGroup
from .base import BaseAction
from .chained_action import ChainedAction
from .fallback_action import FallbackAction
from .http_action import HTTPAction from .http_action import HTTPAction
from .io_action import BaseIOAction, ShellAction from .io_action import BaseIOAction, ShellAction
from .literal_input_action import LiteralInputAction
from .menu_action import MenuAction from .menu_action import MenuAction
from .process_action import ProcessAction
from .process_pool_action import ProcessPoolAction
from .prompt_menu_action import PromptMenuAction
from .select_file_action import SelectFileAction from .select_file_action import SelectFileAction
from .selection_action import SelectionAction from .selection_action import SelectionAction
from .signal_action import SignalAction from .signal_action import SignalAction
@ -40,4 +40,6 @@ __all__ = [
"FallbackAction", "FallbackAction",
"LiteralInputAction", "LiteralInputAction",
"UserInputAction", "UserInputAction",
"PromptMenuAction",
"ProcessPoolAction",
] ]

View File

@ -1,167 +1,21 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action.py """action.py"""
Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Core guarantees:
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
- ActionGroup: runs actions in parallel and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
This design promotes clean, fault-tolerant, modular CLI and automation systems.
"""
from __future__ import annotations from __future__ import annotations
import asyncio
import random
from abc import ABC, abstractmethod
from concurrent.futures import ProcessPoolExecutor
from functools import cached_property, partial
from typing import Any, Callable from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.context import ExecutionContext, SharedContext from falyx.action.base import BaseAction
from falyx.debug import register_debug_hooks from falyx.context import ExecutionContext
from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async from falyx.utils import ensure_async
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
_requires_injection (bool): Whether the action requires input injection.
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool = False,
logging_hooks: bool = False,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result
self.inject_into: str = inject_into
self._never_prompt: bool = never_prompt
self._requires_injection: bool = False
self._skip_in_chain: bool = False
self.console = Console(color_system="auto")
self.options_manager: OptionsManager | None = None
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@property
def last_result(self) -> Any:
"""Return the last result from the shared context."""
if self.shared_context:
return self.shared_context.last_result()
return None
@property
def never_prompt(self) -> bool:
return self.get_option("never_prompt", self._never_prompt)
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
if options_manager:
self.set_options_manager(options_manager)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.shared_context:
key = self.inject_into
if key in kwargs:
logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.shared_context.last_result()
return kwargs
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
def requires_io_injection(self) -> bool:
"""Checks to see if the action requires input injection."""
return self._requires_injection
def __repr__(self) -> str:
return str(self)
class Action(BaseAction): class Action(BaseAction):
""" """
Action wraps a simple function or coroutine into a standard executable unit. Action wraps a simple function or coroutine into a standard executable unit.
@ -246,6 +100,13 @@ class Action(BaseAction):
if policy.enabled: if policy.enabled:
self.enable_retry() self.enable_retry()
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
"""
Returns the callable to be used for argument inference.
By default, it returns the action itself.
"""
return self.action, None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
combined_args = args + self.args combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
@ -268,7 +129,7 @@ class Action(BaseAction):
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None: if context.result is not None:
logger.info("[%s] Recovered: %s", self.name, self.name) logger.info("[%s] Recovered: %s", self.name, self.name)
return context.result return context.result
raise raise
finally: finally:
@ -299,556 +160,3 @@ class Action(BaseAction):
f"args={self.args!r}, kwargs={self.kwargs!r}, " f"args={self.args!r}, kwargs={self.kwargs!r}, "
f"retry={self.retry_policy.enabled})" f"retry={self.retry_policy.enabled})"
) )
class LiteralInputAction(Action):
"""
LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
- Starting a pipeline with a fixed input.
- Supplying missing context manually.
Args:
value (Any): The static value to inject.
"""
def __init__(self, value: Any):
self._value = value
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@cached_property
def value(self) -> Any:
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
"""
def __init__(self, fallback: Any):
self._fallback = fallback
async def _fallback_logic(last_result):
return last_result if last_result is not None else fallback
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
@cached_property
def fallback(self) -> Any:
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"
class ActionListMixin:
"""Mixin for managing a list of actions."""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: list[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes an action by name."""
self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool:
"""Checks if an action with the given name exists."""
return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None:
"""Retrieves an action by name."""
for action in self.actions:
if action.name == name:
return action
return None
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
- Recovers from intermediate errors using FallbackAction if present.
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
self,
name: str,
actions: list[BaseAction | Any] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
self.auto_inject = auto_inject
self.return_list = return_list
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[Any]:
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
shared_context=shared_context,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
for index, action in enumerate(self.actions):
if action._skip_in_chain:
logger.debug(
"[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name
)
continue
shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager)
last_result = shared_context.last_result()
try:
if self.requires_io_injection() and last_result is not None:
result = await prepared(**{prepared.inject_into: last_result})
else:
result = await prepared(*args, **updated_kwargs)
except Exception as error:
if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] ⚠️ Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
)
shared_context.add_result(None)
context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare(shared_context)
result = await fallback()
fallback._skip_in_chain = True
else:
raise
shared_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared)
all_results = context.extra["results"]
assert (
all_results
), f"[{self.name}] No results captured. Something seriously went wrong."
context.result = all_results if self.return_list else all_results[-1]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
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 _rollback(self, rollback_stack, *args, **kwargs):
"""
Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
Actions without rollback handlers are skipped.
Args:
rollback_stack (list): Actions to roll back.
*args, **kwargs: Passed to rollback handlers.
"""
for action in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] ↩️ Rolling back...", action.name)
await action.rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s] ⚠️ Rollback failed: %s", action.name, error)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- Parallel execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Error aggregation: captures all action errors and reports them together.
Behavior:
- If any action fails, the group collects the errors but continues executing
other actions without interruption.
- After all actions complete, ActionGroup raises a single exception summarizing
all failures, or returns all results if successful.
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Isolating errors while maximizing successful execution.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
shared_context=shared_context,
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*args, **updated_kwargs)
shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
shared_context.add_error(shared_context.current_index, error)
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra["errors"])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
f" inject_last_result={self.inject_last_result})"
)
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.action = action
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
async def _run(self, *args, **kwargs):
if self.inject_last_result:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.action, *combined_args, **combined_kwargs)
)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

@ -1,10 +1,10 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""action_factory.py""" """action_factory.py"""
from typing import Any from typing import Any, Callable
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction):
*, *,
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
args: tuple[Any, ...] = (),
kwargs: dict[str, Any] | None = None,
preview_args: tuple[Any, ...] = (), preview_args: tuple[Any, ...] = (),
preview_kwargs: dict[str, Any] | None = None, preview_kwargs: dict[str, Any] | None = None,
): ):
@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction):
inject_into=inject_into, inject_into=inject_into,
) )
self.factory = factory self.factory = factory
self.args = args
self.kwargs = kwargs or {}
self.preview_args = preview_args self.preview_args = preview_args
self.preview_kwargs = preview_kwargs or {} self.preview_kwargs = preview_kwargs or {}
@ -55,7 +59,12 @@ class ActionFactoryAction(BaseAction):
def factory(self, value: ActionFactoryProtocol): def factory(self, value: ActionFactoryProtocol):
self._factory = ensure_async(value) self._factory = ensure_async(value)
def get_infer_target(self) -> tuple[Callable[..., Any], None]:
return self.factory, None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
args = (*self.args, *args)
kwargs = {**self.kwargs, **kwargs}
updated_kwargs = self._maybe_inject_last_result(kwargs) updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(
name=f"{self.name} (factory)", name=f"{self.name} (factory)",
@ -85,7 +94,7 @@ class ActionFactoryAction(BaseAction):
) )
if self.options_manager: if self.options_manager:
generated_action.set_options_manager(self.options_manager) generated_action.set_options_manager(self.options_manager)
context.result = await generated_action(*args, **kwargs) context.result = await generated_action()
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result return context.result
except Exception as error: except Exception as error:

View File

@ -0,0 +1,170 @@
import asyncio
import random
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.action.mixins import ActionListMixin
from falyx.context import ExecutionContext, SharedContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.parsers.utils import same_argument_definitions
from falyx.themes.colors import OneColors
class ActionGroup(BaseAction, ActionListMixin):
"""
ActionGroup executes multiple actions concurrently in parallel.
It is ideal for independent tasks that can be safely run simultaneously,
improving overall throughput and responsiveness of workflows.
Core features:
- Parallel execution of all contained actions.
- Shared last_result injection across all actions if configured.
- Aggregated collection of individual results as (name, result) pairs.
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Error aggregation: captures all action errors and reports them together.
Behavior:
- If any action fails, the group collects the errors but continues executing
other actions without interruption.
- After all actions complete, ActionGroup raises a single exception summarizing
all failures, or returns all results if successful.
Best used for:
- Batch processing multiple independent tasks.
- Reducing latency for workflows with parallelizable steps.
- Isolating errors while maximizing successful execution.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
"""
def __init__(
self,
name: str,
actions: list[BaseAction] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
raise TypeError(
"ActionGroup only accepts BaseAction or callable, got "
f"{type(action).__name__}"
)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
arg_defs = same_argument_definitions(self.actions)
if arg_defs:
return self.actions[0].get_infer_target()
logger.debug(
"[%s] auto_args disabled: mismatched ActionGroup arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "errors": []},
shared_context=shared_context,
)
async def run_one(action: BaseAction):
try:
prepared = action.prepare(shared_context, self.options_manager)
result = await prepared(*args, **updated_kwargs)
shared_context.add_result((action.name, result))
context.extra["results"].append((action.name, result))
except Exception as error:
shared_context.add_error(shared_context.current_index, error)
context.extra["errors"].append((action.name, error))
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
await asyncio.gather(*[run_one(a) for a in self.actions])
if context.extra["errors"]:
context.exception = Exception(
f"{len(context.extra['errors'])} action(s) failed: "
f"{' ,'.join(name for name, _ in context.extra['errors'])}"
)
await self.hooks.trigger(HookType.ON_ERROR, context)
raise context.exception
context.result = context.extra["results"]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
super().register_hooks_recursively(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r},"
f" inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)

156
falyx/action/base.py Normal file
View File

@ -0,0 +1,156 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""base.py
Core action system for Falyx.
This module defines the building blocks for executable actions and workflows,
providing a structured way to compose, execute, recover, and manage sequences of
operations.
All actions are callable and follow a unified signature:
result = action(*args, **kwargs)
Core guarantees:
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
- Consistent timing and execution context tracking for each run.
- Unified, predictable result handling and error propagation.
- Optional last_result injection to enable flexible, data-driven workflows.
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback
recovery.
Key components:
- Action: wraps a function or coroutine into a standard executable unit.
- ChainedAction: runs actions sequentially, optionally injecting last results.
- ActionGroup: runs actions in parallel and gathers results.
- ProcessAction: executes CPU-bound functions in a separate process.
- LiteralInputAction: injects static values into workflows.
- FallbackAction: gracefully recovers from failures or missing data.
This design promotes clean, fault-tolerant, modular CLI and automation systems.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Callable
from rich.console import Console
from rich.tree import Tree
from falyx.context import SharedContext
from falyx.debug import register_debug_hooks
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
class BaseAction(ABC):
"""
Base class for actions. Actions can be simple functions or more
complex actions like `ChainedAction` or `ActionGroup`. They can also
be run independently or as part of Falyx.
inject_last_result (bool): Whether to inject the previous action's result
into kwargs.
inject_into (str): The name of the kwarg key to inject the result as
(default: 'last_result').
"""
def __init__(
self,
name: str,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
never_prompt: bool = False,
logging_hooks: bool = False,
) -> None:
self.name = name
self.hooks = hooks or HookManager()
self.is_retryable: bool = False
self.shared_context: SharedContext | None = None
self.inject_last_result: bool = inject_last_result
self.inject_into: str = inject_into
self._never_prompt: bool = never_prompt
self._skip_in_chain: bool = False
self.console = Console(color_system="truecolor")
self.options_manager: OptionsManager | None = None
if logging_hooks:
register_debug_hooks(self.hooks)
async def __call__(self, *args, **kwargs) -> Any:
return await self._run(*args, **kwargs)
@abstractmethod
async def _run(self, *args, **kwargs) -> Any:
raise NotImplementedError("_run must be implemented by subclasses")
@abstractmethod
async def preview(self, parent: Tree | None = None):
raise NotImplementedError("preview must be implemented by subclasses")
@abstractmethod
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
"""
Returns the callable to be used for argument inference.
By default, it returns None.
"""
raise NotImplementedError("get_infer_target must be implemented by subclasses")
def set_options_manager(self, options_manager: OptionsManager) -> None:
self.options_manager = options_manager
def set_shared_context(self, shared_context: SharedContext) -> None:
self.shared_context = shared_context
def get_option(self, option_name: str, default: Any = None) -> Any:
"""
Resolve an option from the OptionsManager if present, otherwise use the fallback.
"""
if self.options_manager:
return self.options_manager.get(option_name, default)
return default
@property
def last_result(self) -> Any:
"""Return the last result from the shared context."""
if self.shared_context:
return self.shared_context.last_result()
return None
@property
def never_prompt(self) -> bool:
return self.get_option("never_prompt", self._never_prompt)
def prepare(
self, shared_context: SharedContext, options_manager: OptionsManager | None = None
) -> BaseAction:
"""
Prepare the action specifically for sequential (ChainedAction) execution.
Can be overridden for chain-specific logic.
"""
self.set_shared_context(shared_context)
if options_manager:
self.set_options_manager(options_manager)
return self
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
if self.inject_last_result and self.shared_context:
key = self.inject_into
if key in kwargs:
logger.warning("[%s] Overriding '%s' with last_result", self.name, key)
kwargs = dict(kwargs)
kwargs[key] = self.shared_context.last_result()
return kwargs
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
async def _write_stdout(self, data: str) -> None:
"""Override in subclasses that produce terminal output."""
def __repr__(self) -> str:
return str(self)

View File

@ -0,0 +1,208 @@
from __future__ import annotations
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.action.fallback_action import FallbackAction
from falyx.action.literal_input_action import LiteralInputAction
from falyx.action.mixins import ActionListMixin
from falyx.context import ExecutionContext, SharedContext
from falyx.exceptions import EmptyChainError
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger
from falyx.themes import OneColors
class ChainedAction(BaseAction, ActionListMixin):
"""
ChainedAction executes a sequence of actions one after another.
Features:
- Supports optional automatic last_result injection (auto_inject).
- Recovers from intermediate errors using FallbackAction if present.
- Rolls back all previously executed actions if a failure occurs.
- Handles literal values with LiteralInputAction.
Best used for defining robust, ordered workflows where each step can depend on
previous results.
Args:
name (str): Name of the chain.
actions (list): List of actions or literals to execute.
hooks (HookManager, optional): Hooks for lifecycle events.
inject_last_result (bool, optional): Whether to inject last results into kwargs
by default.
inject_into (str, optional): Key name for injection.
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
return_list (bool, optional): Whether to return a list of all results. False
returns the last result.
"""
def __init__(
self,
name: str,
actions: list[BaseAction | Any] | None = None,
*,
hooks: HookManager | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
auto_inject: bool = False,
return_list: bool = False,
) -> None:
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
ActionListMixin.__init__(self)
self.auto_inject = auto_inject
self.return_list = return_list
if actions:
self.set_actions(actions)
def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction:
if isinstance(action, BaseAction):
return action
elif callable(action):
return Action(name=action.__name__, action=action)
else:
return LiteralInputAction(action)
def add_action(self, action: BaseAction | Any) -> None:
action = self._wrap_if_needed(action)
if self.actions and self.auto_inject and not action.inject_last_result:
action.inject_last_result = True
super().add_action(action)
if hasattr(action, "register_teardown") and callable(action.register_teardown):
action.register_teardown(self.hooks)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if self.actions:
return self.actions[0].get_infer_target()
return None, None
def _clear_args(self):
return (), {}
async def _run(self, *args, **kwargs) -> list[Any]:
if not self.actions:
raise EmptyChainError(f"[{self.name}] No actions to execute.")
shared_context = SharedContext(name=self.name, action=self)
if self.shared_context:
shared_context.add_result(self.shared_context.last_result())
updated_kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
extra={"results": [], "rollback_stack": []},
shared_context=shared_context,
)
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
for index, action in enumerate(self.actions):
if action._skip_in_chain:
logger.debug(
"[%s] Skipping consumed action '%s'", self.name, action.name
)
continue
shared_context.current_index = index
prepared = action.prepare(shared_context, self.options_manager)
try:
result = await prepared(*args, **updated_kwargs)
except Exception as error:
if index + 1 < len(self.actions) and isinstance(
self.actions[index + 1], FallbackAction
):
logger.warning(
"[%s] Fallback triggered: %s, recovering with fallback "
"'%s'.",
self.name,
error,
self.actions[index + 1].name,
)
shared_context.add_result(None)
context.extra["results"].append(None)
fallback = self.actions[index + 1].prepare(shared_context)
result = await fallback()
fallback._skip_in_chain = True
else:
raise
args, updated_kwargs = self._clear_args()
shared_context.add_result(result)
context.extra["results"].append(result)
context.extra["rollback_stack"].append(prepared)
all_results = context.extra["results"]
assert (
all_results
), f"[{self.name}] No results captured. Something seriously went wrong."
context.result = all_results if self.return_list else all_results[-1]
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return context.result
except Exception as error:
context.exception = error
shared_context.add_error(shared_context.current_index, error)
await self._rollback(context.extra["rollback_stack"], *args, **kwargs)
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 _rollback(self, rollback_stack, *args, **kwargs):
"""
Roll back all executed actions in reverse order.
Rollbacks run even if a fallback recovered from failure,
ensuring consistent undo of all side effects.
Actions without rollback handlers are skipped.
Args:
rollback_stack (list): Actions to roll back.
*args, **kwargs: Passed to rollback handlers.
"""
for action in reversed(rollback_stack):
rollback = getattr(action, "rollback", None)
if rollback:
try:
logger.warning("[%s] Rolling back...", action.name)
await action.rollback(*args, **kwargs)
except Exception as error:
logger.error("[%s] Rollback failed: %s", action.name, error)
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
"""Register a hook for all actions and sub-actions."""
self.hooks.register(hook_type, hook)
for action in self.actions:
action.register_hooks_recursively(hook_type, hook)
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
for action in self.actions:
await action.preview(parent=tree)
if not parent:
self.console.print(tree)
def __str__(self):
return (
f"ChainedAction(name={self.name!r}, "
f"actions={[a.name for a in self.actions]!r}, "
f"auto_inject={self.auto_inject}, return_list={self.return_list})"
)

View File

@ -0,0 +1,49 @@
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class FallbackAction(Action):
"""
FallbackAction provides a default value if the previous action failed or
returned None.
It injects the last result and checks:
- If last_result is not None, it passes it through unchanged.
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
When activated, it consumes the preceding error and allows the chain to continue
normally.
Args:
fallback (Any): The fallback value to use if last_result is None.
"""
def __init__(self, fallback: Any):
self._fallback = fallback
async def _fallback_logic(last_result):
return last_result if last_result is not None else fallback
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
@cached_property
def fallback(self) -> Any:
"""Return the fallback value."""
return self._fallback
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"]
label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"FallbackAction(fallback={self.fallback!r})"

View File

@ -28,7 +28,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None:
if session and should_close: if session and should_close:
await session.close() await session.close()
except Exception as error: except Exception as error:
logger.warning("⚠️ Error closing shared HTTP session: %s", error) logger.warning("Error closing shared HTTP session: %s", error)
class HTTPAction(Action): class HTTPAction(Action):

View File

@ -19,11 +19,11 @@ import asyncio
import shlex import shlex
import subprocess import subprocess
import sys import sys
from typing import Any from typing import Any, Callable
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.exceptions import FalyxError from falyx.exceptions import FalyxError
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
@ -73,7 +73,6 @@ class BaseIOAction(BaseAction):
inject_last_result=inject_last_result, inject_last_result=inject_last_result,
) )
self.mode = mode self.mode = mode
self._requires_injection = True
def from_input(self, raw: str | bytes) -> Any: def from_input(self, raw: str | bytes) -> Any:
raise NotImplementedError raise NotImplementedError
@ -81,15 +80,15 @@ class BaseIOAction(BaseAction):
def to_output(self, result: Any) -> str | bytes: def to_output(self, result: Any) -> str | bytes:
raise NotImplementedError raise NotImplementedError
async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: async def _resolve_input(
last_result = kwargs.pop(self.inject_into, None) self, args: tuple[Any], kwargs: dict[str, Any]
) -> str | bytes:
data = await self._read_stdin() data = await self._read_stdin()
if data: if data:
return self.from_input(data) return self.from_input(data)
if last_result is not None: if len(args) == 1:
return last_result return self.from_input(args[0])
if self.inject_last_result and self.shared_context: if self.inject_last_result and self.shared_context:
return self.shared_context.last_result() return self.shared_context.last_result()
@ -99,6 +98,9 @@ class BaseIOAction(BaseAction):
) )
raise FalyxError("No input provided and no last result to inject.") raise FalyxError("No input provided and no last result to inject.")
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
return None, None
async def __call__(self, *args, **kwargs): async def __call__(self, *args, **kwargs):
context = ExecutionContext( context = ExecutionContext(
name=self.name, name=self.name,
@ -117,8 +119,8 @@ class BaseIOAction(BaseAction):
pass pass
result = getattr(self, "_last_result", None) result = getattr(self, "_last_result", None)
else: else:
parsed_input = await self._resolve_input(kwargs) parsed_input = await self._resolve_input(args, kwargs)
result = await self._run(parsed_input, *args, **kwargs) result = await self._run(parsed_input)
output = self.to_output(result) output = self.to_output(result)
await self._write_stdout(output) await self._write_stdout(output)
context.result = result context.result = result
@ -195,7 +197,6 @@ class ShellAction(BaseIOAction):
- Captures stdout and stderr from shell execution - Captures stdout and stderr from shell execution
- Raises on non-zero exit codes with stderr as the error - Raises on non-zero exit codes with stderr as the error
- Result is returned as trimmed stdout string - Result is returned as trimmed stdout string
- Compatible with ChainedAction and Command.requires_input detection
Args: Args:
name (str): Name of the action. name (str): Name of the action.
@ -220,11 +221,19 @@ class ShellAction(BaseIOAction):
) )
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]:
if sys.stdin.isatty():
return self._run, {"parsed_input": {"help": self.command_template}}
return None, None
async def _run(self, parsed_input: str) -> str: async def _run(self, parsed_input: str) -> str:
# Replace placeholder in template, or use raw input as full command # Replace placeholder in template, or use raw input as full command
command = self.command_template.format(parsed_input) command = self.command_template.format(parsed_input)
if self.safe_mode: 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) result = subprocess.run(args, capture_output=True, text=True, check=True)
else: else:
result = subprocess.run( result = subprocess.run(

View File

@ -0,0 +1,47 @@
from __future__ import annotations
from functools import cached_property
from typing import Any
from rich.tree import Tree
from falyx.action.action import Action
from falyx.themes import OneColors
class LiteralInputAction(Action):
"""
LiteralInputAction injects a static value into a ChainedAction.
This allows embedding hardcoded values mid-pipeline, useful when:
- Providing default or fallback inputs.
- Starting a pipeline with a fixed input.
- Supplying missing context manually.
Args:
value (Any): The static value to inject.
"""
def __init__(self, value: Any):
self._value = value
async def literal(*_, **__):
return value
super().__init__("Input", literal)
@cached_property
def value(self) -> Any:
"""Return the literal value."""
return self._value
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"]
label.append(f" [dim](value = {repr(self.value)})[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return f"LiteralInputAction(value={self.value!r})"

View File

@ -7,7 +7,7 @@ from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
@ -51,7 +51,10 @@ class MenuAction(BaseAction):
self.columns = columns self.columns = columns
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.default_selection = default_selection self.default_selection = default_selection
self.console = console or Console(color_system="auto") if isinstance(console, Console):
self.console = console
elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.include_reserved = include_reserved self.include_reserved = include_reserved
self.show_table = show_table self.show_table = show_table
@ -73,6 +76,9 @@ class MenuAction(BaseAction):
table.add_row(*row) table.add_row(*row)
return table return table
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs) kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(
@ -121,10 +127,10 @@ class MenuAction(BaseAction):
return result return result
except BackSignal: except BackSignal:
logger.debug("[%s][BackSignal] Returning to previous menu", self.name) logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name)
return None return None
except QuitSignal: except QuitSignal:
logger.debug("[%s][QuitSignal] Exiting application", self.name) logger.debug("[%s][QuitSignal] <- Exiting application", self.name)
raise raise
except Exception as error: except Exception as error:
context.exception = error context.exception = error

33
falyx/action/mixins.py Normal file
View File

@ -0,0 +1,33 @@
from falyx.action.base import BaseAction
class ActionListMixin:
"""Mixin for managing a list of actions."""
def __init__(self) -> None:
self.actions: list[BaseAction] = []
def set_actions(self, actions: list[BaseAction]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: BaseAction) -> None:
"""Adds an action to the list."""
self.actions.append(action)
def remove_action(self, name: str) -> None:
"""Removes an action by name."""
self.actions = [action for action in self.actions if action.name != name]
def has_action(self, name: str) -> bool:
"""Checks if an action with the given name exists."""
return any(action.name == name for action in self.actions)
def get_action(self, name: str) -> BaseAction | None:
"""Retrieves an action by name."""
for action in self.actions:
if action.name == name:
return action
return None

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
from concurrent.futures import ProcessPoolExecutor
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.themes import OneColors
class ProcessAction(BaseAction):
"""
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
Features:
- Executes CPU-bound or blocking tasks without blocking the main event loop.
- Supports last_result injection into the subprocess.
- Validates that last_result is pickleable when injection is enabled.
Args:
name (str): Name of the action.
func (Callable): Function to execute in a new process.
args (tuple, optional): Positional arguments.
kwargs (dict, optional): Keyword arguments.
hooks (HookManager, optional): Hook manager for lifecycle events.
executor (ProcessPoolExecutor, optional): Custom executor if desired.
inject_last_result (bool, optional): Inject last result into the function.
inject_into (str, optional): Name of the injected key.
"""
def __init__(
self,
name: str,
action: Callable[..., Any],
*,
args: tuple = (),
kwargs: dict[str, Any] | None = None,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.action = action
self.args = args
self.kwargs = kwargs or {}
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
return self.action, None
async def _run(self, *args, **kwargs) -> Any:
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
combined_args = args + self.args
combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs})
context = ExecutionContext(
name=self.name,
args=combined_args,
kwargs=combined_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
result = await loop.run_in_executor(
self.executor, partial(self.action, *combined_args, **combined_kwargs)
)
context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return result
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [
f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'"
]
if self.inject_last_result:
label.append(f" [dim](injects '{self.inject_into}')[/dim]")
if parent:
parent.add("".join(label))
else:
self.console.print(Tree("".join(label)))
def __str__(self) -> str:
return (
f"ProcessAction(name={self.name!r}, "
f"action={getattr(self.action, '__name__', repr(self.action))}, "
f"args={self.args!r}, kwargs={self.kwargs!r})"
)

View File

@ -0,0 +1,166 @@
from __future__ import annotations
import asyncio
import random
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable
from rich.tree import Tree
from falyx.action.base import BaseAction
from falyx.context import ExecutionContext, SharedContext
from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger
from falyx.parsers.utils import same_argument_definitions
from falyx.themes import OneColors
@dataclass
class ProcessTask:
task: Callable[..., Any]
args: tuple = ()
kwargs: dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not callable(self.task):
raise TypeError(f"Expected a callable task, got {type(self.task).__name__}")
class ProcessPoolAction(BaseAction):
""" """
def __init__(
self,
name: str,
actions: list[ProcessTask] | None = None,
*,
hooks: HookManager | None = None,
executor: ProcessPoolExecutor | None = None,
inject_last_result: bool = False,
inject_into: str = "last_result",
):
super().__init__(
name,
hooks=hooks,
inject_last_result=inject_last_result,
inject_into=inject_into,
)
self.executor = executor or ProcessPoolExecutor()
self.is_retryable = True
self.actions: list[ProcessTask] = []
if actions:
self.set_actions(actions)
def set_actions(self, actions: list[ProcessTask]) -> None:
"""Replaces the current action list with a new one."""
self.actions.clear()
for action in actions:
self.add_action(action)
def add_action(self, action: ProcessTask) -> None:
if not isinstance(action, ProcessTask):
raise TypeError(f"Expected a ProcessTask, got {type(action).__name__}")
self.actions.append(action)
def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]:
arg_defs = same_argument_definitions([action.task for action in self.actions])
if arg_defs:
return self.actions[0].task, None
logger.debug(
"[%s] auto_args disabled: mismatched ProcessPoolAction arguments",
self.name,
)
return None, None
async def _run(self, *args, **kwargs) -> Any:
shared_context = SharedContext(name=self.name, action=self, is_parallel=True)
if self.shared_context:
shared_context.set_shared_result(self.shared_context.last_result())
if self.inject_last_result and self.shared_context:
last_result = self.shared_context.last_result()
if not self._validate_pickleable(last_result):
raise ValueError(
f"Cannot inject last result into {self.name}: "
f"last result is not pickleable."
)
print(kwargs)
updated_kwargs = self._maybe_inject_last_result(kwargs)
print(updated_kwargs)
context = ExecutionContext(
name=self.name,
args=args,
kwargs=updated_kwargs,
action=self,
)
loop = asyncio.get_running_loop()
context.start_timer()
try:
await self.hooks.trigger(HookType.BEFORE, context)
futures = [
loop.run_in_executor(
self.executor,
partial(
task.task,
*(*args, *task.args),
**{**updated_kwargs, **task.kwargs},
),
)
for task in self.actions
]
results = await asyncio.gather(*futures, return_exceptions=True)
context.result = results
await self.hooks.trigger(HookType.ON_SUCCESS, context)
return results
except Exception as error:
context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context)
if context.result is not None:
return context.result
raise
finally:
context.stop_timer()
await self.hooks.trigger(HookType.AFTER, context)
await self.hooks.trigger(HookType.ON_TEARDOWN, context)
er.record(context)
def _validate_pickleable(self, obj: Any) -> bool:
try:
import pickle
pickle.dumps(obj)
return True
except (pickle.PicklingError, TypeError):
return False
async def preview(self, parent: Tree | None = None):
label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessPoolAction[/] '{self.name}'"]
if self.inject_last_result:
label.append(f" [dim](receives '{self.inject_into}')[/dim]")
tree = parent.add("".join(label)) if parent else Tree("".join(label))
actions = self.actions.copy()
random.shuffle(actions)
for action in actions:
label = [
f"[{OneColors.DARK_YELLOW_b}] - {getattr(action.task, '__name__', repr(action.task))}[/] "
f"[dim]({', '.join(map(repr, action.args))})[/]"
]
if action.kwargs:
label.append(
f" [dim]({', '.join(f'{k}={v!r}' for k, v in action.kwargs.items())})[/]"
)
tree.add("".join(label))
if not parent:
self.console.print(tree)
def __str__(self) -> str:
return (
f"ProcessPoolAction(name={self.name!r}, "
f"actions={[getattr(action.task, '__name__', repr(action.task)) for action in self.actions]}, "
f"inject_last_result={self.inject_last_result}, "
f"inject_into={self.inject_into!r})"
)

View File

@ -0,0 +1,137 @@
# 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.base 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
if isinstance(console, Console):
self.console = console
elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`")
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

@ -14,7 +14,7 @@ from prompt_toolkit import PromptSession
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
from falyx.action.types import FileReturnType from falyx.action.types import FileReturnType
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
@ -25,6 +25,7 @@ from falyx.selection import (
prompt_for_selection, prompt_for_selection,
render_selection_dict_table, render_selection_dict_table,
) )
from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
@ -75,7 +76,10 @@ class SelectFileAction(BaseAction):
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.suffix_filter = suffix_filter self.suffix_filter = suffix_filter
self.style = style self.style = style
self.console = console or Console(color_system="auto") if isinstance(console, Console):
self.console = console
elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.return_type = self._coerce_return_type(return_type) self.return_type = self._coerce_return_type(return_type)
@ -118,9 +122,19 @@ class SelectFileAction(BaseAction):
description=file.name, value=value, style=self.style description=file.name, value=value, style=self.style
) )
except Exception as error: except Exception as error:
logger.warning("[ERROR] Failed to parse %s: %s", file.name, error) logger.error("Failed to parse %s: %s", file.name, error)
return options return options
def _find_cancel_key(self, options) -> str:
"""Return first numeric value not already used in the selection dict."""
for index in range(len(options)):
if str(index) not in options:
return str(index)
return str(len(options))
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self)
context.start_timer() context.start_timer()
@ -128,28 +142,38 @@ class SelectFileAction(BaseAction):
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
files = [ files = [
f file
for f in self.directory.iterdir() for file in self.directory.iterdir()
if f.is_file() if file.is_file()
and (self.suffix_filter is None or f.suffix == self.suffix_filter) and (self.suffix_filter is None or file.suffix == self.suffix_filter)
] ]
if not files: if not files:
raise FileNotFoundError("No files found in directory.") raise FileNotFoundError("No files found in directory.")
options = self.get_options(files) options = self.get_options(files)
cancel_key = self._find_cancel_key(options)
cancel_option = {
cancel_key: SelectionOption(
description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED
)
}
table = render_selection_dict_table( table = render_selection_dict_table(
title=self.title, selections=options, columns=self.columns title=self.title, selections=options | cancel_option, columns=self.columns
) )
key = await prompt_for_selection( key = await prompt_for_selection(
options.keys(), (options | cancel_option).keys(),
table, table,
console=self.console, console=self.console,
prompt_session=self.prompt_session, prompt_session=self.prompt_session,
prompt_message=self.prompt_message, prompt_message=self.prompt_message,
) )
if key == cancel_key:
raise CancelSignal("User canceled the selection.")
result = options[key].value result = options[key].value
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -176,11 +200,11 @@ class SelectFileAction(BaseAction):
try: try:
files = list(self.directory.iterdir()) files = list(self.directory.iterdir())
if self.suffix_filter: if self.suffix_filter:
files = [f for f in files if f.suffix == self.suffix_filter] files = [file for file in files if file.suffix == self.suffix_filter]
sample = files[:10] sample = files[:10]
file_list = tree.add("[dim]Files:[/]") file_list = tree.add("[dim]Files:[/]")
for f in sample: for file in sample:
file_list.add(f"[dim]{f.name}[/]") file_list.add(f"[dim]{file.name}[/]")
if len(files) > 10: if len(files) > 10:
file_list.add(f"[dim]... ({len(files) - 10} more)[/]") file_list.add(f"[dim]... ({len(files) - 10} more)[/]")
except Exception as error: except Exception as error:

View File

@ -6,20 +6,22 @@ from prompt_toolkit import PromptSession
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
from falyx.action.types import SelectionReturnType
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.selection import ( from falyx.selection import (
SelectionOption, SelectionOption,
SelectionOptionMap,
prompt_for_index, prompt_for_index,
prompt_for_selection, prompt_for_selection,
render_selection_dict_table, render_selection_dict_table,
render_selection_indexed_table, render_selection_indexed_table,
) )
from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict
class SelectionAction(BaseAction): class SelectionAction(BaseAction):
@ -34,7 +36,13 @@ class SelectionAction(BaseAction):
def __init__( def __init__(
self, self,
name: str, name: str,
selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption], selections: (
list[str]
| set[str]
| tuple[str, ...]
| dict[str, SelectionOption]
| dict[str, Any]
),
*, *,
title: str = "Select an option", title: str = "Select an option",
columns: int = 5, columns: int = 5,
@ -42,7 +50,7 @@ class SelectionAction(BaseAction):
default_selection: str = "", default_selection: str = "",
inject_last_result: bool = False, inject_last_result: bool = False,
inject_into: str = "last_result", inject_into: str = "last_result",
return_key: bool = False, return_type: SelectionReturnType | str = "value",
console: Console | None = None, console: Console | None = None,
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
never_prompt: bool = False, never_prompt: bool = False,
@ -55,18 +63,29 @@ class SelectionAction(BaseAction):
never_prompt=never_prompt, never_prompt=never_prompt,
) )
# Setter normalizes to correct type, mypy can't infer that # Setter normalizes to correct type, mypy can't infer that
self.selections: list[str] | CaseInsensitiveDict = selections # type: ignore[assignment] self.selections: list[str] | SelectionOptionMap = selections # type: ignore[assignment]
self.return_key = return_key self.return_type: SelectionReturnType = self._coerce_return_type(return_type)
self.title = title self.title = title
self.columns = columns self.columns = columns
self.console = console or Console(color_system="auto") if isinstance(console, Console):
self.console = console
elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
self.default_selection = default_selection self.default_selection = default_selection
self.prompt_message = prompt_message self.prompt_message = prompt_message
self.show_table = show_table self.show_table = show_table
self.cancel_key = self._find_cancel_key()
def _coerce_return_type(
self, return_type: SelectionReturnType | str
) -> SelectionReturnType:
if isinstance(return_type, SelectionReturnType):
return return_type
return SelectionReturnType(return_type)
@property @property
def selections(self) -> list[str] | CaseInsensitiveDict: def selections(self) -> list[str] | SelectionOptionMap:
return self._selections return self._selections
@selections.setter @selections.setter
@ -74,17 +93,69 @@ class SelectionAction(BaseAction):
self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption]
): ):
if isinstance(value, (list, tuple, set)): if isinstance(value, (list, tuple, set)):
self._selections: list[str] | CaseInsensitiveDict = list(value) self._selections: list[str] | SelectionOptionMap = list(value)
elif isinstance(value, dict): elif isinstance(value, dict):
cid = CaseInsensitiveDict() som = SelectionOptionMap()
cid.update(value) if all(isinstance(key, str) for key in value) and all(
self._selections = cid not isinstance(value[key], SelectionOption) for key in value
):
som.update(
{
str(index): SelectionOption(key, option)
for index, (key, option) in enumerate(value.items())
}
)
elif all(isinstance(key, str) for key in value) and all(
isinstance(value[key], SelectionOption) for key in value
):
som.update(value)
else:
raise ValueError("Invalid dictionary format. Keys must be strings")
self._selections = som
else: else:
raise TypeError( raise TypeError(
"'selections' must be a list[str] or dict[str, SelectionOption], " "'selections' must be a list[str] or dict[str, SelectionOption], "
f"got {type(value).__name__}" f"got {type(value).__name__}"
) )
def _find_cancel_key(self) -> str:
"""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
async def _run(self, *args, **kwargs) -> Any: async def _run(self, *args, **kwargs) -> Any:
kwargs = self._maybe_inject_last_result(kwargs) kwargs = self._maybe_inject_last_result(kwargs)
context = ExecutionContext( context = ExecutionContext(
@ -125,16 +196,18 @@ class SelectionAction(BaseAction):
context.start_timer() context.start_timer()
try: try:
self.cancel_key = self._find_cancel_key()
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
if isinstance(self.selections, list): if isinstance(self.selections, list):
table = render_selection_indexed_table( table = render_selection_indexed_table(
title=self.title, title=self.title,
selections=self.selections, selections=self.selections + ["Cancel"],
columns=self.columns, columns=self.columns,
formatter=self.cancel_formatter,
) )
if not self.never_prompt: if not self.never_prompt:
index = await prompt_for_index( index: int | str = await prompt_for_index(
len(self.selections) - 1, len(self.selections),
table, table,
default_selection=effective_default, default_selection=effective_default,
console=self.console, console=self.console,
@ -144,14 +217,23 @@ class SelectionAction(BaseAction):
) )
else: else:
index = effective_default index = effective_default
result = self.selections[int(index)] if int(index) == int(self.cancel_key):
raise CancelSignal("User cancelled the selection.")
result: Any = self.selections[int(index)]
elif isinstance(self.selections, dict): elif isinstance(self.selections, dict):
cancel_option = {
self.cancel_key: SelectionOption(
description="Cancel", value=CancelSignal, style=OneColors.DARK_RED
)
}
table = render_selection_dict_table( table = render_selection_dict_table(
title=self.title, selections=self.selections, columns=self.columns title=self.title,
selections=self.selections | cancel_option,
columns=self.columns,
) )
if not self.never_prompt: if not self.never_prompt:
key = await prompt_for_selection( key = await prompt_for_selection(
self.selections.keys(), (self.selections | cancel_option).keys(),
table, table,
default_selection=effective_default, default_selection=effective_default,
console=self.console, console=self.console,
@ -161,10 +243,25 @@ class SelectionAction(BaseAction):
) )
else: else:
key = effective_default key = effective_default
result = key if self.return_key else self.selections[key].value if key == self.cancel_key:
raise CancelSignal("User cancelled the selection.")
if self.return_type == SelectionReturnType.KEY:
result = key
elif self.return_type == SelectionReturnType.VALUE:
result = self.selections[key].value
elif self.return_type == SelectionReturnType.ITEMS:
result = {key: self.selections[key]}
elif self.return_type == SelectionReturnType.DESCRIPTION:
result = self.selections[key].description
elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE:
result = {
self.selections[key].description: self.selections[key].value
}
else:
raise ValueError(f"Unsupported return type: {self.return_type}")
else: else:
raise TypeError( raise TypeError(
"'selections' must be a list[str] or dict[str, tuple[str, Any]], " "'selections' must be a list[str] or dict[str, Any], "
f"got {type(self.selections).__name__}" f"got {type(self.selections).__name__}"
) )
context.result = result context.result = result
@ -203,7 +300,7 @@ class SelectionAction(BaseAction):
return return
tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'")
tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}") tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}")
tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}")
if not parent: if not parent:
@ -218,6 +315,6 @@ class SelectionAction(BaseAction):
return ( return (
f"SelectionAction(name={self.name!r}, type={selection_type}, " f"SelectionAction(name={self.name!r}, type={selection_type}, "
f"default_selection={self.default_selection!r}, " f"default_selection={self.default_selection!r}, "
f"return_key={self.return_key}, " f"return_type={self.return_type!r}, "
f"prompt={'off' if self.never_prompt else 'on'})" f"prompt={'off' if self.never_prompt else 'on'})"
) )

View File

@ -35,3 +35,18 @@ class FileReturnType(Enum):
return member return member
valid = ", ".join(member.value for member in cls) valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}") raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}")
class SelectionReturnType(Enum):
"""Enum for dictionary return types."""
KEY = "key"
VALUE = "value"
DESCRIPTION = "description"
DESCRIPTION_VALUE = "description_value"
ITEMS = "items"
@classmethod
def _missing_(cls, value: object) -> SelectionReturnType:
valid = ", ".join(member.value for member in cls)
raise ValueError(f"Invalid DictReturnType: '{value}'. Must be one of: {valid}")

View File

@ -3,7 +3,7 @@ from prompt_toolkit.validation import Validator
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action import BaseAction from falyx.action.base import BaseAction
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
@ -40,9 +40,15 @@ class UserInputAction(BaseAction):
) )
self.prompt_text = prompt_text self.prompt_text = prompt_text
self.validator = validator self.validator = validator
self.console = console or Console(color_system="auto") if isinstance(console, Console):
self.console = console
elif console:
raise ValueError("`console` must be an instance of `rich.console.Console`")
self.prompt_session = prompt_session or PromptSession() self.prompt_session = prompt_session or PromptSession()
def get_infer_target(self) -> tuple[None, None]:
return None, None
async def _run(self, *args, **kwargs) -> str: async def _run(self, *args, **kwargs) -> str:
context = ExecutionContext( context = ExecutionContext(
name=self.name, name=self.name,

View File

@ -30,7 +30,7 @@ class BottomBar:
key_validator: Callable[[str], bool] | None = None, key_validator: Callable[[str], bool] | None = None,
) -> None: ) -> None:
self.columns = columns self.columns = columns
self.console = Console(color_system="auto") self.console = Console(color_system="truecolor")
self._named_items: dict[str, Callable[[], HTML]] = {} self._named_items: dict[str, Callable[[], HTML]] = {}
self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict()
self.toggle_keys: list[str] = [] self.toggle_keys: list[str] = []

View File

@ -19,7 +19,6 @@ in building robust interactive menus.
from __future__ import annotations from __future__ import annotations
import shlex import shlex
from functools import cached_property
from typing import Any, Callable from typing import Any, Callable
from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.formatted_text import FormattedText
@ -27,15 +26,16 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console from rich.console import Console
from rich.tree import Tree from rich.tree import Tree
from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction from falyx.action.action import Action
from falyx.action.io_action import BaseIOAction from falyx.action.base import BaseAction
from falyx.argparse import CommandArgumentParser
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.debug import register_debug_hooks from falyx.debug import register_debug_hooks
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import HookManager, HookType from falyx.hook_manager import HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers.argparse import CommandArgumentParser
from falyx.parsers.signature import infer_args_from_func
from falyx.prompt_utils import confirm_async, should_prompt_user from falyx.prompt_utils import confirm_async, should_prompt_user
from falyx.protocols import ArgParserProtocol from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
@ -44,7 +44,7 @@ from falyx.signals import CancelSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import ensure_async from falyx.utils import ensure_async
console = Console(color_system="auto") console = Console(color_system="truecolor")
class Command(BaseModel): class Command(BaseModel):
@ -89,7 +89,11 @@ class Command(BaseModel):
retry_policy (RetryPolicy): Retry behavior configuration. retry_policy (RetryPolicy): Retry behavior configuration.
tags (list[str]): Organizational tags for the command. tags (list[str]): Organizational tags for the command.
logging_hooks (bool): Whether to attach logging hooks automatically. 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: Methods:
__call__(): Executes the command, respecting hooks and retries. __call__(): Executes the command, respecting hooks and retries.
@ -101,12 +105,13 @@ class Command(BaseModel):
key: str key: str
description: str description: str
action: BaseAction | Callable[[], Any] action: BaseAction | Callable[..., Any]
args: tuple = () args: tuple = ()
kwargs: dict[str, Any] = Field(default_factory=dict) kwargs: dict[str, Any] = Field(default_factory=dict)
hidden: bool = False hidden: bool = False
aliases: list[str] = Field(default_factory=list) aliases: list[str] = Field(default_factory=list)
help_text: str = "" help_text: str = ""
help_epilog: str = ""
style: str = OneColors.WHITE style: str = OneColors.WHITE
confirm: bool = False confirm: bool = False
confirm_message: str = "Are you sure?" confirm_message: str = "Are you sure?"
@ -122,25 +127,55 @@ class Command(BaseModel):
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
tags: list[str] = Field(default_factory=list) tags: list[str] = Field(default_factory=list)
logging_hooks: bool = False logging_hooks: bool = False
requires_input: bool | None = None
options_manager: OptionsManager = Field(default_factory=OptionsManager) options_manager: OptionsManager = Field(default_factory=OptionsManager)
arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser) arg_parser: CommandArgumentParser | None = None
arguments: list[dict[str, Any]] = Field(default_factory=list)
argument_config: Callable[[CommandArgumentParser], None] | None = None
custom_parser: ArgParserProtocol | None = None custom_parser: ArgParserProtocol | None = None
custom_help: Callable[[], str | None] | None = None custom_help: Callable[[], str | None] | None = None
auto_args: bool = True
arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
simple_help_signature: bool = False
_context: ExecutionContext | None = PrivateAttr(default=None) _context: ExecutionContext | None = PrivateAttr(default=None)
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]: async def parse_args(
if self.custom_parser: self, raw_args: list[str] | str, from_validate: bool = False
) -> tuple[tuple, dict]:
if callable(self.custom_parser):
if isinstance(raw_args, str): 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) return self.custom_parser(raw_args)
if isinstance(raw_args, str): if isinstance(raw_args, str):
raw_args = shlex.split(raw_args) try:
return self.arg_parser.parse_args_split(raw_args) raw_args = shlex.split(raw_args)
except ValueError:
logger.warning(
"[Command:%s] Failed to split arguments: %s",
self.key,
raw_args,
)
return ((), {})
if not isinstance(self.arg_parser, CommandArgumentParser):
logger.warning(
"[Command:%s] No argument parser configured, using default parsing.",
self.key,
)
return ((), {})
return await self.arg_parser.parse_args_split(
raw_args, from_validate=from_validate
)
@field_validator("action", mode="before") @field_validator("action", mode="before")
@classmethod @classmethod
@ -151,11 +186,26 @@ class Command(BaseModel):
return ensure_async(action) return ensure_async(action)
raise TypeError("Action must be a callable or an instance of BaseAction") 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 callable(self.argument_config) and isinstance(
self.arg_parser, CommandArgumentParser
):
self.argument_config(self.arg_parser)
elif self.auto_args:
if isinstance(self.action, BaseAction):
infer_target, maybe_metadata = self.action.get_infer_target()
# merge metadata with the action's metadata if not already in self.arg_metadata
if maybe_metadata:
self.arg_metadata = {**maybe_metadata, **self.arg_metadata}
return infer_args_from_func(infer_target, self.arg_metadata)
elif callable(self.action):
return infer_args_from_func(self.action, self.arg_metadata)
return []
def model_post_init(self, _: Any) -> None: def model_post_init(self, _: Any) -> None:
"""Post-initialization to set up the action and hooks.""" """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): if self.retry and isinstance(self.action, Action):
self.action.enable_retry() self.action.enable_retry()
elif self.retry_policy and isinstance(self.action, Action): elif self.retry_policy and isinstance(self.action, Action):
@ -177,26 +227,17 @@ class Command(BaseModel):
if self.logging_hooks and isinstance(self.action, BaseAction): if self.logging_hooks and isinstance(self.action, BaseAction):
register_debug_hooks(self.action.hooks) register_debug_hooks(self.action.hooks)
if self.requires_input is None and self.detect_requires_input: if self.arg_parser is None:
self.requires_input = True self.arg_parser = CommandArgumentParser(
self.hidden = True command_key=self.key,
elif self.requires_input is None: command_description=self.description,
self.requires_input = False command_style=self.style,
help_text=self.help_text,
@cached_property help_epilog=self.help_epilog,
def detect_requires_input(self) -> bool: aliases=self.aliases,
"""Detect if the action requires input based on its type."""
if isinstance(self.action, BaseIOAction):
return True
elif isinstance(self.action, ChainedAction):
return (
isinstance(self.action.actions[0], BaseIOAction)
if self.action.actions
else False
) )
elif isinstance(self.action, ActionGroup): for arg_def in self.get_argument_definitions():
return any(isinstance(action, BaseIOAction) for action in self.action.actions) self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def)
return False
def _inject_options_manager(self) -> None: def _inject_options_manager(self) -> None:
"""Inject the options manager into the action if applicable.""" """Inject the options manager into the action if applicable."""
@ -223,7 +264,7 @@ class Command(BaseModel):
if self.preview_before_confirm: if self.preview_before_confirm:
await self.preview() await self.preview()
if not await confirm_async(self.confirmation_prompt): if not await confirm_async(self.confirmation_prompt):
logger.info("[Command:%s] Cancelled by user.", self.key) logger.info("[Command:%s] Cancelled by user.", self.key)
raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.")
context.start_timer() context.start_timer()
@ -284,13 +325,39 @@ class Command(BaseModel):
return FormattedText(prompt) return FormattedText(prompt)
@property
def usage(self) -> str:
"""Generate a help string for the command arguments."""
if not self.arg_parser:
return "No arguments defined."
command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True)
options_text = self.arg_parser.get_options_text(plain_text=True)
return f" {command_keys_text:<20} {options_text} "
@property
def help_signature(self) -> str:
"""Generate a help signature for the command."""
if self.arg_parser and not self.simple_help_signature:
signature = [self.arg_parser.get_usage()]
signature.append(f" {self.help_text or self.description}")
if self.tags:
signature.append(f" [dim]Tags: {', '.join(self.tags)}[/dim]")
return "\n".join(signature).strip()
command_keys = " | ".join(
[f"[{self.style}]{self.key}[/{self.style}]"]
+ [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases]
)
return f"{command_keys} {self.description}"
def log_summary(self) -> None: def log_summary(self) -> None:
if self._context: if self._context:
self._context.log_summary() self._context.log_summary()
def show_help(self) -> bool: def show_help(self) -> bool:
"""Display the help message for the command.""" """Display the help message for the command."""
if self.custom_help: if callable(self.custom_help):
output = self.custom_help() output = self.custom_help()
if output: if output:
console.print(output) console.print(output)

View File

@ -13,14 +13,15 @@ import yaml
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from rich.console import Console from rich.console import Console
from falyx.action.action import Action, BaseAction from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.command import Command from falyx.command import Command
from falyx.falyx import Falyx from falyx.falyx import Falyx
from falyx.logger import logger from falyx.logger import logger
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.themes import OneColors from falyx.themes import OneColors
console = Console(color_system="auto") console = Console(color_system="truecolor")
def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
@ -98,9 +99,9 @@ class RawCommand(BaseModel):
retry: bool = False retry: bool = False
retry_all: bool = False retry_all: bool = False
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
requires_input: bool | None = None
hidden: bool = False hidden: bool = False
help_text: str = "" help_text: str = ""
help_epilog: str = ""
@field_validator("retry_policy") @field_validator("retry_policy")
@classmethod @classmethod
@ -126,6 +127,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
} }
) )
) )
return commands return commands

View File

@ -70,7 +70,7 @@ class ExecutionContext(BaseModel):
name: str name: str
args: tuple = () args: tuple = ()
kwargs: dict = {} kwargs: dict = Field(default_factory=dict)
action: Any action: Any
result: Any | None = None result: Any | None = None
exception: Exception | None = None exception: Exception | None = None
@ -80,8 +80,10 @@ class ExecutionContext(BaseModel):
start_wall: datetime | None = None start_wall: datetime | None = None
end_wall: datetime | None = None end_wall: datetime | None = None
index: int | None = None
extra: dict[str, Any] = Field(default_factory=dict) extra: dict[str, Any] = Field(default_factory=dict)
console: Console = Field(default_factory=lambda: Console(color_system="auto")) console: Console = Field(default_factory=lambda: Console(color_system="truecolor"))
shared_context: SharedContext | None = None shared_context: SharedContext | None = None
@ -118,6 +120,17 @@ class ExecutionContext(BaseModel):
def status(self) -> str: def status(self) -> str:
return "OK" if self.success else "ERROR" return "OK" if self.success else "ERROR"
@property
def signature(self) -> str:
"""
Returns a string representation of the action signature, including
its name and arguments.
"""
args = ", ".join(map(repr, self.args))
kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs]))
return f"{self.name} ({signature})"
def as_dict(self) -> dict: def as_dict(self) -> dict:
return { return {
"name": self.name, "name": self.name,
@ -140,9 +153,9 @@ class ExecutionContext(BaseModel):
message.append(f"Duration: {summary['duration']:.3f}s | ") message.append(f"Duration: {summary['duration']:.3f}s | ")
if summary["exception"]: if summary["exception"]:
message.append(f"Exception: {summary['exception']}") message.append(f"Exception: {summary['exception']}")
else: else:
message.append(f"Result: {summary['result']}") message.append(f"Result: {summary['result']}")
(logger or self.console.print)("".join(message)) (logger or self.console.print)("".join(message))
def to_log_line(self) -> str: def to_log_line(self) -> str:

View File

@ -8,9 +8,9 @@ from falyx.logger import logger
def log_before(context: ExecutionContext): def log_before(context: ExecutionContext):
"""Log the start of an action.""" """Log the start of an action."""
args = ", ".join(map(repr, context.args)) args = ", ".join(map(repr, context.args))
kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items())
signature = ", ".join(filter(None, [args, kwargs])) signature = ", ".join(filter(None, [args, kwargs]))
logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature)
def log_success(context: ExecutionContext): def log_success(context: ExecutionContext):
@ -18,18 +18,18 @@ def log_success(context: ExecutionContext):
result_str = repr(context.result) result_str = repr(context.result)
if len(result_str) > 100: if len(result_str) > 100:
result_str = f"{result_str[:100]} ..." result_str = f"{result_str[:100]} ..."
logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) logger.debug("[%s] Success -> Result: %s", context.name, result_str)
def log_after(context: ExecutionContext): def log_after(context: ExecutionContext):
"""Log the completion of an action, regardless of success or failure.""" """Log the completion of an action, regardless of success or failure."""
logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) logger.debug("[%s] Finished in %.3fs", context.name, context.duration)
def log_error(context: ExecutionContext): def log_error(context: ExecutionContext):
"""Log an error that occurred during the action.""" """Log an error that occurred during the action."""
logger.error( logger.error(
"[%s] Error (%s): %s", "[%s] Error (%s): %s",
context.name, context.name,
type(context.exception).__name__, type(context.exception).__name__,
context.exception, context.exception,

View File

@ -29,7 +29,8 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Dict, List from threading import Lock
from typing import Literal
from rich import box from rich import box
from rich.console import Console from rich.console import Console
@ -70,23 +71,30 @@ class ExecutionRegistry:
ExecutionRegistry.summary() ExecutionRegistry.summary()
""" """
_store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list)
_store_all: List[ExecutionContext] = [] _store_by_index: dict[int, ExecutionContext] = {}
_console = Console(color_system="auto") _store_all: list[ExecutionContext] = []
_console = Console(color_system="truecolor")
_index = 0
_lock = Lock()
@classmethod @classmethod
def record(cls, context: ExecutionContext): def record(cls, context: ExecutionContext):
"""Record an execution context.""" """Record an execution context."""
logger.debug(context.to_log_line()) logger.debug(context.to_log_line())
with cls._lock:
context.index = cls._index
cls._store_by_index[cls._index] = context
cls._index += 1
cls._store_by_name[context.name].append(context) cls._store_by_name[context.name].append(context)
cls._store_all.append(context) cls._store_all.append(context)
@classmethod @classmethod
def get_all(cls) -> List[ExecutionContext]: def get_all(cls) -> list[ExecutionContext]:
return cls._store_all return cls._store_all
@classmethod @classmethod
def get_by_name(cls, name: str) -> List[ExecutionContext]: def get_by_name(cls, name: str) -> list[ExecutionContext]:
return cls._store_by_name.get(name, []) return cls._store_by_name.get(name, [])
@classmethod @classmethod
@ -97,11 +105,79 @@ class ExecutionRegistry:
def clear(cls): def clear(cls):
cls._store_by_name.clear() cls._store_by_name.clear()
cls._store_all.clear() cls._store_all.clear()
cls._store_by_index.clear()
@classmethod @classmethod
def summary(cls): def summary(
table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE) cls,
name: str = "",
index: int | None = None,
result: int | None = None,
clear: bool = False,
last_result: bool = False,
status: Literal["all", "success", "error"] = "all",
):
if clear:
cls.clear()
cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.")
return
if last_result:
for ctx in reversed(cls._store_all):
if ctx.name.upper() not in [
"HISTORY",
"HELP",
"EXIT",
"VIEW EXECUTION HISTORY",
"BACK",
]:
cls._console.print(ctx.result)
return
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result."
)
return
if result is not None and result >= 0:
try:
result_context = cls._store_by_index[result]
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
)
return
cls._console.print(f"{result_context.signature}:")
if result_context.exception:
cls._console.print(result_context.exception)
else:
cls._console.print(result_context.result)
return
if name:
contexts = cls.get_by_name(name)
if not contexts:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'."
)
return
title = f"📊 Execution History for '{contexts[0].name}'"
elif index is not None and index >= 0:
try:
contexts = [cls._store_by_index[index]]
print(contexts)
except KeyError:
cls._console.print(
f"[{OneColors.DARK_RED}]❌ No execution found for index {index}."
)
return
title = f"📊 Execution History for Index {index}"
else:
contexts = cls.get_all()
title = "📊 Execution History"
table = Table(title=title, expand=True, box=box.SIMPLE)
table.add_column("Index", justify="right", style="dim")
table.add_column("Name", style="bold cyan") table.add_column("Name", style="bold cyan")
table.add_column("Start", justify="right", style="dim") table.add_column("Start", justify="right", style="dim")
table.add_column("End", justify="right", style="dim") table.add_column("End", justify="right", style="dim")
@ -109,7 +185,7 @@ class ExecutionRegistry:
table.add_column("Status", style="bold") table.add_column("Status", style="bold")
table.add_column("Result / Exception", overflow="fold") table.add_column("Result / Exception", overflow="fold")
for ctx in cls.get_all(): for ctx in contexts:
start = ( start = (
datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S")
if ctx.start_time if ctx.start_time
@ -122,15 +198,19 @@ class ExecutionRegistry:
) )
duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
if ctx.exception: if ctx.exception and status.lower() in ["all", "error"]:
status = f"[{OneColors.DARK_RED}]❌ Error" final_status = f"[{OneColors.DARK_RED}]❌ Error"
result = repr(ctx.exception) final_result = repr(ctx.exception)
elif status.lower() in ["all", "success"]:
final_status = f"[{OneColors.GREEN}]✅ Success"
final_result = repr(ctx.result)
if len(final_result) > 1000:
final_result = f"{final_result[:1000]}..."
else: else:
status = f"[{OneColors.GREEN}]✅ Success" continue
result = repr(ctx.result)
if len(result) > 1000:
result = f"{result[:1000]}..."
table.add_row(ctx.name, start, end, duration, status, result) table.add_row(
str(ctx.index), ctx.name, start, end, duration, final_status, final_result
)
cls._console.print(table) cls._console.print(table)

View File

@ -25,7 +25,7 @@ import asyncio
import logging import logging
import shlex import shlex
import sys import sys
from argparse import Namespace from argparse import ArgumentParser, Namespace, _SubParsersAction
from difflib import get_close_matches from difflib import get_close_matches
from enum import Enum from enum import Enum
from functools import cached_property from functools import cached_property
@ -42,7 +42,8 @@ from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.table import Table from rich.table import Table
from falyx.action.action import Action, BaseAction from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.bottom_bar import BottomBar from falyx.bottom_bar import BottomBar
from falyx.command import Command from falyx.command import Command
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
@ -58,11 +59,12 @@ from falyx.execution_registry import ExecutionRegistry as er
from falyx.hook_manager import Hook, HookManager, HookType from falyx.hook_manager import Hook, HookManager, HookType
from falyx.logger import logger from falyx.logger import logger
from falyx.options_manager import OptionsManager from falyx.options_manager import OptionsManager
from falyx.parsers import get_arg_parsers from falyx.parsers import CommandArgumentParser, FalyxParsers, get_arg_parsers
from falyx.protocols import ArgParserProtocol
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal
from falyx.themes import OneColors, get_nord_theme from falyx.themes import OneColors, get_nord_theme
from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation from falyx.utils import CaseInsensitiveDict, _noop, chunks
from falyx.version import __version__ from falyx.version import __version__
@ -82,14 +84,26 @@ class CommandValidator(Validator):
self.error_message = error_message self.error_message = error_message
def validate(self, document) -> None: def validate(self, document) -> None:
if not document.text:
raise ValidationError(
message=self.error_message,
cursor_position=len(document.text),
)
async def validate_async(self, document) -> None:
text = document.text text = document.text
is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True) if not text:
raise ValidationError(
message=self.error_message,
cursor_position=len(text),
)
is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True)
if is_preview: if is_preview:
return None return None
if not choice: if not choice:
raise ValidationError( raise ValidationError(
message=self.error_message, message=self.error_message,
cursor_position=document.get_end_of_document_position(), cursor_position=len(text),
) )
@ -110,6 +124,8 @@ class Falyx:
- Submenu nesting and action chaining - Submenu nesting and action chaining
- History tracking, help generation, and run key execution modes - History tracking, help generation, and run key execution modes
- Seamless CLI argument parsing and integration via argparse - Seamless CLI argument parsing and integration via argparse
- Declarative option management with OptionsManager
- Command level argument parsing and validation
- Extensible with user-defined hooks, bottom bars, and custom layouts - Extensible with user-defined hooks, bottom bars, and custom layouts
Args: Args:
@ -125,7 +141,7 @@ class Falyx:
never_prompt (bool): Seed default for `OptionsManager["never_prompt"]` never_prompt (bool): Seed default for `OptionsManager["never_prompt"]`
force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` force_confirm (bool): Seed default for `OptionsManager["force_confirm"]`
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
options (OptionsManager | None): Declarative option mappings. options (OptionsManager | None): Declarative option mappings for global state.
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table
generator. generator.
@ -145,6 +161,12 @@ class Falyx:
self, self,
title: str | Markdown = "Menu", title: str | Markdown = "Menu",
*, *,
program: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: str | None = None,
version: str = __version__,
version_style: str = OneColors.BLUE_b,
prompt: str | AnyFormattedText = "> ", prompt: str | AnyFormattedText = "> ",
columns: int = 3, columns: int = 3,
bottom_bar: BottomBar | str | Callable[[], Any] | None = None, bottom_bar: BottomBar | str | Callable[[], Any] | None = None,
@ -157,11 +179,18 @@ class Falyx:
force_confirm: bool = False, force_confirm: bool = False,
cli_args: Namespace | None = None, cli_args: Namespace | None = None,
options: OptionsManager | None = None, options: OptionsManager | None = None,
render_menu: Callable[["Falyx"], None] | None = None, render_menu: Callable[[Falyx], None] | None = None,
custom_table: Callable[["Falyx"], Table] | Table | None = None, custom_table: Callable[[Falyx], Table] | Table | None = None,
hide_menu_table: bool = False,
) -> None: ) -> None:
"""Initializes the Falyx object.""" """Initializes the Falyx object."""
self.title: str | Markdown = title self.title: str | Markdown = title
self.program: str | None = program
self.usage: str | None = usage
self.description: str | None = description
self.epilog: str | None = epilog
self.version: str = version
self.version_style: str = version_style
self.prompt: str | AnyFormattedText = prompt self.prompt: str | AnyFormattedText = prompt
self.columns: int = columns self.columns: int = columns
self.commands: dict[str, Command] = CaseInsensitiveDict() self.commands: dict[str, Command] = CaseInsensitiveDict()
@ -172,7 +201,7 @@ class Falyx:
self.help_command: Command | None = ( self.help_command: Command | None = (
self._get_help_command() if include_help_command else None self._get_help_command() if include_help_command else None
) )
self.console: Console = Console(color_system="auto", theme=get_nord_theme()) self.console: Console = Console(color_system="truecolor", theme=get_nord_theme())
self.welcome_message: str | Markdown | dict[str, Any] = welcome_message self.welcome_message: str | Markdown | dict[str, Any] = welcome_message
self.exit_message: str | Markdown | dict[str, Any] = exit_message self.exit_message: str | Markdown | dict[str, Any] = exit_message
self.hooks: HookManager = HookManager() self.hooks: HookManager = HookManager()
@ -182,8 +211,9 @@ class Falyx:
self._never_prompt: bool = never_prompt self._never_prompt: bool = never_prompt
self._force_confirm: bool = force_confirm self._force_confirm: bool = force_confirm
self.cli_args: Namespace | None = cli_args self.cli_args: Namespace | None = cli_args
self.render_menu: Callable[["Falyx"], None] | None = render_menu self.render_menu: Callable[[Falyx], None] | None = render_menu
self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table
self._hide_menu_table: bool = hide_menu_table
self.validate_options(cli_args, options) self.validate_options(cli_args, options)
self._prompt_session: PromptSession | None = None self._prompt_session: PromptSession | None = None
self.mode = FalyxMode.MENU self.mode = FalyxMode.MENU
@ -265,72 +295,117 @@ class Falyx:
action=Action("Exit", action=_noop), action=Action("Exit", action=_noop),
aliases=["EXIT", "QUIT"], aliases=["EXIT", "QUIT"],
style=OneColors.DARK_RED, style=OneColors.DARK_RED,
simple_help_signature=True,
) )
def _get_history_command(self) -> Command: def _get_history_command(self) -> Command:
"""Returns the history command for the menu.""" """Returns the history command for the menu."""
parser = CommandArgumentParser(
command_key="Y",
command_description="History",
command_style=OneColors.DARK_YELLOW,
aliases=["HISTORY"],
)
parser.add_argument(
"-n",
"--name",
help="Filter by execution name.",
)
parser.add_argument(
"-i",
"--index",
type=int,
help="Filter by execution index (0-based).",
)
parser.add_argument(
"-s",
"--status",
choices=["all", "success", "error"],
default="all",
help="Filter by execution status (default: all).",
)
parser.add_argument(
"-c",
"--clear",
action="store_true",
help="Clear the Execution History.",
)
parser.add_argument("-r", "--result", type=int, help="Get the result by index")
parser.add_argument(
"-l", "--last-result", action="store_true", help="Get the last result"
)
return Command( return Command(
key="Y", key="Y",
description="History", description="History",
aliases=["HISTORY"], aliases=["HISTORY"],
action=Action(name="View Execution History", action=er.summary), action=Action(name="View Execution History", action=er.summary),
style=OneColors.DARK_YELLOW, style=OneColors.DARK_YELLOW,
simple_help_signature=True,
arg_parser=parser,
help_text="View the execution history of commands.",
) )
async def _show_help(self): async def _show_help(self, tag: str = "") -> None:
table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE) if tag:
table.add_column("Key", style="bold", no_wrap=True) table = Table(
table.add_column("Aliases", style="dim", no_wrap=True) title=tag.upper(),
table.add_column("Description", style="dim", overflow="fold") title_justify="left",
table.add_column("Tags", style="dim", no_wrap=True) show_header=False,
box=box.SIMPLE,
for command in self.commands.values(): show_footer=False,
help_text = command.help_text or command.description
if command.requires_input:
help_text += " [dim](requires input)[/dim]"
table.add_row(
f"[{command.style}]{command.key}[/]",
", ".join(command.aliases) if command.aliases else "",
help_text,
", ".join(command.tags) if command.tags else "",
) )
tag_lower = tag.lower()
table.add_row( commands = [
f"[{self.exit_command.style}]{self.exit_command.key}[/]", command
", ".join(self.exit_command.aliases), for command in self.commands.values()
"Exit this menu or program", if any(tag_lower == tag.lower() for tag in command.tags)
) ]
for command in commands:
if self.history_command: table.add_row(command.help_signature)
table.add_row( self.console.print(table)
f"[{self.history_command.style}]{self.history_command.key}[/]", return
", ".join(self.history_command.aliases), else:
"History of executed actions", table = Table(
title="Help",
title_justify="left",
title_style=OneColors.LIGHT_YELLOW_b,
show_header=False,
show_footer=False,
box=box.SIMPLE,
) )
for command in self.commands.values():
table.add_row(command.help_signature)
if self.help_command: if self.help_command:
table.add_row( table.add_row(self.help_command.help_signature)
f"[{self.help_command.style}]{self.help_command.key}[/]", if self.history_command:
", ".join(self.help_command.aliases), table.add_row(self.history_command.help_signature)
"Show this help menu", table.add_row(self.exit_command.help_signature)
) table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ")
self.console.print(table)
self.console.print(table, justify="center")
if self.mode == FalyxMode.MENU:
self.console.print(
f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command "
"before running it.\n",
justify="center",
)
def _get_help_command(self) -> Command: def _get_help_command(self) -> Command:
"""Returns the help command for the menu.""" """Returns the help command for the menu."""
parser = CommandArgumentParser(
command_key="H",
command_description="Help",
command_style=OneColors.LIGHT_YELLOW,
aliases=["?", "HELP", "LIST"],
)
parser.add_argument(
"-t",
"--tag",
nargs="?",
default="",
help="Optional tag to filter commands by.",
)
return Command( return Command(
key="H", key="H",
aliases=["HELP", "?"], aliases=["?", "HELP", "LIST"],
description="Help", description="Help",
help_text="Show this help menu",
action=Action("Help", self._show_help), action=Action("Help", self._show_help),
style=OneColors.LIGHT_YELLOW, style=OneColors.LIGHT_YELLOW,
arg_parser=parser,
) )
def _get_completer(self) -> WordCompleter: def _get_completer(self) -> WordCompleter:
@ -443,7 +518,7 @@ class Falyx:
validator=CommandValidator(self, self._get_validator_error_message()), validator=CommandValidator(self, self._get_validator_error_message()),
bottom_toolbar=self._get_bottom_bar_render(), bottom_toolbar=self._get_bottom_bar_render(),
key_bindings=self.key_bindings, key_bindings=self.key_bindings,
validate_while_typing=False, validate_while_typing=True,
) )
return self._prompt_session return self._prompt_session
@ -524,7 +599,7 @@ class Falyx:
key: str = "X", key: str = "X",
description: str = "Exit", description: str = "Exit",
aliases: list[str] | None = None, aliases: list[str] | None = None,
action: Callable[[], Any] | None = None, action: Callable[..., Any] | None = None,
style: str = OneColors.DARK_RED, style: str = OneColors.DARK_RED,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
@ -551,7 +626,9 @@ class Falyx:
if not isinstance(submenu, Falyx): if not isinstance(submenu, Falyx):
raise NotAFalyxError("submenu must be an instance of Falyx.") raise NotAFalyxError("submenu must be an instance of Falyx.")
self._validate_command_key(key) self._validate_command_key(key)
self.add_command(key, description, submenu.menu, style=style) self.add_command(
key, description, submenu.menu, style=style, simple_help_signature=True
)
if submenu.exit_command.key == "X": if submenu.exit_command.key == "X":
submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
@ -578,13 +655,14 @@ class Falyx:
self, self,
key: str, key: str,
description: str, description: str,
action: BaseAction | Callable[[], Any], action: BaseAction | Callable[..., Any],
*, *,
args: tuple = (), args: tuple = (),
kwargs: dict[str, Any] | None = None, kwargs: dict[str, Any] | None = None,
hidden: bool = False, hidden: bool = False,
aliases: list[str] | None = None, aliases: list[str] | None = None,
help_text: str = "", help_text: str = "",
help_epilog: str = "",
style: str = OneColors.WHITE, style: str = OneColors.WHITE,
confirm: bool = False, confirm: bool = False,
confirm_message: str = "Are you sure?", confirm_message: str = "Are you sure?",
@ -605,10 +683,25 @@ class Falyx:
retry: bool = False, retry: bool = False,
retry_all: bool = False, retry_all: bool = False,
retry_policy: RetryPolicy | None = None, 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 = True,
arg_metadata: dict[str, str | dict[str, Any]] | None = None,
simple_help_signature: bool = False,
) -> Command: ) -> Command:
"""Adds an command to the menu, preventing duplicates.""" """Adds an command to the menu, preventing duplicates."""
self._validate_command_key(key) 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
command = Command( command = Command(
key=key, key=key,
description=description, description=description,
@ -618,6 +711,7 @@ class Falyx:
hidden=hidden, hidden=hidden,
aliases=aliases if aliases else [], aliases=aliases if aliases else [],
help_text=help_text, help_text=help_text,
help_epilog=help_epilog,
style=style, style=style,
confirm=confirm, confirm=confirm,
confirm_message=confirm_message, confirm_message=confirm_message,
@ -632,8 +726,15 @@ class Falyx:
retry=retry, retry=retry,
retry_all=retry_all, retry_all=retry_all,
retry_policy=retry_policy or RetryPolicy(), retry_policy=retry_policy or RetryPolicy(),
requires_input=requires_input,
options_manager=self.options, 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 {},
simple_help_signature=simple_help_signature,
) )
if hooks: if hooks:
@ -658,16 +759,16 @@ class Falyx:
def get_bottom_row(self) -> list[str]: def get_bottom_row(self) -> list[str]:
"""Returns the bottom row of the table for displaying additional commands.""" """Returns the bottom row of the table for displaying additional commands."""
bottom_row = [] bottom_row = []
if self.history_command:
bottom_row.append(
f"[{self.history_command.key}] [{self.history_command.style}]"
f"{self.history_command.description}"
)
if self.help_command: if self.help_command:
bottom_row.append( bottom_row.append(
f"[{self.help_command.key}] [{self.help_command.style}]" f"[{self.help_command.key}] [{self.help_command.style}]"
f"{self.help_command.description}" f"{self.help_command.description}"
) )
if self.history_command:
bottom_row.append(
f"[{self.history_command.key}] [{self.history_command.style}]"
f"{self.history_command.description}"
)
bottom_row.append( bottom_row.append(
f"[{self.exit_command.key}] [{self.exit_command.style}]" f"[{self.exit_command.key}] [{self.exit_command.style}]"
f"{self.exit_command.description}" f"{self.exit_command.description}"
@ -679,7 +780,7 @@ class Falyx:
Build the standard table layout. Developers can subclass or call this Build the standard table layout. Developers can subclass or call this
in custom tables. in custom tables.
""" """
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) # type: ignore[arg-type] table = Table(title=self.title, show_header=False, box=box.SIMPLE) # type: ignore[arg-type]
visible_commands = [item for item in self.commands.items() if not item[1].hidden] visible_commands = [item for item in self.commands.items() if not item[1].hidden]
for chunk in chunks(visible_commands, self.columns): for chunk in chunks(visible_commands, self.columns):
row = [] row = []
@ -695,7 +796,12 @@ class Falyx:
def table(self) -> Table: def table(self) -> Table:
"""Creates or returns a custom table to display the menu commands.""" """Creates or returns a custom table to display the menu commands."""
if callable(self.custom_table): if callable(self.custom_table):
return self.custom_table(self) custom_table = self.custom_table(self)
if not isinstance(custom_table, Table):
raise FalyxError(
"custom_table must return an instance of rich.table.Table."
)
return custom_table
elif isinstance(self.custom_table, Table): elif isinstance(self.custom_table, Table):
return self.custom_table return self.custom_table
else: else:
@ -706,7 +812,7 @@ class Falyx:
return True, input_str[1:].strip() return True, input_str[1:].strip()
return False, input_str.strip() return False, input_str.strip()
def get_command( async def get_command(
self, raw_choices: str, from_validate=False self, raw_choices: str, from_validate=False
) -> tuple[bool, Command | None, tuple, dict[str, Any]]: ) -> tuple[bool, Command | None, tuple, dict[str, Any]]:
""" """
@ -715,13 +821,16 @@ class Falyx:
""" """
args = () args = ()
kwargs: dict[str, Any] = {} 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) is_preview, choice = self.parse_preview_command(choice)
if is_preview and not choice and self.help_command: if is_preview and not choice and self.help_command:
is_preview = False is_preview = False
choice = "?" choice = "?"
elif is_preview and not choice: elif is_preview and not choice:
# No help command enabled # No help (list) command enabled
if not from_validate: if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode." f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode."
@ -730,29 +839,39 @@ class Falyx:
choice = choice.upper() choice = choice.upper()
name_map = self._name_map name_map = self._name_map
if choice in name_map: run_command = None
if not from_validate: if name_map.get(choice):
logger.info("Command '%s' selected.", choice) run_command = name_map[choice]
if input_args and name_map[choice].arg_parser: else:
try: prefix_matches = [
args, kwargs = name_map[choice].parse_args(input_args) cmd for key, cmd in name_map.items() if key.startswith(choice)
except CommandArgumentError as error: ]
if not from_validate: if len(prefix_matches) == 1:
if not name_map[choice].show_help(): run_command = prefix_matches[0]
self.console.print(
f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}"
)
else:
name_map[choice].show_help()
raise ValidationError(
message=str(error), cursor_position=len(raw_choices)
)
return is_preview, 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)] if run_command:
if len(prefix_matches) == 1: if not from_validate:
return is_preview, prefix_matches[0], args, kwargs logger.info("Command '%s' selected.", run_command.key)
if is_preview:
return True, run_command, args, kwargs
elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}:
return False, run_command, args, kwargs
try:
args, kwargs = await run_command.parse_args(input_args, from_validate)
except (CommandArgumentError, Exception) as error:
if not from_validate:
run_command.show_help()
self.console.print(
f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}"
)
else:
raise ValidationError(
message=str(error), cursor_position=len(raw_choices)
)
return is_preview, None, args, kwargs
except HelpSignal:
return True, None, args, kwargs
return is_preview, run_command, args, kwargs
fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7)
if fuzzy_matches: if fuzzy_matches:
@ -761,22 +880,35 @@ class Falyx:
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. " f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. "
"Did you mean:" "Did you mean:"
) )
for match in fuzzy_matches: for match in fuzzy_matches:
cmd = name_map[match] cmd = name_map[match]
self.console.print(f" • [bold]{match}[/] → {cmd.description}") self.console.print(f" • [bold]{match}[/] → {cmd.description}")
else:
raise ValidationError(
message=f"Unknown command '{choice}'. Did you mean: "
f"{', '.join(fuzzy_matches)}?",
cursor_position=len(raw_choices),
)
else: else:
if not from_validate: if not from_validate:
self.console.print( self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]" f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]"
) )
else:
raise ValidationError(
message=f"Unknown command '{choice}'.",
cursor_position=len(raw_choices),
)
return is_preview, None, args, kwargs return is_preview, None, args, kwargs
def _create_context(self, selected_command: Command) -> ExecutionContext: def _create_context(
"""Creates a context dictionary for the selected command.""" self, selected_command: Command, args: tuple, kwargs: dict[str, Any]
) -> ExecutionContext:
"""Creates an ExecutionContext object for the selected command."""
return ExecutionContext( return ExecutionContext(
name=selected_command.description, name=selected_command.description,
args=tuple(), args=args,
kwargs={}, kwargs=kwargs,
action=selected_command, action=selected_command,
) )
@ -794,7 +926,7 @@ class Falyx:
"""Processes the action of the selected command.""" """Processes the action of the selected command."""
with patch_stdout(raw=True): with patch_stdout(raw=True):
choice = await self.prompt_session.prompt_async() 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: if not selected_command:
logger.info("Invalid command '%s'.", choice) logger.info("Invalid command '%s'.", choice)
return True return True
@ -804,26 +936,16 @@ class Falyx:
await selected_command.preview() await selected_command.preview()
return True return True
if selected_command.requires_input:
program = get_program_invocation()
self.console.print(
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires"
f" input and must be run via [{OneColors.MAGENTA}]'{program} run"
f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]"
)
return True
self.last_run_command = selected_command self.last_run_command = selected_command
if selected_command == self.exit_command: if selected_command == self.exit_command:
logger.info("🔙 Back selected: exiting %s", self.get_title()) logger.info("Back selected: exiting %s", self.get_title())
return False return False
context = self._create_context(selected_command) context = self._create_context(selected_command, args, kwargs)
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
print(args, kwargs)
result = await selected_command(*args, **kwargs) result = await selected_command(*args, **kwargs)
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
@ -846,7 +968,7 @@ class Falyx:
) -> Any: ) -> Any:
"""Run a command by key without displaying the menu (non-interactive mode).""" """Run a command by key without displaying the menu (non-interactive mode)."""
self.debug_hooks() 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 {} kwargs = kwargs or {}
self.last_run_command = selected_command self.last_run_command = selected_command
@ -860,12 +982,12 @@ class Falyx:
return None return None
logger.info( logger.info(
"[run_key] 🚀 Executing: %s%s", "[run_key] Executing: %s%s",
selected_command.key, selected_command.key,
selected_command.description, selected_command.description,
) )
context = self._create_context(selected_command) context = self._create_context(selected_command, args, kwargs)
context.start_timer() context.start_timer()
try: try:
await self.hooks.trigger(HookType.BEFORE, context) await self.hooks.trigger(HookType.BEFORE, context)
@ -873,10 +995,10 @@ class Falyx:
context.result = result context.result = result
await self.hooks.trigger(HookType.ON_SUCCESS, context) await self.hooks.trigger(HookType.ON_SUCCESS, context)
logger.info("[run_key] '%s' complete.", selected_command.description) logger.info("[run_key] '%s' complete.", selected_command.description)
except (KeyboardInterrupt, EOFError) as error: except (KeyboardInterrupt, EOFError) as error:
logger.warning( logger.warning(
"[run_key] ⚠️ Interrupted by user: %s", selected_command.description "[run_key] Interrupted by user: %s", selected_command.description
) )
raise FalyxError( raise FalyxError(
f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." f"[run_key] ⚠️ '{selected_command.description}' interrupted by user."
@ -885,7 +1007,7 @@ class Falyx:
context.exception = error context.exception = error
await self.hooks.trigger(HookType.ON_ERROR, context) await self.hooks.trigger(HookType.ON_ERROR, context)
logger.error( logger.error(
"[run_key] Failed: %s%s: %s", "[run_key] Failed: %s%s: %s",
selected_command.description, selected_command.description,
type(error).__name__, type(error).__name__,
error, error,
@ -939,16 +1061,17 @@ class Falyx:
async def menu(self) -> None: async def menu(self) -> None:
"""Runs the menu and handles user input.""" """Runs the menu and handles user input."""
logger.info("Running menu: %s", self.get_title()) logger.info("Starting menu: %s", self.get_title())
self.debug_hooks() self.debug_hooks()
if self.welcome_message: if self.welcome_message:
self.print_message(self.welcome_message) self.print_message(self.welcome_message)
try: try:
while True: while True:
if callable(self.render_menu): if not self.options.get("hide_menu_table", self._hide_menu_table):
self.render_menu(self) if callable(self.render_menu):
else: self.render_menu(self)
self.console.print(self.table, justify="center") else:
self.console.print(self.table, justify="center")
try: try:
task = asyncio.create_task(self.process_command()) task = asyncio.create_task(self.process_command())
should_continue = await task should_continue = await task
@ -958,49 +1081,77 @@ class Falyx:
logger.info("EOF or KeyboardInterrupt. Exiting menu.") logger.info("EOF or KeyboardInterrupt. Exiting menu.")
break break
except QuitSignal: except QuitSignal:
logger.info("QuitSignal received. Exiting menu.") logger.info("[QuitSignal]. <- Exiting menu.")
break break
except BackSignal: except BackSignal:
logger.info("BackSignal received.") logger.info("[BackSignal]. <- Returning to the menu.")
except CancelSignal: except CancelSignal:
logger.info("CancelSignal received.") logger.info("[CancelSignal]. <- Returning to the menu.")
except HelpSignal:
logger.info("HelpSignal received.")
finally: finally:
logger.info("Exiting menu: %s", self.get_title()) logger.info("Exiting menu: %s", self.get_title())
if self.exit_message: if self.exit_message:
self.print_message(self.exit_message) self.print_message(self.exit_message)
async def run(self) -> None: async def run(
self,
falyx_parsers: FalyxParsers | None = None,
root_parser: ArgumentParser | None = None,
subparsers: _SubParsersAction | None = None,
callback: Callable[..., Any] | None = None,
) -> None:
"""Run Falyx CLI with structured subcommands.""" """Run Falyx CLI with structured subcommands."""
if not self.cli_args: if self.cli_args:
self.cli_args = get_arg_parsers().root.parse_args() raise FalyxError(
"Run is incompatible with CLI arguments. Use 'run_key' instead."
)
if falyx_parsers:
if not isinstance(falyx_parsers, FalyxParsers):
raise FalyxError("falyx_parsers must be an instance of FalyxParsers.")
else:
falyx_parsers = get_arg_parsers(
self.program,
self.usage,
self.description,
self.epilog,
commands=self.commands,
root_parser=root_parser,
subparsers=subparsers,
)
self.cli_args = falyx_parsers.parse_args()
self.options.from_namespace(self.cli_args, "cli_args") self.options.from_namespace(self.cli_args, "cli_args")
if callback:
if not callable(callback):
raise FalyxError("Callback must be a callable function.")
callback(self.cli_args)
if not self.options.get("never_prompt"): if not self.options.get("never_prompt"):
self.options.set("never_prompt", self._never_prompt) self.options.set("never_prompt", self._never_prompt)
if not self.options.get("force_confirm"): if not self.options.get("force_confirm"):
self.options.set("force_confirm", self._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: if self.cli_args.verbose:
logging.getLogger("falyx").setLevel(logging.DEBUG) logging.getLogger("falyx").setLevel(logging.DEBUG)
if self.cli_args.debug_hooks: if self.cli_args.debug_hooks:
logger.debug("✅ Enabling global debug hooks for all commands") logger.debug("Enabling global debug hooks for all commands")
self.register_all_with_debug_hooks() self.register_all_with_debug_hooks()
if self.cli_args.command == "list": if self.cli_args.command == "list":
await self._show_help() await self._show_help(tag=self.cli_args.tag)
sys.exit(0) sys.exit(0)
if self.cli_args.command == "version" or self.cli_args.version: 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"[{self.version_style}]{self.program} v{__version__}[/]")
sys.exit(0) sys.exit(0)
if self.cli_args.command == "preview": if self.cli_args.command == "preview":
self.mode = FalyxMode.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: if not command:
self.console.print( self.console.print(
f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found."
@ -1014,7 +1165,7 @@ class Falyx:
if self.cli_args.command == "run": if self.cli_args.command == "run":
self.mode = FalyxMode.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 is_preview:
if command is None: if command is None:
sys.exit(1) sys.exit(1)
@ -1025,14 +1176,27 @@ class Falyx:
sys.exit(1) sys.exit(1)
self._set_retry_policy(command) self._set_retry_policy(command)
try: try:
args, kwargs = command.parse_args(self.cli_args.command_args) args, kwargs = await command.parse_args(self.cli_args.command_args)
except HelpSignal: except HelpSignal:
sys.exit(0) sys.exit(0)
except CommandArgumentError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}")
command.show_help()
sys.exit(1)
try: try:
await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) await self.run_key(self.cli_args.name, args=args, kwargs=kwargs)
except FalyxError as error: except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
sys.exit(1) sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(0)
except BackSignal:
logger.info("[BackSignal]. <- Exiting run.")
sys.exit(0)
except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(0)
if self.cli_args.summary: if self.cli_args.summary:
er.summary() er.summary()
@ -1056,9 +1220,23 @@ class Falyx:
f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] " f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] "
f"{self.cli_args.tag}" f"{self.cli_args.tag}"
) )
for cmd in matching: for cmd in matching:
self._set_retry_policy(cmd) self._set_retry_policy(cmd)
await self.run_key(cmd.key) try:
await self.run_key(cmd.key)
except FalyxError as error:
self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
sys.exit(1)
except QuitSignal:
logger.info("[QuitSignal]. <- Exiting run.")
sys.exit(0)
except BackSignal:
logger.info("[BackSignal]. <- Exiting run.")
sys.exit(0)
except CancelSignal:
logger.info("[CancelSignal]. <- Exiting run.")
sys.exit(0)
if self.cli_args.summary: if self.cli_args.summary:
er.summary() er.summary()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import inspect import inspect
from enum import Enum from enum import Enum
from typing import Awaitable, Callable, Dict, List, Optional, Union from typing import Awaitable, Callable, Union
from falyx.context import ExecutionContext from falyx.context import ExecutionContext
from falyx.logger import logger from falyx.logger import logger
@ -24,7 +24,7 @@ class HookType(Enum):
ON_TEARDOWN = "on_teardown" ON_TEARDOWN = "on_teardown"
@classmethod @classmethod
def choices(cls) -> List[HookType]: def choices(cls) -> list[HookType]:
"""Return a list of all hook type choices.""" """Return a list of all hook type choices."""
return list(cls) return list(cls)
@ -37,16 +37,17 @@ class HookManager:
"""HookManager""" """HookManager"""
def __init__(self) -> None: def __init__(self) -> None:
self._hooks: Dict[HookType, List[Hook]] = { self._hooks: dict[HookType, list[Hook]] = {
hook_type: [] for hook_type in HookType hook_type: [] for hook_type in HookType
} }
def register(self, hook_type: HookType, hook: Hook): def register(self, hook_type: HookType | str, hook: Hook):
if hook_type not in HookType: """Raises ValueError if the hook type is not supported."""
raise ValueError(f"Unsupported hook type: {hook_type}") if not isinstance(hook_type, HookType):
hook_type = HookType(hook_type)
self._hooks[hook_type].append(hook) self._hooks[hook_type].append(hook)
def clear(self, hook_type: Optional[HookType] = None): def clear(self, hook_type: HookType | None = None):
if hook_type: if hook_type:
self._hooks[hook_type] = [] self._hooks[hook_type] = []
else: else:
@ -64,7 +65,7 @@ class HookManager:
hook(context) hook(context)
except Exception as hook_error: except Exception as hook_error:
logger.warning( logger.warning(
"⚠️ Hook '%s' raised an exception during '%s' for '%s': %s", "[Hook:%s] raised an exception during '%s' for '%s': %s",
hook.__name__, hook.__name__,
hook_type, hook_type,
context.name, context.name,

View File

@ -56,10 +56,10 @@ class CircuitBreaker:
if self.open_until: if self.open_until:
if time.time() < self.open_until: if time.time() < self.open_until:
raise CircuitBreakerOpen( raise CircuitBreakerOpen(
f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." f"Circuit open for '{name}' until {time.ctime(self.open_until)}."
) )
else: else:
logger.info("🟢 Circuit closed again for '%s'.") logger.info("Circuit closed again for '%s'.")
self.failures = 0 self.failures = 0
self.open_until = None self.open_until = None
@ -67,7 +67,7 @@ class CircuitBreaker:
name = context.name name = context.name
self.failures += 1 self.failures += 1
logger.warning( logger.warning(
"⚠️ CircuitBreaker: '%s' failure %s/%s.", "CircuitBreaker: '%s' failure %s/%s.",
name, name,
self.failures, self.failures,
self.max_failures, self.max_failures,
@ -75,7 +75,7 @@ class CircuitBreaker:
if self.failures >= self.max_failures: if self.failures >= self.max_failures:
self.open_until = time.time() + self.reset_timeout self.open_until = time.time() + self.reset_timeout
logger.error( logger.error(
"🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) "Circuit opened for '%s' until %s.", name, time.ctime(self.open_until)
) )
def after_hook(self, _: ExecutionContext): def after_hook(self, _: ExecutionContext):
@ -87,4 +87,4 @@ class CircuitBreaker:
def reset(self): def reset(self):
self.failures = 0 self.failures = 0
self.open_until = None self.open_until = None
logger.info("🔄 Circuit reset.") logger.info("Circuit reset.")

View File

@ -11,9 +11,7 @@ TEMPLATE_TASKS = """\
import asyncio import asyncio
import json import json
from falyx.action import Action, ChainedAction from falyx.action import Action, ChainedAction, ShellAction, SelectionAction
from falyx.io_action import ShellAction
from falyx.selection_action import SelectionAction
post_ids = ["1", "2", "3", "4", "5"] post_ids = ["1", "2", "3", "4", "5"]
@ -100,10 +98,10 @@ commands:
aliases: [clean, cleanup] aliases: [clean, cleanup]
""" """
console = Console(color_system="auto") console = Console(color_system="truecolor")
def init_project(name: str = ".") -> None: def init_project(name: str) -> None:
target = Path(name).resolve() target = Path(name).resolve()
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)

View File

@ -2,7 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from falyx.action import BaseAction from prompt_toolkit.formatted_text import FormattedText
from falyx.action.base import BaseAction
from falyx.signals import BackSignal, QuitSignal from falyx.signals import BackSignal, QuitSignal
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import CaseInsensitiveDict from falyx.utils import CaseInsensitiveDict
@ -26,6 +28,12 @@ class MenuOption:
"""Render the menu option for display.""" """Render the menu option for display."""
return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" 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): class MenuOptionMap(CaseInsensitiveDict):
""" """
@ -33,7 +41,7 @@ class MenuOptionMap(CaseInsensitiveDict):
and special signal entries like Quit and Back. and special signal entries like Quit and Back.
""" """
RESERVED_KEYS = {"Q", "B"} RESERVED_KEYS = {"B", "X"}
def __init__( def __init__(
self, self,
@ -49,14 +57,14 @@ class MenuOptionMap(CaseInsensitiveDict):
def _inject_reserved_defaults(self): def _inject_reserved_defaults(self):
from falyx.action import SignalAction from falyx.action import SignalAction
self._add_reserved(
"Q",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
self._add_reserved( self._add_reserved(
"B", "B",
MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW),
) )
self._add_reserved(
"X",
MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED),
)
def _add_reserved(self, key: str, option: MenuOption) -> None: def _add_reserved(self, key: str, option: MenuOption) -> None:
"""Add a reserved key, bypassing validation.""" """Add a reserved key, bypassing validation."""
@ -78,8 +86,20 @@ class MenuOptionMap(CaseInsensitiveDict):
raise ValueError(f"Cannot delete reserved option '{key}'.") raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key) super().__delitem__(key)
def update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, MenuOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
def items(self, include_reserved: bool = True): def items(self, include_reserved: bool = True):
for k, v in super().items(): for key, option in super().items():
if not include_reserved and k in self.RESERVED_KEYS: if not include_reserved and key in self.RESERVED_KEYS:
continue continue
yield k, v yield key, option

0
falyx/parsers/.pytyped Normal file
View File

19
falyx/parsers/__init__.py Normal file
View File

@ -0,0 +1,19 @@
"""
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, get_root_parser, get_subparsers
__all__ = [
"Argument",
"ArgumentAction",
"CommandArgumentParser",
"get_arg_parsers",
"get_root_parser",
"get_subparsers",
"FalyxParsers",
]

View File

@ -1,19 +1,25 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, Iterable from typing import Any, Iterable
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.markup import escape
from rich.text import Text
from falyx.action.base import BaseAction
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers.utils import coerce_value
from falyx.signals import HelpSignal from falyx.signals import HelpSignal
class ArgumentAction(Enum): class ArgumentAction(Enum):
"""Defines the action to be taken when the argument is encountered.""" """Defines the action to be taken when the argument is encountered."""
ACTION = "action"
STORE = "store" STORE = "store"
STORE_TRUE = "store_true" STORE_TRUE = "store_true"
STORE_FALSE = "store_false" STORE_FALSE = "store_false"
@ -22,12 +28,21 @@ class ArgumentAction(Enum):
COUNT = "count" COUNT = "count"
HELP = "help" HELP = "help"
@classmethod
def choices(cls) -> list[ArgumentAction]:
"""Return a list of all argument actions."""
return list(cls)
def __str__(self) -> str:
"""Return the string representation of the argument action."""
return self.value
@dataclass @dataclass
class Argument: class Argument:
"""Represents a command-line argument.""" """Represents a command-line argument."""
flags: list[str] flags: tuple[str, ...]
dest: str # Destination name for the argument dest: str # Destination name for the argument
action: ArgumentAction = ( action: ArgumentAction = (
ArgumentAction.STORE ArgumentAction.STORE
@ -37,8 +52,81 @@ class Argument:
choices: list[str] | None = None # List of valid choices for the argument choices: list[str] | None = None # List of valid choices for the argument
required: bool = False # True if the argument is required required: bool = False # True if the argument is required
help: str = "" # Help text for the argument 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 positional: bool = False # True if no leading - or -- in flags
resolver: BaseAction | None = None # Action object for the argument
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 self.action in (
ArgumentAction.STORE,
ArgumentAction.APPEND,
ArgumentAction.EXTEND,
) or 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
and self.default == other.default
and self.help == other.help
)
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,
self.default,
self.help,
)
)
class CommandArgumentParser: class CommandArgumentParser:
@ -61,22 +149,38 @@ class CommandArgumentParser:
- Render Help using Rich library. - 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_epilog: str = "",
aliases: list[str] | None = None,
) -> None:
"""Initialize the CommandArgumentParser.""" """Initialize the CommandArgumentParser."""
self.command_description: str = "" self.console = Console(color_system="truecolor")
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_epilog: str = help_epilog
self.aliases: list[str] = aliases or []
self._arguments: list[Argument] = [] self._arguments: list[Argument] = []
self._positional: dict[str, Argument] = {}
self._keyword: dict[str, Argument] = {}
self._keyword_list: list[Argument] = []
self._flag_map: dict[str, Argument] = {} self._flag_map: dict[str, Argument] = {}
self._dest_set: set[str] = set() self._dest_set: set[str] = set()
self._add_help() self._add_help()
self.console = Console(color_system="auto")
def _add_help(self): def _add_help(self):
"""Add help argument to the parser.""" """Add help argument to the parser."""
self.add_argument( self.add_argument(
"--help",
"-h", "-h",
"--help",
action=ArgumentAction.HELP, action=ArgumentAction.HELP,
help="Show this help message and exit.", help="Show this help message.",
dest="help", dest="help",
) )
@ -90,9 +194,7 @@ class CommandArgumentParser:
raise CommandArgumentError("Positional arguments cannot have multiple flags") raise CommandArgumentError("Positional arguments cannot have multiple flags")
return positional return positional
def _get_dest_from_flags( def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str:
self, flags: tuple[str, ...], dest: str | None
) -> str | None:
"""Convert flags to a destination name.""" """Convert flags to a destination name."""
if dest: if dest:
if not dest.replace("_", "").isalnum(): if not dest.replace("_", "").isalnum():
@ -121,12 +223,18 @@ class CommandArgumentParser:
return dest return dest
def _determine_required( def _determine_required(
self, required: bool, positional: bool, nargs: int | str self, required: bool, positional: bool, nargs: int | str | None
) -> bool: ) -> bool:
"""Determine if the argument is required.""" """Determine if the argument is required."""
if required: if required:
return True return True
if positional: if positional:
assert (
nargs is None
or isinstance(nargs, int)
or isinstance(nargs, str)
and nargs in ("+", "*", "?")
), f"Invalid nargs value: {nargs}"
if isinstance(nargs, int): if isinstance(nargs, int):
return nargs > 0 return nargs > 0
elif isinstance(nargs, str): elif isinstance(nargs, str):
@ -134,12 +242,27 @@ class CommandArgumentParser:
return True return True
elif nargs in ("*", "?"): elif nargs in ("*", "?"):
return False return False
else: else:
raise CommandArgumentError(f"Invalid nargs value: {nargs}") return True
return required 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:
return None
allowed_nargs = ("?", "*", "+") allowed_nargs = ("?", "*", "+")
if isinstance(nargs, int): if isinstance(nargs, int):
if nargs <= 0: if nargs <= 0:
@ -151,7 +274,9 @@ class CommandArgumentParser:
raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}")
return 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 choices is not None:
if isinstance(choices, dict): if isinstance(choices, dict):
raise CommandArgumentError("choices cannot be a dict") raise CommandArgumentError("choices cannot be a dict")
@ -166,11 +291,11 @@ class CommandArgumentParser:
for choice in choices: for choice in choices:
if not isinstance(choice, expected_type): if not isinstance(choice, expected_type):
try: try:
expected_type(choice) coerce_value(choice, expected_type)
except Exception: except Exception as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}" f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}"
) ) from error
return choices return choices
def _validate_default_type( def _validate_default_type(
@ -179,11 +304,11 @@ class CommandArgumentParser:
"""Validate the default value type.""" """Validate the default value type."""
if default is not None and not isinstance(default, expected_type): if default is not None and not isinstance(default, expected_type):
try: try:
expected_type(default) coerce_value(default, expected_type)
except Exception: except Exception as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
) ) from error
def _validate_default_list_type( def _validate_default_list_type(
self, default: list[Any], expected_type: type, dest: str self, default: list[Any], expected_type: type, dest: str
@ -192,14 +317,57 @@ class CommandArgumentParser:
for item in default: for item in default:
if not isinstance(item, expected_type): if not isinstance(item, expected_type):
try: try:
expected_type(item) coerce_value(item, expected_type)
except Exception: except Exception as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}"
) ) from error
def _validate_resolver(
self, action: ArgumentAction, resolver: BaseAction | None
) -> BaseAction | None:
"""Validate the action object."""
if action != ArgumentAction.ACTION and resolver is None:
return None
elif action == ArgumentAction.ACTION and resolver is None:
raise CommandArgumentError("resolver must be provided for ACTION action")
elif action != ArgumentAction.ACTION and resolver is not None:
raise CommandArgumentError(
f"resolver should not be provided for action {action}"
)
if not isinstance(resolver, BaseAction):
raise CommandArgumentError("resolver must be an instance of BaseAction")
return resolver
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( def _resolve_default(
self, action: ArgumentAction, default: Any, nargs: str | int self,
default: Any,
action: ArgumentAction,
nargs: str | int | None,
) -> Any: ) -> Any:
"""Get the default value for the argument.""" """Get the default value for the argument."""
if default is None: if default is None:
@ -211,6 +379,8 @@ class CommandArgumentParser:
return 0 return 0
elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND):
return [] return []
elif isinstance(nargs, int):
return []
elif nargs in ("+", "*"): elif nargs in ("+", "*"):
return [] return []
else: else:
@ -233,8 +403,26 @@ class CommandArgumentParser:
f"Flag '{flag}' must be a single character or start with '--'" 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,
resolver: BaseAction | None = None,
) -> None:
"""Add an argument to the parser. """Add an argument to the parser.
For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind
of inputs are passed to the `resolver`.
The return value of the `resolver` is used directly (no type coercion is applied).
Validation, structure, and post-processing should be handled within the `resolver`.
Args: Args:
name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx').
action: The action to be taken when the argument is encountered. action: The action to be taken when the argument is encountered.
@ -245,29 +433,22 @@ class CommandArgumentParser:
required: Whether or not the argument is required. required: Whether or not the argument is required.
help: A brief description of the argument. help: A brief description of the argument.
dest: The name of the attribute to be added to the object returned by parse_args(). dest: The name of the attribute to be added to the object returned by parse_args().
resolver: A BaseAction called with optional nargs specified parsed arguments.
""" """
expected_type = type
self._validate_flags(flags) self._validate_flags(flags)
positional = self._is_positional(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: if dest in self._dest_set:
raise CommandArgumentError( raise CommandArgumentError(
f"Destination '{dest}' is already defined.\n" f"Destination '{dest}' is already defined.\n"
"Merging multiple arguments into the same dest (e.g. positional + flagged) " "Merging multiple arguments into the same dest (e.g. positional + flagged) "
"is not supported. Define a unique 'dest' for each argument." "is not supported. Define a unique 'dest' for each argument."
) )
self._dest_set.add(dest) action = self._validate_action(action, positional)
action = kwargs.get("action", ArgumentAction.STORE) resolver = self._validate_resolver(action, resolver)
if not isinstance(action, ArgumentAction): nargs = self._validate_nargs(nargs, action)
try: default = self._resolve_default(default, action, nargs)
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)
if ( if (
action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND)
and default is not None and default is not None
@ -276,14 +457,12 @@ class CommandArgumentParser:
self._validate_default_list_type(default, expected_type, dest) self._validate_default_list_type(default, expected_type, dest)
else: else:
self._validate_default_type(default, expected_type, dest) 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: if default is not None and choices and default not in choices:
raise CommandArgumentError( raise CommandArgumentError(
f"Default value '{default}' not in allowed choices: {choices}" f"Default value '{default}' not in allowed choices: {choices}"
) )
required = self._determine_required( required = self._determine_required(required, positional, nargs)
kwargs.get("required", False), positional, nargs
)
argument = Argument( argument = Argument(
flags=flags, flags=flags,
dest=dest, dest=dest,
@ -292,9 +471,10 @@ class CommandArgumentParser:
default=default, default=default,
choices=choices, choices=choices,
required=required, required=required,
help=kwargs.get("help", ""), help=help,
nargs=nargs, nargs=nargs,
positional=positional, positional=positional,
resolver=resolver,
) )
for flag in flags: for flag in flags:
if flag in self._flag_map: if flag in self._flag_map:
@ -302,21 +482,51 @@ class CommandArgumentParser:
raise CommandArgumentError( raise CommandArgumentError(
f"Flag '{flag}' is already used by argument '{existing.dest}'" f"Flag '{flag}' is already used by argument '{existing.dest}'"
) )
for flag in flags:
self._flag_map[flag] = argument self._flag_map[flag] = argument
if not positional:
self._keyword[flag] = argument
self._dest_set.add(dest)
self._arguments.append(argument) self._arguments.append(argument)
if positional:
self._positional[dest] = argument
else:
self._keyword_list.append(argument)
def get_argument(self, dest: str) -> Argument | None: def get_argument(self, dest: str) -> Argument | None:
return next((a for a in self._arguments if a.dest == dest), 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,
"default": arg.default,
"help": arg.help,
}
)
return defs
def _consume_nargs( def _consume_nargs(
self, args: list[str], start: int, spec: Argument self, args: list[str], start: int, spec: Argument
) -> tuple[list[str], int]: ) -> tuple[list[str], int]:
assert (
spec.nargs is None
or isinstance(spec.nargs, int)
or isinstance(spec.nargs, str)
and spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}"
values = [] values = []
i = start i = start
if isinstance(spec.nargs, int): if isinstance(spec.nargs, int):
# assert i + spec.nargs <= len(
# args
# ), "Not enough arguments provided: shouldn't happen"
values = args[i : i + spec.nargs] values = args[i : i + spec.nargs]
return values, i + spec.nargs return values, i + spec.nargs
elif spec.nargs == "+": elif spec.nargs == "+":
@ -338,10 +548,13 @@ class CommandArgumentParser:
if i < len(args) and not args[i].startswith("-"): if i < len(args) and not args[i].startswith("-"):
return [args[i]], i + 1 return [args[i]], i + 1
return [], i return [], i
else: elif spec.nargs is None:
assert False, "Invalid nargs value: shouldn't happen" if i < len(args) and not args[i].startswith("-"):
return [args[i]], i + 1
return [], i
assert False, "Invalid nargs value: shouldn't happen"
def _consume_all_positional_args( async def _consume_all_positional_args(
self, self,
args: list[str], args: list[str],
result: dict[str, Any], result: dict[str, Any],
@ -361,7 +574,15 @@ class CommandArgumentParser:
remaining = len(args) - i remaining = len(args) - i
min_required = 0 min_required = 0
for next_spec in positional_args[j + 1 :]: for next_spec in positional_args[j + 1 :]:
if isinstance(next_spec.nargs, int): assert (
next_spec.nargs is None
or isinstance(next_spec.nargs, int)
or isinstance(next_spec.nargs, str)
and next_spec.nargs in ("+", "*", "?")
), f"Invalid nargs value: {spec.nargs}"
if next_spec.nargs is None:
min_required += 1
elif isinstance(next_spec.nargs, int):
min_required += next_spec.nargs min_required += next_spec.nargs
elif next_spec.nargs == "+": elif next_spec.nargs == "+":
min_required += 1 min_required += 1
@ -369,23 +590,30 @@ class CommandArgumentParser:
min_required += 0 min_required += 0
elif next_spec.nargs == "*": elif next_spec.nargs == "*":
min_required += 0 min_required += 0
else:
assert False, "Invalid nargs value: shouldn't happen"
slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)]
values, new_i = self._consume_nargs(slice_args, 0, spec) values, new_i = self._consume_nargs(slice_args, 0, spec)
i += new_i i += new_i
try: try:
typed = [spec.type(v) for v in values] typed = [coerce_value(value, spec.type) for value in values]
except Exception: except Exception as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" f"Invalid value for '{spec.dest}': {error}"
) ) from error
if spec.action == ArgumentAction.ACTION:
if spec.action == ArgumentAction.APPEND: assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
try:
result[spec.dest] = await spec.resolver(*typed)
except Exception as error:
raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}"
) from error
elif spec.action == ArgumentAction.APPEND:
assert result.get(spec.dest) is not None, "dest should not be None" assert result.get(spec.dest) is not None, "dest should not be None"
if spec.nargs in (None, 1): if spec.nargs is None:
result[spec.dest].append(typed[0]) result[spec.dest].append(typed[0])
else: else:
result[spec.dest].append(typed) result[spec.dest].append(typed)
@ -401,30 +629,76 @@ class CommandArgumentParser:
consumed_positional_indicies.add(j) consumed_positional_indicies.add(j)
if i < len(args): if i < len(args):
raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}") plural = "s" if len(args[i:]) > 1 else ""
raise CommandArgumentError(
f"Unexpected positional argument{plural}: {', '.join(args[i:])}"
)
return i return i
def parse_args(self, args: list[str] | None = None) -> dict[str, Any]: def _expand_posix_bundling(self, args: list[str]) -> list[str]:
"""Expand POSIX-style bundled arguments into separate arguments."""
expanded = []
for token in args:
if token.startswith("-") and not token.startswith("--") and len(token) > 2:
# POSIX bundle
# e.g. -abc -> -a -b -c
for char in token[1:]:
flag = f"-{char}"
arg = self._flag_map.get(flag)
if not arg:
raise CommandArgumentError(f"Unrecognized option: {flag}")
expanded.append(flag)
else:
expanded.append(token)
return expanded
async def parse_args(
self, args: list[str] | None = None, from_validate: bool = False
) -> dict[str, Any]:
"""Parse Falyx Command arguments.""" """Parse Falyx Command arguments."""
if args is None: if args is None:
args = [] args = []
args = self._expand_posix_bundling(args)
result = {arg.dest: deepcopy(arg.default) for arg in self._arguments} result = {arg.dest: deepcopy(arg.default) for arg in self._arguments}
positional_args = [arg for arg in self._arguments if arg.positional] positional_args = [arg for arg in self._arguments if arg.positional]
consumed_positional_indices: set[int] = set() consumed_positional_indices: set[int] = set()
consumed_indices: set[int] = set() consumed_indices: set[int] = set()
i = 0 i = 0
while i < len(args): while i < len(args):
token = args[i] token = args[i]
if token in self._flag_map: if token in self._keyword:
spec = self._flag_map[token] spec = self._keyword[token]
action = spec.action action = spec.action
if action == ArgumentAction.HELP: if action == ArgumentAction.HELP:
self.render_help() if not from_validate:
self.render_help()
raise HelpSignal() raise HelpSignal()
elif action == ArgumentAction.ACTION:
assert isinstance(
spec.resolver, BaseAction
), "resolver should be an instance of BaseAction"
values, new_i = self._consume_nargs(args, i + 1, spec)
try:
typed_values = [
coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': {error}"
) from error
try:
result[spec.dest] = await spec.resolver(*typed_values)
except Exception as error:
raise CommandArgumentError(
f"[{spec.dest}] Action failed: {error}"
) from error
consumed_indices.update(range(i, new_i))
i = new_i
elif action == ArgumentAction.STORE_TRUE: elif action == ArgumentAction.STORE_TRUE:
result[spec.dest] = True result[spec.dest] = True
consumed_indices.add(i) consumed_indices.add(i)
@ -441,18 +715,15 @@ class CommandArgumentParser:
assert result.get(spec.dest) is not None, "dest should not be None" assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec) values, new_i = self._consume_nargs(args, i + 1, spec)
try: try:
typed_values = [spec.type(value) for value in values] typed_values = [
except ValueError: coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" f"Invalid value for '{spec.dest}': {error}"
) ) from error
if spec.nargs in (None, 1): if spec.nargs is None:
try: result[spec.dest].append(spec.type(values[0]))
result[spec.dest].append(spec.type(values[0]))
except ValueError:
raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}"
)
else: else:
result[spec.dest].append(typed_values) result[spec.dest].append(typed_values)
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
@ -461,21 +732,29 @@ class CommandArgumentParser:
assert result.get(spec.dest) is not None, "dest should not be None" assert result.get(spec.dest) is not None, "dest should not be None"
values, new_i = self._consume_nargs(args, i + 1, spec) values, new_i = self._consume_nargs(args, i + 1, spec)
try: try:
typed_values = [spec.type(value) for value in values] typed_values = [
except ValueError: coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" f"Invalid value for '{spec.dest}': {error}"
) ) from error
result[spec.dest].extend(typed_values) result[spec.dest].extend(typed_values)
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
i = new_i i = new_i
else: else:
values, new_i = self._consume_nargs(args, i + 1, spec) values, new_i = self._consume_nargs(args, i + 1, spec)
try: try:
typed_values = [spec.type(v) for v in values] typed_values = [
except ValueError: coerce_value(value, spec.type) for value in values
]
except ValueError as error:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" f"Invalid value for '{spec.dest}': {error}"
) from error
if not typed_values and spec.nargs not in ("*", "?"):
raise CommandArgumentError(
f"Expected at least one value for '{spec.dest}'"
) )
if ( if (
spec.nargs in (None, 1, "?") spec.nargs in (None, 1, "?")
@ -488,6 +767,9 @@ class CommandArgumentParser:
result[spec.dest] = typed_values result[spec.dest] = typed_values
consumed_indices.update(range(i, new_i)) consumed_indices.update(range(i, new_i))
i = new_i i = new_i
elif token.startswith("-"):
# Handle unrecognized option
raise CommandArgumentError(f"Unrecognized flag: {token}")
else: else:
# Get the next flagged argument index if it exists # Get the next flagged argument index if it exists
next_flagged_index = -1 next_flagged_index = -1
@ -497,8 +779,7 @@ class CommandArgumentParser:
break break
if next_flagged_index == -1: if next_flagged_index == -1:
next_flagged_index = len(args) next_flagged_index = len(args)
args_consumed = await self._consume_all_positional_args(
args_consumed = self._consume_all_positional_args(
args[i:next_flagged_index], args[i:next_flagged_index],
result, result,
positional_args, positional_args,
@ -518,26 +799,22 @@ class CommandArgumentParser:
f"Invalid value for {spec.dest}: must be one of {spec.choices}" f"Invalid value for {spec.dest}: must be one of {spec.choices}"
) )
if spec.action == ArgumentAction.ACTION:
continue
if isinstance(spec.nargs, int) and spec.nargs > 1: if isinstance(spec.nargs, int) and spec.nargs > 1:
if not isinstance(result.get(spec.dest), list): assert isinstance(
raise CommandArgumentError( result.get(spec.dest), list
f"Invalid value for {spec.dest}: expected a list" ), f"Invalid value for {spec.dest}: expected a list"
) if not result[spec.dest] and not spec.required:
continue
if spec.action == ArgumentAction.APPEND: if spec.action == ArgumentAction.APPEND:
if not isinstance(result[spec.dest], list):
raise CommandArgumentError(
f"Invalid value for {spec.dest}: expected a list"
)
for group in result[spec.dest]: for group in result[spec.dest]:
if len(group) % spec.nargs != 0: if len(group) % spec.nargs != 0:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
) )
elif spec.action == ArgumentAction.EXTEND: elif spec.action == ArgumentAction.EXTEND:
if not isinstance(result[spec.dest], list):
raise CommandArgumentError(
f"Invalid value for {spec.dest}: expected a list"
)
if len(result[spec.dest]) % spec.nargs != 0: if len(result[spec.dest]) % spec.nargs != 0:
raise CommandArgumentError( raise CommandArgumentError(
f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}"
@ -550,13 +827,15 @@ class CommandArgumentParser:
result.pop("help", None) result.pop("help", None)
return result return result
def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]: async def parse_args_split(
self, args: list[str], from_validate: bool = False
) -> tuple[tuple[Any, ...], dict[str, Any]]:
""" """
Returns: Returns:
tuple[args, kwargs] - Positional arguments in defined order, tuple[args, kwargs] - Positional arguments in defined order,
followed by keyword argument mapping. followed by keyword argument mapping.
""" """
parsed = self.parse_args(args) parsed = await self.parse_args(args, from_validate)
args_list = [] args_list = []
kwargs_dict = {} kwargs_dict = {}
for arg in self._arguments: for arg in self._arguments:
@ -568,28 +847,102 @@ class CommandArgumentParser:
kwargs_dict[arg.dest] = parsed[arg.dest] kwargs_dict[arg.dest] = parsed[arg.dest]
return tuple(args_list), kwargs_dict return tuple(args_list), kwargs_dict
def render_help(self): def get_options_text(self, plain_text=False) -> str:
table = Table(title=f"{self.command_description} Help") # Options
table.add_column("Flags") # Add all keyword arguments to the options list
table.add_column("Help") options_list = []
for arg in self._arguments: for arg in self._keyword_list:
if arg.dest == "help": choice_text = arg.get_choice_text()
continue if choice_text:
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest options_list.extend([f"[{arg.flags[0]} {choice_text}]"])
table.add_row(flag_str, arg.help or "") else:
table.add_section() options_list.extend([f"[{arg.flags[0]}]"])
arg = self.get_argument("help")
flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest # Add positional arguments to the options list
table.add_row(flag_str, arg.help or "") for arg in self._positional.values():
self.console.print(table) choice_text = arg.get_choice_text()
if isinstance(arg.nargs, int):
choice_text = " ".join([choice_text] * arg.nargs)
if plain_text:
options_list.append(choice_text)
else:
options_list.append(escape(choice_text))
return " ".join(options_list)
def get_command_keys_text(self, plain_text=False) -> str:
if plain_text:
command_keys = " | ".join(
[f"{self.command_key}"] + [f"{alias}" for alias in self.aliases]
)
else:
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
]
)
return command_keys
def get_usage(self, plain_text=False) -> str:
"""Get the usage text for the command."""
command_keys = self.get_command_keys_text(plain_text)
options_text = self.get_options_text(plain_text)
if options_text:
return f"{command_keys} {options_text}"
return command_keys
def render_help(self) -> None:
usage = self.get_usage()
self.console.print(f"[bold]usage: {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.values():
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_list:
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)
# Epilog
if self.help_epilog:
self.console.print("\n" + self.help_epilog, 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: def __str__(self) -> str:
positional = sum(arg.positional for arg in self._arguments) positional = sum(arg.positional for arg in self._arguments)
required = sum(arg.required for arg in self._arguments) required = sum(arg.required for arg in self._arguments)
return ( return (
f"CommandArgumentParser(args={len(self._arguments)}, " f"CommandArgumentParser(args={len(self._arguments)}, "
f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, " f"flags={len(self._flag_map)}, keywords={len(self._keyword)}, "
f"required={required}, positional={positional})" f"positional={positional}, required={required})"
) )
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -2,10 +2,18 @@
"""parsers.py """parsers.py
This module contains the argument parsers used for the Falyx CLI. This module contains the argument parsers used for the Falyx CLI.
""" """
from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction from argparse import (
REMAINDER,
ArgumentParser,
Namespace,
RawDescriptionHelpFormatter,
_SubParsersAction,
)
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any, Sequence from typing import Any, Sequence
from falyx.command import Command
@dataclass @dataclass
class FalyxParsers: class FalyxParsers:
@ -32,7 +40,7 @@ class FalyxParsers:
return self.as_dict().get(name) return self.as_dict().get(name)
def get_arg_parsers( def get_root_parser(
prog: str | None = "falyx", prog: str | None = "falyx",
usage: str | None = None, usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.", description: str | None = "Falyx CLI - Run structured async command workflows.",
@ -47,8 +55,7 @@ def get_arg_parsers(
add_help: bool = True, add_help: bool = True,
allow_abbrev: bool = True, allow_abbrev: bool = True,
exit_on_error: bool = True, exit_on_error: bool = True,
) -> FalyxParsers: ) -> ArgumentParser:
"""Returns the argument parser for the CLI."""
parser = ArgumentParser( parser = ArgumentParser(
prog=prog, prog=prog,
usage=usage, usage=usage,
@ -77,10 +84,91 @@ def get_arg_parsers(
help="Enable default lifecycle debug logging", help="Enable default lifecycle debug logging",
) )
parser.add_argument("--version", action="store_true", help="Show Falyx version") parser.add_argument("--version", action="store_true", help="Show Falyx version")
subparsers = parser.add_subparsers(dest="command") return parser
run_parser = subparsers.add_parser("run", help="Run a specific command")
run_parser.add_argument("name", help="Key, alias, or description of the command") def get_subparsers(
parser: ArgumentParser,
title: str = "Falyx Commands",
description: str | None = "Available commands for the Falyx CLI.",
) -> _SubParsersAction:
"""Create and return a subparsers action for the given parser."""
if not isinstance(parser, ArgumentParser):
raise TypeError("parser must be an instance of ArgumentParser")
subparsers = parser.add_subparsers(
title=title,
description=description,
metavar="COMMAND",
dest="command",
)
return subparsers
def get_arg_parsers(
prog: str | None = "falyx",
usage: str | None = None,
description: str | None = "Falyx CLI - Run structured async command workflows.",
epilog: (
str | None
) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.",
parents: Sequence[ArgumentParser] | None = None,
prefix_chars: str = "-",
fromfile_prefix_chars: str | None = None,
argument_default: Any = None,
conflict_handler: str = "error",
add_help: bool = True,
allow_abbrev: bool = True,
exit_on_error: bool = True,
commands: dict[str, Command] | None = None,
root_parser: ArgumentParser | None = None,
subparsers: _SubParsersAction | None = None,
) -> FalyxParsers:
"""Returns the argument parser for the CLI."""
if root_parser is None:
parser = get_root_parser(
prog=prog,
usage=usage,
description=description,
epilog=epilog,
parents=parents,
prefix_chars=prefix_chars,
fromfile_prefix_chars=fromfile_prefix_chars,
argument_default=argument_default,
conflict_handler=conflict_handler,
add_help=add_help,
allow_abbrev=allow_abbrev,
exit_on_error=exit_on_error,
)
else:
if not isinstance(root_parser, ArgumentParser):
raise TypeError("root_parser must be an instance of ArgumentParser")
parser = root_parser
if subparsers is None:
subparsers = get_subparsers(parser)
if not isinstance(subparsers, _SubParsersAction):
raise TypeError("subparsers must be an instance of _SubParsersAction")
run_description = ["Run a command by its key or alias.\n"]
run_description.append("commands:")
if isinstance(commands, dict):
for command in commands.values():
run_description.append(command.usage)
command_description = command.description or command.help_text
run_description.append(f"{' '*24}{command_description}")
run_epilog = (
"Tip: Use 'falyx run ?[COMMAND]' to preview commands by their key or alias."
)
run_parser = subparsers.add_parser(
"run",
help="Run a specific command",
description="\n".join(run_description),
epilog=run_epilog,
formatter_class=RawDescriptionHelpFormatter,
)
run_parser.add_argument(
"name", help="Run a command by its key or alias", metavar="COMMAND"
)
run_parser.add_argument( run_parser.add_argument(
"--summary", "--summary",
action="store_true", action="store_true",
@ -114,10 +202,11 @@ def get_arg_parsers(
help="Skip confirmation prompts", help="Skip confirmation prompts",
) )
run_group.add_argument( run_parser.add_argument(
"command_args", "command_args",
nargs=REMAINDER, nargs=REMAINDER,
help="Arguments to pass to the command (if applicable)", help="Arguments to pass to the command (if applicable)",
metavar="ARGS",
) )
run_all_parser = subparsers.add_parser( run_all_parser = subparsers.add_parser(
@ -166,6 +255,10 @@ def get_arg_parsers(
"list", help="List all available commands with tags" "list", help="List all available commands with tags"
) )
list_parser.add_argument(
"-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None
)
version_parser = subparsers.add_parser("version", help="Show the Falyx version") version_parser = subparsers.add_parser("version", help="Show the Falyx version")
return FalyxParsers( return FalyxParsers(

View File

@ -0,0 +1,80 @@
import inspect
from typing import Any, Callable
from falyx.logger import logger
def infer_args_from_func(
func: Callable[[Any], Any] | None,
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.
"""
if not callable(func):
logger.debug("Provided argument is not callable: %s", func)
return []
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
if metadata.get("type"):
arg_type = metadata["type"]
else:
arg_type = (
param.annotation
if param.annotation is not inspect.Parameter.empty
else str
)
if isinstance(arg_type, str):
arg_type = 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 | None = None
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

97
falyx/parsers/utils.py Normal file
View File

@ -0,0 +1,97 @@
import types
from datetime import datetime
from enum import EnumMeta
from typing import Any, Literal, Union, get_args, get_origin
from dateutil import parser as date_parser
from falyx.action.base import BaseAction
from falyx.logger import logger
from falyx.parsers.signature import infer_args_from_func
def coerce_bool(value: str) -> bool:
if isinstance(value, bool):
return value
value = value.strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
elif value in {"false", "0", "no", "off"}:
return False
return bool(value)
def coerce_enum(value: Any, enum_type: EnumMeta) -> Any:
if isinstance(value, enum_type):
return value
if isinstance(value, str):
try:
return enum_type[value]
except KeyError:
pass
base_type = type(next(iter(enum_type)).value)
print(base_type)
try:
coerced_value = base_type(value)
return enum_type(coerced_value)
except (ValueError, TypeError):
raise ValueError(f"Value '{value}' could not be coerced to enum type {enum_type}")
def coerce_value(value: str, target_type: type) -> Any:
origin = get_origin(target_type)
args = get_args(target_type)
if origin is Literal:
if value not in args:
raise ValueError(
f"Value '{value}' is not a valid literal for type {target_type}"
)
return value
if isinstance(target_type, types.UnionType) or get_origin(target_type) is Union:
for arg in args:
try:
return coerce_value(value, arg)
except Exception:
continue
raise ValueError(f"Value '{value}' could not be coerced to any of {args!r}")
if isinstance(target_type, EnumMeta):
return coerce_enum(value, target_type)
if target_type is bool:
return coerce_bool(value)
if target_type is datetime:
try:
return date_parser.parse(value)
except ValueError as e:
raise ValueError(f"Value '{value}' could not be parsed as a datetime") from e
return target_type(value)
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, BaseAction):
infer_target, _ = action.get_infer_target()
arg_defs = infer_args_from_func(infer_target, 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

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Awaitable, Protocol, runtime_checkable from typing import Any, Awaitable, Protocol, runtime_checkable
from falyx.action.action import BaseAction from falyx.action.base import BaseAction
@runtime_checkable @runtime_checkable

View File

@ -53,7 +53,7 @@ class RetryHandler:
self.policy.delay = delay self.policy.delay = delay
self.policy.backoff = backoff self.policy.backoff = backoff
self.policy.jitter = jitter self.policy.jitter = jitter
logger.info("🔄 Retry policy enabled: %s", self.policy) logger.info("Retry policy enabled: %s", self.policy)
async def retry_on_error(self, context: ExecutionContext) -> None: async def retry_on_error(self, context: ExecutionContext) -> None:
from falyx.action import Action from falyx.action import Action
@ -67,21 +67,21 @@ class RetryHandler:
last_error = error last_error = error
if not target: if not target:
logger.warning("[%s] ⚠️ No action target. Cannot retry.", name) logger.warning("[%s] No action target. Cannot retry.", name)
return None return None
if not isinstance(target, Action): if not isinstance(target, Action):
logger.warning( logger.warning(
"[%s] RetryHandler only supports only supports Action objects.", name "[%s] RetryHandler only supports only supports Action objects.", name
) )
return None return None
if not getattr(target, "is_retryable", False): if not getattr(target, "is_retryable", False):
logger.warning("[%s] Not retryable.", name) logger.warning("[%s] Not retryable.", name)
return None return None
if not self.policy.enabled: if not self.policy.enabled:
logger.warning("[%s] Retry policy is disabled.", name) logger.warning("[%s] Retry policy is disabled.", name)
return None return None
while retries_done < self.policy.max_retries: while retries_done < self.policy.max_retries:
@ -92,7 +92,7 @@ class RetryHandler:
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
logger.info( logger.info(
"[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...", "[%s] Retrying (%s/%s) in %ss due to '%s'...",
name, name,
retries_done, retries_done,
self.policy.max_retries, self.policy.max_retries,
@ -104,13 +104,13 @@ class RetryHandler:
result = await target.action(*context.args, **context.kwargs) result = await target.action(*context.args, **context.kwargs)
context.result = result context.result = result
context.exception = None context.exception = None
logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done) logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done)
return None return None
except Exception as retry_error: except Exception as retry_error:
last_error = retry_error last_error = retry_error
current_delay *= self.policy.backoff current_delay *= self.policy.backoff
logger.warning( logger.warning(
"[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.", "[%s] Retry attempt %s/%s failed due to '%s'.",
name, name,
retries_done, retries_done,
self.policy.max_retries, self.policy.max_retries,
@ -118,4 +118,4 @@ class RetryHandler:
) )
context.exception = last_error context.exception = last_error
logger.error("[%s] All %s retries failed.", name, self.policy.max_retries) logger.error("[%s] All %s retries failed.", name, self.policy.max_retries)

View File

@ -1,6 +1,7 @@
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
"""retry_utils.py""" """retry_utils.py"""
from falyx.action.action import Action, BaseAction from falyx.action.action import Action
from falyx.action.base import BaseAction
from falyx.hook_manager import HookType from falyx.hook_manager import HookType
from falyx.retry import RetryHandler, RetryPolicy from falyx.retry import RetryHandler, RetryPolicy

View File

@ -10,7 +10,7 @@ from rich.markup import escape
from rich.table import Table from rich.table import Table
from falyx.themes import OneColors from falyx.themes import OneColors
from falyx.utils import chunks from falyx.utils import CaseInsensitiveDict, chunks
from falyx.validators import int_range_validator, key_validator from falyx.validators import int_range_validator, key_validator
@ -32,6 +32,62 @@ class SelectionOption:
return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]"
class SelectionOptionMap(CaseInsensitiveDict):
"""
Manages selection options including validation and reserved key protection.
"""
RESERVED_KEYS: set[str] = set()
def __init__(
self,
options: dict[str, SelectionOption] | None = None,
allow_reserved: bool = False,
):
super().__init__()
self.allow_reserved = allow_reserved
if options:
self.update(options)
def _add_reserved(self, key: str, option: SelectionOption) -> None:
"""Add a reserved key, bypassing validation."""
norm_key = key.upper()
super().__setitem__(norm_key, option)
def __setitem__(self, key: str, option: SelectionOption) -> None:
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
norm_key = key.upper()
if norm_key in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(
f"Key '{key}' is reserved and cannot be used in SelectionOptionMap."
)
super().__setitem__(norm_key, option)
def __delitem__(self, key: str) -> None:
if key.upper() in self.RESERVED_KEYS and not self.allow_reserved:
raise ValueError(f"Cannot delete reserved option '{key}'.")
super().__delitem__(key)
def update(self, other=None, **kwargs):
"""Update the selection options with another dictionary."""
if other:
for key, option in other.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
for key, option in kwargs.items():
if not isinstance(option, SelectionOption):
raise TypeError(f"Value for key '{key}' must be a SelectionOption.")
self[key] = option
def items(self, include_reserved: bool = True):
for k, v in super().items():
if not include_reserved and k in self.RESERVED_KEYS:
continue
yield k, v
def render_table_base( def render_table_base(
title: str, title: str,
*, *,
@ -215,9 +271,9 @@ async def prompt_for_index(
prompt_session: PromptSession | None = None, prompt_session: PromptSession | None = None,
prompt_message: str = "Select an option > ", prompt_message: str = "Select an option > ",
show_table: bool = True, show_table: bool = True,
): ) -> int:
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="truecolor")
if show_table: if show_table:
console.print(table, justify="center") console.print(table, justify="center")
@ -242,7 +298,7 @@ async def prompt_for_selection(
) -> str: ) -> str:
"""Prompt the user to select a key from a set of options. Return the selected key.""" """Prompt the user to select a key from a set of options. Return the selected key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="truecolor")
if show_table: if show_table:
console.print(table, justify="center") console.print(table, justify="center")
@ -295,7 +351,7 @@ async def select_value_from_list(
highlight=highlight, highlight=highlight,
) )
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="truecolor")
selection_index = await prompt_for_index( selection_index = await prompt_for_index(
len(selections) - 1, len(selections) - 1,
@ -320,7 +376,7 @@ async def select_key_from_dict(
) -> Any: ) -> Any:
"""Prompt for a key from a dict, returns the key.""" """Prompt for a key from a dict, returns the key."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="truecolor")
console.print(table, justify="center") console.print(table, justify="center")
@ -345,7 +401,7 @@ async def select_value_from_dict(
) -> Any: ) -> Any:
"""Prompt for a key from a dict, but return the value.""" """Prompt for a key from a dict, but return the value."""
prompt_session = prompt_session or PromptSession() prompt_session = prompt_session or PromptSession()
console = console or Console(color_system="auto") console = console or Console(color_system="truecolor")
console.print(table, justify="center") console.print(table, justify="center")

View File

@ -184,7 +184,7 @@ def setup_logging(
console_handler.setLevel(console_log_level) console_handler.setLevel(console_log_level)
root.addHandler(console_handler) root.addHandler(console_handler)
file_handler = logging.FileHandler(log_filename) file_handler = logging.FileHandler(log_filename, "a", "UTF-8")
file_handler.setLevel(file_log_level) file_handler.setLevel(file_log_level)
if json_log_to_file: if json_log_to_file:
file_handler.setFormatter( file_handler.setFormatter(

View File

@ -1 +1 @@
__version__ = "0.1.28" __version__ = "0.1.50"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "falyx" name = "falyx"
version = "0.1.28" version = "0.1.50"
description = "Reliable and introspectable async CLI action framework." description = "Reliable and introspectable async CLI action framework."
authors = ["Roland Thomas Jr <roland@rtj.dev>"] authors = ["Roland Thomas Jr <roland@rtj.dev>"]
license = "MIT" license = "MIT"
@ -16,6 +16,7 @@ python-json-logger = "^3.3.0"
toml = "^0.10" toml = "^0.10"
pyyaml = "^6.0" pyyaml = "^6.0"
aiohttp = "^3.11" aiohttp = "^3.11"
python-dateutil = "^2.8"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^8.3.5" pytest = "^8.3.5"

View File

@ -0,0 +1,25 @@
import pytest
from falyx.action import Action, ActionFactoryAction, ChainedAction
def make_chain(value) -> ChainedAction:
return ChainedAction(
"test_chain",
[
Action("action1", lambda: value + "_1"),
Action("action2", lambda: value + "_2"),
],
return_list=True,
)
@pytest.mark.asyncio
async def test_action_factory_action():
action = ActionFactoryAction(
name="test_action", factory=make_chain, args=("test_value",)
)
result = await action()
assert result == ["test_value_1", "test_value_2"]

View File

@ -1,7 +1,7 @@
# test_command.py # test_command.py
import pytest import pytest
from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction from falyx.action import Action, BaseIOAction, ChainedAction
from falyx.command import Command from falyx.command import Command
from falyx.execution_registry import ExecutionRegistry as er from falyx.execution_registry import ExecutionRegistry as er
from falyx.retry import RetryPolicy from falyx.retry import RetryPolicy
@ -56,102 +56,6 @@ def test_command_str():
) )
@pytest.mark.parametrize(
"action_factory, expected_requires_input",
[
(lambda: Action(name="normal", action=dummy_action), False),
(lambda: DummyInputAction(name="io"), True),
(
lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]),
True,
),
(
lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]),
True,
),
],
)
def test_command_requires_input_detection(action_factory, expected_requires_input):
action = action_factory()
cmd = Command(key="TEST", description="Test Command", action=action)
assert cmd.requires_input == expected_requires_input
if expected_requires_input:
assert cmd.hidden is True
else:
assert cmd.hidden is False
def test_requires_input_flag_detected_for_baseioaction():
"""Command should automatically detect requires_input=True for BaseIOAction."""
cmd = Command(
key="X",
description="Echo input",
action=DummyInputAction(name="dummy"),
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_requires_input_manual_override():
"""Command manually set requires_input=False should not auto-hide."""
cmd = Command(
key="Y",
description="Custom input command",
action=DummyInputAction(name="dummy"),
requires_input=False,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_default_command_does_not_require_input():
"""Normal Command without IO Action should not require input."""
cmd = Command(
key="Z",
description="Simple action",
action=lambda: 42,
)
assert cmd.requires_input is False
assert cmd.hidden is False
def test_chain_requires_input():
"""If first action in a chain requires input, the command should require input."""
chain = ChainedAction(
name="ChainWithInput",
actions=[
DummyInputAction(name="dummy"),
Action(name="action1", action=lambda: 1),
],
)
cmd = Command(
key="A",
description="Chain with input",
action=chain,
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_group_requires_input():
"""If any action in a group requires input, the command should require input."""
group = ActionGroup(
name="GroupWithInput",
actions=[
Action(name="action1", action=lambda: 1),
DummyInputAction(name="dummy"),
],
)
cmd = Command(
key="B",
description="Group with input",
action=group,
)
assert cmd.requires_input is True
assert cmd.hidden is True
def test_enable_retry(): def test_enable_retry():
"""Command should enable retry if action is an Action and retry is set to True.""" """Command should enable retry if action is an Action and retry is set to True."""
cmd = Command( cmd = Command(

View File

@ -1,102 +1,113 @@
import pytest import pytest
from falyx.argparse import ArgumentAction, CommandArgumentParser
from falyx.exceptions import CommandArgumentError from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
from falyx.signals import HelpSignal from falyx.signals import HelpSignal
def build_parser_and_parse(args, config): async def build_parser_and_parse(args, config):
cap = CommandArgumentParser() cap = CommandArgumentParser()
config(cap) 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): def config(parser):
parser.add_argument("--foo", type=str) 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 assert parsed["foo"] is None
def test_append_multiple_flags(): @pytest.mark.asyncio
async def test_append_multiple_flags():
def config(parser): def config(parser):
parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) 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"] 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): def config(parser):
parser.add_argument("files", nargs="+", type=str) parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1) 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["files"] == ["a", "b", "c"]
assert parsed["mode"] == "prod" assert parsed["mode"] == "prod"
def test_type_validation_failure(): @pytest.mark.asyncio
async def test_type_validation_failure():
def config(parser): def config(parser):
parser.add_argument("--count", type=int) parser.add_argument("--count", type=int)
with pytest.raises(CommandArgumentError): 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): def config(parser):
parser.add_argument("--env", type=str, required=True) parser.add_argument("--env", type=str, required=True)
with pytest.raises(CommandArgumentError): 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): def config(parser):
parser.add_argument("--mode", choices=["dev", "prod"]) parser.add_argument("--mode", choices=["dev", "prod"])
with pytest.raises(CommandArgumentError): 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): def config(parser):
parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE)
parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) 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["debug"] is True
assert parsed["no_debug"] is False assert parsed["no_debug"] is False
parsed = build_parser_and_parse([], config) parsed = await build_parser_and_parse([], config)
print(parsed)
assert parsed["debug"] is False assert parsed["debug"] is False
assert parsed["no_debug"] is True assert parsed["no_debug"] is True
def test_count_action(): @pytest.mark.asyncio
async def test_count_action():
def config(parser): def config(parser):
parser.add_argument("-v", action=ArgumentAction.COUNT) 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 assert parsed["v"] == 3
def test_nargs_star(): @pytest.mark.asyncio
async def test_nargs_star():
def config(parser): def config(parser):
parser.add_argument("args", nargs="*", type=str) 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"] 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): def config(parser):
parser.add_argument("--env", type=str) parser.add_argument("--env", type=str)
parser.add_argument("tasks", nargs="+") 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["env"] == "prod"
assert parsed["tasks"] == ["build", "test"] assert parsed["tasks"] == ["build", "test"]
@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest():
parser.add_argument("-f", "--falyx") parser.add_argument("-f", "--falyx")
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx"] assert arg.flags == ("-f", "--falyx")
def test_add_argument_flag_dest_conflict(): 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") parser.add_argument("-f", "--falyx", "--test", dest="falyx")
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx", "--test"] assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_multiple_flags_dest(): def test_add_argument_multiple_flags_dest():
@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest():
parser.add_argument("-f", "--falyx", "--test") parser.add_argument("-f", "--falyx", "--test")
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["-f", "--falyx", "--test"] assert arg.flags == ("-f", "--falyx", "--test")
def test_add_argument_single_flag_dest(): def test_add_argument_single_flag_dest():
@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest():
parser.add_argument("-f") parser.add_argument("-f")
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "f" assert arg.dest == "f"
assert arg.flags == ["-f"] assert arg.flags == ("-f",)
def test_add_argument_bad_dest(): def test_add_argument_bad_dest():
@ -257,7 +268,7 @@ def test_add_argument_default_value():
parser.add_argument("--falyx", default="default_value") parser.add_argument("--falyx", default="default_value")
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["--falyx"] assert arg.flags == ("--falyx",)
assert arg.default == "default_value" 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") 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() parser = CommandArgumentParser()
# ✅ Choices provided # ✅ Choices provided
parser.add_argument("--falyx", choices=["a", "b", "c"]) parser.add_argument("--falyx", choices=["a", "b", "c"])
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["--falyx"] assert arg.flags == ("--falyx",)
assert arg.choices == ["a", "b", "c"] assert arg.choices == ["a", "b", "c"]
args = parser.parse_args(["--falyx", "a"]) args = await parser.parse_args(["--falyx", "a"])
assert args["falyx"] == "a" assert args["falyx"] == "a"
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
parser.parse_args(["--falyx", "d"]) await parser.parse_args(["--falyx", "d"])
def test_add_argument_choices_invalid(): def test_add_argument_choices_invalid():
@ -333,26 +345,28 @@ def test_add_argument_choices_invalid():
def test_add_argument_bad_nargs(): def test_add_argument_bad_nargs():
parser = CommandArgumentParser() parser = CommandArgumentParser()
# ❌ Invalid nargs value
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs="invalid") parser.add_argument("--falyx", nargs="invalid")
# ❌ Invalid nargs type
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs=123) parser.add_argument("--foo", nargs="123")
# ❌ Invalid nargs type
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
parser.add_argument("--falyx", nargs=None) parser.add_argument("--foo", nargs=[1, 2])
with pytest.raises(CommandArgumentError):
parser.add_argument("--too", action="count", nargs=5)
with pytest.raises(CommandArgumentError):
parser.add_argument("falyx", action="store_true", nargs=5)
def test_add_argument_nargs(): def test_add_argument_nargs():
parser = CommandArgumentParser() parser = CommandArgumentParser()
# ✅ Valid nargs value
parser.add_argument("--falyx", nargs=2) parser.add_argument("--falyx", nargs=2)
arg = parser._arguments[-1] arg = parser._arguments[-1]
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["--falyx"] assert arg.flags == ("--falyx",)
assert arg.nargs == 2 assert arg.nargs == 2
@ -377,56 +391,62 @@ def test_get_argument():
parser.add_argument("--falyx", type=str, default="default_value") parser.add_argument("--falyx", type=str, default="default_value")
arg = parser.get_argument("falyx") arg = parser.get_argument("falyx")
assert arg.dest == "falyx" assert arg.dest == "falyx"
assert arg.flags == ["--falyx"] assert arg.flags == ("--falyx",)
assert arg.default == "default_value" assert arg.default == "default_value"
def test_parse_args_nargs(): @pytest.mark.asyncio
async def test_parse_args_nargs():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str) parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1) parser.add_argument("mode", nargs=1)
parser.add_argument("--action", action="store_true")
args = parser.parse_args(["a", "b", "c"]) args = await parser.parse_args(["a", "b", "c", "--action"])
args = await parser.parse_args(["--action", "a", "b", "c"])
assert args["files"] == ["a", "b"] assert args["files"] == ["a", "b"]
assert args["mode"] == "c" assert args["mode"] == "c"
def test_parse_args_nargs_plus(): @pytest.mark.asyncio
async def test_parse_args_nargs_plus():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str) 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"] assert args["files"] == ["a", "b", "c"]
args = parser.parse_args(["a"]) args = await parser.parse_args(["a"])
assert args["files"] == ["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 = CommandArgumentParser()
parser.add_argument("--files", nargs="+", type=str) 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"] assert args["files"] == ["a", "b", "c"]
args = parser.parse_args(["--files", "a"]) args = await parser.parse_args(["--files", "a"])
print(args) print(args)
assert args["files"] == ["a"] assert args["files"] == ["a"]
args = parser.parse_args([]) args = await parser.parse_args([])
assert args["files"] == [] assert args["files"] == []
def test_parse_args_numbered_nargs(): @pytest.mark.asyncio
async def test_parse_args_numbered_nargs():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str) 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"] assert args["files"] == ["a", "b"]
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
args = parser.parse_args(["a"]) args = await parser.parse_args(["a"])
print(args) print(args)
@ -436,48 +456,53 @@ def test_parse_args_nargs_zero():
parser.add_argument("files", nargs=0, type=str) 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 = CommandArgumentParser()
parser.add_argument("files", nargs=2, type=str) parser.add_argument("files", nargs=2, type=str)
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
parser.add_argument("files", nargs="?", type=str) parser.add_argument("files", nargs="?", type=str)
args = parser.parse_args(["a"]) args = await parser.parse_args(["a"])
assert args["files"] == "a" assert args["files"] == "a"
args = parser.parse_args([]) args = await parser.parse_args([])
assert args["files"] is None assert args["files"] is None
def test_parse_args_nargs_positional(): @pytest.mark.asyncio
async def test_parse_args_nargs_positional():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", nargs="*", type=str) 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"] assert args["files"] == ["a", "b", "c"]
args = parser.parse_args([]) args = await parser.parse_args([])
assert args["files"] == [] assert args["files"] == []
def test_parse_args_nargs_positional_plus(): @pytest.mark.asyncio
async def test_parse_args_nargs_positional_plus():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str) 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"] assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
parser.add_argument("files", nargs="+", type=str) parser.add_argument("files", nargs="+", type=str)
parser.add_argument("mode", nargs=1) parser.add_argument("mode", nargs=1)
@ -485,7 +510,7 @@ def test_parse_args_nargs_multiple_positional():
parser.add_argument("target", nargs="*") parser.add_argument("target", nargs="*")
parser.add_argument("extra", 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["files"] == ["a", "b", "c"]
assert args["mode"] == "d" assert args["mode"] == "d"
assert args["action"] == [] assert args["action"] == []
@ -493,186 +518,311 @@ def test_parse_args_nargs_multiple_positional():
assert args["extra"] == ["e"] assert args["extra"] == ["e"]
with pytest.raises(CommandArgumentError): 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_none():
parser = CommandArgumentParser()
parser.add_argument("numbers", type=int)
parser.add_argument("mode")
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_invalid_positional_arguments():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("numbers", nargs="*", type=int) parser.add_argument("numbers", nargs="*", type=int)
parser.add_argument("mode", nargs=1) parser.add_argument("mode", nargs=1)
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) 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] assert args["numbers"] == [1, 2, 3]
args = parser.parse_args(["--numbers", "1"]) args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [1] assert args["numbers"] == [1]
args = parser.parse_args([]) args = await parser.parse_args([])
assert args["numbers"] == [] assert args["numbers"] == []
def test_parse_args_nargs_append(): @pytest.mark.asyncio
async def test_parse_args_nargs_int_append():
parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"])
assert args["numbers"] == [[1], [2], [3]]
args = await parser.parse_args(["--numbers", "1"])
assert args["numbers"] == [[1]]
args = await parser.parse_args([])
assert args["numbers"] == []
@pytest.mark.asyncio
async def test_parse_args_nargs_append():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*")
parser.add_argument("--mode") parser.add_argument("--mode")
args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) args = await parser.parse_args(["1"])
assert args["numbers"] == [[1, 2, 3], [4, 5]]
args = parser.parse_args(["1"])
assert args["numbers"] == [[1]] assert args["numbers"] == [[1]]
args = parser.parse_args([]) args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"])
assert args["numbers"] == [[1, 2, 3], [4, 5]]
assert args["mode"] == "numbers"
args = await parser.parse_args(["1", "2", "3"])
assert args["numbers"] == [[1, 2, 3]]
args = await parser.parse_args([])
assert args["numbers"] == [] assert args["numbers"] == []
def test_parse_args_append_flagged_invalid_type(): @pytest.mark.asyncio
async def test_parse_args_int_optional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
args = await parser.parse_args(["1"])
assert args["numbers"] == [1]
@pytest.mark.asyncio
async def test_parse_args_int_optional_append_multiple_values():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2"])
@pytest.mark.asyncio
async def test_parse_args_nargs_int_positional_append():
parser = CommandArgumentParser()
parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1)
args = await parser.parse_args(["1"])
assert args["numbers"] == [[1]]
with pytest.raises(CommandArgumentError):
await parser.parse_args(["1", "2", "3"])
parser2 = CommandArgumentParser()
parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2)
args = await parser2.parse_args(["1", "2"])
assert args["numbers"] == [[1, 2]]
with pytest.raises(CommandArgumentError):
await parser2.parse_args(["1", "2", "3"])
@pytest.mark.asyncio
async def test_parse_args_append_flagged_invalid_type():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int)
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) 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"]] assert parsed["item"] == [["a", "b"], ["c", "d"]]
with pytest.raises(CommandArgumentError):
await cap.parse_args(["--item", "a", "b", "--item", "c"])
def test_extend_flattened():
@pytest.mark.asyncio
async def test_extend_flattened():
cap = CommandArgumentParser() cap = CommandArgumentParser()
cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) 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"] assert parsed["value"] == ["x", "y"]
def test_parse_args_split_order(): @pytest.mark.asyncio
async def test_parse_args_split_order():
cap = CommandArgumentParser() cap = CommandArgumentParser()
cap.add_argument("a") cap.add_argument("a")
cap.add_argument("--x") cap.add_argument("--x")
cap.add_argument("b", nargs="*") 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 args == ("1", ["2"])
assert kwargs == {"x": "100"} assert kwargs == {"x": "100"}
def test_help_signal_triggers(): @pytest.mark.asyncio
async def test_help_signal_triggers():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--foo") parser.add_argument("--foo")
with pytest.raises(HelpSignal): 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() parser = CommandArgumentParser()
with pytest.raises(HelpSignal): 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 = CommandArgumentParser()
parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) 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"] assert args["tag"] == ["a", "b", "c"]
def test_extend_nargs_2(): @pytest.mark.asyncio
async def test_extend_nargs_2():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) 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"] assert args["pair"] == ["a", "b", "c", "d"]
def test_extend_nargs_star(): @pytest.mark.asyncio
async def test_extend_nargs_star():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") 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"] assert args["files"] == ["x", "y", "z"]
args = parser.parse_args(["--files"]) args = await parser.parse_args(["--files"])
assert args["files"] == [] assert args["files"] == []
def test_extend_nargs_plus(): @pytest.mark.asyncio
async def test_extend_nargs_plus():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") 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] assert args["inputs"] == [1, 2, 3, 4]
def test_extend_invalid_type(): @pytest.mark.asyncio
async def test_extend_invalid_type():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int)
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
parser.add_argument("--nums", nargs="*", type=int) parser.add_argument("--nums", nargs="*", type=int)
with pytest.raises(CommandArgumentError): 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 = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, 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"] ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"]
) )
assert args["x"] == [["a", "b"], ["c", "d"]] assert args["x"] == [["a", "b"], ["c", "d"]]
assert args["y"] == ["1", "2", "3", "4"] 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 = CommandArgumentParser()
parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2)
parser.add_argument("--y", action=ArgumentAction.EXTEND, 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 # This should raise an error because the last argument is not a valid pair
with pytest.raises(CommandArgumentError): 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): 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 = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") 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"] assert args["files"] == ["a", "b", "c"]
args = parser.parse_args([]) args = await parser.parse_args([])
assert args["files"] == [] assert args["files"] == []
def test_extend_positional_nargs(): @pytest.mark.asyncio
async def test_extend_positional_nargs():
parser = CommandArgumentParser() parser = CommandArgumentParser()
parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") 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"] assert args["files"] == ["a", "b", "c"]
with pytest.raises(CommandArgumentError): with pytest.raises(CommandArgumentError):
parser.parse_args([]) await parser.parse_args([])
def test_command_argument_parser_equality():
parser1 = CommandArgumentParser()
parser2 = CommandArgumentParser()
parser1.add_argument("--foo", type=str)
parser2.add_argument("--foo", type=str)
assert parser1 == parser2
parser1.add_argument("--bar", type=int)
assert parser1 != parser2
parser2.add_argument("--bar", type=int)
assert parser1 == parser2
assert parser1 != "not a parser"
assert parser1 is not None
assert parser1 != object()
assert parser1.to_definition_list() == parser2.to_definition_list()
assert hash(parser1) == hash(parser2)
@pytest.mark.asyncio
async def test_render_help():
parser = CommandArgumentParser()
parser.add_argument("--foo", type=str, help="Foo help")
parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help")
assert parser.render_help() is None

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from falyx.__main__ import bootstrap, find_falyx_config, get_falyx_parsers, run from falyx.__main__ import bootstrap, find_falyx_config, main
def test_find_falyx_config(): def test_find_falyx_config():
@ -50,63 +50,3 @@ def test_bootstrap_with_global_config():
assert str(config_file.parent) in sys.path assert str(config_file.parent) in sys.path
config_file.unlink() config_file.unlink()
sys.path = sys_path_before sys.path = sys_path_before
def test_parse_args():
"""Test if the parse_args function works correctly."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
assert args.command == "init"
assert args.name == "test_project"
args = falyx_parsers.parse_args(["init-global"])
assert args.command == "init-global"
def test_run():
"""Test if the run function works correctly."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
run(args)
assert args.command == "init"
assert args.name == "test_project"
# Check if the project directory was created
assert Path("test_project").exists()
# Clean up
(Path("test_project") / "falyx.yaml").unlink()
(Path("test_project") / "tasks.py").unlink()
Path("test_project").rmdir()
# Test init-global
args = falyx_parsers.parse_args(["init-global"])
run(args)
# Check if the global config directory was created
assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists()
# Clean up
(Path.home() / ".config" / "falyx" / "falyx.yaml").unlink()
(Path.home() / ".config" / "falyx" / "tasks.py").unlink()
(Path.home() / ".config" / "falyx").rmdir()
def test_no_bootstrap():
"""Test if the main function works correctly when no config file is found."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["list"])
assert run(args) is None
# Check if the task was run
assert args.command == "list"
def test_run_test_project():
"""Test if the main function works correctly with a test project."""
falyx_parsers = get_falyx_parsers()
args = falyx_parsers.parse_args(["init", "test_project"])
run(args)
args = falyx_parsers.parse_args(["run", "B"])
os.chdir("test_project")
with pytest.raises(SystemExit):
assert run(args) == "Build complete!"
os.chdir("..")
shutil.rmtree("test_project")
assert not Path("test_project").exists()

View File

@ -0,0 +1,227 @@
import pytest
from falyx.action import Action, SelectionAction
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
def test_add_argument():
"""Test the add_argument method."""
parser = CommandArgumentParser()
action = Action("test_action", lambda: "value")
parser.add_argument(
"test", action=ArgumentAction.ACTION, help="Test argument", resolver=action
)
with pytest.raises(CommandArgumentError):
parser.add_argument("test1", action=ArgumentAction.ACTION, help="Test argument")
with pytest.raises(CommandArgumentError):
parser.add_argument(
"test2",
action=ArgumentAction.ACTION,
help="Test argument",
resolver="Not an action",
)
@pytest.mark.asyncio
async def test_falyx_actions():
"""Test the Falyx actions."""
parser = CommandArgumentParser()
action = Action("test_action", lambda: "value")
parser.add_argument(
"-a",
"--alpha",
action=ArgumentAction.ACTION,
resolver=action,
help="Alpha option",
)
# Test valid cases
args = await parser.parse_args(["-a"])
assert args["alpha"] == "value"
@pytest.mark.asyncio
async def test_action_basic():
parser = CommandArgumentParser()
action = Action("hello", lambda: "hi")
parser.add_argument("--greet", action=ArgumentAction.ACTION, resolver=action)
args = await parser.parse_args(["--greet"])
assert args["greet"] == "hi"
@pytest.mark.asyncio
async def test_action_with_nargs():
parser = CommandArgumentParser()
def multiply(a, b):
return int(a) * int(b)
action = Action("multiply", multiply)
parser.add_argument("--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
args = await parser.parse_args(["--mul", "3", "4"])
assert args["mul"] == 12
@pytest.mark.asyncio
async def test_action_with_nargs_positional():
parser = CommandArgumentParser()
def multiply(a, b):
return int(a) * int(b)
action = Action("multiply", multiply)
parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2)
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3", "4", "5"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--mul", "3", "4"])
@pytest.mark.asyncio
async def test_action_with_nargs_positional_int():
parser = CommandArgumentParser()
def multiply(a, b):
return a * b
action = Action("multiply", multiply)
parser.add_argument(
"mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
)
args = await parser.parse_args(["3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["3"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["abc", "3"])
@pytest.mark.asyncio
async def test_action_with_nargs_type():
parser = CommandArgumentParser()
def multiply(a, b):
return a * b
action = Action("multiply", multiply)
parser.add_argument(
"--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int
)
args = await parser.parse_args(["--mul", "3", "4"])
assert args["mul"] == 12
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--mul", "abc", "3"])
@pytest.mark.asyncio
async def test_action_with_custom_type():
parser = CommandArgumentParser()
def upcase(s):
return s.upper()
action = Action("upcase", upcase)
parser.add_argument("--word", action=ArgumentAction.ACTION, resolver=action, type=str)
args = await parser.parse_args(["--word", "hello"])
assert args["word"] == "HELLO"
@pytest.mark.asyncio
async def test_action_with_nargs_star():
parser = CommandArgumentParser()
def joiner(*args):
return "-".join(args)
action = Action("join", joiner)
parser.add_argument(
"--tags", action=ArgumentAction.ACTION, resolver=action, nargs="*"
)
args = await parser.parse_args(["--tags", "a", "b", "c"])
assert args["tags"] == "a-b-c"
@pytest.mark.asyncio
async def test_action_nargs_plus_missing():
parser = CommandArgumentParser()
action = Action("noop", lambda *args: args)
parser.add_argument("--x", action=ArgumentAction.ACTION, resolver=action, nargs="+")
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--x"])
@pytest.mark.asyncio
async def test_action_with_default():
parser = CommandArgumentParser()
action = Action("default", lambda value: value)
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
args = await parser.parse_args([])
assert args["default"] == "default_value"
@pytest.mark.asyncio
async def test_action_with_default_and_value():
parser = CommandArgumentParser()
action = Action("default", lambda value: value)
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
args = await parser.parse_args(["--default", "new_value"])
assert args["default"] == "new_value"
@pytest.mark.asyncio
async def test_action_with_default_and_value_not():
parser = CommandArgumentParser()
action = Action("default", lambda: "default_value")
parser.add_argument(
"--default",
action=ArgumentAction.ACTION,
resolver=action,
default="default_value",
)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--default", "new_value"])
@pytest.mark.asyncio
async def test_action_with_default_and_value_positional():
parser = CommandArgumentParser()
action = Action("default", lambda: "default_value")
parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action)
with pytest.raises(CommandArgumentError):
await parser.parse_args([])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["be"])
# @pytest.mark.asyncio
# async def test_selection_action():
# parser = CommandArgumentParser()
# action = SelectionAction("select", selections=["a", "b", "c"])
# parser.add_argument("--select", action=ArgumentAction.ACTION, resolver=action)
# args = await parser.parse_args(["--select"])

View File

@ -0,0 +1,90 @@
import pytest
from falyx.parsers import Argument, ArgumentAction
def test_positional_text_with_choices():
arg = Argument(flags=("path",), dest="path", positional=True, choices=["a", "b"])
assert arg.get_positional_text() == "{a,b}"
def test_positional_text_without_choices():
arg = Argument(flags=("path",), dest="path", positional=True)
assert arg.get_positional_text() == "path"
@pytest.mark.parametrize(
"nargs,expected",
[
(None, "VALUE"),
(1, "VALUE"),
("?", "[VALUE]"),
("*", "[VALUE ...]"),
("+", "VALUE [VALUE ...]"),
],
)
def test_choice_text_store_action_variants(nargs, expected):
arg = Argument(
flags=("--value",), dest="value", action=ArgumentAction.STORE, nargs=nargs
)
assert arg.get_choice_text() == expected
@pytest.mark.parametrize(
"nargs,expected",
[
(None, "value"),
(1, "value"),
("?", "[value]"),
("*", "[value ...]"),
("+", "value [value ...]"),
],
)
def test_choice_text_store_action_variants_positional(nargs, expected):
arg = Argument(
flags=("value",),
dest="value",
action=ArgumentAction.STORE,
nargs=nargs,
positional=True,
)
assert arg.get_choice_text() == expected
def test_choice_text_with_choices():
arg = Argument(flags=("--mode",), dest="mode", choices=["dev", "prod"])
assert arg.get_choice_text() == "{dev,prod}"
def test_choice_text_append_and_extend():
for action in [ArgumentAction.APPEND, ArgumentAction.EXTEND]:
arg = Argument(flags=("--tag",), dest="tag", action=action)
assert arg.get_choice_text() == "TAG"
def test_equality():
a1 = Argument(flags=("--f",), dest="f")
a2 = Argument(flags=("--f",), dest="f")
a3 = Argument(flags=("-x",), dest="x")
assert a1 == a2
assert a1 != a3
assert hash(a1) == hash(a2)
def test_inequality_with_non_argument():
arg = Argument(flags=("--f",), dest="f")
assert arg != "not an argument"
def test_argument_equality():
arg = Argument("--foo", dest="foo", type=str, default="default_value")
arg2 = Argument("--foo", dest="foo", type=str, default="default_value")
arg3 = Argument("--bar", dest="bar", type=int, default=42)
arg4 = Argument("--foo", dest="foo", type=str, default="foobar")
assert arg == arg2
assert arg != arg3
assert arg != arg4
assert arg != "not an argument"
assert arg is not None
assert arg != object()

View File

@ -0,0 +1,11 @@
from falyx.parsers import ArgumentAction
def test_argument_action():
action = ArgumentAction.APPEND
assert action == ArgumentAction.APPEND
assert action != ArgumentAction.STORE
assert action != "invalid_action"
assert action.value == "append"
assert str(action) == "append"
assert len(ArgumentAction.choices()) == 8

View File

@ -0,0 +1,49 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parsers import CommandArgumentParser
def test_str():
"""Test the string representation of CommandArgumentParser."""
parser = CommandArgumentParser()
assert (
str(parser)
== "CommandArgumentParser(args=1, flags=2, keywords=2, positional=0, required=0)"
)
parser.add_argument("test", action="store", help="Test argument")
assert (
str(parser)
== "CommandArgumentParser(args=2, flags=3, keywords=2, positional=1, required=1)"
)
parser.add_argument("-o", "--optional", action="store", help="Optional argument")
assert (
str(parser)
== "CommandArgumentParser(args=3, flags=5, keywords=4, positional=1, required=1)"
)
parser.add_argument("--flag", action="store", help="Flag argument", required=True)
assert (
str(parser)
== "CommandArgumentParser(args=4, flags=6, keywords=5, positional=1, required=2)"
)
assert (
repr(parser)
== "CommandArgumentParser(args=4, flags=6, keywords=5, positional=1, required=2)"
)
@pytest.mark.asyncio
async def test_positional_text_with_choices():
parser = CommandArgumentParser()
parser.add_argument("path", choices=["a", "b"])
args = await parser.parse_args(["a"])
assert args["path"] == "a"
with pytest.raises(CommandArgumentError):
await parser.parse_args(["c"])
with pytest.raises(CommandArgumentError):
await parser.parse_args([])

View File

@ -0,0 +1,153 @@
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Literal
import pytest
from falyx.parsers.utils import coerce_value
# --- Tests ---
@pytest.mark.parametrize(
"value, target_type, expected",
[
("42", int, 42),
("3.14", float, 3.14),
("True", bool, True),
("hello", str, "hello"),
("", str, ""),
("False", bool, False),
],
)
def test_coerce_value_basic(value, target_type, expected):
assert coerce_value(value, target_type) == expected
@pytest.mark.parametrize(
"value, target_type, expected",
[
("42", int | float, 42),
("3.14", int | float, 3.14),
("hello", str | int, "hello"),
("1", bool | str, True),
],
)
def test_coerce_value_union_success(value, target_type, expected):
assert coerce_value(value, target_type) == expected
def test_coerce_value_union_failure():
with pytest.raises(ValueError) as excinfo:
coerce_value("abc", int | float)
assert "could not be coerced" in str(excinfo.value)
def test_coerce_value_typing_union_equivalent():
from typing import Union
assert coerce_value("123", Union[int, str]) == 123
assert coerce_value("abc", Union[int, str]) == "abc"
def test_coerce_value_edge_cases():
# int -> raises
with pytest.raises(ValueError):
coerce_value("not-an-int", int | float)
# empty string with str fallback
assert coerce_value("", int | str) == ""
# bool conversion
assert coerce_value("False", bool | str) is False
def test_coerce_value_enum():
class Color(Enum):
RED = "red"
GREEN = "green"
BLUE = "blue"
assert coerce_value("red", Color) == Color.RED
assert coerce_value("green", Color) == Color.GREEN
assert coerce_value("blue", Color) == Color.BLUE
with pytest.raises(ValueError):
coerce_value("yellow", Color) # Not a valid enum value
def test_coerce_value_int_enum():
class Status(Enum):
SUCCESS = 0
FAILURE = 1
PENDING = 2
assert coerce_value("0", Status) == Status.SUCCESS
assert coerce_value(1, Status) == Status.FAILURE
assert coerce_value("PENDING", Status) == Status.PENDING
assert coerce_value(Status.SUCCESS, Status) == Status.SUCCESS
with pytest.raises(ValueError):
coerce_value("3", Status)
with pytest.raises(ValueError):
coerce_value(3, Status)
class Mode(Enum):
DEV = "dev"
PROD = "prod"
def test_literal_coercion():
assert coerce_value("dev", Literal["dev", "prod"]) == "dev"
try:
coerce_value("staging", Literal["dev", "prod"])
assert False
except ValueError:
assert True
def test_enum_coercion():
assert coerce_value("dev", Mode) == Mode.DEV
assert coerce_value("DEV", Mode) == Mode.DEV
try:
coerce_value("staging", Mode)
assert False
except ValueError:
assert True
def test_union_coercion():
assert coerce_value("123", int | str) == 123
assert coerce_value("abc", int | str) == "abc"
assert coerce_value("False", bool | str) is False
def test_path_coercion():
result = coerce_value("/tmp/test.txt", Path)
assert isinstance(result, Path)
assert str(result) == "/tmp/test.txt"
def test_datetime_coercion():
result = coerce_value("2023-10-01T13:00:00", datetime)
assert isinstance(result, datetime)
assert result.year == 2023 and result.month == 10
with pytest.raises(ValueError):
coerce_value("not-a-date", datetime)
def test_bool_coercion():
assert coerce_value("true", bool) is True
assert coerce_value("False", bool) is False
assert coerce_value("0", bool) is False
assert coerce_value("", bool) is False
assert coerce_value("1", bool) is True
assert coerce_value("yes", bool) is True
assert coerce_value("no", bool) is False
assert coerce_value("on", bool) is True
assert coerce_value("off", bool) is False
assert coerce_value(True, bool) is True
assert coerce_value(False, bool) is False

View File

@ -0,0 +1,56 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
@pytest.mark.asyncio
async def test_nargs():
"""Test the nargs argument for command-line arguments."""
parser = CommandArgumentParser()
parser.add_argument(
"-a",
"--alpha",
action=ArgumentAction.STORE,
nargs=2,
help="Alpha option with two arguments",
)
parser.add_argument(
"-b",
"--beta",
action=ArgumentAction.STORE,
nargs="+",
help="Beta option with one or more arguments",
)
parser.add_argument(
"-c",
"--charlie",
action=ArgumentAction.STORE,
nargs="*",
help="Charlie option with zero or more arguments",
)
# Test valid cases
args = await parser.parse_args(["-a", "value1", "value2"])
assert args["alpha"] == ["value1", "value2"]
args = await parser.parse_args(["-b", "value1", "value2", "value3"])
assert args["beta"] == ["value1", "value2", "value3"]
args = await parser.parse_args(["-c", "value1", "value2"])
assert args["charlie"] == ["value1", "value2"]
args = await parser.parse_args(["-c"])
assert args["charlie"] == []
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "value1"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "value1", "value2", "value3"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-b"])

View File

@ -0,0 +1,128 @@
import pytest
from falyx.exceptions import CommandArgumentError
from falyx.parsers import ArgumentAction, CommandArgumentParser
@pytest.mark.asyncio
async def test_posix_bundling():
"""Test the bundling of short options in the POSIX style."""
parser = CommandArgumentParser()
parser.add_argument(
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
)
parser.add_argument(
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
)
parser.add_argument(
"-c", "--charlie", action=ArgumentAction.STORE_TRUE, help="Charlie option"
)
# Test valid bundling
args = await parser.parse_args(["-abc"])
assert args["alpha"] is False
assert args["beta"] is True
assert args["charlie"] is True
@pytest.mark.asyncio
async def test_posix_bundling_last_has_value():
"""Test the bundling of short options in the POSIX style with last option having a value."""
parser = CommandArgumentParser()
parser.add_argument(
"-a", "--alpha", action=ArgumentAction.STORE_TRUE, help="Alpha option"
)
parser.add_argument(
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
)
parser.add_argument(
"-c", "--charlie", action=ArgumentAction.STORE, help="Charlie option"
)
# Test valid bundling with last option having a value
args = await parser.parse_args(["-abc", "value"])
assert args["alpha"] is True
assert args["beta"] is True
assert args["charlie"] == "value"
@pytest.mark.asyncio
async def test_posix_bundling_invalid():
"""Test the bundling of short options in the POSIX style with invalid cases."""
parser = CommandArgumentParser()
parser.add_argument(
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
)
parser.add_argument(
"-b", "--beta", action=ArgumentAction.STORE_TRUE, help="Beta option"
)
parser.add_argument(
"-c", "--charlie", action=ArgumentAction.STORE, help="Charlie option"
)
# Test invalid bundling
args = await parser.parse_args(["-abc", "value"])
assert args["alpha"] is False
assert args["beta"] is True
assert args["charlie"] == "value"
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "value"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-b", "value"])
args = await parser.parse_args(["-c", "value"])
assert args["alpha"] is True
assert args["beta"] is False
assert args["charlie"] == "value"
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-cab", "value"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "-b", "value"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-dbc", "value"])
with pytest.raises(CommandArgumentError):
args = await parser.parse_args(["-c"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-abc"])
@pytest.mark.asyncio
async def test_posix_bundling_fuzz():
"""Test the bundling of short options in the POSIX style with fuzzing."""
parser = CommandArgumentParser()
parser.add_argument(
"-a", "--alpha", action=ArgumentAction.STORE_FALSE, help="Alpha option"
)
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--=value"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["--flag="])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a=b"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["---"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "-b", "-c"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "--", "-b", "-c"])
with pytest.raises(CommandArgumentError):
await parser.parse_args(["-a", "--flag", "-b", "-c"])

View File

@ -1,6 +1,7 @@
import pytest import pytest
from falyx import Action, Falyx from falyx import Falyx
from falyx.action import Action
@pytest.mark.asyncio @pytest.mark.asyncio