Compare commits
	
		
			27 Commits
		
	
	
		
			command-ar
			...
			2d1177e820
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2d1177e820 | |||
| 3c7ef3eb1c | |||
| 53ba6a896a | |||
| b24079ea7e | |||
| ac82076511 | |||
| 09eeb90dc6 | |||
| e3ebc1b17b | |||
| 079bc0ee77 | |||
| 1c97857cb8 | |||
| 21af003bc7 | |||
| 1585098513 | |||
| 3d3a706784 | |||
| c2eb854e5a | |||
| 8a3c1d6cc8 | |||
| f196e38c57 | |||
| fb1ffbe9f6 | |||
| 429b434566 | |||
| 4f3632bc6b | |||
| ba562168aa | |||
| ddb78bd5a7 | |||
| b0c0e7dc16 | |||
| 0a1ba22a3d | |||
| b51ba87999 | |||
| 3c0a81359c | |||
| 4fa6e3bf1f | |||
| afa47b0bac | |||
| 70a527358d | 
| @@ -52,7 +52,8 @@ poetry install | ||||
| import asyncio | ||||
| 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 | ||||
| async def flaky_step(): | ||||
| @@ -62,8 +63,8 @@ async def flaky_step(): | ||||
|     return "ok" | ||||
|  | ||||
| # Create the actions | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
| step1 = Action(name="step_1", action=flaky_step) | ||||
| step2 = Action(name="step_2", action=flaky_step) | ||||
|  | ||||
| # Chain the actions | ||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||
| @@ -74,9 +75,9 @@ falyx.add_command( | ||||
|     key="R", | ||||
|     description="Run My Pipeline", | ||||
|     action=chain, | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
|     retry_all=True, | ||||
| ) | ||||
|  | ||||
| # Entry point | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ActionGroup, ChainedAction | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
|  | ||||
|  | ||||
| # Actions can be defined as synchronous functions | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, Selecti | ||||
| # Selection of a post ID to fetch (just an example set) | ||||
| post_selector = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=["1", "2", "3", "4", "5"], | ||||
|     selections=["15", "25", "35", "45", "55"], | ||||
|     title="Choose a Post ID to submit", | ||||
|     prompt_message="Post ID > ", | ||||
|     show_table=True, | ||||
| @@ -14,7 +14,7 @@ post_selector = SelectionAction( | ||||
|  | ||||
|  | ||||
| # Factory that builds and executes the actual HTTP POST request | ||||
| def build_post_action(post_id) -> HTTPAction: | ||||
| async def build_post_action(post_id) -> HTTPAction: | ||||
|     print(f"Building HTTPAction for Post ID: {post_id}") | ||||
|     return HTTPAction( | ||||
|         name=f"POST to /posts (id={post_id})", | ||||
|   | ||||
							
								
								
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal 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()) | ||||
							
								
								
									
										59
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal 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()) | ||||
| @@ -3,7 +3,7 @@ commands: | ||||
|     description: Pipeline Demo | ||||
|     action: pipeline_demo.pipeline | ||||
|     tags: [pipeline, demo] | ||||
|     help_text: Run Demployment Pipeline with retries. | ||||
|     help_text: Run Deployment Pipeline with retries. | ||||
|  | ||||
|   - key: G | ||||
|     description: Run HTTP Action Group | ||||
|   | ||||
| @@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
| from argparse import Namespace | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | ||||
| from falyx.version import __version__ | ||||
|  | ||||
|  | ||||
| @@ -74,17 +72,10 @@ class Foo: | ||||
|         await self.flx.run() | ||||
|  | ||||
|  | ||||
| def parse_args() -> Namespace: | ||||
|     parsers: FalyxParsers = get_arg_parsers() | ||||
|     return parsers.parse_args() | ||||
|  | ||||
|  | ||||
| async def main() -> None: | ||||
|     """Build and return a Falyx instance with all your commands.""" | ||||
|     args = parse_args() | ||||
|     flx = Falyx( | ||||
|         title="🚀 Falyx CLI", | ||||
|         cli_args=args, | ||||
|         columns=5, | ||||
|         welcome_message="Welcome to Falyx CLI!", | ||||
|         exit_message="Goodbye!", | ||||
|   | ||||
| @@ -6,11 +6,12 @@ from falyx.action.types import FileReturnType | ||||
|  | ||||
| sf = SelectFileAction( | ||||
|     name="select_file", | ||||
|     suffix_filter=".py", | ||||
|     suffix_filter=".yaml", | ||||
|     title="Select a YAML file", | ||||
|     prompt_message="Choose > ", | ||||
|     prompt_message="Choose 2 > ", | ||||
|     return_type=FileReturnType.TEXT, | ||||
|     columns=3, | ||||
|     number_selections=2, | ||||
| ) | ||||
|  | ||||
| flx = Falyx() | ||||
|   | ||||
| @@ -2,9 +2,8 @@ import asyncio | ||||
|  | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx import ActionGroup, Falyx | ||||
| from falyx.action import HTTPAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx import Falyx | ||||
| from falyx.action import ActionGroup, HTTPAction | ||||
| from falyx.hooks import ResultReporter | ||||
|  | ||||
| console = Console() | ||||
| @@ -49,7 +48,7 @@ action_group = ActionGroup( | ||||
| reporter = ResultReporter() | ||||
|  | ||||
| action_group.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -2,8 +2,16 @@ import asyncio | ||||
| import time | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     MenuAction, | ||||
|     ProcessAction, | ||||
|     PromptMenuAction, | ||||
| ) | ||||
| from falyx.menu import MenuOption, MenuOptionMap | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| # Basic coroutine for Action | ||||
| @@ -77,20 +85,28 @@ parallel = ActionGroup( | ||||
|  | ||||
| process = ProcessAction(name="compute", action=heavy_computation) | ||||
|  | ||||
| menu_options = MenuOptionMap( | ||||
|     { | ||||
|         "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), | ||||
|         "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), | ||||
|         "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), | ||||
|         "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| # Menu setup | ||||
|  | ||||
| menu = MenuAction( | ||||
|     name="main-menu", | ||||
|     title="Choose a task to run", | ||||
|     menu_options=MenuOptionMap( | ||||
|         { | ||||
|             "1": MenuOption("Run basic Action", basic_action), | ||||
|             "2": MenuOption("Run ChainedAction", chained), | ||||
|             "3": MenuOption("Run ActionGroup (parallel)", parallel), | ||||
|             "4": MenuOption("Run ProcessAction (heavy task)", process), | ||||
|         } | ||||
|     ), | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
|  | ||||
| prompt_menu = PromptMenuAction( | ||||
|     name="select-user", | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
| flx = Falyx( | ||||
| @@ -108,6 +124,13 @@ flx.add_command( | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="P", | ||||
|     description="Show Prompt Menu", | ||||
|     action=prompt_menu, | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ActionGroup, ChainedAction | ||||
| from falyx import ExecutionRegistry as er | ||||
| from falyx import ProcessAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
|  | ||||
|  | ||||
| @@ -47,7 +45,7 @@ def build_pipeline(): | ||||
|     checkout = Action("Checkout", checkout_code) | ||||
|     analysis = ProcessAction("Static Analysis", run_static_analysis) | ||||
|     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 | ||||
|     deploy_group = ActionGroup( | ||||
|   | ||||
| @@ -1,25 +1,36 @@ | ||||
| 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 | ||||
|  | ||||
| console = Console() | ||||
| falyx = Falyx(title="🚀 Process Pool Demo") | ||||
|  | ||||
|  | ||||
| def generate_primes(n): | ||||
|     primes = [] | ||||
|     for num in range(2, n): | ||||
| def generate_primes(start: int = 2, end: int = 100_000) -> list[int]: | ||||
|     primes: list[int] = [] | ||||
|     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): | ||||
|             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 | ||||
|  | ||||
|  | ||||
| # Will not block the event loop | ||||
| heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,)) | ||||
| actions = [ProcessTask(task=generate_primes)] | ||||
|  | ||||
| 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__": | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action | ||||
|  | ||||
|  | ||||
| async def main(): | ||||
|   | ||||
| @@ -1,22 +1,70 @@ | ||||
| import asyncio | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
| ) | ||||
| from falyx import Falyx | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
| from falyx.signals import CancelSignal | ||||
|  | ||||
| menu = { | ||||
|     "A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")), | ||||
|     "B": SelectionOption("Deploy to staging", lambda: print("Deploying...")), | ||||
| selections = { | ||||
|     "1": SelectionOption( | ||||
|         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", | ||||
|     selections=menu, | ||||
|  | ||||
| select = SelectionAction( | ||||
|     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)) | ||||
| print(f"You selected: {key}") | ||||
| list_selections = [uuid4() for _ in range(10)] | ||||
|  | ||||
| menu[key.upper()].value() | ||||
| list_select = SelectionAction( | ||||
|     name="Select Deployments", | ||||
|     selections=list_selections, | ||||
|     title="Select Deployments", | ||||
|     columns=3, | ||||
|     prompt_message="Select 3 Deployments > ", | ||||
|     return_type="value", | ||||
|     show_table=True, | ||||
|     number_selections=3, | ||||
| ) | ||||
|  | ||||
|  | ||||
| flx = Falyx() | ||||
|  | ||||
| flx.add_command( | ||||
|     key="S", | ||||
|     description="Select a deployment", | ||||
|     action=select, | ||||
|     help_text="Select a deployment from the list", | ||||
| ) | ||||
| flx.add_command( | ||||
|     key="L", | ||||
|     description="Select deployments", | ||||
|     action=list_select, | ||||
|     help_text="Select multiple deployments from the list", | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|  | ||||
|     try: | ||||
|         print(asyncio.run(select())) | ||||
|     except CancelSignal: | ||||
|         print("Selection was cancelled.") | ||||
|  | ||||
|     try: | ||||
|         print(asyncio.run(list_select())) | ||||
|     except CancelSignal: | ||||
|         print("Selection was cancelled.") | ||||
|  | ||||
|     asyncio.run(flx.run()) | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| #!/usr/bin/env python | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx.action import ShellAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction, ShellAction | ||||
| from falyx.hooks import ResultReporter | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| @@ -42,12 +41,12 @@ reporter = ResultReporter() | ||||
|  | ||||
| a1 = Action("a1", a1, inject_last_result=True) | ||||
| a1.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
| a2 = Action("a2", a2, inject_last_result=True) | ||||
| a2.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|   | ||||
							
								
								
									
										100
									
								
								examples/type_validation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								examples/type_validation.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import asyncio | ||||
| from uuid import UUID, uuid4 | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.parser 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()) | ||||
| @@ -7,24 +7,12 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
|  | ||||
| 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 .falyx import Falyx | ||||
| from .hook_manager import HookType | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
|  | ||||
| __all__ = [ | ||||
|     "Action", | ||||
|     "ChainedAction", | ||||
|     "ActionGroup", | ||||
|     "ProcessAction", | ||||
|     "Falyx", | ||||
|     "Command", | ||||
|     "ExecutionContext", | ||||
|     "SharedContext", | ||||
|     "ExecutionRegistry", | ||||
|     "HookType", | ||||
| ] | ||||
|   | ||||
| @@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
| import asyncio | ||||
| import os | ||||
| import sys | ||||
| from argparse import Namespace | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.config import loader | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | ||||
| from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers | ||||
|  | ||||
|  | ||||
| def find_falyx_config() -> Path | None: | ||||
| @@ -39,44 +39,81 @@ def bootstrap() -> Path | None: | ||||
|     return config_path | ||||
|  | ||||
|  | ||||
| def get_falyx_parsers() -> FalyxParsers: | ||||
|     falyx_parsers: FalyxParsers = get_arg_parsers() | ||||
|     init_parser = falyx_parsers.subparsers.add_parser( | ||||
|         "init", help="Create a new Falyx CLI project" | ||||
| def init_config(parser: CommandArgumentParser) -> None: | ||||
|     parser.add_argument( | ||||
|         "name", | ||||
|         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": | ||||
|         from falyx.init import init_project | ||||
|  | ||||
|         init_project(args.name) | ||||
|         return | ||||
|  | ||||
|     if args.command == "init-global": | ||||
|     elif args.command == "init_global": | ||||
|         from falyx.init import 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() | ||||
|     if not bootstrap_path: | ||||
|         print("No Falyx config file found. Exiting.") | ||||
|         return None | ||||
|         from falyx.init import init_global, init_project | ||||
|  | ||||
|     flx: Falyx = loader(bootstrap_path) | ||||
|     return asyncio.run(flx.run()) | ||||
|         flx: Falyx = Falyx() | ||||
|         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(): | ||||
|     parsers = get_falyx_parsers() | ||||
|     args = parsers.parse_args() | ||||
|     run(args) | ||||
|     return asyncio.run( | ||||
|         flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback) | ||||
|     ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
							
								
								
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							| @@ -5,21 +5,22 @@ Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     BaseAction, | ||||
|     ChainedAction, | ||||
|     FallbackAction, | ||||
|     LiteralInputAction, | ||||
|     ProcessAction, | ||||
| ) | ||||
| from .action import Action | ||||
| 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 .io_action import BaseIOAction, ShellAction | ||||
| from .io_action import BaseIOAction | ||||
| from .literal_input_action import LiteralInputAction | ||||
| 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 .selection_action import SelectionAction | ||||
| from .shell_action import ShellAction | ||||
| from .signal_action import SignalAction | ||||
| from .user_input_action import UserInputAction | ||||
|  | ||||
| @@ -40,4 +41,6 @@ __all__ = [ | ||||
|     "FallbackAction", | ||||
|     "LiteralInputAction", | ||||
|     "UserInputAction", | ||||
|     "PromptMenuAction", | ||||
|     "ProcessPoolAction", | ||||
| ] | ||||
|   | ||||
| @@ -1,167 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """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. | ||||
| """ | ||||
| """action.py""" | ||||
| 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 rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.exceptions import EmptyChainError | ||||
| from falyx.action.base import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| 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.options_manager import OptionsManager | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx.themes import OneColors | ||||
| 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): | ||||
|     """ | ||||
|     Action wraps a simple function or coroutine into a standard executable unit. | ||||
| @@ -246,6 +100,13 @@ class Action(BaseAction): | ||||
|         if policy.enabled: | ||||
|             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: | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||
| @@ -268,7 +129,7 @@ class Action(BaseAction): | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             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 | ||||
|             raise | ||||
|         finally: | ||||
| @@ -296,559 +157,6 @@ class Action(BaseAction): | ||||
|         return ( | ||||
|             f"Action(name={self.name!r}, action=" | ||||
|             f"{getattr(self._action, '__name__', repr(self._action))}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             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})" | ||||
|             f"retry={self.retry_policy.enabled}, " | ||||
|             f"rollback={self.rollback is not None})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_factory.py""" | ||||
| from typing import Any | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| 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 | ||||
| @@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction): | ||||
|         *, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         preview_args: tuple[Any, ...] = (), | ||||
|         preview_kwargs: dict[str, Any] | None = None, | ||||
|     ): | ||||
| @@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction): | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.factory = factory | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.preview_args = preview_args | ||||
|         self.preview_kwargs = preview_kwargs or {} | ||||
|  | ||||
| @@ -55,7 +59,12 @@ class ActionFactoryAction(BaseAction): | ||||
|     def factory(self, value: ActionFactoryProtocol): | ||||
|         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: | ||||
|         args = (*self.args, *args) | ||||
|         kwargs = {**self.kwargs, **kwargs} | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=f"{self.name} (factory)", | ||||
| @@ -85,7 +94,7 @@ class ActionFactoryAction(BaseAction): | ||||
|                     ) | ||||
|             if 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) | ||||
|             return context.result | ||||
|         except Exception as error: | ||||
|   | ||||
							
								
								
									
										172
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_group.py""" | ||||
| 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.parser.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
									
								
							
							
						
						
									
										156
									
								
								falyx/action/base.py
									
									
									
									
									
										Normal 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) | ||||
							
								
								
									
										210
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """chained_action.py""" | ||||
| 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})" | ||||
|         ) | ||||
							
								
								
									
										51
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """fallback_action.py""" | ||||
| 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})" | ||||
| @@ -28,7 +28,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None: | ||||
|         if session and should_close: | ||||
|             await session.close() | ||||
|     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): | ||||
|   | ||||
| @@ -16,19 +16,15 @@ Common usage includes shell-like filters, input transformers, or any tool that | ||||
| needs to consume input from another process or pipeline. | ||||
| """ | ||||
| import asyncio | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.base import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| @@ -73,7 +69,6 @@ class BaseIOAction(BaseAction): | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.mode = mode | ||||
|         self._requires_injection = True | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> Any: | ||||
|         raise NotImplementedError | ||||
| @@ -81,23 +76,23 @@ class BaseIOAction(BaseAction): | ||||
|     def to_output(self, result: Any) -> str | bytes: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: | ||||
|         last_result = kwargs.pop(self.inject_into, None) | ||||
|  | ||||
|     async def _resolve_input( | ||||
|         self, args: tuple[Any], kwargs: dict[str, Any] | ||||
|     ) -> str | bytes: | ||||
|         data = await self._read_stdin() | ||||
|         if data: | ||||
|             return self.from_input(data) | ||||
|  | ||||
|         if last_result is not None: | ||||
|             return last_result | ||||
|         if len(args) == 1: | ||||
|             return self.from_input(args[0]) | ||||
|  | ||||
|         if self.inject_last_result and self.shared_context: | ||||
|             return self.shared_context.last_result() | ||||
|  | ||||
|         logger.debug( | ||||
|             "[%s] No input provided and no last result found for injection.", self.name | ||||
|         ) | ||||
|         raise FalyxError("No input provided and no last result to inject.") | ||||
|         return "" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs): | ||||
|         context = ExecutionContext( | ||||
| @@ -117,8 +112,8 @@ class BaseIOAction(BaseAction): | ||||
|                     pass | ||||
|                 result = getattr(self, "_last_result", None) | ||||
|             else: | ||||
|                 parsed_input = await self._resolve_input(kwargs) | ||||
|                 result = await self._run(parsed_input, *args, **kwargs) | ||||
|                 parsed_input = await self._resolve_input(args, kwargs) | ||||
|                 result = await self._run(parsed_input) | ||||
|                 output = self.to_output(result) | ||||
|                 await self._write_stdout(output) | ||||
|             context.result = result | ||||
| @@ -172,85 +167,3 @@ class BaseIOAction(BaseAction): | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|  | ||||
| class ShellAction(BaseIOAction): | ||||
|     """ | ||||
|     ShellAction wraps a shell command template for CLI pipelines. | ||||
|  | ||||
|     This Action takes parsed input (from stdin, literal, or last_result), | ||||
|     substitutes it into the provided shell command template, and executes | ||||
|     the command asynchronously using subprocess. | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
|     - Compatible with ChainedAction and Command.requires_input detection | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
|             raise TypeError( | ||||
|                 f"{self.name} expected str or bytes input, got {type(raw).__name__}" | ||||
|             ) | ||||
|         return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() | ||||
|  | ||||
|     async def _run(self, parsed_input: str) -> str: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             args = shlex.split(command) | ||||
|             result = subprocess.run(args, capture_output=True, text=True, check=True) | ||||
|         else: | ||||
|             result = subprocess.run( | ||||
|                 command, shell=True, text=True, capture_output=True, check=True | ||||
|             ) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|  | ||||
|     def to_output(self, result: str) -> str: | ||||
|         return result | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         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)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," | ||||
|             f" safe_mode={self.safe_mode})" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										49
									
								
								falyx/action/literal_input_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								falyx/action/literal_input_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """literal_input_action.py""" | ||||
| 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})" | ||||
| @@ -7,7 +7,7 @@ from rich.console import Console | ||||
| from rich.table import Table | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| 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 | ||||
| @@ -51,7 +51,10 @@ class MenuAction(BaseAction): | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         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.include_reserved = include_reserved | ||||
|         self.show_table = show_table | ||||
| @@ -73,6 +76,9 @@ class MenuAction(BaseAction): | ||||
|             table.add_row(*row) | ||||
|         return table | ||||
|  | ||||
|     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( | ||||
| @@ -105,7 +111,7 @@ class MenuAction(BaseAction): | ||||
|             key = effective_default | ||||
|             if not self.never_prompt: | ||||
|                 table = self._build_table() | ||||
|                 key = await prompt_for_selection( | ||||
|                 key_ = await prompt_for_selection( | ||||
|                     self.menu_options.keys(), | ||||
|                     table, | ||||
|                     default_selection=self.default_selection, | ||||
| @@ -114,6 +120,10 @@ class MenuAction(BaseAction): | ||||
|                     prompt_message=self.prompt_message, | ||||
|                     show_table=self.show_table, | ||||
|                 ) | ||||
|                 if isinstance(key_, str): | ||||
|                     key = key_ | ||||
|                 else: | ||||
|                     assert False, "Unreachable, MenuAction only supports single selection" | ||||
|             option = self.menu_options[key] | ||||
|             result = await option.action(*args, **kwargs) | ||||
|             context.result = result | ||||
| @@ -121,10 +131,10 @@ class MenuAction(BaseAction): | ||||
|             return result | ||||
|  | ||||
|         except BackSignal: | ||||
|             logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) | ||||
|             logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name) | ||||
|             return None | ||||
|         except QuitSignal: | ||||
|             logger.debug("[%s][QuitSignal] ← Exiting application", self.name) | ||||
|             logger.debug("[%s][QuitSignal] <- Exiting application", self.name) | ||||
|             raise | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|   | ||||
							
								
								
									
										35
									
								
								falyx/action/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								falyx/action/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """mixins.py""" | ||||
| 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 | ||||
							
								
								
									
										130
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """process_action.py""" | ||||
| 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})" | ||||
|         ) | ||||
							
								
								
									
										168
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """process_pool_action.py""" | ||||
| 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.parser.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})" | ||||
|         ) | ||||
							
								
								
									
										137
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal 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'})" | ||||
|         ) | ||||
| @@ -14,7 +14,7 @@ from prompt_toolkit import PromptSession | ||||
| from rich.console import Console | ||||
| 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.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| @@ -25,6 +25,7 @@ from falyx.selection import ( | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| @@ -65,6 +66,9 @@ class SelectFileAction(BaseAction): | ||||
|         style: str = OneColors.WHITE, | ||||
|         suffix_filter: str | None = None, | ||||
|         return_type: FileReturnType | str = FileReturnType.PATH, | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|     ): | ||||
| @@ -75,10 +79,31 @@ class SelectFileAction(BaseAction): | ||||
|         self.prompt_message = prompt_message | ||||
|         self.suffix_filter = suffix_filter | ||||
|         self.style = style | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         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.return_type = self._coerce_return_type(return_type) | ||||
|  | ||||
|     @property | ||||
|     def number_selections(self) -> int | str: | ||||
|         return self._number_selections | ||||
|  | ||||
|     @number_selections.setter | ||||
|     def number_selections(self, value: int | str): | ||||
|         if isinstance(value, int) and value > 0: | ||||
|             self._number_selections: int | str = value | ||||
|         elif isinstance(value, str): | ||||
|             if value not in ("*"): | ||||
|                 raise ValueError("number_selections string must be one of '*'") | ||||
|             self._number_selections = value | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or one of '*'") | ||||
|  | ||||
|     def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: | ||||
|         if isinstance(return_type, FileReturnType): | ||||
|             return return_type | ||||
| @@ -118,9 +143,19 @@ class SelectFileAction(BaseAction): | ||||
|                     description=file.name, value=value, style=self.style | ||||
|                 ) | ||||
|             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 | ||||
|  | ||||
|     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: | ||||
|         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) | ||||
|         context.start_timer() | ||||
| @@ -128,29 +163,46 @@ class SelectFileAction(BaseAction): | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             files = [ | ||||
|                 f | ||||
|                 for f in self.directory.iterdir() | ||||
|                 if f.is_file() | ||||
|                 and (self.suffix_filter is None or f.suffix == self.suffix_filter) | ||||
|                 file | ||||
|                 for file in self.directory.iterdir() | ||||
|                 if file.is_file() | ||||
|                 and (self.suffix_filter is None or file.suffix == self.suffix_filter) | ||||
|             ] | ||||
|             if not files: | ||||
|                 raise FileNotFoundError("No files found in directory.") | ||||
|  | ||||
|             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( | ||||
|                 title=self.title, selections=options, columns=self.columns | ||||
|                 title=self.title, selections=options | cancel_option, columns=self.columns | ||||
|             ) | ||||
|  | ||||
|             key = await prompt_for_selection( | ||||
|                 options.keys(), | ||||
|             keys = await prompt_for_selection( | ||||
|                 (options | cancel_option).keys(), | ||||
|                 table, | ||||
|                 console=self.console, | ||||
|                 prompt_session=self.prompt_session, | ||||
|                 prompt_message=self.prompt_message, | ||||
|                 number_selections=self.number_selections, | ||||
|                 separator=self.separator, | ||||
|                 allow_duplicates=self.allow_duplicates, | ||||
|                 cancel_key=cancel_key, | ||||
|             ) | ||||
|  | ||||
|             result = options[key].value | ||||
|             if isinstance(keys, str): | ||||
|                 if keys == cancel_key: | ||||
|                     raise CancelSignal("User canceled the selection.") | ||||
|                 result = options[keys].value | ||||
|             elif isinstance(keys, list): | ||||
|                 result = [options[key].value for key in keys] | ||||
|  | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
| @@ -176,11 +228,11 @@ class SelectFileAction(BaseAction): | ||||
|         try: | ||||
|             files = list(self.directory.iterdir()) | ||||
|             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] | ||||
|             file_list = tree.add("[dim]Files:[/]") | ||||
|             for f in sample: | ||||
|                 file_list.add(f"[dim]{f.name}[/]") | ||||
|             for file in sample: | ||||
|                 file_list.add(f"[dim]{file.name}[/]") | ||||
|             if len(files) > 10: | ||||
|                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") | ||||
|         except Exception as error: | ||||
|   | ||||
| @@ -6,20 +6,22 @@ from prompt_toolkit import PromptSession | ||||
| from rich.console import Console | ||||
| 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.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     SelectionOptionMap, | ||||
|     prompt_for_index, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
|     render_selection_indexed_table, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
|  | ||||
| class SelectionAction(BaseAction): | ||||
| @@ -34,15 +36,24 @@ class SelectionAction(BaseAction): | ||||
|     def __init__( | ||||
|         self, | ||||
|         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", | ||||
|         columns: int = 5, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         return_key: bool = False, | ||||
|         return_type: SelectionReturnType | str = "value", | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
| @@ -55,18 +66,46 @@ class SelectionAction(BaseAction): | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         # Setter normalizes to correct type, mypy can't infer that | ||||
|         self.selections: list[str] | CaseInsensitiveDict = selections  # type: ignore[assignment] | ||||
|         self.return_key = return_key | ||||
|         self.selections: list[str] | SelectionOptionMap = selections  # type: ignore[assignment] | ||||
|         self.return_type: SelectionReturnType = self._coerce_return_type(return_type) | ||||
|         self.title = title | ||||
|         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.default_selection = default_selection | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_message = prompt_message | ||||
|         self.show_table = show_table | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | CaseInsensitiveDict: | ||||
|     def number_selections(self) -> int | str: | ||||
|         return self._number_selections | ||||
|  | ||||
|     @number_selections.setter | ||||
|     def number_selections(self, value: int | str): | ||||
|         if isinstance(value, int) and value > 0: | ||||
|             self._number_selections: int | str = value | ||||
|         elif isinstance(value, str): | ||||
|             if value not in ("*"): | ||||
|                 raise ValueError("number_selections string must be '*'") | ||||
|             self._number_selections = value | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or '*'") | ||||
|  | ||||
|     def _coerce_return_type( | ||||
|         self, return_type: SelectionReturnType | str | ||||
|     ) -> SelectionReturnType: | ||||
|         if isinstance(return_type, SelectionReturnType): | ||||
|             return return_type | ||||
|         return SelectionReturnType(return_type) | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | SelectionOptionMap: | ||||
|         return self._selections | ||||
|  | ||||
|     @selections.setter | ||||
| @@ -74,17 +113,101 @@ class SelectionAction(BaseAction): | ||||
|         self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] | ||||
|     ): | ||||
|         if isinstance(value, (list, tuple, set)): | ||||
|             self._selections: list[str] | CaseInsensitiveDict = list(value) | ||||
|             self._selections: list[str] | SelectionOptionMap = list(value) | ||||
|         elif isinstance(value, dict): | ||||
|             cid = CaseInsensitiveDict() | ||||
|             cid.update(value) | ||||
|             self._selections = cid | ||||
|             som = SelectionOptionMap() | ||||
|             if all(isinstance(key, str) for key in value) and all( | ||||
|                 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: | ||||
|             raise TypeError( | ||||
|                 "'selections' must be a list[str] or dict[str, SelectionOption], " | ||||
|                 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 | ||||
|  | ||||
|     def _get_result_from_keys(self, keys: str | list[str]) -> Any: | ||||
|         if not isinstance(self.selections, dict): | ||||
|             raise TypeError("Selections must be a dictionary to get result by keys.") | ||||
|         if self.return_type == SelectionReturnType.KEY: | ||||
|             result: Any = keys | ||||
|         elif self.return_type == SelectionReturnType.VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].value for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].value | ||||
|         elif self.return_type == SelectionReturnType.ITEMS: | ||||
|             if isinstance(keys, list): | ||||
|                 result = {key: self.selections[key] for key in keys} | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {keys: self.selections[keys]} | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].description for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].description | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = { | ||||
|                     self.selections[key].description: self.selections[key].value | ||||
|                     for key in keys | ||||
|                 } | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {self.selections[keys].description: self.selections[keys].value} | ||||
|         else: | ||||
|             raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|         return result | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
| @@ -120,51 +243,87 @@ class SelectionAction(BaseAction): | ||||
|         if self.never_prompt and not effective_default: | ||||
|             raise ValueError( | ||||
|                 f"[{self.name}] 'never_prompt' is True but no valid default_selection " | ||||
|                 "was provided." | ||||
|                 "or usable last_result was available." | ||||
|             ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             self.cancel_key = self._find_cancel_key() | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             if isinstance(self.selections, list): | ||||
|                 table = render_selection_indexed_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections, | ||||
|                     selections=self.selections + ["Cancel"], | ||||
|                     columns=self.columns, | ||||
|                     formatter=self.cancel_formatter, | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     indices: int | list[int] = await prompt_for_index( | ||||
|                         len(self.selections), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         console=self.console, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                         number_selections=self.number_selections, | ||||
|                         separator=self.separator, | ||||
|                         allow_duplicates=self.allow_duplicates, | ||||
|                         cancel_key=self.cancel_key, | ||||
|                     ) | ||||
|                 else: | ||||
|                     if effective_default: | ||||
|                         indices = int(effective_default) | ||||
|                     else: | ||||
|                         raise ValueError( | ||||
|                             f"[{self.name}] 'never_prompt' is True but no valid " | ||||
|                             "default_selection was provided." | ||||
|                         ) | ||||
|  | ||||
|                 if indices == int(self.cancel_key): | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|                 if isinstance(indices, list): | ||||
|                     result: str | list[str] = [ | ||||
|                         self.selections[index] for index in indices | ||||
|                     ] | ||||
|                 elif isinstance(indices, int): | ||||
|                     result = self.selections[indices] | ||||
|                 else: | ||||
|                     assert False, "unreachable" | ||||
|             elif isinstance(self.selections, dict): | ||||
|                 cancel_option = { | ||||
|                     self.cancel_key: SelectionOption( | ||||
|                         description="Cancel", value=CancelSignal, style=OneColors.DARK_RED | ||||
|                     ) | ||||
|                 } | ||||
|                 table = render_selection_dict_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections | cancel_option, | ||||
|                     columns=self.columns, | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     index = await prompt_for_index( | ||||
|                         len(self.selections) - 1, | ||||
|                     keys = await prompt_for_selection( | ||||
|                         (self.selections | cancel_option).keys(), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         console=self.console, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                         number_selections=self.number_selections, | ||||
|                         separator=self.separator, | ||||
|                         allow_duplicates=self.allow_duplicates, | ||||
|                         cancel_key=self.cancel_key, | ||||
|                     ) | ||||
|                 else: | ||||
|                     index = effective_default | ||||
|                 result = self.selections[int(index)] | ||||
|             elif isinstance(self.selections, dict): | ||||
|                 table = render_selection_dict_table( | ||||
|                     title=self.title, selections=self.selections, columns=self.columns | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     key = await prompt_for_selection( | ||||
|                         self.selections.keys(), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         console=self.console, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                     ) | ||||
|                 else: | ||||
|                     key = effective_default | ||||
|                 result = key if self.return_key else self.selections[key].value | ||||
|                     keys = effective_default | ||||
|                 if keys == self.cancel_key: | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|  | ||||
|                 result = self._get_result_from_keys(keys) | ||||
|             else: | ||||
|                 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__}" | ||||
|                 ) | ||||
|             context.result = result | ||||
| @@ -203,7 +362,7 @@ class SelectionAction(BaseAction): | ||||
|             return | ||||
|  | ||||
|         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'}") | ||||
|  | ||||
|         if not parent: | ||||
| @@ -218,6 +377,6 @@ class SelectionAction(BaseAction): | ||||
|         return ( | ||||
|             f"SelectionAction(name={self.name!r}, type={selection_type}, " | ||||
|             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'})" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										105
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """shell_action.py | ||||
| Execute shell commands with input substitution.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.io_action import BaseIOAction | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ShellAction(BaseIOAction): | ||||
|     """ | ||||
|     ShellAction wraps a shell command template for CLI pipelines. | ||||
|  | ||||
|     This Action takes parsed input (from stdin, literal, or last_result), | ||||
|     substitutes it into the provided shell command template, and executes | ||||
|     the command asynchronously using subprocess. | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
|             raise TypeError( | ||||
|                 f"{self.name} expected str or bytes input, got {type(raw).__name__}" | ||||
|             ) | ||||
|         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: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             try: | ||||
|                 args = shlex.split(command) | ||||
|             except ValueError as error: | ||||
|                 raise FalyxError(f"Invalid command template: {error}") | ||||
|             result = subprocess.run(args, capture_output=True, text=True, check=True) | ||||
|         else: | ||||
|             result = subprocess.run( | ||||
|                 command, shell=True, text=True, capture_output=True, check=True | ||||
|             ) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|  | ||||
|     def to_output(self, result: str) -> str: | ||||
|         return result | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         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)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," | ||||
|             f" safe_mode={self.safe_mode})" | ||||
|         ) | ||||
| @@ -1,3 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """types.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
| @@ -35,3 +37,18 @@ class FileReturnType(Enum): | ||||
|                     return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         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}") | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """user_input_action.py""" | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.validation import Validator | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action import BaseAction | ||||
| 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 | ||||
| @@ -29,6 +31,7 @@ class UserInputAction(BaseAction): | ||||
|         name: str, | ||||
|         *, | ||||
|         prompt_text: str = "Input > ", | ||||
|         default_text: str = "", | ||||
|         validator: Validator | None = None, | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
| @@ -40,8 +43,15 @@ class UserInputAction(BaseAction): | ||||
|         ) | ||||
|         self.prompt_text = prompt_text | ||||
|         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.default_text = default_text | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> str: | ||||
|         context = ExecutionContext( | ||||
| @@ -61,6 +71,7 @@ class UserInputAction(BaseAction): | ||||
|             answer = await self.prompt_session.prompt_async( | ||||
|                 prompt_text, | ||||
|                 validator=self.validator, | ||||
|                 default=kwargs.get("default_text", self.default_text), | ||||
|             ) | ||||
|             context.result = answer | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|   | ||||
| @@ -30,7 +30,7 @@ class BottomBar: | ||||
|         key_validator: Callable[[str], bool] | None = None, | ||||
|     ) -> None: | ||||
|         self.columns = columns | ||||
|         self.console = Console(color_system="auto") | ||||
|         self.console = Console(color_system="truecolor") | ||||
|         self._named_items: dict[str, Callable[[], HTML]] = {} | ||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||
|         self.toggle_keys: list[str] = [] | ||||
|   | ||||
							
								
								
									
										143
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										143
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -19,7 +19,6 @@ in building robust interactive menus. | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| from functools import cached_property | ||||
| from typing import Any, Callable | ||||
|  | ||||
| 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.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction | ||||
| from falyx.action.io_action import BaseIOAction | ||||
| from falyx.argparse import CommandArgumentParser | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.command_argument_parser import CommandArgumentParser | ||||
| from falyx.parser.signature import infer_args_from_func | ||||
| from falyx.prompt_utils import confirm_async, should_prompt_user | ||||
| from falyx.protocols import ArgParserProtocol | ||||
| from falyx.retry import RetryPolicy | ||||
| @@ -44,7 +44,7 @@ from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
| console = Console(color_system="auto") | ||||
| console = Console(color_system="truecolor") | ||||
|  | ||||
|  | ||||
| class Command(BaseModel): | ||||
| @@ -89,7 +89,11 @@ class Command(BaseModel): | ||||
|         retry_policy (RetryPolicy): Retry behavior configuration. | ||||
|         tags (list[str]): Organizational tags for the command. | ||||
|         logging_hooks (bool): Whether to attach logging hooks automatically. | ||||
|         requires_input (bool | None): Indicates if the action needs input. | ||||
|         options_manager (OptionsManager): Manages global command-line options. | ||||
|         arg_parser (CommandArgumentParser): Parses command arguments. | ||||
|         custom_parser (ArgParserProtocol | None): Custom argument parser. | ||||
|         custom_help (Callable[[], str | None] | None): Custom help message generator. | ||||
|         auto_args (bool): Automatically infer arguments from the action. | ||||
|  | ||||
|     Methods: | ||||
|         __call__(): Executes the command, respecting hooks and retries. | ||||
| @@ -101,12 +105,13 @@ class Command(BaseModel): | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     action: BaseAction | Callable[[], Any] | ||||
|     action: BaseAction | Callable[..., Any] | ||||
|     args: tuple = () | ||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     hidden: bool = False | ||||
|     aliases: list[str] = Field(default_factory=list) | ||||
|     help_text: str = "" | ||||
|     help_epilog: str = "" | ||||
|     style: str = OneColors.WHITE | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
| @@ -122,25 +127,55 @@ class Command(BaseModel): | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     tags: list[str] = Field(default_factory=list) | ||||
|     logging_hooks: bool = False | ||||
|     requires_input: bool | None = None | ||||
|     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_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) | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
|     def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]: | ||||
|         if self.custom_parser: | ||||
|     async def parse_args( | ||||
|         self, raw_args: list[str] | str, from_validate: bool = False | ||||
|     ) -> tuple[tuple, dict]: | ||||
|         if callable(self.custom_parser): | ||||
|             if isinstance(raw_args, str): | ||||
|                 raw_args = shlex.split(raw_args) | ||||
|                 try: | ||||
|                     raw_args = shlex.split(raw_args) | ||||
|                 except ValueError: | ||||
|                     logger.warning( | ||||
|                         "[Command:%s] Failed to split arguments: %s", | ||||
|                         self.key, | ||||
|                         raw_args, | ||||
|                     ) | ||||
|                     return ((), {}) | ||||
|             return self.custom_parser(raw_args) | ||||
|  | ||||
|         if isinstance(raw_args, str): | ||||
|             raw_args = shlex.split(raw_args) | ||||
|         return self.arg_parser.parse_args_split(raw_args) | ||||
|             try: | ||||
|                 raw_args = shlex.split(raw_args) | ||||
|             except ValueError: | ||||
|                 logger.warning( | ||||
|                     "[Command:%s] Failed to split arguments: %s", | ||||
|                     self.key, | ||||
|                     raw_args, | ||||
|                 ) | ||||
|                 return ((), {}) | ||||
|         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") | ||||
|     @classmethod | ||||
| @@ -151,11 +186,26 @@ class Command(BaseModel): | ||||
|             return ensure_async(action) | ||||
|         raise TypeError("Action must be a callable or an instance of BaseAction") | ||||
|  | ||||
|     def get_argument_definitions(self) -> list[dict[str, Any]]: | ||||
|         if self.arguments: | ||||
|             return self.arguments | ||||
|         elif 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: | ||||
|         """Post-initialization to set up the action and hooks.""" | ||||
|         if isinstance(self.arg_parser, CommandArgumentParser): | ||||
|             self.arg_parser.command_description = self.description | ||||
|  | ||||
|         if self.retry and isinstance(self.action, Action): | ||||
|             self.action.enable_retry() | ||||
|         elif self.retry_policy and isinstance(self.action, Action): | ||||
| @@ -177,26 +227,17 @@ class Command(BaseModel): | ||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||
|             register_debug_hooks(self.action.hooks) | ||||
|  | ||||
|         if self.requires_input is None and self.detect_requires_input: | ||||
|             self.requires_input = True | ||||
|             self.hidden = True | ||||
|         elif self.requires_input is None: | ||||
|             self.requires_input = False | ||||
|  | ||||
|     @cached_property | ||||
|     def detect_requires_input(self) -> bool: | ||||
|         """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 | ||||
|         if self.arg_parser is None: | ||||
|             self.arg_parser = CommandArgumentParser( | ||||
|                 command_key=self.key, | ||||
|                 command_description=self.description, | ||||
|                 command_style=self.style, | ||||
|                 help_text=self.help_text, | ||||
|                 help_epilog=self.help_epilog, | ||||
|                 aliases=self.aliases, | ||||
|             ) | ||||
|         elif isinstance(self.action, ActionGroup): | ||||
|             return any(isinstance(action, BaseIOAction) for action in self.action.actions) | ||||
|         return False | ||||
|             for arg_def in self.get_argument_definitions(): | ||||
|                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||
|  | ||||
|     def _inject_options_manager(self) -> None: | ||||
|         """Inject the options manager into the action if applicable.""" | ||||
| @@ -223,7 +264,7 @@ class Command(BaseModel): | ||||
|             if self.preview_before_confirm: | ||||
|                 await self.preview() | ||||
|             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.") | ||||
|  | ||||
|         context.start_timer() | ||||
| @@ -284,13 +325,39 @@ class Command(BaseModel): | ||||
|  | ||||
|         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: | ||||
|         if self._context: | ||||
|             self._context.log_summary() | ||||
|  | ||||
|     def show_help(self) -> bool: | ||||
|         """Display the help message for the command.""" | ||||
|         if self.custom_help: | ||||
|         if callable(self.custom_help): | ||||
|             output = self.custom_help() | ||||
|             if output: | ||||
|                 console.print(output) | ||||
|   | ||||
| @@ -13,14 +13,15 @@ import yaml | ||||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||||
| 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.falyx import Falyx | ||||
| from falyx.logger import logger | ||||
| from falyx.retry import RetryPolicy | ||||
| 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: | ||||
| @@ -98,9 +99,9 @@ class RawCommand(BaseModel): | ||||
|     retry: bool = False | ||||
|     retry_all: bool = False | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     requires_input: bool | None = None | ||||
|     hidden: bool = False | ||||
|     help_text: str = "" | ||||
|     help_epilog: str = "" | ||||
|  | ||||
|     @field_validator("retry_policy") | ||||
|     @classmethod | ||||
| @@ -126,6 +127,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     return commands | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -70,7 +70,7 @@ class ExecutionContext(BaseModel): | ||||
|  | ||||
|     name: str | ||||
|     args: tuple = () | ||||
|     kwargs: dict = {} | ||||
|     kwargs: dict = Field(default_factory=dict) | ||||
|     action: Any | ||||
|     result: Any | None = None | ||||
|     exception: Exception | None = None | ||||
| @@ -80,8 +80,10 @@ class ExecutionContext(BaseModel): | ||||
|     start_wall: datetime | None = None | ||||
|     end_wall: datetime | None = None | ||||
|  | ||||
|     index: int | None = None | ||||
|  | ||||
|     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 | ||||
|  | ||||
| @@ -118,6 +120,17 @@ class ExecutionContext(BaseModel): | ||||
|     def status(self) -> str: | ||||
|         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.action} ({signature})" | ||||
|  | ||||
|     def as_dict(self) -> dict: | ||||
|         return { | ||||
|             "name": self.name, | ||||
| @@ -140,9 +153,9 @@ class ExecutionContext(BaseModel): | ||||
|         message.append(f"Duration: {summary['duration']:.3f}s | ") | ||||
|  | ||||
|         if summary["exception"]: | ||||
|             message.append(f"❌ Exception: {summary['exception']}") | ||||
|             message.append(f"Exception: {summary['exception']}") | ||||
|         else: | ||||
|             message.append(f"✅ Result: {summary['result']}") | ||||
|             message.append(f"Result: {summary['result']}") | ||||
|         (logger or self.console.print)("".join(message)) | ||||
|  | ||||
|     def to_log_line(self) -> str: | ||||
|   | ||||
| @@ -8,9 +8,9 @@ from falyx.logger import logger | ||||
| def log_before(context: ExecutionContext): | ||||
|     """Log the start of an action.""" | ||||
|     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])) | ||||
|     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): | ||||
| @@ -18,18 +18,18 @@ def log_success(context: ExecutionContext): | ||||
|     result_str = repr(context.result) | ||||
|     if len(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): | ||||
|     """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): | ||||
|     """Log an error that occurred during the action.""" | ||||
|     logger.error( | ||||
|         "[%s] ❌ Error (%s): %s", | ||||
|         "[%s] Error (%s): %s", | ||||
|         context.name, | ||||
|         type(context.exception).__name__, | ||||
|         context.exception, | ||||
|   | ||||
| @@ -29,7 +29,8 @@ from __future__ import annotations | ||||
|  | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from typing import Dict, List | ||||
| from threading import Lock | ||||
| from typing import Literal | ||||
|  | ||||
| from rich import box | ||||
| from rich.console import Console | ||||
| @@ -70,23 +71,30 @@ class ExecutionRegistry: | ||||
|         ExecutionRegistry.summary() | ||||
|     """ | ||||
|  | ||||
|     _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) | ||||
|     _store_all: List[ExecutionContext] = [] | ||||
|     _console = Console(color_system="auto") | ||||
|     _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) | ||||
|     _store_by_index: dict[int, ExecutionContext] = {} | ||||
|     _store_all: list[ExecutionContext] = [] | ||||
|     _console = Console(color_system="truecolor") | ||||
|     _index = 0 | ||||
|     _lock = Lock() | ||||
|  | ||||
|     @classmethod | ||||
|     def record(cls, context: ExecutionContext): | ||||
|         """Record an execution context.""" | ||||
|         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_all.append(context) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_all(cls) -> List[ExecutionContext]: | ||||
|     def get_all(cls) -> list[ExecutionContext]: | ||||
|         return cls._store_all | ||||
|  | ||||
|     @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, []) | ||||
|  | ||||
|     @classmethod | ||||
| @@ -97,11 +105,79 @@ class ExecutionRegistry: | ||||
|     def clear(cls): | ||||
|         cls._store_by_name.clear() | ||||
|         cls._store_all.clear() | ||||
|         cls._store_by_index.clear() | ||||
|  | ||||
|     @classmethod | ||||
|     def summary(cls): | ||||
|         table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE) | ||||
|     def summary( | ||||
|         cls, | ||||
|         name: str = "", | ||||
|         index: int | None = None, | ||||
|         result_index: 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_index is not None and result_index >= 0: | ||||
|             try: | ||||
|                 result_context = cls._store_by_index[result_index] | ||||
|             except KeyError: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No execution found for index {result_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("Start", 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("Result / Exception", overflow="fold") | ||||
|  | ||||
|         for ctx in cls.get_all(): | ||||
|         for ctx in contexts: | ||||
|             start = ( | ||||
|                 datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") | ||||
|                 if ctx.start_time | ||||
| @@ -122,15 +198,19 @@ class ExecutionRegistry: | ||||
|             ) | ||||
|             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||
|  | ||||
|             if ctx.exception: | ||||
|                 status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 result = repr(ctx.exception) | ||||
|             if ctx.exception and status.lower() in ["all", "error"]: | ||||
|                 final_status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 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: | ||||
|                 status = f"[{OneColors.GREEN}]✅ Success" | ||||
|                 result = repr(ctx.result) | ||||
|                 if len(result) > 1000: | ||||
|                     result = f"{result[:1000]}..." | ||||
|                 continue | ||||
|  | ||||
|             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) | ||||
|   | ||||
							
								
								
									
										462
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										462
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -25,7 +25,7 @@ import asyncio | ||||
| import logging | ||||
| import shlex | ||||
| import sys | ||||
| from argparse import Namespace | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from difflib import get_close_matches | ||||
| from enum import Enum | ||||
| from functools import cached_property | ||||
| @@ -42,7 +42,8 @@ from rich.console import Console | ||||
| from rich.markdown import Markdown | ||||
| 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.command import Command | ||||
| 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.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parsers import get_arg_parsers | ||||
| from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers | ||||
| from falyx.protocols import ArgParserProtocol | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal | ||||
| 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__ | ||||
|  | ||||
|  | ||||
| @@ -82,14 +84,26 @@ class CommandValidator(Validator): | ||||
|         self.error_message = error_message | ||||
|  | ||||
|     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 | ||||
|         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: | ||||
|             return None | ||||
|         if not choice: | ||||
|             raise ValidationError( | ||||
|                 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 | ||||
|     - History tracking, help generation, and run key execution modes | ||||
|     - 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 | ||||
|  | ||||
|     Args: | ||||
| @@ -125,7 +141,7 @@ class Falyx: | ||||
|         never_prompt (bool): Seed default for `OptionsManager["never_prompt"]` | ||||
|         force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` | ||||
|         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 | ||||
|                                                                 generator. | ||||
|  | ||||
| @@ -145,6 +161,12 @@ class Falyx: | ||||
|         self, | ||||
|         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 = "> ", | ||||
|         columns: int = 3, | ||||
|         bottom_bar: BottomBar | str | Callable[[], Any] | None = None, | ||||
| @@ -157,11 +179,18 @@ class Falyx: | ||||
|         force_confirm: bool = False, | ||||
|         cli_args: Namespace | None = None, | ||||
|         options: OptionsManager | None = None, | ||||
|         render_menu: Callable[["Falyx"], None] | None = None, | ||||
|         custom_table: Callable[["Falyx"], Table] | Table | None = None, | ||||
|         render_menu: Callable[[Falyx], None] | None = None, | ||||
|         custom_table: Callable[[Falyx], Table] | Table | None = None, | ||||
|         hide_menu_table: bool = False, | ||||
|     ) -> None: | ||||
|         """Initializes the Falyx object.""" | ||||
|         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.columns: int = columns | ||||
|         self.commands: dict[str, Command] = CaseInsensitiveDict() | ||||
| @@ -172,7 +201,7 @@ class Falyx: | ||||
|         self.help_command: Command | 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.exit_message: str | Markdown | dict[str, Any] = exit_message | ||||
|         self.hooks: HookManager = HookManager() | ||||
| @@ -182,8 +211,9 @@ class Falyx: | ||||
|         self._never_prompt: bool = never_prompt | ||||
|         self._force_confirm: bool = force_confirm | ||||
|         self.cli_args: Namespace | None = cli_args | ||||
|         self.render_menu: Callable[["Falyx"], None] | None = render_menu | ||||
|         self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table | ||||
|         self.render_menu: Callable[[Falyx], None] | None = render_menu | ||||
|         self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table | ||||
|         self._hide_menu_table: bool = hide_menu_table | ||||
|         self.validate_options(cli_args, options) | ||||
|         self._prompt_session: PromptSession | None = None | ||||
|         self.mode = FalyxMode.MENU | ||||
| @@ -265,72 +295,123 @@ class Falyx: | ||||
|             action=Action("Exit", action=_noop), | ||||
|             aliases=["EXIT", "QUIT"], | ||||
|             style=OneColors.DARK_RED, | ||||
|             simple_help_signature=True, | ||||
|         ) | ||||
|  | ||||
|     def _get_history_command(self) -> Command: | ||||
|         """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, | ||||
|             dest="result_index", | ||||
|             help="Get the result by index", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "-l", "--last-result", action="store_true", help="Get the last result" | ||||
|         ) | ||||
|         return Command( | ||||
|             key="Y", | ||||
|             description="History", | ||||
|             aliases=["HISTORY"], | ||||
|             action=Action(name="View Execution History", action=er.summary), | ||||
|             style=OneColors.DARK_YELLOW, | ||||
|             simple_help_signature=True, | ||||
|             arg_parser=parser, | ||||
|             help_text="View the execution history of commands.", | ||||
|         ) | ||||
|  | ||||
|     async def _show_help(self): | ||||
|         table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE) | ||||
|         table.add_column("Key", style="bold", no_wrap=True) | ||||
|         table.add_column("Aliases", style="dim", no_wrap=True) | ||||
|         table.add_column("Description", style="dim", overflow="fold") | ||||
|         table.add_column("Tags", style="dim", no_wrap=True) | ||||
|  | ||||
|         for command in self.commands.values(): | ||||
|             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 "", | ||||
|     async def _show_help(self, tag: str = "") -> None: | ||||
|         if tag: | ||||
|             table = Table( | ||||
|                 title=tag.upper(), | ||||
|                 title_justify="left", | ||||
|                 show_header=False, | ||||
|                 box=box.SIMPLE, | ||||
|                 show_footer=False, | ||||
|             ) | ||||
|  | ||||
|         table.add_row( | ||||
|             f"[{self.exit_command.style}]{self.exit_command.key}[/]", | ||||
|             ", ".join(self.exit_command.aliases), | ||||
|             "Exit this menu or program", | ||||
|         ) | ||||
|  | ||||
|         if self.history_command: | ||||
|             table.add_row( | ||||
|                 f"[{self.history_command.style}]{self.history_command.key}[/]", | ||||
|                 ", ".join(self.history_command.aliases), | ||||
|                 "History of executed actions", | ||||
|             tag_lower = tag.lower() | ||||
|             commands = [ | ||||
|                 command | ||||
|                 for command in self.commands.values() | ||||
|                 if any(tag_lower == tag.lower() for tag in command.tags) | ||||
|             ] | ||||
|             for command in commands: | ||||
|                 table.add_row(command.help_signature) | ||||
|             self.console.print(table) | ||||
|             return | ||||
|         else: | ||||
|             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: | ||||
|             table.add_row( | ||||
|                 f"[{self.help_command.style}]{self.help_command.key}[/]", | ||||
|                 ", ".join(self.help_command.aliases), | ||||
|                 "Show this help menu", | ||||
|             ) | ||||
|  | ||||
|         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", | ||||
|             ) | ||||
|             table.add_row(self.help_command.help_signature) | ||||
|         if self.history_command: | ||||
|             table.add_row(self.history_command.help_signature) | ||||
|         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) | ||||
|  | ||||
|     def _get_help_command(self) -> Command: | ||||
|         """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( | ||||
|             key="H", | ||||
|             aliases=["HELP", "?"], | ||||
|             aliases=["?", "HELP", "LIST"], | ||||
|             description="Help", | ||||
|             help_text="Show this help menu", | ||||
|             action=Action("Help", self._show_help), | ||||
|             style=OneColors.LIGHT_YELLOW, | ||||
|             arg_parser=parser, | ||||
|         ) | ||||
|  | ||||
|     def _get_completer(self) -> WordCompleter: | ||||
| @@ -443,7 +524,7 @@ class Falyx: | ||||
|                 validator=CommandValidator(self, self._get_validator_error_message()), | ||||
|                 bottom_toolbar=self._get_bottom_bar_render(), | ||||
|                 key_bindings=self.key_bindings, | ||||
|                 validate_while_typing=False, | ||||
|                 validate_while_typing=True, | ||||
|             ) | ||||
|         return self._prompt_session | ||||
|  | ||||
| @@ -524,7 +605,7 @@ class Falyx: | ||||
|         key: str = "X", | ||||
|         description: str = "Exit", | ||||
|         aliases: list[str] | None = None, | ||||
|         action: Callable[[], Any] | None = None, | ||||
|         action: Callable[..., Any] | None = None, | ||||
|         style: str = OneColors.DARK_RED, | ||||
|         confirm: bool = False, | ||||
|         confirm_message: str = "Are you sure?", | ||||
| @@ -551,7 +632,9 @@ class Falyx: | ||||
|         if not isinstance(submenu, Falyx): | ||||
|             raise NotAFalyxError("submenu must be an instance of Falyx.") | ||||
|         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": | ||||
|             submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) | ||||
|  | ||||
| @@ -578,13 +661,14 @@ class Falyx: | ||||
|         self, | ||||
|         key: str, | ||||
|         description: str, | ||||
|         action: BaseAction | Callable[[], Any], | ||||
|         action: BaseAction | Callable[..., Any], | ||||
|         *, | ||||
|         args: tuple = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hidden: bool = False, | ||||
|         aliases: list[str] | None = None, | ||||
|         help_text: str = "", | ||||
|         help_epilog: str = "", | ||||
|         style: str = OneColors.WHITE, | ||||
|         confirm: bool = False, | ||||
|         confirm_message: str = "Are you sure?", | ||||
| @@ -605,10 +689,25 @@ class Falyx: | ||||
|         retry: bool = False, | ||||
|         retry_all: bool = False, | ||||
|         retry_policy: RetryPolicy | None = None, | ||||
|         requires_input: bool | None = None, | ||||
|         arg_parser: CommandArgumentParser | None = None, | ||||
|         arguments: list[dict[str, Any]] | None = None, | ||||
|         argument_config: Callable[[CommandArgumentParser], None] | None = None, | ||||
|         custom_parser: ArgParserProtocol | None = None, | ||||
|         custom_help: Callable[[], str | None] | None = None, | ||||
|         auto_args: bool = True, | ||||
|         arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
|         simple_help_signature: bool = False, | ||||
|     ) -> Command: | ||||
|         """Adds an command to the menu, preventing duplicates.""" | ||||
|         self._validate_command_key(key) | ||||
|  | ||||
|         if arg_parser: | ||||
|             if not isinstance(arg_parser, CommandArgumentParser): | ||||
|                 raise NotAFalyxError( | ||||
|                     "arg_parser must be an instance of CommandArgumentParser." | ||||
|                 ) | ||||
|             arg_parser = arg_parser | ||||
|  | ||||
|         command = Command( | ||||
|             key=key, | ||||
|             description=description, | ||||
| @@ -618,6 +717,7 @@ class Falyx: | ||||
|             hidden=hidden, | ||||
|             aliases=aliases if aliases else [], | ||||
|             help_text=help_text, | ||||
|             help_epilog=help_epilog, | ||||
|             style=style, | ||||
|             confirm=confirm, | ||||
|             confirm_message=confirm_message, | ||||
| @@ -632,8 +732,15 @@ class Falyx: | ||||
|             retry=retry, | ||||
|             retry_all=retry_all, | ||||
|             retry_policy=retry_policy or RetryPolicy(), | ||||
|             requires_input=requires_input, | ||||
|             options_manager=self.options, | ||||
|             arg_parser=arg_parser, | ||||
|             arguments=arguments or [], | ||||
|             argument_config=argument_config, | ||||
|             custom_parser=custom_parser, | ||||
|             custom_help=custom_help, | ||||
|             auto_args=auto_args, | ||||
|             arg_metadata=arg_metadata or {}, | ||||
|             simple_help_signature=simple_help_signature, | ||||
|         ) | ||||
|  | ||||
|         if hooks: | ||||
| @@ -658,16 +765,16 @@ class Falyx: | ||||
|     def get_bottom_row(self) -> list[str]: | ||||
|         """Returns the bottom row of the table for displaying additional commands.""" | ||||
|         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: | ||||
|             bottom_row.append( | ||||
|                 f"[{self.help_command.key}] [{self.help_command.style}]" | ||||
|                 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( | ||||
|             f"[{self.exit_command.key}] [{self.exit_command.style}]" | ||||
|             f"{self.exit_command.description}" | ||||
| @@ -679,7 +786,7 @@ class Falyx: | ||||
|         Build the standard table layout. Developers can subclass or call this | ||||
|         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] | ||||
|         for chunk in chunks(visible_commands, self.columns): | ||||
|             row = [] | ||||
| @@ -695,7 +802,12 @@ class Falyx: | ||||
|     def table(self) -> Table: | ||||
|         """Creates or returns a custom table to display the menu commands.""" | ||||
|         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): | ||||
|             return self.custom_table | ||||
|         else: | ||||
| @@ -706,7 +818,7 @@ class Falyx: | ||||
|             return True, input_str[1:].strip() | ||||
|         return False, input_str.strip() | ||||
|  | ||||
|     def get_command( | ||||
|     async def get_command( | ||||
|         self, raw_choices: str, from_validate=False | ||||
|     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: | ||||
|         """ | ||||
| @@ -715,13 +827,16 @@ class Falyx: | ||||
|         """ | ||||
|         args = () | ||||
|         kwargs: dict[str, Any] = {} | ||||
|         choice, *input_args = shlex.split(raw_choices) | ||||
|         try: | ||||
|             choice, *input_args = shlex.split(raw_choices) | ||||
|         except ValueError: | ||||
|             return False, None, args, kwargs | ||||
|         is_preview, choice = self.parse_preview_command(choice) | ||||
|         if is_preview and not choice and self.help_command: | ||||
|             is_preview = False | ||||
|             choice = "?" | ||||
|         elif is_preview and not choice: | ||||
|             # No help command enabled | ||||
|             # No help (list) command enabled | ||||
|             if not from_validate: | ||||
|                 self.console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode." | ||||
| @@ -730,29 +845,39 @@ class Falyx: | ||||
|  | ||||
|         choice = choice.upper() | ||||
|         name_map = self._name_map | ||||
|         if choice in name_map: | ||||
|             if not from_validate: | ||||
|                 logger.info("Command '%s' selected.", choice) | ||||
|             if input_args and name_map[choice].arg_parser: | ||||
|                 try: | ||||
|                     args, kwargs = name_map[choice].parse_args(input_args) | ||||
|                 except CommandArgumentError as error: | ||||
|                     if not from_validate: | ||||
|                         if not name_map[choice].show_help(): | ||||
|                             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 | ||||
|         run_command = None | ||||
|         if name_map.get(choice): | ||||
|             run_command = name_map[choice] | ||||
|         else: | ||||
|             prefix_matches = [ | ||||
|                 cmd for key, cmd in name_map.items() if key.startswith(choice) | ||||
|             ] | ||||
|             if len(prefix_matches) == 1: | ||||
|                 run_command = prefix_matches[0] | ||||
|  | ||||
|         prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)] | ||||
|         if len(prefix_matches) == 1: | ||||
|             return is_preview, prefix_matches[0], args, kwargs | ||||
|         if run_command: | ||||
|             if not from_validate: | ||||
|                 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) | ||||
|         if fuzzy_matches: | ||||
| @@ -761,22 +886,35 @@ class Falyx: | ||||
|                     f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. " | ||||
|                     "Did you mean:" | ||||
|                 ) | ||||
|             for match in fuzzy_matches: | ||||
|                 cmd = name_map[match] | ||||
|                 self.console.print(f"  • [bold]{match}[/] → {cmd.description}") | ||||
|                 for match in fuzzy_matches: | ||||
|                     cmd = name_map[match] | ||||
|                     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: | ||||
|             if not from_validate: | ||||
|                 self.console.print( | ||||
|                     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 | ||||
|  | ||||
|     def _create_context(self, selected_command: Command) -> ExecutionContext: | ||||
|         """Creates a context dictionary for the selected command.""" | ||||
|     def _create_context( | ||||
|         self, selected_command: Command, args: tuple, kwargs: dict[str, Any] | ||||
|     ) -> ExecutionContext: | ||||
|         """Creates an ExecutionContext object for the selected command.""" | ||||
|         return ExecutionContext( | ||||
|             name=selected_command.description, | ||||
|             args=tuple(), | ||||
|             kwargs={}, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             action=selected_command, | ||||
|         ) | ||||
|  | ||||
| @@ -794,7 +932,7 @@ class Falyx: | ||||
|         """Processes the action of the selected command.""" | ||||
|         with patch_stdout(raw=True): | ||||
|             choice = await self.prompt_session.prompt_async() | ||||
|         is_preview, selected_command, args, kwargs = self.get_command(choice) | ||||
|         is_preview, selected_command, args, kwargs = await self.get_command(choice) | ||||
|         if not selected_command: | ||||
|             logger.info("Invalid command '%s'.", choice) | ||||
|             return True | ||||
| @@ -804,26 +942,16 @@ class Falyx: | ||||
|             await selected_command.preview() | ||||
|             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 | ||||
|  | ||||
|         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 | ||||
|  | ||||
|         context = self._create_context(selected_command) | ||||
|         context = self._create_context(selected_command, args, kwargs) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             print(args, kwargs) | ||||
|             result = await selected_command(*args, **kwargs) | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
| @@ -846,7 +974,7 @@ class Falyx: | ||||
|     ) -> Any: | ||||
|         """Run a command by key without displaying the menu (non-interactive mode).""" | ||||
|         self.debug_hooks() | ||||
|         is_preview, selected_command, _, __ = self.get_command(command_key) | ||||
|         is_preview, selected_command, _, __ = await self.get_command(command_key) | ||||
|         kwargs = kwargs or {} | ||||
|  | ||||
|         self.last_run_command = selected_command | ||||
| @@ -860,12 +988,12 @@ class Falyx: | ||||
|             return None | ||||
|  | ||||
|         logger.info( | ||||
|             "[run_key] 🚀 Executing: %s — %s", | ||||
|             "[run_key] Executing: %s — %s", | ||||
|             selected_command.key, | ||||
|             selected_command.description, | ||||
|         ) | ||||
|  | ||||
|         context = self._create_context(selected_command) | ||||
|         context = self._create_context(selected_command, args, kwargs) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
| @@ -873,10 +1001,10 @@ class Falyx: | ||||
|             context.result = result | ||||
|  | ||||
|             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: | ||||
|             logger.warning( | ||||
|                 "[run_key] ⚠️ Interrupted by user: %s", selected_command.description | ||||
|                 "[run_key] Interrupted by user: %s", selected_command.description | ||||
|             ) | ||||
|             raise FalyxError( | ||||
|                 f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." | ||||
| @@ -885,7 +1013,7 @@ class Falyx: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             logger.error( | ||||
|                 "[run_key] ❌ Failed: %s — %s: %s", | ||||
|                 "[run_key] Failed: %s — %s: %s", | ||||
|                 selected_command.description, | ||||
|                 type(error).__name__, | ||||
|                 error, | ||||
| @@ -939,16 +1067,17 @@ class Falyx: | ||||
|  | ||||
|     async def menu(self) -> None: | ||||
|         """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() | ||||
|         if self.welcome_message: | ||||
|             self.print_message(self.welcome_message) | ||||
|         try: | ||||
|             while True: | ||||
|                 if callable(self.render_menu): | ||||
|                     self.render_menu(self) | ||||
|                 else: | ||||
|                     self.console.print(self.table, justify="center") | ||||
|                 if not self.options.get("hide_menu_table", self._hide_menu_table): | ||||
|                     if callable(self.render_menu): | ||||
|                         self.render_menu(self) | ||||
|                     else: | ||||
|                         self.console.print(self.table, justify="center") | ||||
|                 try: | ||||
|                     task = asyncio.create_task(self.process_command()) | ||||
|                     should_continue = await task | ||||
| @@ -958,49 +1087,77 @@ class Falyx: | ||||
|                     logger.info("EOF or KeyboardInterrupt. Exiting menu.") | ||||
|                     break | ||||
|                 except QuitSignal: | ||||
|                     logger.info("QuitSignal received. Exiting menu.") | ||||
|                     logger.info("[QuitSignal]. <- Exiting menu.") | ||||
|                     break | ||||
|                 except BackSignal: | ||||
|                     logger.info("BackSignal received.") | ||||
|                     logger.info("[BackSignal]. <- Returning to the menu.") | ||||
|                 except CancelSignal: | ||||
|                     logger.info("CancelSignal received.") | ||||
|                 except HelpSignal: | ||||
|                     logger.info("HelpSignal received.") | ||||
|                     logger.info("[CancelSignal]. <- Returning to the menu.") | ||||
|         finally: | ||||
|             logger.info("Exiting menu: %s", self.get_title()) | ||||
|             if 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.""" | ||||
|         if not self.cli_args: | ||||
|             self.cli_args = get_arg_parsers().root.parse_args() | ||||
|         if self.cli_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") | ||||
|  | ||||
|         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"): | ||||
|             self.options.set("never_prompt", self._never_prompt) | ||||
|  | ||||
|         if not self.options.get("force_confirm"): | ||||
|             self.options.set("force_confirm", self._force_confirm) | ||||
|  | ||||
|         if not self.options.get("hide_menu_table"): | ||||
|             self.options.set("hide_menu_table", self._hide_menu_table) | ||||
|  | ||||
|         if self.cli_args.verbose: | ||||
|             logging.getLogger("falyx").setLevel(logging.DEBUG) | ||||
|  | ||||
|         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() | ||||
|  | ||||
|         if self.cli_args.command == "list": | ||||
|             await self._show_help() | ||||
|             await self._show_help(tag=self.cli_args.tag) | ||||
|             sys.exit(0) | ||||
|  | ||||
|         if self.cli_args.command == "version" or self.cli_args.version: | ||||
|             self.console.print(f"[{OneColors.GREEN_b}]Falyx CLI v{__version__}[/]") | ||||
|             self.console.print(f"[{self.version_style}]{self.program} v{__version__}[/]") | ||||
|             sys.exit(0) | ||||
|  | ||||
|         if self.cli_args.command == "preview": | ||||
|             self.mode = FalyxMode.PREVIEW | ||||
|             _, command, args, kwargs = self.get_command(self.cli_args.name) | ||||
|             _, command, args, kwargs = await self.get_command(self.cli_args.name) | ||||
|             if not command: | ||||
|                 self.console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." | ||||
| @@ -1014,7 +1171,7 @@ class Falyx: | ||||
|  | ||||
|         if self.cli_args.command == "run": | ||||
|             self.mode = FalyxMode.RUN | ||||
|             is_preview, command, _, __ = self.get_command(self.cli_args.name) | ||||
|             is_preview, command, _, __ = await self.get_command(self.cli_args.name) | ||||
|             if is_preview: | ||||
|                 if command is None: | ||||
|                     sys.exit(1) | ||||
| @@ -1025,14 +1182,27 @@ class Falyx: | ||||
|                 sys.exit(1) | ||||
|             self._set_retry_policy(command) | ||||
|             try: | ||||
|                 args, kwargs = command.parse_args(self.cli_args.command_args) | ||||
|                 args, kwargs = await command.parse_args(self.cli_args.command_args) | ||||
|             except HelpSignal: | ||||
|                 sys.exit(0) | ||||
|             except CommandArgumentError as error: | ||||
|                 self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") | ||||
|                 command.show_help() | ||||
|                 sys.exit(1) | ||||
|             try: | ||||
|                 await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) | ||||
|             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: | ||||
|                 er.summary() | ||||
| @@ -1056,9 +1226,23 @@ class Falyx: | ||||
|                 f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] " | ||||
|                 f"{self.cli_args.tag}" | ||||
|             ) | ||||
|  | ||||
|             for cmd in matching: | ||||
|                 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: | ||||
|                 er.summary() | ||||
|   | ||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | ||||
|  | ||||
| import inspect | ||||
| 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.logger import logger | ||||
| @@ -24,7 +24,7 @@ class HookType(Enum): | ||||
|     ON_TEARDOWN = "on_teardown" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> List[HookType]: | ||||
|     def choices(cls) -> list[HookType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
| @@ -37,16 +37,17 @@ class HookManager: | ||||
|     """HookManager""" | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self._hooks: Dict[HookType, List[Hook]] = { | ||||
|         self._hooks: dict[HookType, list[Hook]] = { | ||||
|             hook_type: [] for hook_type in HookType | ||||
|         } | ||||
|  | ||||
|     def register(self, hook_type: HookType, hook: Hook): | ||||
|         if hook_type not in HookType: | ||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") | ||||
|     def register(self, hook_type: HookType | str, hook: Hook): | ||||
|         """Raises ValueError if the hook type is not supported.""" | ||||
|         if not isinstance(hook_type, HookType): | ||||
|             hook_type = HookType(hook_type) | ||||
|         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: | ||||
|             self._hooks[hook_type] = [] | ||||
|         else: | ||||
| @@ -64,7 +65,7 @@ class HookManager: | ||||
|                     hook(context) | ||||
|             except Exception as hook_error: | ||||
|                 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_type, | ||||
|                     context.name, | ||||
|   | ||||
| @@ -56,10 +56,10 @@ class CircuitBreaker: | ||||
|         if self.open_until: | ||||
|             if time.time() < self.open_until: | ||||
|                 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: | ||||
|                 logger.info("🟢 Circuit closed again for '%s'.") | ||||
|                 logger.info("Circuit closed again for '%s'.") | ||||
|                 self.failures = 0 | ||||
|                 self.open_until = None | ||||
|  | ||||
| @@ -67,7 +67,7 @@ class CircuitBreaker: | ||||
|         name = context.name | ||||
|         self.failures += 1 | ||||
|         logger.warning( | ||||
|             "⚠️ CircuitBreaker: '%s' failure %s/%s.", | ||||
|             "CircuitBreaker: '%s' failure %s/%s.", | ||||
|             name, | ||||
|             self.failures, | ||||
|             self.max_failures, | ||||
| @@ -75,7 +75,7 @@ class CircuitBreaker: | ||||
|         if self.failures >= self.max_failures: | ||||
|             self.open_until = time.time() + self.reset_timeout | ||||
|             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): | ||||
| @@ -87,4 +87,4 @@ class CircuitBreaker: | ||||
|     def reset(self): | ||||
|         self.failures = 0 | ||||
|         self.open_until = None | ||||
|         logger.info("🔄 Circuit reset.") | ||||
|         logger.info("Circuit reset.") | ||||
|   | ||||
| @@ -11,9 +11,7 @@ TEMPLATE_TASKS = """\ | ||||
| import asyncio | ||||
| import json | ||||
|  | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.io_action import ShellAction | ||||
| from falyx.selection_action import SelectionAction | ||||
| from falyx.action import Action, ChainedAction, ShellAction, SelectionAction | ||||
|  | ||||
|  | ||||
| post_ids = ["1", "2", "3", "4", "5"] | ||||
| @@ -100,10 +98,10 @@ commands: | ||||
|     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.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,9 @@ from __future__ import annotations | ||||
|  | ||||
| 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.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
| @@ -26,6 +28,12 @@ class MenuOption: | ||||
|         """Render the menu option for display.""" | ||||
|         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|     def render_prompt(self, key: str) -> FormattedText: | ||||
|         """Render the menu option for prompt display.""" | ||||
|         return FormattedText( | ||||
|             [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class MenuOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
| @@ -33,7 +41,7 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|     and special signal entries like Quit and Back. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS = {"Q", "B"} | ||||
|     RESERVED_KEYS = {"B", "X"} | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -49,14 +57,14 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|     def _inject_reserved_defaults(self): | ||||
|         from falyx.action import SignalAction | ||||
|  | ||||
|         self._add_reserved( | ||||
|             "Q", | ||||
|             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), | ||||
|         ) | ||||
|         self._add_reserved( | ||||
|             "B", | ||||
|             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: | ||||
|         """Add a reserved key, bypassing validation.""" | ||||
| @@ -78,8 +86,20 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|             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, 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): | ||||
|         for k, v in super().items(): | ||||
|             if not include_reserved and k in self.RESERVED_KEYS: | ||||
|         for key, option in super().items(): | ||||
|             if not include_reserved and key in self.RESERVED_KEYS: | ||||
|                 continue | ||||
|             yield k, v | ||||
|             yield key, option | ||||
|   | ||||
							
								
								
									
										0
									
								
								falyx/parser/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								falyx/parser/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								falyx/parser/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								falyx/parser/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .argument import Argument | ||||
| from .argument_action import ArgumentAction | ||||
| from .command_argument_parser import 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", | ||||
| ] | ||||
							
								
								
									
										98
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """argument.py""" | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.action.base import BaseAction | ||||
| from falyx.parser.argument_action import ArgumentAction | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class Argument: | ||||
|     """Represents a command-line argument.""" | ||||
|  | ||||
|     flags: tuple[str, ...] | ||||
|     dest: str  # Destination name for the argument | ||||
|     action: ArgumentAction = ( | ||||
|         ArgumentAction.STORE | ||||
|     )  # Action to be taken when the argument is encountered | ||||
|     type: Any = str  # Type of the argument (e.g., str, int, float) or callable | ||||
|     default: Any = None  # Default value if the argument is not provided | ||||
|     choices: list[str] | None = None  # List of valid choices for the argument | ||||
|     required: bool = False  # True if the argument is required | ||||
|     help: str = ""  # Help text for the argument | ||||
|     nargs: int | str | None = None  # int, '?', '*', '+', None | ||||
|     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, | ||||
|             ) | ||||
|         ) | ||||
							
								
								
									
										27
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """argument_action.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class ArgumentAction(Enum): | ||||
|     """Defines the action to be taken when the argument is encountered.""" | ||||
|  | ||||
|     ACTION = "action" | ||||
|     STORE = "store" | ||||
|     STORE_TRUE = "store_true" | ||||
|     STORE_FALSE = "store_false" | ||||
|     APPEND = "append" | ||||
|     EXTEND = "extend" | ||||
|     COUNT = "count" | ||||
|     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 | ||||
| @@ -1,46 +1,22 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """command_argument_parser.py""" | ||||
| from __future__ import annotations | ||||
| 
 | ||||
| from copy import deepcopy | ||||
| from dataclasses import dataclass | ||||
| from enum import Enum | ||||
| from typing import Any, Iterable | ||||
| 
 | ||||
| from rich.console import Console | ||||
| from rich.table import Table | ||||
| from rich.markup import escape | ||||
| from rich.text import Text | ||||
| 
 | ||||
| from falyx.action.base import BaseAction | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser.argument import Argument | ||||
| from falyx.parser.argument_action import ArgumentAction | ||||
| from falyx.parser.utils import coerce_value | ||||
| from falyx.signals import HelpSignal | ||||
| 
 | ||||
| 
 | ||||
| class ArgumentAction(Enum): | ||||
|     """Defines the action to be taken when the argument is encountered.""" | ||||
| 
 | ||||
|     STORE = "store" | ||||
|     STORE_TRUE = "store_true" | ||||
|     STORE_FALSE = "store_false" | ||||
|     APPEND = "append" | ||||
|     EXTEND = "extend" | ||||
|     COUNT = "count" | ||||
|     HELP = "help" | ||||
| 
 | ||||
| 
 | ||||
| @dataclass | ||||
| class Argument: | ||||
|     """Represents a command-line argument.""" | ||||
| 
 | ||||
|     flags: list[str] | ||||
|     dest: str  # Destination name for the argument | ||||
|     action: ArgumentAction = ( | ||||
|         ArgumentAction.STORE | ||||
|     )  # Action to be taken when the argument is encountered | ||||
|     type: Any = str  # Type of the argument (e.g., str, int, float) or callable | ||||
|     default: Any = None  # Default value if the argument is not provided | ||||
|     choices: list[str] | None = None  # List of valid choices for the argument | ||||
|     required: bool = False  # True if the argument is required | ||||
|     help: str = ""  # Help text for the argument | ||||
|     nargs: int | str = 1  # int, '?', '*', '+' | ||||
|     positional: bool = False  # True if no leading - or -- in flags | ||||
| 
 | ||||
| 
 | ||||
| class CommandArgumentParser: | ||||
|     """ | ||||
|     Custom argument parser for Falyx Commands. | ||||
| @@ -61,22 +37,38 @@ class CommandArgumentParser: | ||||
|     - Render Help using Rich library. | ||||
|     """ | ||||
| 
 | ||||
|     def __init__(self) -> None: | ||||
|     def __init__( | ||||
|         self, | ||||
|         command_key: str = "", | ||||
|         command_description: str = "", | ||||
|         command_style: str = "bold", | ||||
|         help_text: str = "", | ||||
|         help_epilog: str = "", | ||||
|         aliases: list[str] | None = None, | ||||
|     ) -> None: | ||||
|         """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._positional: dict[str, Argument] = {} | ||||
|         self._keyword: dict[str, Argument] = {} | ||||
|         self._keyword_list: list[Argument] = [] | ||||
|         self._flag_map: dict[str, Argument] = {} | ||||
|         self._dest_set: set[str] = set() | ||||
|         self._add_help() | ||||
|         self.console = Console(color_system="auto") | ||||
| 
 | ||||
|     def _add_help(self): | ||||
|         """Add help argument to the parser.""" | ||||
|         self.add_argument( | ||||
|             "--help", | ||||
|             "-h", | ||||
|             "--help", | ||||
|             action=ArgumentAction.HELP, | ||||
|             help="Show this help message and exit.", | ||||
|             help="Show this help message.", | ||||
|             dest="help", | ||||
|         ) | ||||
| 
 | ||||
| @@ -90,9 +82,7 @@ class CommandArgumentParser: | ||||
|             raise CommandArgumentError("Positional arguments cannot have multiple flags") | ||||
|         return positional | ||||
| 
 | ||||
|     def _get_dest_from_flags( | ||||
|         self, flags: tuple[str, ...], dest: str | None | ||||
|     ) -> str | None: | ||||
|     def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: | ||||
|         """Convert flags to a destination name.""" | ||||
|         if dest: | ||||
|             if not dest.replace("_", "").isalnum(): | ||||
| @@ -121,12 +111,18 @@ class CommandArgumentParser: | ||||
|         return dest | ||||
| 
 | ||||
|     def _determine_required( | ||||
|         self, required: bool, positional: bool, nargs: int | str | ||||
|         self, required: bool, positional: bool, nargs: int | str | None | ||||
|     ) -> bool: | ||||
|         """Determine if the argument is required.""" | ||||
|         if required: | ||||
|             return True | ||||
|         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): | ||||
|                 return nargs > 0 | ||||
|             elif isinstance(nargs, str): | ||||
| @@ -134,12 +130,27 @@ class CommandArgumentParser: | ||||
|                     return True | ||||
|                 elif nargs in ("*", "?"): | ||||
|                     return False | ||||
|                 else: | ||||
|                     raise CommandArgumentError(f"Invalid nargs value: {nargs}") | ||||
|             else: | ||||
|                 return True | ||||
| 
 | ||||
|         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 = ("?", "*", "+") | ||||
|         if isinstance(nargs, int): | ||||
|             if nargs <= 0: | ||||
| @@ -151,7 +162,9 @@ class CommandArgumentParser: | ||||
|             raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") | ||||
|         return nargs | ||||
| 
 | ||||
|     def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]: | ||||
|     def _normalize_choices( | ||||
|         self, choices: Iterable | None, expected_type: Any | ||||
|     ) -> list[Any]: | ||||
|         if choices is not None: | ||||
|             if isinstance(choices, dict): | ||||
|                 raise CommandArgumentError("choices cannot be a dict") | ||||
| @@ -166,11 +179,11 @@ class CommandArgumentParser: | ||||
|         for choice in choices: | ||||
|             if not isinstance(choice, expected_type): | ||||
|                 try: | ||||
|                     expected_type(choice) | ||||
|                 except Exception: | ||||
|                     coerce_value(choice, expected_type) | ||||
|                 except Exception as error: | ||||
|                     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 | ||||
| 
 | ||||
|     def _validate_default_type( | ||||
| @@ -179,11 +192,11 @@ class CommandArgumentParser: | ||||
|         """Validate the default value type.""" | ||||
|         if default is not None and not isinstance(default, expected_type): | ||||
|             try: | ||||
|                 expected_type(default) | ||||
|             except Exception: | ||||
|                 coerce_value(default, expected_type) | ||||
|             except Exception as error: | ||||
|                 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( | ||||
|         self, default: list[Any], expected_type: type, dest: str | ||||
| @@ -192,14 +205,57 @@ class CommandArgumentParser: | ||||
|             for item in default: | ||||
|                 if not isinstance(item, expected_type): | ||||
|                     try: | ||||
|                         expected_type(item) | ||||
|                     except Exception: | ||||
|                         coerce_value(item, expected_type) | ||||
|                     except Exception as error: | ||||
|                         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( | ||||
|         self, action: ArgumentAction, default: Any, nargs: str | int | ||||
|         self, | ||||
|         default: Any, | ||||
|         action: ArgumentAction, | ||||
|         nargs: str | int | None, | ||||
|     ) -> Any: | ||||
|         """Get the default value for the argument.""" | ||||
|         if default is None: | ||||
| @@ -211,6 +267,8 @@ class CommandArgumentParser: | ||||
|                 return 0 | ||||
|             elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): | ||||
|                 return [] | ||||
|             elif isinstance(nargs, int): | ||||
|                 return [] | ||||
|             elif nargs in ("+", "*"): | ||||
|                 return [] | ||||
|             else: | ||||
| @@ -233,8 +291,26 @@ class CommandArgumentParser: | ||||
|                     f"Flag '{flag}' must be a single character or start with '--'" | ||||
|                 ) | ||||
| 
 | ||||
|     def add_argument(self, *flags, **kwargs): | ||||
|     def add_argument( | ||||
|         self, | ||||
|         *flags, | ||||
|         action: str | ArgumentAction = "store", | ||||
|         nargs: int | str | None = None, | ||||
|         default: Any = None, | ||||
|         type: Any = str, | ||||
|         choices: Iterable | None = None, | ||||
|         required: bool = False, | ||||
|         help: str = "", | ||||
|         dest: str | None = None, | ||||
|         resolver: BaseAction | None = None, | ||||
|     ) -> None: | ||||
|         """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: | ||||
|             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. | ||||
| @@ -245,29 +321,22 @@ class CommandArgumentParser: | ||||
|             required: Whether or not the argument is required. | ||||
|             help: A brief description of the argument. | ||||
|             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) | ||||
|         positional = self._is_positional(flags) | ||||
|         dest = self._get_dest_from_flags(flags, kwargs.get("dest")) | ||||
|         dest = self._get_dest_from_flags(flags, dest) | ||||
|         if dest in self._dest_set: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Destination '{dest}' is already defined.\n" | ||||
|                 "Merging multiple arguments into the same dest (e.g. positional + flagged) " | ||||
|                 "is not supported. Define a unique 'dest' for each argument." | ||||
|             ) | ||||
|         self._dest_set.add(dest) | ||||
|         action = kwargs.get("action", ArgumentAction.STORE) | ||||
|         if not isinstance(action, ArgumentAction): | ||||
|             try: | ||||
|                 action = ArgumentAction(action) | ||||
|             except ValueError: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||
|                 ) | ||||
|         flags = list(flags) | ||||
|         nargs = self._validate_nargs(kwargs.get("nargs", 1)) | ||||
|         default = self._resolve_default(action, kwargs.get("default"), nargs) | ||||
|         expected_type = kwargs.get("type", str) | ||||
|         action = self._validate_action(action, positional) | ||||
|         resolver = self._validate_resolver(action, resolver) | ||||
|         nargs = self._validate_nargs(nargs, action) | ||||
|         default = self._resolve_default(default, action, nargs) | ||||
|         if ( | ||||
|             action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) | ||||
|             and default is not None | ||||
| @@ -276,14 +345,12 @@ class CommandArgumentParser: | ||||
|                 self._validate_default_list_type(default, expected_type, dest) | ||||
|             else: | ||||
|                 self._validate_default_type(default, expected_type, dest) | ||||
|         choices = self._normalize_choices(kwargs.get("choices"), expected_type) | ||||
|         choices = self._normalize_choices(choices, expected_type) | ||||
|         if default is not None and choices and default not in choices: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Default value '{default}' not in allowed choices: {choices}" | ||||
|             ) | ||||
|         required = self._determine_required( | ||||
|             kwargs.get("required", False), positional, nargs | ||||
|         ) | ||||
|         required = self._determine_required(required, positional, nargs) | ||||
|         argument = Argument( | ||||
|             flags=flags, | ||||
|             dest=dest, | ||||
| @@ -292,9 +359,10 @@ class CommandArgumentParser: | ||||
|             default=default, | ||||
|             choices=choices, | ||||
|             required=required, | ||||
|             help=kwargs.get("help", ""), | ||||
|             help=help, | ||||
|             nargs=nargs, | ||||
|             positional=positional, | ||||
|             resolver=resolver, | ||||
|         ) | ||||
|         for flag in flags: | ||||
|             if flag in self._flag_map: | ||||
| @@ -302,21 +370,51 @@ class CommandArgumentParser: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Flag '{flag}' is already used by argument '{existing.dest}'" | ||||
|                 ) | ||||
|         for flag in flags: | ||||
|             self._flag_map[flag] = argument | ||||
|             if not positional: | ||||
|                 self._keyword[flag] = argument | ||||
|         self._dest_set.add(dest) | ||||
|         self._arguments.append(argument) | ||||
|         if positional: | ||||
|             self._positional[dest] = argument | ||||
|         else: | ||||
|             self._keyword_list.append(argument) | ||||
| 
 | ||||
|     def get_argument(self, dest: str) -> Argument | None: | ||||
|         return next((a for a in self._arguments if a.dest == dest), None) | ||||
| 
 | ||||
|     def to_definition_list(self) -> list[dict[str, Any]]: | ||||
|         defs = [] | ||||
|         for arg in self._arguments: | ||||
|             defs.append( | ||||
|                 { | ||||
|                     "flags": arg.flags, | ||||
|                     "dest": arg.dest, | ||||
|                     "action": arg.action, | ||||
|                     "type": arg.type, | ||||
|                     "choices": arg.choices, | ||||
|                     "required": arg.required, | ||||
|                     "nargs": arg.nargs, | ||||
|                     "positional": arg.positional, | ||||
|                     "default": arg.default, | ||||
|                     "help": arg.help, | ||||
|                 } | ||||
|             ) | ||||
|         return defs | ||||
| 
 | ||||
|     def _consume_nargs( | ||||
|         self, args: list[str], start: int, spec: Argument | ||||
|     ) -> 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 = [] | ||||
|         i = start | ||||
|         if isinstance(spec.nargs, int): | ||||
|             # assert i + spec.nargs <= len( | ||||
|             #     args | ||||
|             # ), "Not enough arguments provided: shouldn't happen" | ||||
|             values = args[i : i + spec.nargs] | ||||
|             return values, i + spec.nargs | ||||
|         elif spec.nargs == "+": | ||||
| @@ -338,10 +436,13 @@ class CommandArgumentParser: | ||||
|             if i < len(args) and not args[i].startswith("-"): | ||||
|                 return [args[i]], i + 1 | ||||
|             return [], i | ||||
|         else: | ||||
|             assert False, "Invalid nargs value: shouldn't happen" | ||||
|         elif spec.nargs is None: | ||||
|             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, | ||||
|         args: list[str], | ||||
|         result: dict[str, Any], | ||||
| @@ -361,7 +462,15 @@ class CommandArgumentParser: | ||||
|             remaining = len(args) - i | ||||
|             min_required = 0 | ||||
|             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 | ||||
|                 elif next_spec.nargs == "+": | ||||
|                     min_required += 1 | ||||
| @@ -369,23 +478,30 @@ class CommandArgumentParser: | ||||
|                     min_required += 0 | ||||
|                 elif next_spec.nargs == "*": | ||||
|                     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)] | ||||
|             values, new_i = self._consume_nargs(slice_args, 0, spec) | ||||
|             i += new_i | ||||
| 
 | ||||
|             try: | ||||
|                 typed = [spec.type(v) for v in values] | ||||
|             except Exception: | ||||
|                 typed = [coerce_value(value, spec.type) for value in values] | ||||
|             except Exception as error: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                 ) | ||||
| 
 | ||||
|             if spec.action == ArgumentAction.APPEND: | ||||
|                     f"Invalid value for '{spec.dest}': {error}" | ||||
|                 ) from error | ||||
|             if spec.action == ArgumentAction.ACTION: | ||||
|                 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" | ||||
|                 if spec.nargs in (None, 1): | ||||
|                 if spec.nargs is None: | ||||
|                     result[spec.dest].append(typed[0]) | ||||
|                 else: | ||||
|                     result[spec.dest].append(typed) | ||||
| @@ -401,30 +517,76 @@ class CommandArgumentParser: | ||||
|                 consumed_positional_indicies.add(j) | ||||
| 
 | ||||
|         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 | ||||
| 
 | ||||
|     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.""" | ||||
|         if args is None: | ||||
|             args = [] | ||||
| 
 | ||||
|         args = self._expand_posix_bundling(args) | ||||
| 
 | ||||
|         result = {arg.dest: deepcopy(arg.default) for arg in self._arguments} | ||||
|         positional_args = [arg for arg in self._arguments if arg.positional] | ||||
|         consumed_positional_indices: set[int] = set() | ||||
| 
 | ||||
|         consumed_indices: set[int] = set() | ||||
| 
 | ||||
|         i = 0 | ||||
|         while i < len(args): | ||||
|             token = args[i] | ||||
|             if token in self._flag_map: | ||||
|                 spec = self._flag_map[token] | ||||
|             if token in self._keyword: | ||||
|                 spec = self._keyword[token] | ||||
|                 action = spec.action | ||||
| 
 | ||||
|                 if action == ArgumentAction.HELP: | ||||
|                     self.render_help() | ||||
|                     if not from_validate: | ||||
|                         self.render_help() | ||||
|                     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: | ||||
|                     result[spec.dest] = True | ||||
|                     consumed_indices.add(i) | ||||
| @@ -441,18 +603,15 @@ class CommandArgumentParser: | ||||
|                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(value) for value in values] | ||||
|                     except ValueError: | ||||
|                         typed_values = [ | ||||
|                             coerce_value(value, spec.type) for value in values | ||||
|                         ] | ||||
|                     except ValueError as error: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                         ) | ||||
|                     if spec.nargs in (None, 1): | ||||
|                         try: | ||||
|                             result[spec.dest].append(spec.type(values[0])) | ||||
|                         except ValueError: | ||||
|                             raise CommandArgumentError( | ||||
|                                 f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                             ) | ||||
|                             f"Invalid value for '{spec.dest}': {error}" | ||||
|                         ) from error | ||||
|                     if spec.nargs is None: | ||||
|                         result[spec.dest].append(spec.type(values[0])) | ||||
|                     else: | ||||
|                         result[spec.dest].append(typed_values) | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
| @@ -461,21 +620,29 @@ class CommandArgumentParser: | ||||
|                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(value) for value in values] | ||||
|                     except ValueError: | ||||
|                         typed_values = [ | ||||
|                             coerce_value(value, spec.type) for value in values | ||||
|                         ] | ||||
|                     except ValueError as error: | ||||
|                         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) | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
|                     i = new_i | ||||
|                 else: | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(v) for v in values] | ||||
|                     except ValueError: | ||||
|                         typed_values = [ | ||||
|                             coerce_value(value, spec.type) for value in values | ||||
|                         ] | ||||
|                     except ValueError as error: | ||||
|                         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 ( | ||||
|                         spec.nargs in (None, 1, "?") | ||||
| @@ -488,6 +655,9 @@ class CommandArgumentParser: | ||||
|                         result[spec.dest] = typed_values | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
|                     i = new_i | ||||
|             elif token.startswith("-"): | ||||
|                 # Handle unrecognized option | ||||
|                 raise CommandArgumentError(f"Unrecognized flag: {token}") | ||||
|             else: | ||||
|                 # Get the next flagged argument index if it exists | ||||
|                 next_flagged_index = -1 | ||||
| @@ -497,8 +667,7 @@ class CommandArgumentParser: | ||||
|                         break | ||||
|                 if next_flagged_index == -1: | ||||
|                     next_flagged_index = len(args) | ||||
| 
 | ||||
|                 args_consumed = self._consume_all_positional_args( | ||||
|                 args_consumed = await self._consume_all_positional_args( | ||||
|                     args[i:next_flagged_index], | ||||
|                     result, | ||||
|                     positional_args, | ||||
| @@ -518,26 +687,22 @@ class CommandArgumentParser: | ||||
|                     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 not isinstance(result.get(spec.dest), list): | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid value for {spec.dest}: expected a list" | ||||
|                     ) | ||||
|                 assert isinstance( | ||||
|                     result.get(spec.dest), 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 not isinstance(result[spec.dest], list): | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for {spec.dest}: expected a list" | ||||
|                         ) | ||||
|                     for group in result[spec.dest]: | ||||
|                         if len(group) % spec.nargs != 0: | ||||
|                             raise CommandArgumentError( | ||||
|                                 f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||
|                             ) | ||||
|                 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: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||
| @@ -550,13 +715,15 @@ class CommandArgumentParser: | ||||
|         result.pop("help", None) | ||||
|         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: | ||||
|             tuple[args, kwargs] - Positional arguments in defined order, | ||||
|             followed by keyword argument mapping. | ||||
|         """ | ||||
|         parsed = self.parse_args(args) | ||||
|         parsed = await self.parse_args(args, from_validate) | ||||
|         args_list = [] | ||||
|         kwargs_dict = {} | ||||
|         for arg in self._arguments: | ||||
| @@ -568,28 +735,102 @@ class CommandArgumentParser: | ||||
|                 kwargs_dict[arg.dest] = parsed[arg.dest] | ||||
|         return tuple(args_list), kwargs_dict | ||||
| 
 | ||||
|     def render_help(self): | ||||
|         table = Table(title=f"{self.command_description} Help") | ||||
|         table.add_column("Flags") | ||||
|         table.add_column("Help") | ||||
|         for arg in self._arguments: | ||||
|             if arg.dest == "help": | ||||
|                 continue | ||||
|             flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest | ||||
|             table.add_row(flag_str, arg.help or "") | ||||
|         table.add_section() | ||||
|         arg = self.get_argument("help") | ||||
|         flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest | ||||
|         table.add_row(flag_str, arg.help or "") | ||||
|         self.console.print(table) | ||||
|     def get_options_text(self, plain_text=False) -> str: | ||||
|         # Options | ||||
|         # Add all keyword arguments to the options list | ||||
|         options_list = [] | ||||
|         for arg in self._keyword_list: | ||||
|             choice_text = arg.get_choice_text() | ||||
|             if choice_text: | ||||
|                 options_list.extend([f"[{arg.flags[0]} {choice_text}]"]) | ||||
|             else: | ||||
|                 options_list.extend([f"[{arg.flags[0]}]"]) | ||||
| 
 | ||||
|         # Add positional arguments to the options list | ||||
|         for arg in self._positional.values(): | ||||
|             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: | ||||
|         positional = sum(arg.positional for arg in self._arguments) | ||||
|         required = sum(arg.required for arg in self._arguments) | ||||
|         return ( | ||||
|             f"CommandArgumentParser(args={len(self._arguments)}, " | ||||
|             f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, " | ||||
|             f"required={required}, positional={positional})" | ||||
|             f"flags={len(self._flag_map)}, keywords={len(self._keyword)}, " | ||||
|             f"positional={positional}, required={required})" | ||||
|         ) | ||||
| 
 | ||||
|     def __repr__(self) -> str: | ||||
| @@ -2,10 +2,18 @@ | ||||
| """parsers.py | ||||
| 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 typing import Any, Sequence | ||||
| 
 | ||||
| from falyx.command import Command | ||||
| 
 | ||||
| 
 | ||||
| @dataclass | ||||
| class FalyxParsers: | ||||
| @@ -32,7 +40,7 @@ class FalyxParsers: | ||||
|         return self.as_dict().get(name) | ||||
| 
 | ||||
| 
 | ||||
| def get_arg_parsers( | ||||
| def get_root_parser( | ||||
|     prog: str | None = "falyx", | ||||
|     usage: str | None = None, | ||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||
| @@ -47,8 +55,7 @@ def get_arg_parsers( | ||||
|     add_help: bool = True, | ||||
|     allow_abbrev: bool = True, | ||||
|     exit_on_error: bool = True, | ||||
| ) -> FalyxParsers: | ||||
|     """Returns the argument parser for the CLI.""" | ||||
| ) -> ArgumentParser: | ||||
|     parser = ArgumentParser( | ||||
|         prog=prog, | ||||
|         usage=usage, | ||||
| @@ -77,10 +84,91 @@ def get_arg_parsers( | ||||
|         help="Enable default lifecycle debug logging", | ||||
|     ) | ||||
|     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( | ||||
|         "--summary", | ||||
|         action="store_true", | ||||
| @@ -114,10 +202,11 @@ def get_arg_parsers( | ||||
|         help="Skip confirmation prompts", | ||||
|     ) | ||||
| 
 | ||||
|     run_group.add_argument( | ||||
|     run_parser.add_argument( | ||||
|         "command_args", | ||||
|         nargs=REMAINDER, | ||||
|         help="Arguments to pass to the command (if applicable)", | ||||
|         metavar="ARGS", | ||||
|     ) | ||||
| 
 | ||||
|     run_all_parser = subparsers.add_parser( | ||||
| @@ -166,6 +255,10 @@ def get_arg_parsers( | ||||
|         "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") | ||||
| 
 | ||||
|     return FalyxParsers( | ||||
							
								
								
									
										81
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| 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 | ||||
							
								
								
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| 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.parser.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 | ||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Protocol, runtime_checkable | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.base import BaseAction | ||||
|  | ||||
|  | ||||
| @runtime_checkable | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class RetryHandler: | ||||
|         self.policy.delay = delay | ||||
|         self.policy.backoff = backoff | ||||
|         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: | ||||
|         from falyx.action import Action | ||||
| @@ -67,21 +67,21 @@ class RetryHandler: | ||||
|         last_error = error | ||||
|  | ||||
|         if not target: | ||||
|             logger.warning("[%s] ⚠️ No action target. Cannot retry.", name) | ||||
|             logger.warning("[%s] No action target. Cannot retry.", name) | ||||
|             return None | ||||
|  | ||||
|         if not isinstance(target, Action): | ||||
|             logger.warning( | ||||
|                 "[%s] ❌ RetryHandler only supports only supports Action objects.", name | ||||
|                 "[%s] RetryHandler only supports only supports Action objects.", name | ||||
|             ) | ||||
|             return None | ||||
|  | ||||
|         if not getattr(target, "is_retryable", False): | ||||
|             logger.warning("[%s] ❌ Not retryable.", name) | ||||
|             logger.warning("[%s] Not retryable.", name) | ||||
|             return None | ||||
|  | ||||
|         if not self.policy.enabled: | ||||
|             logger.warning("[%s] ❌ Retry policy is disabled.", name) | ||||
|             logger.warning("[%s] Retry policy is disabled.", name) | ||||
|             return None | ||||
|  | ||||
|         while retries_done < self.policy.max_retries: | ||||
| @@ -92,7 +92,7 @@ class RetryHandler: | ||||
|                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||
|  | ||||
|             logger.info( | ||||
|                 "[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 "[%s] Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 name, | ||||
|                 retries_done, | ||||
|                 self.policy.max_retries, | ||||
| @@ -104,13 +104,13 @@ class RetryHandler: | ||||
|                 result = await target.action(*context.args, **context.kwargs) | ||||
|                 context.result = result | ||||
|                 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 | ||||
|             except Exception as retry_error: | ||||
|                 last_error = retry_error | ||||
|                 current_delay *= self.policy.backoff | ||||
|                 logger.warning( | ||||
|                     "[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.", | ||||
|                     "[%s] Retry attempt %s/%s failed due to '%s'.", | ||||
|                     name, | ||||
|                     retries_done, | ||||
|                     self.policy.max_retries, | ||||
| @@ -118,4 +118,4 @@ class RetryHandler: | ||||
|                 ) | ||||
|  | ||||
|         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) | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """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.retry import RetryHandler, RetryPolicy | ||||
|  | ||||
|   | ||||
| @@ -10,8 +10,8 @@ from rich.markup import escape | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import chunks | ||||
| from falyx.validators import int_range_validator, key_validator | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
| from falyx.validators import MultiIndexValidator, MultiKeyValidator | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @@ -32,6 +32,62 @@ class SelectionOption: | ||||
|         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( | ||||
|     title: str, | ||||
|     *, | ||||
| @@ -215,19 +271,35 @@ async def prompt_for_index( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
| ): | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> int | list[int]: | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|     console = console or Console(color_system="truecolor") | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selection = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=int_range_validator(min_index, max_index), | ||||
|         validator=MultiIndexValidator( | ||||
|             min_index, | ||||
|             max_index, | ||||
|             number_selections, | ||||
|             separator, | ||||
|             allow_duplicates, | ||||
|             cancel_key, | ||||
|         ), | ||||
|         default=default_selection, | ||||
|     ) | ||||
|     return int(selection) | ||||
|  | ||||
|     if selection.strip() == cancel_key: | ||||
|         return int(cancel_key) | ||||
|     if isinstance(number_selections, int) and number_selections == 1: | ||||
|         return int(selection.strip()) | ||||
|     return [int(index.strip()) for index in selection.strip().split(separator)] | ||||
|  | ||||
|  | ||||
| async def prompt_for_selection( | ||||
| @@ -239,21 +311,31 @@ async def prompt_for_selection( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
| ) -> str: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt the user to select a key from a set of options. Return the selected key.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|     console = console or Console(color_system="truecolor") | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selected = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=key_validator(keys), | ||||
|         validator=MultiKeyValidator( | ||||
|             keys, number_selections, separator, allow_duplicates, cancel_key | ||||
|         ), | ||||
|         default=default_selection, | ||||
|     ) | ||||
|  | ||||
|     return selected | ||||
|     if selected.strip() == cancel_key: | ||||
|         return cancel_key | ||||
|     if isinstance(number_selections, int) and number_selections == 1: | ||||
|         return selected.strip() | ||||
|     return [key.strip() for key in selected.strip().split(separator)] | ||||
|  | ||||
|  | ||||
| async def select_value_from_list( | ||||
| @@ -264,6 +346,10 @@ async def select_value_from_list( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
|     columns: int = 4, | ||||
|     caption: str = "", | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
| @@ -276,7 +362,7 @@ async def select_value_from_list( | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
| ): | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a selection. Return the selected item.""" | ||||
|     table = render_selection_indexed_table( | ||||
|         title=title, | ||||
| @@ -295,7 +381,7 @@ async def select_value_from_list( | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|     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( | ||||
|         len(selections) - 1, | ||||
| @@ -304,8 +390,14 @@ async def select_value_from_list( | ||||
|         console=console, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|     if isinstance(selection_index, list): | ||||
|         return [selections[i] for i in selection_index] | ||||
|     return selections[selection_index] | ||||
|  | ||||
|  | ||||
| @@ -317,10 +409,14 @@ async def select_key_from_dict( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
| ) -> Any: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a key from a dict, returns the key.""" | ||||
|     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") | ||||
|  | ||||
| @@ -331,6 +427,10 @@ async def select_key_from_dict( | ||||
|         console=console, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -342,10 +442,14 @@ async def select_value_from_dict( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
| ) -> Any: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> Any | list[Any]: | ||||
|     """Prompt for a key from a dict, but return the value.""" | ||||
|     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") | ||||
|  | ||||
| @@ -356,8 +460,14 @@ async def select_value_from_dict( | ||||
|         console=console, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|     if isinstance(selection_key, list): | ||||
|         return [selections[key].value for key in selection_key] | ||||
|     return selections[selection_key].value | ||||
|  | ||||
|  | ||||
| @@ -369,7 +479,11 @@ async def get_selection_from_dict_menu( | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
| ): | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> Any | list[Any]: | ||||
|     """Prompt for a key from a dict, but return the value.""" | ||||
|     table = render_selection_dict_table( | ||||
|         title, | ||||
| @@ -383,4 +497,8 @@ async def get_selection_from_dict_menu( | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         default_selection=default_selection, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|   | ||||
| @@ -184,7 +184,7 @@ def setup_logging( | ||||
|     console_handler.setLevel(console_log_level) | ||||
|     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) | ||||
|     if json_log_to_file: | ||||
|         file_handler.setFormatter( | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| """validators.py""" | ||||
| from typing import KeysView, Sequence | ||||
|  | ||||
| from prompt_toolkit.validation import Validator | ||||
| from prompt_toolkit.validation import ValidationError, Validator | ||||
|  | ||||
|  | ||||
| def int_range_validator(minimum: int, maximum: int) -> Validator: | ||||
| @@ -45,3 +45,91 @@ def yes_no_validator() -> Validator: | ||||
|         return True | ||||
|  | ||||
|     return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.") | ||||
|  | ||||
|  | ||||
| class MultiIndexValidator(Validator): | ||||
|     def __init__( | ||||
|         self, | ||||
|         minimum: int, | ||||
|         maximum: int, | ||||
|         number_selections: int | str, | ||||
|         separator: str, | ||||
|         allow_duplicates: bool, | ||||
|         cancel_key: str, | ||||
|     ) -> None: | ||||
|         self.minimum = minimum | ||||
|         self.maximum = maximum | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.cancel_key = cancel_key | ||||
|         super().__init__() | ||||
|  | ||||
|     def validate(self, document): | ||||
|         selections = [ | ||||
|             index.strip() for index in document.text.strip().split(self.separator) | ||||
|         ] | ||||
|         if not selections or selections == [""]: | ||||
|             raise ValidationError(message="Select at least 1 item.") | ||||
|         if self.cancel_key in selections and len(selections) == 1: | ||||
|             return | ||||
|         elif self.cancel_key in selections: | ||||
|             raise ValidationError(message="Cancel key must be selected alone.") | ||||
|         for selection in selections: | ||||
|             try: | ||||
|                 index = int(selection) | ||||
|                 if not self.minimum <= index <= self.maximum: | ||||
|                     raise ValidationError( | ||||
|                         message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||
|                     ) | ||||
|             except ValueError: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||
|                 ) | ||||
|             if not self.allow_duplicates and selections.count(selection) > 1: | ||||
|                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||
|         if isinstance(self.number_selections, int): | ||||
|             if self.number_selections == 1 and len(selections) > 1: | ||||
|                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||
|             if len(selections) != self.number_selections: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class MultiKeyValidator(Validator): | ||||
|     def __init__( | ||||
|         self, | ||||
|         keys: Sequence[str] | KeysView[str], | ||||
|         number_selections: int | str, | ||||
|         separator: str, | ||||
|         allow_duplicates: bool, | ||||
|         cancel_key: str, | ||||
|     ) -> None: | ||||
|         self.keys = keys | ||||
|         self.separator = separator | ||||
|         self.number_selections = number_selections | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.cancel_key = cancel_key | ||||
|         super().__init__() | ||||
|  | ||||
|     def validate(self, document): | ||||
|         selections = [key.strip() for key in document.text.strip().split(self.separator)] | ||||
|         if not selections or selections == [""]: | ||||
|             raise ValidationError(message="Select at least 1 item.") | ||||
|         if self.cancel_key in selections and len(selections) == 1: | ||||
|             return | ||||
|         elif self.cancel_key in selections: | ||||
|             raise ValidationError(message="Cancel key must be selected alone.") | ||||
|         for selection in selections: | ||||
|             if selection.upper() not in [key.upper() for key in self.keys]: | ||||
|                 raise ValidationError(message=f"Invalid selection: {selection}") | ||||
|             if not self.allow_duplicates and selections.count(selection) > 1: | ||||
|                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||
|         if isinstance(self.number_selections, int): | ||||
|             if self.number_selections == 1 and len(selections) > 1: | ||||
|                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||
|             if len(selections) != self.number_selections: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||
|                 ) | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.28" | ||||
| __version__ = "0.1.52" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.28" | ||||
| version = "0.1.52" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
| @@ -16,6 +16,7 @@ python-json-logger = "^3.3.0" | ||||
| toml = "^0.10" | ||||
| pyyaml = "^6.0" | ||||
| aiohttp = "^3.11" | ||||
| python-dateutil = "^2.8" | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| pytest = "^8.3.5" | ||||
|   | ||||
| @@ -38,13 +38,14 @@ async def test_action_async_callable(): | ||||
|     action = Action("test_action", async_callable) | ||||
|     result = await action() | ||||
|     assert result == "Hello, World!" | ||||
|     print(action) | ||||
|     assert ( | ||||
|         str(action) | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|     ) | ||||
|     assert ( | ||||
|         repr(action) | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										25
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal 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"] | ||||
| @@ -1,7 +1,7 @@ | ||||
| # test_command.py | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction | ||||
| from falyx.action import Action, BaseIOAction, ChainedAction | ||||
| from falyx.command import Command | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.retry import RetryPolicy | ||||
| @@ -50,108 +50,13 @@ def test_command_str(): | ||||
|     """Test if Command string representation is correct.""" | ||||
|     action = Action("test_action", dummy_action) | ||||
|     cmd = Command(key="TEST", description="Test Command", action=action) | ||||
|     print(cmd) | ||||
|     assert ( | ||||
|         str(cmd) | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')" | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @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(): | ||||
|     """Command should enable retry if action is an Action and  retry is set to True.""" | ||||
|     cmd = Command( | ||||
|   | ||||
| @@ -1,102 +1,113 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.argparse import ArgumentAction, CommandArgumentParser | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser import ArgumentAction, CommandArgumentParser | ||||
| from falyx.signals import HelpSignal | ||||
|  | ||||
|  | ||||
| def build_parser_and_parse(args, config): | ||||
| async def build_parser_and_parse(args, config): | ||||
|     cap = CommandArgumentParser() | ||||
|     config(cap) | ||||
|     return cap.parse_args(args) | ||||
|     return await cap.parse_args(args) | ||||
|  | ||||
|  | ||||
| def test_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_none(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--foo", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(None, config) | ||||
|     parsed = await build_parser_and_parse(None, config) | ||||
|     assert parsed["foo"] is None | ||||
|  | ||||
|  | ||||
| def test_append_multiple_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_multiple_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config) | ||||
|     parsed = await build_parser_and_parse( | ||||
|         ["--tag", "a", "--tag", "b", "--tag", "c"], config | ||||
|     ) | ||||
|     assert parsed["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_positional_nargs_plus_and_single(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_positional_nargs_plus_and_single(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("files", nargs="+", type=str) | ||||
|         parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     assert parsed["files"] == ["a", "b", "c"] | ||||
|     assert parsed["mode"] == "prod" | ||||
|  | ||||
|  | ||||
| def test_type_validation_failure(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_type_validation_failure(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--count", type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--count", "abc"], config) | ||||
|         await build_parser_and_parse(["--count", "abc"], config) | ||||
|  | ||||
|  | ||||
| def test_required_field_missing(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_required_field_missing(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str, required=True) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse([], config) | ||||
|         await build_parser_and_parse([], config) | ||||
|  | ||||
|  | ||||
| def test_choices_enforced(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_choices_enforced(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--mode", choices=["dev", "prod"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--mode", "staging"], config) | ||||
|         await build_parser_and_parse(["--mode", "staging"], config) | ||||
|  | ||||
|  | ||||
| def test_boolean_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_boolean_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) | ||||
|         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     assert parsed["debug"] is True | ||||
|     assert parsed["no_debug"] is False | ||||
|     parsed = build_parser_and_parse([], config) | ||||
|     print(parsed) | ||||
|     parsed = await build_parser_and_parse([], config) | ||||
|     assert parsed["debug"] is False | ||||
|     assert parsed["no_debug"] is True | ||||
|  | ||||
|  | ||||
| def test_count_action(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_count_action(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("-v", action=ArgumentAction.COUNT) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     assert parsed["v"] == 3 | ||||
|  | ||||
|  | ||||
| def test_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_nargs_star(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("args", nargs="*", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["one", "two", "three"], config) | ||||
|     parsed = await build_parser_and_parse(["one", "two", "three"], config) | ||||
|     assert parsed["args"] == ["one", "two", "three"] | ||||
|  | ||||
|  | ||||
| def test_flag_and_positional_mix(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_flag_and_positional_mix(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str) | ||||
|         parser.add_argument("tasks", nargs="+") | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     assert parsed["env"] == "prod" | ||||
|     assert parsed["tasks"] == ["build", "test"] | ||||
|  | ||||
| @@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest(): | ||||
|     parser.add_argument("-f", "--falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx"] | ||||
|     assert arg.flags == ("-f", "--falyx") | ||||
|  | ||||
|  | ||||
| def test_add_argument_flag_dest_conflict(): | ||||
| @@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test", dest="falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_multiple_flags_dest(): | ||||
| @@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_single_flag_dest(): | ||||
| @@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest(): | ||||
|     parser.add_argument("-f") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "f" | ||||
|     assert arg.flags == ["-f"] | ||||
|     assert arg.flags == ("-f",) | ||||
|  | ||||
|  | ||||
| def test_add_argument_bad_dest(): | ||||
| @@ -257,7 +268,7 @@ def test_add_argument_default_value(): | ||||
|     parser.add_argument("--falyx", default="default_value") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| @@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices(): | ||||
|         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_add_argument_choices(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     # ✅ Choices provided | ||||
|     parser.add_argument("--falyx", choices=["a", "b", "c"]) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.choices == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--falyx", "a"]) | ||||
|     args = await parser.parse_args(["--falyx", "a"]) | ||||
|     assert args["falyx"] == "a" | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--falyx", "d"]) | ||||
|         await parser.parse_args(["--falyx", "d"]) | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices_invalid(): | ||||
| @@ -333,26 +345,28 @@ def test_add_argument_choices_invalid(): | ||||
| def test_add_argument_bad_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     # ❌ Invalid nargs value | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--falyx", nargs="invalid") | ||||
|  | ||||
|     # ❌ Invalid nargs type | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--falyx", nargs=123) | ||||
|         parser.add_argument("--foo", nargs="123") | ||||
|  | ||||
|     # ❌ Invalid nargs type | ||||
|     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(): | ||||
|     parser = CommandArgumentParser() | ||||
|     # ✅ Valid nargs value | ||||
|     parser.add_argument("--falyx", nargs=2) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.nargs == 2 | ||||
|  | ||||
|  | ||||
| @@ -377,56 +391,62 @@ def test_get_argument(): | ||||
|     parser.add_argument("--falyx", type=str, default="default_value") | ||||
|     arg = parser.get_argument("falyx") | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|     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["mode"] == "c" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_flagged_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_flagged_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     args = await parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a"]) | ||||
|     args = await parser.parse_args(["--files", "a"]) | ||||
|     print(args) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_numbered_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_numbered_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b"]) | ||||
|     args = await parser.parse_args(["a", "b"]) | ||||
|     assert args["files"] == ["a", "b"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args(["a"]) | ||||
|         args = await parser.parse_args(["a"]) | ||||
|         print(args) | ||||
|  | ||||
|  | ||||
| @@ -436,48 +456,53 @@ def test_parse_args_nargs_zero(): | ||||
|         parser.add_argument("files", nargs=0, type=str) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_more_than_expected(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_more_than_expected(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["a", "b", "c", "d"]) | ||||
|         await parser.parse_args(["a", "b", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_one_or_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_one_or_none(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="?", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == "a" | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] is None | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="*", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args([]) | ||||
|         args = await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_multiple_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_multiple_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
| @@ -485,7 +510,7 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     parser.add_argument("target", nargs="*") | ||||
|     parser.add_argument("extra", nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     args = await parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|     assert args["mode"] == "d" | ||||
|     assert args["action"] == [] | ||||
| @@ -493,186 +518,311 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     assert args["extra"] == ["e"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_invalid_positional_arguments(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_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.add_argument("numbers", nargs="*", type=int) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["1", "2", "c", "d"]) | ||||
|         await parser.parse_args(["1", "2", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     assert args["numbers"] == [1, 2, 3] | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1"]) | ||||
|     args = await parser.parse_args(["--numbers", "1"]) | ||||
|     assert args["numbers"] == [1] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_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.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") | ||||
|     parser.add_argument("--mode") | ||||
|  | ||||
|     args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||
|     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||
|  | ||||
|     args = parser.parse_args(["1"]) | ||||
|     args = await parser.parse_args(["1"]) | ||||
|     assert args["numbers"] == [[1]] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args(["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"] == [] | ||||
|  | ||||
|  | ||||
| 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.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--numbers", "a"]) | ||||
|         await parser.parse_args(["--numbers", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_groups_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_groups_nargs(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) | ||||
|  | ||||
|     parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     assert parsed["item"] == [["a", "b"], ["c", "d"]] | ||||
|  | ||||
|     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.add_argument("--value", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     parsed = cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     parsed = await cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     assert parsed["value"] == ["x", "y"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_split_order(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_split_order(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("a") | ||||
|     cap.add_argument("--x") | ||||
|     cap.add_argument("b", nargs="*") | ||||
|     args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     assert args == ("1", ["2"]) | ||||
|     assert kwargs == {"x": "100"} | ||||
|  | ||||
|  | ||||
| def test_help_signal_triggers(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_help_signal_triggers(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--foo") | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_empty_parser_defaults(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_empty_parser_defaults(): | ||||
|     parser = CommandArgumentParser() | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_extend_basic(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_basic(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     assert args["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_2(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_2(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     assert args["pair"] == ["a", "b", "c", "d"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_star(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     args = await parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     assert args["files"] == ["x", "y", "z"] | ||||
|  | ||||
|     args = parser.parse_args(["--files"]) | ||||
|     args = await parser.parse_args(["--files"]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     assert args["inputs"] == [1, 2, 3, 4] | ||||
|  | ||||
|  | ||||
| def test_extend_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_greedy_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_greedy_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", nargs="*", type=int) | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args( | ||||
|     args = await parser.parse_args( | ||||
|         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] | ||||
|     ) | ||||
|     assert args["x"] == [["a", "b"], ["c", "d"]] | ||||
|     assert args["y"] == ["1", "2", "3", "4"] | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior_error(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior_error(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     # This should raise an error because the last argument is not a valid pair | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] | ||||
|         ) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def test_extend_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_positional_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| 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 | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from pathlib import Path | ||||
|  | ||||
| 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(): | ||||
| @@ -50,63 +50,3 @@ def test_bootstrap_with_global_config(): | ||||
|     assert str(config_file.parent) in sys.path | ||||
|     config_file.unlink() | ||||
|     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() | ||||
|   | ||||
							
								
								
									
										227
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										227
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,227 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, SelectionAction | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser 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"]) | ||||
							
								
								
									
										90
									
								
								tests/test_parsers/test_argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								tests/test_parsers/test_argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.parser 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() | ||||
							
								
								
									
										11
									
								
								tests/test_parsers/test_argument_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								tests/test_parsers/test_argument_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| from falyx.parser 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 | ||||
							
								
								
									
										49
									
								
								tests/test_parsers/test_basics.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								tests/test_parsers/test_basics.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser 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([]) | ||||
							
								
								
									
										153
									
								
								tests/test_parsers/test_coerce_value.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								tests/test_parsers/test_coerce_value.py
									
									
									
									
									
										Normal 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.parser.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 | ||||
							
								
								
									
										56
									
								
								tests/test_parsers/test_nargs.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								tests/test_parsers/test_nargs.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser 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"]) | ||||
							
								
								
									
										128
									
								
								tests/test_parsers/test_posix_bundling.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								tests/test_parsers/test_posix_bundling.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser 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"]) | ||||
| @@ -1,6 +1,7 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx import Action, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
|   | ||||
		Reference in New Issue
	
	Block a user