Compare commits
	
		
			38 Commits
		
	
	
		
			argparse-i
			...
			fddc3ea8d9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fddc3ea8d9 | |||
| 9b9f6434a4 | |||
| c15e3afa5e | |||
| dc1764e752 | |||
| 2288015cf3 | |||
| 68d7d89d64 | |||
| 9654b9926c | |||
| 294bbc9062 | |||
| 4c1498121f | |||
| ed42f6488e | |||
| e2f0bf5903 | |||
| bb325684ac | |||
| 38f5f1e934 | |||
| 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 | 
| @@ -52,7 +52,8 @@ poetry install | |||||||
| import asyncio | import asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Falyx, Action, ChainedAction | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
|  |  | ||||||
| # A flaky async step that fails randomly | # A flaky async step that fails randomly | ||||||
| async def flaky_step(): | async def flaky_step(): | ||||||
| @@ -62,8 +63,8 @@ async def flaky_step(): | |||||||
|     return "ok" |     return "ok" | ||||||
|  |  | ||||||
| # Create the actions | # Create the actions | ||||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | step1 = Action(name="step_1", action=flaky_step) | ||||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | step2 = Action(name="step_2", action=flaky_step) | ||||||
|  |  | ||||||
| # Chain the actions | # Chain the actions | ||||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||||
| @@ -74,9 +75,9 @@ falyx.add_command( | |||||||
|     key="R", |     key="R", | ||||||
|     description="Run My Pipeline", |     description="Run My Pipeline", | ||||||
|     action=chain, |     action=chain, | ||||||
|     logging_hooks=True, |  | ||||||
|     preview_before_confirm=True, |     preview_before_confirm=True, | ||||||
|     confirm=True, |     confirm=True, | ||||||
|  |     retry_all=True, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| # Entry point | # Entry point | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, ActionGroup, ChainedAction | from falyx.action import Action, ActionGroup, ChainedAction | ||||||
|  |  | ||||||
|  |  | ||||||
| # Actions can be defined as synchronous functions | # Actions can be defined as synchronous functions | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Falyx | from falyx import Falyx | ||||||
| from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction | from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction | ||||||
|  |  | ||||||
| # Selection of a post ID to fetch (just an example set) | # Selection of a post ID to fetch (just an example set) | ||||||
| post_selector = SelectionAction( | post_selector = SelectionAction( | ||||||
|     name="Pick Post ID", |     name="Pick Post ID", | ||||||
|     selections=["1", "2", "3", "4", "5"], |     selections=["15", "25", "35", "45", "55"], | ||||||
|     title="Choose a Post ID to submit", |     title="Choose a Post ID to submit", | ||||||
|     prompt_message="Post ID > ", |     prompt_message="Post ID > ", | ||||||
|     show_table=True, |     show_table=True, | ||||||
| @@ -14,7 +14,7 @@ post_selector = SelectionAction( | |||||||
|  |  | ||||||
|  |  | ||||||
| # Factory that builds and executes the actual HTTP POST request | # Factory that builds and executes the actual HTTP POST request | ||||||
| def build_post_action(post_id) -> HTTPAction: | async def build_post_action(post_id) -> HTTPAction: | ||||||
|     print(f"Building HTTPAction for Post ID: {post_id}") |     print(f"Building HTTPAction for Post ID: {post_id}") | ||||||
|     return HTTPAction( |     return HTTPAction( | ||||||
|         name=f"POST to /posts (id={post_id})", |         name=f"POST to /posts (id={post_id})", | ||||||
| @@ -24,7 +24,7 @@ def build_post_action(post_id) -> HTTPAction: | |||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| post_factory = ActionFactoryAction( | post_factory = ActionFactory( | ||||||
|     name="Build HTTPAction from Post ID", |     name="Build HTTPAction from Post ID", | ||||||
|     factory=build_post_action, |     factory=build_post_action, | ||||||
|     inject_last_result=True, |     inject_last_result=True, | ||||||
|   | |||||||
							
								
								
									
										87
									
								
								examples/argument_examples.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								examples/argument_examples.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | import asyncio | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.action import Action | ||||||
|  | from falyx.parser.command_argument_parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Place(Enum): | ||||||
|  |     """Enum for different places.""" | ||||||
|  |  | ||||||
|  |     NEW_YORK = "New York" | ||||||
|  |     SAN_FRANCISCO = "San Francisco" | ||||||
|  |     LONDON = "London" | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return self.value | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_args( | ||||||
|  |     service: str, | ||||||
|  |     place: Place = Place.NEW_YORK, | ||||||
|  |     region: str = "us-east-1", | ||||||
|  |     tag: str | None = None, | ||||||
|  |     verbose: bool | None = None, | ||||||
|  |     number: int | None = None, | ||||||
|  | ) -> str: | ||||||
|  |     if verbose: | ||||||
|  |         print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") | ||||||
|  |     return f"{service}:{tag}:{number} deployed to {region} at {place}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def default_config(parser: CommandArgumentParser) -> None: | ||||||
|  |     """Default argument configuration for the command.""" | ||||||
|  |     parser.add_argument( | ||||||
|  |         "service", | ||||||
|  |         type=str, | ||||||
|  |         choices=["web", "database", "cache"], | ||||||
|  |         help="Service name to deploy.", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "place", | ||||||
|  |         type=Place, | ||||||
|  |         choices=list(Place), | ||||||
|  |         default=Place.NEW_YORK, | ||||||
|  |         help="Place where the service will be deployed.", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--region", | ||||||
|  |         type=str, | ||||||
|  |         default="us-east-1", | ||||||
|  |         help="Deployment region.", | ||||||
|  |         choices=["us-east-1", "us-west-2", "eu-west-1"], | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--verbose", | ||||||
|  |         action="store_bool_optional", | ||||||
|  |         help="Enable verbose output.", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--tag", | ||||||
|  |         type=str, | ||||||
|  |         help="Optional tag for the deployment.", | ||||||
|  |         suggestions=["latest", "stable", "beta"], | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--number", | ||||||
|  |         type=int, | ||||||
|  |         help="Optional number argument.", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | flx = Falyx("Argument Examples") | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="T", | ||||||
|  |     aliases=["test"], | ||||||
|  |     description="Test Command", | ||||||
|  |     help_text="A command to test argument parsing.", | ||||||
|  |     action=Action( | ||||||
|  |         name="test_args", | ||||||
|  |         action=test_args, | ||||||
|  |     ), | ||||||
|  |     argument_config=default_config, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | asyncio.run(flx.run()) | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, ActionGroup, Command, Falyx | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ActionGroup | ||||||
|  |  | ||||||
|  |  | ||||||
| # Define a shared async function | # Define a shared async function | ||||||
| @@ -19,12 +20,12 @@ action3 = Action("say_hello_3", action=say_hello) | |||||||
| # Combine into an ActionGroup | # Combine into an ActionGroup | ||||||
| group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) | group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) | ||||||
|  |  | ||||||
| # Create the Command with auto_args=True | flx = Falyx("Test Group") | ||||||
| cmd = Command( | flx.add_command( | ||||||
|     key="G", |     key="G", | ||||||
|     description="Greet someone with multiple variations.", |     description="Greet someone with multiple variations.", | ||||||
|  |     aliases=["greet", "hello"], | ||||||
|     action=group, |     action=group, | ||||||
|     auto_args=True, |  | ||||||
|     arg_metadata={ |     arg_metadata={ | ||||||
|         "name": { |         "name": { | ||||||
|             "help": "The name of the person to greet.", |             "help": "The name of the person to greet.", | ||||||
| @@ -34,7 +35,4 @@ cmd = Command( | |||||||
|         }, |         }, | ||||||
|     }, |     }, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| flx = Falyx("Test Group") |  | ||||||
| flx.add_command_from_command(cmd) |  | ||||||
| asyncio.run(flx.run()) | asyncio.run(flx.run()) | ||||||
|   | |||||||
| @@ -1,14 +1,19 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, Falyx | 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): | async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str: | ||||||
|     if verbose: |     if verbose: | ||||||
|         print(f"Deploying {service} to {region}...") |         print(f"Deploying {service} to {region}...") | ||||||
|     await asyncio.sleep(2) |     await asyncio.sleep(2) | ||||||
|     if verbose: |     if verbose: | ||||||
|         print(f"{service} deployed successfully!") |         print(f"{service} deployed successfully!") | ||||||
|  |     return f"{service} deployed to {region}" | ||||||
|  |  | ||||||
|  |  | ||||||
| flx = Falyx("Deployment CLI") | flx = Falyx("Deployment CLI") | ||||||
| @@ -16,17 +21,42 @@ flx = Falyx("Deployment CLI") | |||||||
| flx.add_command( | flx.add_command( | ||||||
|     key="D", |     key="D", | ||||||
|     aliases=["deploy"], |     aliases=["deploy"], | ||||||
|     description="Deploy a service to a specified region.", |     description="Deploy", | ||||||
|  |     help_text="Deploy a service to a specified region.", | ||||||
|     action=Action( |     action=Action( | ||||||
|         name="deploy_service", |         name="deploy_service", | ||||||
|         action=deploy, |         action=deploy, | ||||||
|     ), |     ), | ||||||
|     auto_args=True, |  | ||||||
|     arg_metadata={ |     arg_metadata={ | ||||||
|         "service": "Service name", |         "service": "Service name", | ||||||
|         "region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]}, |         "region": { | ||||||
|  |             "help": "Deployment region", | ||||||
|  |             "choices": ["us-east-1", "us-west-2", "eu-west-1"], | ||||||
|  |         }, | ||||||
|         "verbose": {"help": "Enable verbose mode"}, |         "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()) | asyncio.run(flx.run()) | ||||||
|   | |||||||
							
								
								
									
										121
									
								
								examples/confirm_example.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								examples/confirm_example.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,121 @@ | |||||||
|  | import asyncio | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.action import ( | ||||||
|  |     Action, | ||||||
|  |     ActionFactory, | ||||||
|  |     ChainedAction, | ||||||
|  |     ConfirmAction, | ||||||
|  |     SaveFileAction, | ||||||
|  | ) | ||||||
|  | from falyx.parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Dog(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     age: int | ||||||
|  |     breed: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def get_dogs(*dog_names: str) -> list[Dog]: | ||||||
|  |     """Simulate fetching dog data.""" | ||||||
|  |     await asyncio.sleep(0.1)  # Simulate network delay | ||||||
|  |     dogs = [ | ||||||
|  |         Dog(name="Buddy", age=3, breed="Golden Retriever"), | ||||||
|  |         Dog(name="Max", age=5, breed="Beagle"), | ||||||
|  |         Dog(name="Bella", age=2, breed="Bulldog"), | ||||||
|  |         Dog(name="Charlie", age=4, breed="Poodle"), | ||||||
|  |         Dog(name="Lucy", age=1, breed="Labrador"), | ||||||
|  |         Dog(name="Spot", age=6, breed="German Shepherd"), | ||||||
|  |     ] | ||||||
|  |     dogs = [ | ||||||
|  |         dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names) | ||||||
|  |     ] | ||||||
|  |     if not dogs: | ||||||
|  |         raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}") | ||||||
|  |     return dogs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]: | ||||||
|  |     """Build JSON updates for the dogs.""" | ||||||
|  |     print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}") | ||||||
|  |     return [dog.model_dump(mode="json") for dog in dogs] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def save_dogs(dogs) -> None: | ||||||
|  |     if not dogs: | ||||||
|  |         print("No dogs processed.") | ||||||
|  |         return | ||||||
|  |     for result in dogs: | ||||||
|  |         print(f"Saving {Dog(**result)} to file.") | ||||||
|  |         await SaveFileAction( | ||||||
|  |             name="Save Dog Data", | ||||||
|  |             file_path=f"dogs/{result['name']}.json", | ||||||
|  |             data=result, | ||||||
|  |             file_type="json", | ||||||
|  |         )() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def build_chain(dogs: list[Dog]) -> ChainedAction: | ||||||
|  |     return ChainedAction( | ||||||
|  |         name="test_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action( | ||||||
|  |                 name="build_json_updates", | ||||||
|  |                 action=build_json_updates, | ||||||
|  |                 kwargs={"dogs": dogs}, | ||||||
|  |             ), | ||||||
|  |             ConfirmAction( | ||||||
|  |                 name="test_confirm", | ||||||
|  |                 message="Do you want to process the dogs?", | ||||||
|  |                 confirm_type="yes_no_cancel", | ||||||
|  |                 return_last_result=True, | ||||||
|  |                 inject_into="dogs", | ||||||
|  |             ), | ||||||
|  |             Action( | ||||||
|  |                 name="save_dogs", | ||||||
|  |                 action=save_dogs, | ||||||
|  |                 inject_into="dogs", | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | factory = ActionFactory( | ||||||
|  |     name="Dog Post Factory", | ||||||
|  |     factory=build_chain, | ||||||
|  |     preview_kwargs={"dogs": ["Buddy", "Max"]}, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def dog_config(parser: CommandArgumentParser) -> None: | ||||||
|  |     parser.add_argument( | ||||||
|  |         "dogs", | ||||||
|  |         nargs="+", | ||||||
|  |         action="action", | ||||||
|  |         resolver=Action("Get Dogs", get_dogs), | ||||||
|  |         lazy_resolver=False, | ||||||
|  |         help="List of dogs to process.", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def main(): | ||||||
|  |     flx = Falyx("Save Dogs Example") | ||||||
|  |  | ||||||
|  |     flx.add_command( | ||||||
|  |         key="D", | ||||||
|  |         description="Save Dog Data", | ||||||
|  |         action=factory, | ||||||
|  |         aliases=["save_dogs"], | ||||||
|  |         argument_config=dog_config, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     await flx.run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     asyncio.run(main()) | ||||||
| @@ -3,7 +3,7 @@ commands: | |||||||
|     description: Pipeline Demo |     description: Pipeline Demo | ||||||
|     action: pipeline_demo.pipeline |     action: pipeline_demo.pipeline | ||||||
|     tags: [pipeline, demo] |     tags: [pipeline, demo] | ||||||
|     help_text: Run Demployment Pipeline with retries. |     help_text: Run Deployment Pipeline with retries. | ||||||
|  |  | ||||||
|   - key: G |   - key: G | ||||||
|     description: Run HTTP Action Group |     description: Run HTTP Action Group | ||||||
|   | |||||||
| @@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details. | |||||||
|  |  | ||||||
| import asyncio | import asyncio | ||||||
| import random | import random | ||||||
| from argparse import Namespace |  | ||||||
|  |  | ||||||
| from falyx.action import Action, ActionGroup, ChainedAction | from falyx.action import Action, ActionGroup, ChainedAction | ||||||
| from falyx.falyx import Falyx | from falyx.falyx import Falyx | ||||||
| from falyx.parsers import FalyxParsers, get_arg_parsers |  | ||||||
| from falyx.version import __version__ | from falyx.version import __version__ | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -74,17 +72,10 @@ class Foo: | |||||||
|         await self.flx.run() |         await self.flx.run() | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_args() -> Namespace: |  | ||||||
|     parsers: FalyxParsers = get_arg_parsers() |  | ||||||
|     return parsers.parse_args() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def main() -> None: | async def main() -> None: | ||||||
|     """Build and return a Falyx instance with all your commands.""" |     """Build and return a Falyx instance with all your commands.""" | ||||||
|     args = parse_args() |  | ||||||
|     flx = Falyx( |     flx = Falyx( | ||||||
|         title="🚀 Falyx CLI", |         title="🚀 Falyx CLI", | ||||||
|         cli_args=args, |  | ||||||
|         columns=5, |         columns=5, | ||||||
|         welcome_message="Welcome to Falyx CLI!", |         welcome_message="Welcome to Falyx CLI!", | ||||||
|         exit_message="Goodbye!", |         exit_message="Goodbye!", | ||||||
|   | |||||||
| @@ -2,18 +2,24 @@ import asyncio | |||||||
|  |  | ||||||
| from falyx import Falyx | from falyx import Falyx | ||||||
| from falyx.action import SelectFileAction | from falyx.action import SelectFileAction | ||||||
| from falyx.action.types import FileReturnType | from falyx.action.action_types import FileType | ||||||
|  |  | ||||||
| sf = SelectFileAction( | sf = SelectFileAction( | ||||||
|     name="select_file", |     name="select_file", | ||||||
|     suffix_filter=".py", |     suffix_filter=".yaml", | ||||||
|     title="Select a YAML file", |     title="Select a YAML file", | ||||||
|     prompt_message="Choose > ", |     prompt_message="Choose 2 > ", | ||||||
|     return_type=FileReturnType.TEXT, |     return_type=FileType.TEXT, | ||||||
|     columns=3, |     columns=3, | ||||||
|  |     number_selections=2, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| flx = Falyx() | flx = Falyx( | ||||||
|  |     title="File Selection Example", | ||||||
|  |     description="This example demonstrates how to select files using Falyx.", | ||||||
|  |     version="1.0.0", | ||||||
|  |     program="file_select.py", | ||||||
|  | ) | ||||||
|  |  | ||||||
| flx.add_command( | flx.add_command( | ||||||
|     key="S", |     key="S", | ||||||
|   | |||||||
| @@ -2,9 +2,8 @@ import asyncio | |||||||
|  |  | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
|  |  | ||||||
| from falyx import ActionGroup, Falyx | from falyx import Falyx | ||||||
| from falyx.action import HTTPAction | from falyx.action import ActionGroup, HTTPAction | ||||||
| from falyx.hook_manager import HookType |  | ||||||
| from falyx.hooks import ResultReporter | from falyx.hooks import ResultReporter | ||||||
|  |  | ||||||
| console = Console() | console = Console() | ||||||
| @@ -49,7 +48,7 @@ action_group = ActionGroup( | |||||||
| reporter = ResultReporter() | reporter = ResultReporter() | ||||||
|  |  | ||||||
| action_group.hooks.register( | action_group.hooks.register( | ||||||
|     HookType.ON_SUCCESS, |     "on_success", | ||||||
|     reporter.report, |     reporter.report, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,8 +2,16 @@ import asyncio | |||||||
| import time | import time | ||||||
|  |  | ||||||
| from falyx import Falyx | from falyx import Falyx | ||||||
| from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction | from falyx.action import ( | ||||||
|  |     Action, | ||||||
|  |     ActionGroup, | ||||||
|  |     ChainedAction, | ||||||
|  |     MenuAction, | ||||||
|  |     ProcessAction, | ||||||
|  |     PromptMenuAction, | ||||||
|  | ) | ||||||
| from falyx.menu import MenuOption, MenuOptionMap | from falyx.menu import MenuOption, MenuOptionMap | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
| # Basic coroutine for Action | # Basic coroutine for Action | ||||||
| @@ -77,20 +85,28 @@ parallel = ActionGroup( | |||||||
|  |  | ||||||
| process = ProcessAction(name="compute", action=heavy_computation) | process = ProcessAction(name="compute", action=heavy_computation) | ||||||
|  |  | ||||||
|  | menu_options = MenuOptionMap( | ||||||
|  |     { | ||||||
|  |         "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), | ||||||
|  |         "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), | ||||||
|  |         "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), | ||||||
|  |         "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), | ||||||
|  |     } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| # Menu setup | # Menu setup | ||||||
|  |  | ||||||
| menu = MenuAction( | menu = MenuAction( | ||||||
|     name="main-menu", |     name="main-menu", | ||||||
|     title="Choose a task to run", |     title="Choose a task to run", | ||||||
|     menu_options=MenuOptionMap( |     menu_options=menu_options, | ||||||
|         { | ) | ||||||
|             "1": MenuOption("Run basic Action", basic_action), |  | ||||||
|             "2": MenuOption("Run ChainedAction", chained), |  | ||||||
|             "3": MenuOption("Run ActionGroup (parallel)", parallel), | prompt_menu = PromptMenuAction( | ||||||
|             "4": MenuOption("Run ProcessAction (heavy task)", process), |     name="select-user", | ||||||
|         } |     menu_options=menu_options, | ||||||
|     ), |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| flx = Falyx( | flx = Falyx( | ||||||
| @@ -108,6 +124,13 @@ flx.add_command( | |||||||
|     logging_hooks=True, |     logging_hooks=True, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="P", | ||||||
|  |     description="Show Prompt Menu", | ||||||
|  |     action=prompt_menu, | ||||||
|  |     logging_hooks=True, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     asyncio.run(flx.run()) |     asyncio.run(flx.run()) | ||||||
|   | |||||||
| @@ -1,9 +1,7 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, ActionGroup, ChainedAction |  | ||||||
| from falyx import ExecutionRegistry as er | from falyx import ExecutionRegistry as er | ||||||
| from falyx import ProcessAction | from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction | ||||||
| from falyx.hook_manager import HookType |  | ||||||
| from falyx.retry import RetryHandler, RetryPolicy | from falyx.retry import RetryHandler, RetryPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -47,7 +45,7 @@ def build_pipeline(): | |||||||
|     checkout = Action("Checkout", checkout_code) |     checkout = Action("Checkout", checkout_code) | ||||||
|     analysis = ProcessAction("Static Analysis", run_static_analysis) |     analysis = ProcessAction("Static Analysis", run_static_analysis) | ||||||
|     tests = Action("Run Tests", flaky_tests) |     tests = Action("Run Tests", flaky_tests) | ||||||
|     tests.hooks.register(HookType.ON_ERROR, retry_handler.retry_on_error) |     tests.hooks.register("on_error", retry_handler.retry_on_error) | ||||||
|  |  | ||||||
|     # Parallel deploys |     # Parallel deploys | ||||||
|     deploy_group = ActionGroup( |     deploy_group = ActionGroup( | ||||||
|   | |||||||
| @@ -1,25 +1,36 @@ | |||||||
| from rich.console import Console | from rich.console import Console | ||||||
|  |  | ||||||
| from falyx import Falyx, ProcessAction | from falyx import Falyx | ||||||
|  | from falyx.action import ProcessPoolAction | ||||||
|  | from falyx.action.process_pool_action import ProcessTask | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.themes import NordColors as nc | from falyx.themes import NordColors as nc | ||||||
|  |  | ||||||
| console = Console() | console = Console() | ||||||
| falyx = Falyx(title="🚀 Process Pool Demo") | falyx = Falyx(title="🚀 Process Pool Demo") | ||||||
|  |  | ||||||
|  |  | ||||||
| def generate_primes(n): | def generate_primes(start: int = 2, end: int = 100_000) -> list[int]: | ||||||
|     primes = [] |     primes: list[int] = [] | ||||||
|     for num in range(2, n): |     console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW) | ||||||
|  |     for num in range(start, end): | ||||||
|         if all(num % p != 0 for p in primes): |         if all(num % p != 0 for p in primes): | ||||||
|             primes.append(num) |             primes.append(num) | ||||||
|     console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) |     console.print( | ||||||
|  |         f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN | ||||||
|  |     ) | ||||||
|     return primes |     return primes | ||||||
|  |  | ||||||
|  |  | ||||||
| # Will not block the event loop | actions = [ProcessTask(task=generate_primes)] | ||||||
| heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,)) |  | ||||||
|  |  | ||||||
| falyx.add_command("R", "Generate Primes", heavy_action, spinner=True) | # Will not block the event loop | ||||||
|  | heavy_action = ProcessPoolAction( | ||||||
|  |     name="Prime Generator", | ||||||
|  |     actions=actions, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | falyx.add_command("R", "Generate Primes", heavy_action) | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, Falyx | from falyx import Falyx | ||||||
|  | from falyx.action import Action | ||||||
|  |  | ||||||
|  |  | ||||||
| async def main(): | async def main(): | ||||||
|   | |||||||
| @@ -1,22 +1,70 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  | from uuid import uuid4 | ||||||
|  |  | ||||||
| from falyx.selection import ( | from falyx import Falyx | ||||||
|     SelectionOption, | from falyx.action import SelectionAction | ||||||
|     prompt_for_selection, | from falyx.selection import SelectionOption | ||||||
|     render_selection_dict_table, | from falyx.signals import CancelSignal | ||||||
| ) |  | ||||||
|  |  | ||||||
| menu = { | selections = { | ||||||
|     "A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")), |     "1": SelectionOption( | ||||||
|     "B": SelectionOption("Deploy to staging", lambda: print("Deploying...")), |         description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac" | ||||||
|  |     ), | ||||||
|  |     "2": SelectionOption( | ||||||
|  |         description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac" | ||||||
|  |     ), | ||||||
| } | } | ||||||
|  |  | ||||||
| table = render_selection_dict_table( |  | ||||||
|     title="Main Menu", | select = SelectionAction( | ||||||
|     selections=menu, |     name="Select Deployment", | ||||||
|  |     selections=selections, | ||||||
|  |     title="Select a Deployment", | ||||||
|  |     columns=2, | ||||||
|  |     prompt_message="> ", | ||||||
|  |     return_type="value", | ||||||
|  |     show_table=True, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| key = asyncio.run(prompt_for_selection(menu.keys(), table)) | list_selections = [uuid4() for _ in range(10)] | ||||||
| print(f"You selected: {key}") |  | ||||||
|  |  | ||||||
| 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 | #!/usr/bin/env python | ||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, ChainedAction, Falyx | from falyx import Falyx | ||||||
| from falyx.action import ShellAction | from falyx.action import Action, ChainedAction, ShellAction | ||||||
| from falyx.hook_manager import HookType |  | ||||||
| from falyx.hooks import ResultReporter | from falyx.hooks import ResultReporter | ||||||
| from falyx.utils import setup_logging | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
| @@ -42,12 +41,12 @@ reporter = ResultReporter() | |||||||
|  |  | ||||||
| a1 = Action("a1", a1, inject_last_result=True) | a1 = Action("a1", a1, inject_last_result=True) | ||||||
| a1.hooks.register( | a1.hooks.register( | ||||||
|     HookType.ON_SUCCESS, |     "on_success", | ||||||
|     reporter.report, |     reporter.report, | ||||||
| ) | ) | ||||||
| a2 = Action("a2", a2, inject_last_result=True) | a2 = Action("a2", a2, inject_last_result=True) | ||||||
| a2.hooks.register( | a2.hooks.register( | ||||||
|     HookType.ON_SUCCESS, |     "on_success", | ||||||
|     reporter.report, |     reporter.report, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Action, ChainedAction, Falyx | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
| from falyx.utils import setup_logging | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
| setup_logging() | setup_logging() | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Action, ChainedAction, Falyx | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
| from falyx.utils import setup_logging | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
| setup_logging() | setup_logging() | ||||||
|   | |||||||
							
								
								
									
										100
									
								
								examples/type_validation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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,13 @@ Licensed under the MIT License. See LICENSE file for details. | |||||||
|  |  | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from .action.action import Action, ActionGroup, ChainedAction, ProcessAction |  | ||||||
| from .command import Command |  | ||||||
| from .context import ExecutionContext, SharedContext |  | ||||||
| from .execution_registry import ExecutionRegistry | from .execution_registry import ExecutionRegistry | ||||||
| from .falyx import Falyx | from .falyx import Falyx | ||||||
| from .hook_manager import HookType |  | ||||||
|  |  | ||||||
| logger = logging.getLogger("falyx") | logger = logging.getLogger("falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
| __all__ = [ | __all__ = [ | ||||||
|     "Action", |  | ||||||
|     "ChainedAction", |  | ||||||
|     "ActionGroup", |  | ||||||
|     "ProcessAction", |  | ||||||
|     "Falyx", |     "Falyx", | ||||||
|     "Command", |  | ||||||
|     "ExecutionContext", |  | ||||||
|     "SharedContext", |  | ||||||
|     "ExecutionRegistry", |     "ExecutionRegistry", | ||||||
|     "HookType", |  | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details. | |||||||
| import asyncio | import asyncio | ||||||
| import os | import os | ||||||
| import sys | import sys | ||||||
| from argparse import Namespace | from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from falyx.config import loader | from falyx.config import loader | ||||||
| from falyx.falyx import Falyx | from falyx.falyx import Falyx | ||||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers | ||||||
|  |  | ||||||
|  |  | ||||||
| def find_falyx_config() -> Path | None: | def find_falyx_config() -> Path | None: | ||||||
| @@ -39,44 +39,81 @@ def bootstrap() -> Path | None: | |||||||
|     return config_path |     return config_path | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_falyx_parsers() -> FalyxParsers: | def init_config(parser: CommandArgumentParser) -> None: | ||||||
|     falyx_parsers: FalyxParsers = get_arg_parsers() |     parser.add_argument( | ||||||
|     init_parser = falyx_parsers.subparsers.add_parser( |         "name", | ||||||
|         "init", help="Create a new Falyx CLI project" |         type=str, | ||||||
|  |         help="Name of the new Falyx project", | ||||||
|  |         default=".", | ||||||
|  |         nargs="?", | ||||||
|     ) |     ) | ||||||
|     init_parser.add_argument("name", nargs="?", default=".", help="Project directory") |  | ||||||
|     falyx_parsers.subparsers.add_parser( |  | ||||||
|         "init-global", help="Set up ~/.config/falyx with example tasks" |  | ||||||
|     ) |  | ||||||
|     return falyx_parsers |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def run(args: Namespace) -> Any: | def init_callback(args: Namespace) -> None: | ||||||
|  |     """Callback for the init command.""" | ||||||
|     if args.command == "init": |     if args.command == "init": | ||||||
|         from falyx.init import init_project |         from falyx.init import init_project | ||||||
|  |  | ||||||
|         init_project(args.name) |         init_project(args.name) | ||||||
|         return |     elif args.command == "init_global": | ||||||
|  |  | ||||||
|     if args.command == "init-global": |  | ||||||
|         from falyx.init import init_global |         from falyx.init import init_global | ||||||
|  |  | ||||||
|         init_global() |         init_global() | ||||||
|         return |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]: | ||||||
|  |     root_parser: ArgumentParser = get_root_parser() | ||||||
|  |     subparsers = get_subparsers(root_parser) | ||||||
|  |     init_parser = subparsers.add_parser( | ||||||
|  |         "init", | ||||||
|  |         help="Initialize a new Falyx project", | ||||||
|  |         description="Create a new Falyx project with mock configuration files.", | ||||||
|  |         epilog="If no name is provided, the current directory will be used.", | ||||||
|  |     ) | ||||||
|  |     init_parser.add_argument( | ||||||
|  |         "name", | ||||||
|  |         type=str, | ||||||
|  |         help="Name of the new Falyx project", | ||||||
|  |         default=".", | ||||||
|  |         nargs="?", | ||||||
|  |     ) | ||||||
|  |     subparsers.add_parser( | ||||||
|  |         "init-global", | ||||||
|  |         help="Initialize Falyx global configuration", | ||||||
|  |         description="Create a global Falyx configuration at ~/.config/falyx/.", | ||||||
|  |     ) | ||||||
|  |     return root_parser, subparsers | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def main() -> Any: | ||||||
|     bootstrap_path = bootstrap() |     bootstrap_path = bootstrap() | ||||||
|     if not bootstrap_path: |     if not bootstrap_path: | ||||||
|         print("No Falyx config file found. Exiting.") |         from falyx.init import init_global, init_project | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     flx: Falyx = loader(bootstrap_path) |         flx: Falyx = Falyx() | ||||||
|     return asyncio.run(flx.run()) |         flx.add_command( | ||||||
|  |             "I", | ||||||
|  |             "Initialize a new Falyx project", | ||||||
|  |             init_project, | ||||||
|  |             aliases=["init"], | ||||||
|  |             argument_config=init_config, | ||||||
|  |             help_epilog="If no name is provided, the current directory will be used.", | ||||||
|  |         ) | ||||||
|  |         flx.add_command( | ||||||
|  |             "G", | ||||||
|  |             "Initialize Falyx global configuration", | ||||||
|  |             init_global, | ||||||
|  |             aliases=["init-global"], | ||||||
|  |             help_text="Create a global Falyx configuration at ~/.config/falyx/.", | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         flx = loader(bootstrap_path) | ||||||
|  |  | ||||||
|  |     root_parser, subparsers = get_parsers() | ||||||
|  |  | ||||||
| def main(): |     return asyncio.run( | ||||||
|     parsers = get_falyx_parsers() |         flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback) | ||||||
|     args = parsers.parse_args() |     ) | ||||||
|     run(args) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|   | |||||||
| @@ -5,21 +5,25 @@ Copyright (c) 2025 rtj.dev LLC. | |||||||
| Licensed under the MIT License. See LICENSE file for details. | Licensed under the MIT License. See LICENSE file for details. | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from .action import ( | from .action import Action | ||||||
|     Action, | from .action_factory import ActionFactory | ||||||
|     ActionGroup, | from .action_group import ActionGroup | ||||||
|     BaseAction, | from .base_action import BaseAction | ||||||
|     ChainedAction, | from .chained_action import ChainedAction | ||||||
|     FallbackAction, | from .confirm_action import ConfirmAction | ||||||
|     LiteralInputAction, | from .fallback_action import FallbackAction | ||||||
|     ProcessAction, |  | ||||||
| ) |  | ||||||
| from .action_factory import ActionFactoryAction |  | ||||||
| from .http_action import HTTPAction | from .http_action import HTTPAction | ||||||
| from .io_action import BaseIOAction, ShellAction | from .io_action import BaseIOAction | ||||||
|  | from .literal_input_action import LiteralInputAction | ||||||
|  | from .load_file_action import LoadFileAction | ||||||
| from .menu_action import MenuAction | from .menu_action import MenuAction | ||||||
|  | from .process_action import ProcessAction | ||||||
|  | from .process_pool_action import ProcessPoolAction | ||||||
|  | from .prompt_menu_action import PromptMenuAction | ||||||
|  | from .save_file_action import SaveFileAction | ||||||
| from .select_file_action import SelectFileAction | from .select_file_action import SelectFileAction | ||||||
| from .selection_action import SelectionAction | from .selection_action import SelectionAction | ||||||
|  | from .shell_action import ShellAction | ||||||
| from .signal_action import SignalAction | from .signal_action import SignalAction | ||||||
| from .user_input_action import UserInputAction | from .user_input_action import UserInputAction | ||||||
|  |  | ||||||
| @@ -29,7 +33,7 @@ __all__ = [ | |||||||
|     "BaseAction", |     "BaseAction", | ||||||
|     "ChainedAction", |     "ChainedAction", | ||||||
|     "ProcessAction", |     "ProcessAction", | ||||||
|     "ActionFactoryAction", |     "ActionFactory", | ||||||
|     "HTTPAction", |     "HTTPAction", | ||||||
|     "BaseIOAction", |     "BaseIOAction", | ||||||
|     "ShellAction", |     "ShellAction", | ||||||
| @@ -40,4 +44,9 @@ __all__ = [ | |||||||
|     "FallbackAction", |     "FallbackAction", | ||||||
|     "LiteralInputAction", |     "LiteralInputAction", | ||||||
|     "UserInputAction", |     "UserInputAction", | ||||||
|  |     "PromptMenuAction", | ||||||
|  |     "ProcessPoolAction", | ||||||
|  |     "LoadFileAction", | ||||||
|  |     "SaveFileAction", | ||||||
|  |     "ConfirmAction", | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -1,167 +1,21 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """action.py | """action.py""" | ||||||
|  |  | ||||||
| Core action system for Falyx. |  | ||||||
|  |  | ||||||
| This module defines the building blocks for executable actions and workflows, |  | ||||||
| providing a structured way to compose, execute, recover, and manage sequences of |  | ||||||
| operations. |  | ||||||
|  |  | ||||||
| All actions are callable and follow a unified signature: |  | ||||||
|     result = action(*args, **kwargs) |  | ||||||
|  |  | ||||||
| Core guarantees: |  | ||||||
| - Full hook lifecycle support (before, on_success, on_error, after, on_teardown). |  | ||||||
| - Consistent timing and execution context tracking for each run. |  | ||||||
| - Unified, predictable result handling and error propagation. |  | ||||||
| - Optional last_result injection to enable flexible, data-driven workflows. |  | ||||||
| - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback |  | ||||||
|   recovery. |  | ||||||
|  |  | ||||||
| Key components: |  | ||||||
| - Action: wraps a function or coroutine into a standard executable unit. |  | ||||||
| - ChainedAction: runs actions sequentially, optionally injecting last results. |  | ||||||
| - ActionGroup: runs actions in parallel and gathers results. |  | ||||||
| - ProcessAction: executes CPU-bound functions in a separate process. |  | ||||||
| - LiteralInputAction: injects static values into workflows. |  | ||||||
| - FallbackAction: gracefully recovers from failures or missing data. |  | ||||||
|  |  | ||||||
| This design promotes clean, fault-tolerant, modular CLI and automation systems. |  | ||||||
| """ |  | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| import asyncio | from typing import Any, Awaitable, Callable | ||||||
| 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 rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext, SharedContext | from falyx.action.base_action import BaseAction | ||||||
| from falyx.debug import register_debug_hooks | from falyx.context import ExecutionContext | ||||||
| from falyx.exceptions import EmptyChainError |  | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import Hook, HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.options_manager import OptionsManager |  | ||||||
| from falyx.retry import RetryHandler, RetryPolicy | from falyx.retry import RetryHandler, RetryPolicy | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import ensure_async | from falyx.utils import ensure_async | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAction(ABC): |  | ||||||
|     """ |  | ||||||
|     Base class for actions. Actions can be simple functions or more |  | ||||||
|     complex actions like `ChainedAction` or `ActionGroup`. They can also |  | ||||||
|     be run independently or as part of Falyx. |  | ||||||
|  |  | ||||||
|     inject_last_result (bool): Whether to inject the previous action's result |  | ||||||
|                                into kwargs. |  | ||||||
|     inject_into (str): The name of the kwarg key to inject the result as |  | ||||||
|                                  (default: 'last_result'). |  | ||||||
|     _requires_injection (bool): Whether the action requires input injection. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def __init__( |  | ||||||
|         self, |  | ||||||
|         name: str, |  | ||||||
|         *, |  | ||||||
|         hooks: HookManager | None = None, |  | ||||||
|         inject_last_result: bool = False, |  | ||||||
|         inject_into: str = "last_result", |  | ||||||
|         never_prompt: bool = False, |  | ||||||
|         logging_hooks: bool = False, |  | ||||||
|     ) -> None: |  | ||||||
|         self.name = name |  | ||||||
|         self.hooks = hooks or HookManager() |  | ||||||
|         self.is_retryable: bool = False |  | ||||||
|         self.shared_context: SharedContext | None = None |  | ||||||
|         self.inject_last_result: bool = inject_last_result |  | ||||||
|         self.inject_into: str = inject_into |  | ||||||
|         self._never_prompt: bool = never_prompt |  | ||||||
|         self._requires_injection: bool = False |  | ||||||
|         self._skip_in_chain: bool = False |  | ||||||
|         self.console = Console(color_system="auto") |  | ||||||
|         self.options_manager: OptionsManager | None = None |  | ||||||
|  |  | ||||||
|         if logging_hooks: |  | ||||||
|             register_debug_hooks(self.hooks) |  | ||||||
|  |  | ||||||
|     async def __call__(self, *args, **kwargs) -> Any: |  | ||||||
|         return await self._run(*args, **kwargs) |  | ||||||
|  |  | ||||||
|     @abstractmethod |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |  | ||||||
|         raise NotImplementedError("_run must be implemented by subclasses") |  | ||||||
|  |  | ||||||
|     @abstractmethod |  | ||||||
|     async def preview(self, parent: Tree | None = None): |  | ||||||
|         raise NotImplementedError("preview must be implemented by subclasses") |  | ||||||
|  |  | ||||||
|     def set_options_manager(self, options_manager: OptionsManager) -> None: |  | ||||||
|         self.options_manager = options_manager |  | ||||||
|  |  | ||||||
|     def set_shared_context(self, shared_context: SharedContext) -> None: |  | ||||||
|         self.shared_context = shared_context |  | ||||||
|  |  | ||||||
|     def get_option(self, option_name: str, default: Any = None) -> Any: |  | ||||||
|         """ |  | ||||||
|         Resolve an option from the OptionsManager if present, otherwise use the fallback. |  | ||||||
|         """ |  | ||||||
|         if self.options_manager: |  | ||||||
|             return self.options_manager.get(option_name, default) |  | ||||||
|         return default |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def last_result(self) -> Any: |  | ||||||
|         """Return the last result from the shared context.""" |  | ||||||
|         if self.shared_context: |  | ||||||
|             return self.shared_context.last_result() |  | ||||||
|         return None |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def never_prompt(self) -> bool: |  | ||||||
|         return self.get_option("never_prompt", self._never_prompt) |  | ||||||
|  |  | ||||||
|     def prepare( |  | ||||||
|         self, shared_context: SharedContext, options_manager: OptionsManager | None = None |  | ||||||
|     ) -> BaseAction: |  | ||||||
|         """ |  | ||||||
|         Prepare the action specifically for sequential (ChainedAction) execution. |  | ||||||
|         Can be overridden for chain-specific logic. |  | ||||||
|         """ |  | ||||||
|         self.set_shared_context(shared_context) |  | ||||||
|         if options_manager: |  | ||||||
|             self.set_options_manager(options_manager) |  | ||||||
|         return self |  | ||||||
|  |  | ||||||
|     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: |  | ||||||
|         if self.inject_last_result and self.shared_context: |  | ||||||
|             key = self.inject_into |  | ||||||
|             if key in kwargs: |  | ||||||
|                 logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key) |  | ||||||
|             kwargs = dict(kwargs) |  | ||||||
|             kwargs[key] = self.shared_context.last_result() |  | ||||||
|         return kwargs |  | ||||||
|  |  | ||||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): |  | ||||||
|         """Register a hook for all actions and sub-actions.""" |  | ||||||
|         self.hooks.register(hook_type, hook) |  | ||||||
|  |  | ||||||
|     async def _write_stdout(self, data: str) -> None: |  | ||||||
|         """Override in subclasses that produce terminal output.""" |  | ||||||
|  |  | ||||||
|     def requires_io_injection(self) -> bool: |  | ||||||
|         """Checks to see if the action requires input injection.""" |  | ||||||
|         return self._requires_injection |  | ||||||
|  |  | ||||||
|     def __repr__(self) -> str: |  | ||||||
|         return str(self) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Action(BaseAction): | class Action(BaseAction): | ||||||
|     """ |     """ | ||||||
|     Action wraps a simple function or coroutine into a standard executable unit. |     Action wraps a simple function or coroutine into a standard executable unit. | ||||||
| @@ -188,9 +42,9 @@ class Action(BaseAction): | |||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         name: str, |         name: str, | ||||||
|         action: Callable[..., Any], |         action: Callable[..., Any] | Callable[..., Awaitable[Any]], | ||||||
|         *, |         *, | ||||||
|         rollback: Callable[..., Any] | None = None, |         rollback: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None, | ||||||
|         args: tuple[Any, ...] = (), |         args: tuple[Any, ...] = (), | ||||||
|         kwargs: dict[str, Any] | None = None, |         kwargs: dict[str, Any] | None = None, | ||||||
|         hooks: HookManager | None = None, |         hooks: HookManager | None = None, | ||||||
| @@ -215,19 +69,19 @@ class Action(BaseAction): | |||||||
|             self.enable_retry() |             self.enable_retry() | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def action(self) -> Callable[..., Any]: |     def action(self) -> Callable[..., Awaitable[Any]]: | ||||||
|         return self._action |         return self._action | ||||||
|  |  | ||||||
|     @action.setter |     @action.setter | ||||||
|     def action(self, value: Callable[..., Any]): |     def action(self, value: Callable[..., Awaitable[Any]]): | ||||||
|         self._action = ensure_async(value) |         self._action = ensure_async(value) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def rollback(self) -> Callable[..., Any] | None: |     def rollback(self) -> Callable[..., Awaitable[Any]] | None: | ||||||
|         return self._rollback |         return self._rollback | ||||||
|  |  | ||||||
|     @rollback.setter |     @rollback.setter | ||||||
|     def rollback(self, value: Callable[..., Any] | None): |     def rollback(self, value: Callable[..., Awaitable[Any]] | None): | ||||||
|         if value is None: |         if value is None: | ||||||
|             self._rollback = None |             self._rollback = None | ||||||
|         else: |         else: | ||||||
| @@ -246,6 +100,13 @@ class Action(BaseAction): | |||||||
|         if policy.enabled: |         if policy.enabled: | ||||||
|             self.enable_retry() |             self.enable_retry() | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||||
|  |         """ | ||||||
|  |         Returns the callable to be used for argument inference. | ||||||
|  |         By default, it returns the action itself. | ||||||
|  |         """ | ||||||
|  |         return self.action, None | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|         combined_args = args + self.args |         combined_args = args + self.args | ||||||
|         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) |         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||||
| @@ -268,7 +129,7 @@ class Action(BaseAction): | |||||||
|             context.exception = error |             context.exception = error | ||||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|             if context.result is not None: |             if context.result is not None: | ||||||
|                 logger.info("[%s] ✅ Recovered: %s", self.name, self.name) |                 logger.info("[%s] Recovered: %s", self.name, self.name) | ||||||
|                 return context.result |                 return context.result | ||||||
|             raise |             raise | ||||||
|         finally: |         finally: | ||||||
| @@ -296,559 +157,6 @@ class Action(BaseAction): | |||||||
|         return ( |         return ( | ||||||
|             f"Action(name={self.name!r}, action=" |             f"Action(name={self.name!r}, action=" | ||||||
|             f"{getattr(self._action, '__name__', repr(self._action))}, " |             f"{getattr(self._action, '__name__', repr(self._action))}, " | ||||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " |             f"retry={self.retry_policy.enabled}, " | ||||||
|             f"retry={self.retry_policy.enabled})" |             f"rollback={self.rollback is not None})" | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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})" |  | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """action_factory.py""" | """action_factory_action.py""" | ||||||
| from typing import Any | from typing import Any, Callable | ||||||
|  |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| @@ -14,7 +14,7 @@ from falyx.themes import OneColors | |||||||
| from falyx.utils import ensure_async | from falyx.utils import ensure_async | ||||||
|  |  | ||||||
|  |  | ||||||
| class ActionFactoryAction(BaseAction): | class ActionFactory(BaseAction): | ||||||
|     """ |     """ | ||||||
|     Dynamically creates and runs another Action at runtime using a factory function. |     Dynamically creates and runs another Action at runtime using a factory function. | ||||||
|  |  | ||||||
| @@ -35,6 +35,8 @@ class ActionFactoryAction(BaseAction): | |||||||
|         *, |         *, | ||||||
|         inject_last_result: bool = False, |         inject_last_result: bool = False, | ||||||
|         inject_into: str = "last_result", |         inject_into: str = "last_result", | ||||||
|  |         args: tuple[Any, ...] = (), | ||||||
|  |         kwargs: dict[str, Any] | None = None, | ||||||
|         preview_args: tuple[Any, ...] = (), |         preview_args: tuple[Any, ...] = (), | ||||||
|         preview_kwargs: dict[str, Any] | None = None, |         preview_kwargs: dict[str, Any] | None = None, | ||||||
|     ): |     ): | ||||||
| @@ -44,6 +46,8 @@ class ActionFactoryAction(BaseAction): | |||||||
|             inject_into=inject_into, |             inject_into=inject_into, | ||||||
|         ) |         ) | ||||||
|         self.factory = factory |         self.factory = factory | ||||||
|  |         self.args = args | ||||||
|  |         self.kwargs = kwargs or {} | ||||||
|         self.preview_args = preview_args |         self.preview_args = preview_args | ||||||
|         self.preview_kwargs = preview_kwargs or {} |         self.preview_kwargs = preview_kwargs or {} | ||||||
|  |  | ||||||
| @@ -55,7 +59,12 @@ class ActionFactoryAction(BaseAction): | |||||||
|     def factory(self, value: ActionFactoryProtocol): |     def factory(self, value: ActionFactoryProtocol): | ||||||
|         self._factory = ensure_async(value) |         self._factory = ensure_async(value) | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||||
|  |         return self.factory, None | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         args = (*self.args, *args) | ||||||
|  |         kwargs = {**self.kwargs, **kwargs} | ||||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
|             name=f"{self.name} (factory)", |             name=f"{self.name} (factory)", | ||||||
| @@ -85,7 +94,7 @@ class ActionFactoryAction(BaseAction): | |||||||
|                     ) |                     ) | ||||||
|             if self.options_manager: |             if self.options_manager: | ||||||
|                 generated_action.set_options_manager(self.options_manager) |                 generated_action.set_options_manager(self.options_manager) | ||||||
|             context.result = await generated_action(*args, **kwargs) |             context.result = await generated_action() | ||||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|             return context.result |             return context.result | ||||||
|         except Exception as error: |         except Exception as error: | ||||||
| @@ -103,7 +112,16 @@ class ActionFactoryAction(BaseAction): | |||||||
|         tree = parent.add(label) if parent else Tree(label) |         tree = parent.add(label) if parent else Tree(label) | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             generated = await self.factory(*self.preview_args, **self.preview_kwargs) |             generated = None | ||||||
|  |             if self.args or self.kwargs: | ||||||
|  |                 try: | ||||||
|  |                     generated = await self.factory(*self.args, **self.kwargs) | ||||||
|  |                 except TypeError: | ||||||
|  |                     ... | ||||||
|  |  | ||||||
|  |             if not generated: | ||||||
|  |                 generated = await self.factory(*self.preview_args, **self.preview_kwargs) | ||||||
|  |  | ||||||
|             if isinstance(generated, BaseAction): |             if isinstance(generated, BaseAction): | ||||||
|                 await generated.preview(parent=tree) |                 await generated.preview(parent=tree) | ||||||
|             else: |             else: | ||||||
|   | |||||||
							
								
								
									
										197
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,197 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """action_group.py""" | ||||||
|  | import asyncio | ||||||
|  | import random | ||||||
|  | from typing import Any, Awaitable, Callable, Sequence | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action import Action | ||||||
|  | from falyx.action.action_mixins import ActionListMixin | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.exceptions import EmptyGroupError | ||||||
|  | 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.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: ( | ||||||
|  |             Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | None | ||||||
|  |         ) = None, | ||||||
|  |         *, | ||||||
|  |         args: tuple[Any, ...] = (), | ||||||
|  |         kwargs: dict[str, Any] | 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) | ||||||
|  |         self.args = args | ||||||
|  |         self.kwargs = kwargs or {} | ||||||
|  |         if actions: | ||||||
|  |             self.set_actions(actions) | ||||||
|  |  | ||||||
|  |     def _wrap_if_needed(self, action: BaseAction | Callable[..., 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 | Callable[..., 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 set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None: | ||||||
|  |         """Replaces the current action list with a new one.""" | ||||||
|  |         self.actions.clear() | ||||||
|  |         for action in actions: | ||||||
|  |             self.add_action(action) | ||||||
|  |  | ||||||
|  |     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||||
|  |         super().set_options_manager(options_manager) | ||||||
|  |         for action in self.actions: | ||||||
|  |             action.set_options_manager(options_manager) | ||||||
|  |  | ||||||
|  |     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]]: | ||||||
|  |         if not self.actions: | ||||||
|  |             raise EmptyGroupError(f"[{self.name}] No actions to execute.") | ||||||
|  |  | ||||||
|  |         combined_args = args + self.args | ||||||
|  |         combined_kwargs = {**self.kwargs, **kwargs} | ||||||
|  |  | ||||||
|  |         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(combined_kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=combined_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(*combined_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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										37
									
								
								falyx/action/action_mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								falyx/action/action_mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """action_mixins.py""" | ||||||
|  | from typing import Sequence | ||||||
|  |  | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ActionListMixin: | ||||||
|  |     """Mixin for managing a list of actions.""" | ||||||
|  |  | ||||||
|  |     def __init__(self) -> None: | ||||||
|  |         self.actions: list[BaseAction] = [] | ||||||
|  |  | ||||||
|  |     def set_actions(self, actions: Sequence[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 | ||||||
							
								
								
									
										84
									
								
								falyx/action/action_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								falyx/action/action_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """action_types.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FileType(Enum): | ||||||
|  |     """Enum for file return types.""" | ||||||
|  |  | ||||||
|  |     TEXT = "text" | ||||||
|  |     PATH = "path" | ||||||
|  |     JSON = "json" | ||||||
|  |     TOML = "toml" | ||||||
|  |     YAML = "yaml" | ||||||
|  |     CSV = "csv" | ||||||
|  |     TSV = "tsv" | ||||||
|  |     XML = "xml" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _get_alias(cls, value: str) -> str: | ||||||
|  |         aliases = { | ||||||
|  |             "yml": "yaml", | ||||||
|  |             "txt": "text", | ||||||
|  |             "file": "path", | ||||||
|  |             "filepath": "path", | ||||||
|  |         } | ||||||
|  |         return aliases.get(value, value) | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _missing_(cls, value: object) -> FileType: | ||||||
|  |         if isinstance(value, str): | ||||||
|  |             normalized = value.lower() | ||||||
|  |             alias = cls._get_alias(normalized) | ||||||
|  |             for member in cls: | ||||||
|  |                 if member.value == alias: | ||||||
|  |                     return member | ||||||
|  |         valid = ", ".join(member.value for member in cls) | ||||||
|  |         raise ValueError(f"Invalid FileType: '{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}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ConfirmType(Enum): | ||||||
|  |     """Enum for different confirmation types.""" | ||||||
|  |  | ||||||
|  |     YES_NO = "yes_no" | ||||||
|  |     YES_CANCEL = "yes_cancel" | ||||||
|  |     YES_NO_CANCEL = "yes_no_cancel" | ||||||
|  |     TYPE_WORD = "type_word" | ||||||
|  |     TYPE_WORD_CANCEL = "type_word_cancel" | ||||||
|  |     OK_CANCEL = "ok_cancel" | ||||||
|  |     ACKNOWLEDGE = "acknowledge" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def choices(cls) -> list[ConfirmType]: | ||||||
|  |         """Return a list of all hook type choices.""" | ||||||
|  |         return list(cls) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Return the string representation of the confirm type.""" | ||||||
|  |         return self.value | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _missing_(cls, value: object) -> ConfirmType: | ||||||
|  |         if isinstance(value, str): | ||||||
|  |             for member in cls: | ||||||
|  |                 if member.value == value.lower(): | ||||||
|  |                     return member | ||||||
|  |         valid = ", ".join(member.value for member in cls) | ||||||
|  |         raise ValueError(f"Invalid ConfirmType: '{value}'. Must be one of: {valid}") | ||||||
							
								
								
									
										156
									
								
								falyx/action/base_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								falyx/action/base_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """base_action.py | ||||||
|  |  | ||||||
|  | Core action system for Falyx. | ||||||
|  |  | ||||||
|  | This module defines the building blocks for executable actions and workflows, | ||||||
|  | providing a structured way to compose, execute, recover, and manage sequences of | ||||||
|  | operations. | ||||||
|  |  | ||||||
|  | All actions are callable and follow a unified signature: | ||||||
|  |     result = action(*args, **kwargs) | ||||||
|  |  | ||||||
|  | Core guarantees: | ||||||
|  | - Full hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||||
|  | - Consistent timing and execution context tracking for each run. | ||||||
|  | - Unified, predictable result handling and error propagation. | ||||||
|  | - Optional last_result injection to enable flexible, data-driven workflows. | ||||||
|  | - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback | ||||||
|  |   recovery. | ||||||
|  |  | ||||||
|  | Key components: | ||||||
|  | - Action: wraps a function or coroutine into a standard executable unit. | ||||||
|  | - ChainedAction: runs actions sequentially, optionally injecting last results. | ||||||
|  | - ActionGroup: runs actions in parallel and gathers results. | ||||||
|  | - ProcessAction: executes CPU-bound functions in a separate process. | ||||||
|  | - LiteralInputAction: injects static values into workflows. | ||||||
|  | - FallbackAction: gracefully recovers from failures or missing data. | ||||||
|  |  | ||||||
|  | This design promotes clean, fault-tolerant, modular CLI and automation systems. | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.console import console | ||||||
|  | from falyx.context import SharedContext | ||||||
|  | from falyx.debug import register_debug_hooks | ||||||
|  | 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 = console | ||||||
|  |         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) | ||||||
							
								
								
									
										241
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """chained_action.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Any, Awaitable, Callable, Sequence | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action import Action | ||||||
|  | from falyx.action.action_mixins import ActionListMixin | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.action.fallback_action import FallbackAction | ||||||
|  | from falyx.action.literal_input_action import LiteralInputAction | ||||||
|  | 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.options_manager import OptionsManager | ||||||
|  | from falyx.signals import BreakChainSignal | ||||||
|  | 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: ( | ||||||
|  |             Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]] | ||||||
|  |             | None | ||||||
|  |         ) = None, | ||||||
|  |         *, | ||||||
|  |         args: tuple[Any, ...] = (), | ||||||
|  |         kwargs: dict[str, 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.args = args | ||||||
|  |         self.kwargs = kwargs or {} | ||||||
|  |         self.auto_inject = auto_inject | ||||||
|  |         self.return_list = return_list | ||||||
|  |         if actions: | ||||||
|  |             self.set_actions(actions) | ||||||
|  |  | ||||||
|  |     def _wrap_if_needed(self, action: BaseAction | Callable[..., 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 | Callable[..., 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 set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None: | ||||||
|  |         """Replaces the current action list with a new one.""" | ||||||
|  |         self.actions.clear() | ||||||
|  |         for action in actions: | ||||||
|  |             self.add_action(action) | ||||||
|  |  | ||||||
|  |     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||||
|  |         super().set_options_manager(options_manager) | ||||||
|  |         for action in self.actions: | ||||||
|  |             action.set_options_manager(options_manager) | ||||||
|  |  | ||||||
|  |     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) -> Any: | ||||||
|  |         if not self.actions: | ||||||
|  |             raise EmptyChainError(f"[{self.name}] No actions to execute.") | ||||||
|  |  | ||||||
|  |         combined_args = args + self.args | ||||||
|  |         combined_kwargs = {**self.kwargs, **kwargs} | ||||||
|  |  | ||||||
|  |         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(combined_kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=combined_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(*combined_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, combined_args, updated_kwargs) | ||||||
|  |                 ) | ||||||
|  |                 combined_args, updated_kwargs = self._clear_args() | ||||||
|  |  | ||||||
|  |             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 BreakChainSignal as error: | ||||||
|  |             logger.info("[%s] Chain broken: %s", self.name, error) | ||||||
|  |             context.exception = error | ||||||
|  |             shared_context.add_error(shared_context.current_index, error) | ||||||
|  |             await self._rollback(context.extra["rollback_stack"]) | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             shared_context.add_error(shared_context.current_index, error) | ||||||
|  |             await self._rollback(context.extra["rollback_stack"]) | ||||||
|  |             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: list[tuple[Action, tuple[Any, ...], dict[str, Any]]] | ||||||
|  |     ): | ||||||
|  |         """ | ||||||
|  |         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, args, kwargs in reversed(rollback_stack): | ||||||
|  |             rollback = getattr(action, "rollback", None) | ||||||
|  |             if rollback: | ||||||
|  |                 try: | ||||||
|  |                     logger.warning("[%s] Rolling back...", action.name) | ||||||
|  |                     await 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.""" | ||||||
|  |         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.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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										215
									
								
								falyx/action/confirm_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								falyx/action/confirm_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,215 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from prompt_toolkit import PromptSession | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action_types import ConfirmType | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.prompt_utils import confirm_async, should_prompt_user | ||||||
|  | from falyx.signals import CancelSignal | ||||||
|  | from falyx.themes import OneColors | ||||||
|  | from falyx.validators import word_validator, words_validator | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ConfirmAction(BaseAction): | ||||||
|  |     """ | ||||||
|  |     Action to confirm an operation with the user. | ||||||
|  |  | ||||||
|  |     There are several ways to confirm an action, such as using a simple | ||||||
|  |     yes/no prompt. You can also use a confirmation type that requires the user | ||||||
|  |     to type a specific word or phrase to confirm the action, or use an OK/Cancel | ||||||
|  |     dialog. | ||||||
|  |  | ||||||
|  |     This action can be used to ensure that the user explicitly agrees to proceed | ||||||
|  |     with an operation. | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         name (str): Name of the action. | ||||||
|  |         message (str): The confirmation message to display. | ||||||
|  |         confirm_type (ConfirmType | str): The type of confirmation to use. | ||||||
|  |             Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||||
|  |         prompt_session (PromptSession | None): The session to use for input. | ||||||
|  |         confirm (bool): Whether to prompt the user for confirmation. | ||||||
|  |         word (str): The word to type for TYPE_WORD confirmation. | ||||||
|  |         return_last_result (bool): Whether to return the last result of the action | ||||||
|  |                                    instead of a boolean. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         message: str = "Confirm?", | ||||||
|  |         confirm_type: ConfirmType | str = ConfirmType.YES_NO, | ||||||
|  |         prompt_session: PromptSession | None = None, | ||||||
|  |         never_prompt: bool = False, | ||||||
|  |         word: str = "CONFIRM", | ||||||
|  |         return_last_result: bool = False, | ||||||
|  |         inject_last_result: bool = True, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |     ): | ||||||
|  |         """ | ||||||
|  |         Initialize the ConfirmAction. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             message (str): The confirmation message to display. | ||||||
|  |             confirm_type (ConfirmType): The type of confirmation to use. | ||||||
|  |                 Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||||
|  |             prompt_session (PromptSession | None): The session to use for input. | ||||||
|  |             confirm (bool): Whether to prompt the user for confirmation. | ||||||
|  |             word (str): The word to type for TYPE_WORD confirmation. | ||||||
|  |             return_last_result (bool): Whether to return the last result of the action. | ||||||
|  |         """ | ||||||
|  |         super().__init__( | ||||||
|  |             name=name, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |             never_prompt=never_prompt, | ||||||
|  |         ) | ||||||
|  |         self.message = message | ||||||
|  |         self.confirm_type = self._coerce_confirm_type(confirm_type) | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.word = word | ||||||
|  |         self.return_last_result = return_last_result | ||||||
|  |  | ||||||
|  |     def _coerce_confirm_type(self, confirm_type: ConfirmType | str) -> ConfirmType: | ||||||
|  |         """Coerce the confirm_type to a ConfirmType enum.""" | ||||||
|  |         if isinstance(confirm_type, ConfirmType): | ||||||
|  |             return confirm_type | ||||||
|  |         elif isinstance(confirm_type, str): | ||||||
|  |             return ConfirmType(confirm_type) | ||||||
|  |         return ConfirmType(confirm_type) | ||||||
|  |  | ||||||
|  |     async def _confirm(self) -> bool: | ||||||
|  |         """Confirm the action with the user.""" | ||||||
|  |         match self.confirm_type: | ||||||
|  |             case ConfirmType.YES_NO: | ||||||
|  |                 return await confirm_async( | ||||||
|  |                     self.message, | ||||||
|  |                     prefix="❓ ", | ||||||
|  |                     suffix=" [Y/n] > ", | ||||||
|  |                     session=self.prompt_session, | ||||||
|  |                 ) | ||||||
|  |             case ConfirmType.YES_NO_CANCEL: | ||||||
|  |                 error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort." | ||||||
|  |                 answer = await self.prompt_session.prompt_async( | ||||||
|  |                     f"❓ {self.message} [Y]es, [N]o, or [C]ancel to abort > ", | ||||||
|  |                     validator=words_validator( | ||||||
|  |                         ["Y", "N", "C"], error_message=error_message | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |                 if answer.upper() == "C": | ||||||
|  |                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||||
|  |                 return answer.upper() == "Y" | ||||||
|  |             case ConfirmType.TYPE_WORD: | ||||||
|  |                 answer = await self.prompt_session.prompt_async( | ||||||
|  |                     f"❓ {self.message} [{self.word}] to confirm or [N/n] > ", | ||||||
|  |                     validator=word_validator(self.word), | ||||||
|  |                 ) | ||||||
|  |                 return answer.upper().strip() != "N" | ||||||
|  |             case ConfirmType.TYPE_WORD_CANCEL: | ||||||
|  |                 answer = await self.prompt_session.prompt_async( | ||||||
|  |                     f"❓ {self.message} [{self.word}] to confirm or [N/n] > ", | ||||||
|  |                     validator=word_validator(self.word), | ||||||
|  |                 ) | ||||||
|  |                 if answer.upper().strip() == "N": | ||||||
|  |                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||||
|  |                 return answer.upper().strip() == self.word.upper().strip() | ||||||
|  |             case ConfirmType.YES_CANCEL: | ||||||
|  |                 answer = await confirm_async( | ||||||
|  |                     self.message, | ||||||
|  |                     prefix="❓ ", | ||||||
|  |                     suffix=" [Y/n] > ", | ||||||
|  |                     session=self.prompt_session, | ||||||
|  |                 ) | ||||||
|  |                 if not answer: | ||||||
|  |                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||||
|  |                 return answer | ||||||
|  |             case ConfirmType.OK_CANCEL: | ||||||
|  |                 error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort." | ||||||
|  |                 answer = await self.prompt_session.prompt_async( | ||||||
|  |                     f"❓ {self.message} [O]k to confirm, [C]ancel to abort > ", | ||||||
|  |                     validator=words_validator(["O", "C"], error_message=error_message), | ||||||
|  |                 ) | ||||||
|  |                 if answer.upper() == "C": | ||||||
|  |                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||||
|  |                 return answer.upper() == "O" | ||||||
|  |             case ConfirmType.ACKNOWLEDGE: | ||||||
|  |                 answer = await self.prompt_session.prompt_async( | ||||||
|  |                     f"❓ {self.message} [A]cknowledge > ", | ||||||
|  |                     validator=word_validator("A"), | ||||||
|  |                 ) | ||||||
|  |                 return answer.upper().strip() == "A" | ||||||
|  |             case _: | ||||||
|  |                 raise ValueError(f"Unknown confirm_type: {self.confirm_type}") | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         combined_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, args=args, kwargs=combined_kwargs, action=self | ||||||
|  |         ) | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |             if ( | ||||||
|  |                 self.never_prompt | ||||||
|  |                 or self.options_manager | ||||||
|  |                 and not should_prompt_user(confirm=True, options=self.options_manager) | ||||||
|  |             ): | ||||||
|  |                 logger.debug( | ||||||
|  |                     "Skipping confirmation for '%s' due to never_prompt or options_manager settings.", | ||||||
|  |                     self.name, | ||||||
|  |                 ) | ||||||
|  |                 if self.return_last_result: | ||||||
|  |                     result = combined_kwargs[self.inject_into] | ||||||
|  |                 else: | ||||||
|  |                     result = True | ||||||
|  |             else: | ||||||
|  |                 answer = await self._confirm() | ||||||
|  |                 if self.return_last_result and answer: | ||||||
|  |                     result = combined_kwargs[self.inject_into] | ||||||
|  |                 else: | ||||||
|  |                     result = answer | ||||||
|  |             logger.debug("Action '%s' confirmed with result: %s", self.name, 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) | ||||||
|  |             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) -> None: | ||||||
|  |         tree = ( | ||||||
|  |             Tree( | ||||||
|  |                 f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}", | ||||||
|  |                 guide_style=OneColors.BLUE_b, | ||||||
|  |             ) | ||||||
|  |             if not parent | ||||||
|  |             else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}") | ||||||
|  |         ) | ||||||
|  |         tree.add(f"[bold]Message:[/] {self.message}") | ||||||
|  |         tree.add(f"[bold]Type:[/] {self.confirm_type.value}") | ||||||
|  |         tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}") | ||||||
|  |         if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL): | ||||||
|  |             tree.add(f"[bold]Confirmation Word:[/] {self.word}") | ||||||
|  |         if parent is None: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f"ConfirmAction(name={self.name}, message={self.message}, " | ||||||
|  |             f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" | ||||||
|  |         ) | ||||||
							
								
								
									
										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: |         if session and should_close: | ||||||
|             await session.close() |             await session.close() | ||||||
|     except Exception as error: |     except Exception as error: | ||||||
|         logger.warning("⚠️ Error closing shared HTTP session: %s", error) |         logger.warning("Error closing shared HTTP session: %s", error) | ||||||
|  |  | ||||||
|  |  | ||||||
| class HTTPAction(Action): | class HTTPAction(Action): | ||||||
|   | |||||||
| @@ -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. | needs to consume input from another process or pipeline. | ||||||
| """ | """ | ||||||
| import asyncio | import asyncio | ||||||
| import shlex |  | ||||||
| import subprocess |  | ||||||
| import sys | import sys | ||||||
| from typing import Any | from typing import Any, Callable | ||||||
|  |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.exceptions import FalyxError |  | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.logger import logger |  | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -73,7 +69,6 @@ class BaseIOAction(BaseAction): | |||||||
|             inject_last_result=inject_last_result, |             inject_last_result=inject_last_result, | ||||||
|         ) |         ) | ||||||
|         self.mode = mode |         self.mode = mode | ||||||
|         self._requires_injection = True |  | ||||||
|  |  | ||||||
|     def from_input(self, raw: str | bytes) -> Any: |     def from_input(self, raw: str | bytes) -> Any: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
| @@ -81,23 +76,23 @@ class BaseIOAction(BaseAction): | |||||||
|     def to_output(self, result: Any) -> str | bytes: |     def to_output(self, result: Any) -> str | bytes: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|     async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: |     async def _resolve_input( | ||||||
|         last_result = kwargs.pop(self.inject_into, None) |         self, args: tuple[Any], kwargs: dict[str, Any] | ||||||
|  |     ) -> str | bytes: | ||||||
|         data = await self._read_stdin() |         data = await self._read_stdin() | ||||||
|         if data: |         if data: | ||||||
|             return self.from_input(data) |             return self.from_input(data) | ||||||
|  |  | ||||||
|         if last_result is not None: |         if len(args) == 1: | ||||||
|             return last_result |             return self.from_input(args[0]) | ||||||
|  |  | ||||||
|         if self.inject_last_result and self.shared_context: |         if self.inject_last_result and self.shared_context: | ||||||
|             return self.shared_context.last_result() |             return self.shared_context.last_result() | ||||||
|  |  | ||||||
|         logger.debug( |         return "" | ||||||
|             "[%s] No input provided and no last result found for injection.", self.name |  | ||||||
|         ) |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||||
|         raise FalyxError("No input provided and no last result to inject.") |         return None, None | ||||||
|  |  | ||||||
|     async def __call__(self, *args, **kwargs): |     async def __call__(self, *args, **kwargs): | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
| @@ -117,8 +112,8 @@ class BaseIOAction(BaseAction): | |||||||
|                     pass |                     pass | ||||||
|                 result = getattr(self, "_last_result", None) |                 result = getattr(self, "_last_result", None) | ||||||
|             else: |             else: | ||||||
|                 parsed_input = await self._resolve_input(kwargs) |                 parsed_input = await self._resolve_input(args, kwargs) | ||||||
|                 result = await self._run(parsed_input, *args, **kwargs) |                 result = await self._run(parsed_input) | ||||||
|                 output = self.to_output(result) |                 output = self.to_output(result) | ||||||
|                 await self._write_stdout(output) |                 await self._write_stdout(output) | ||||||
|             context.result = result |             context.result = result | ||||||
| @@ -172,88 +167,3 @@ class BaseIOAction(BaseAction): | |||||||
|             parent.add("".join(label)) |             parent.add("".join(label)) | ||||||
|         else: |         else: | ||||||
|             self.console.print(Tree("".join(label))) |             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: |  | ||||||
|             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})" |  | ||||||
|         ) |  | ||||||
|   | |||||||
							
								
								
									
										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})" | ||||||
							
								
								
									
										194
									
								
								falyx/action/load_file_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										194
									
								
								falyx/action/load_file_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,194 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """load_file_action.py""" | ||||||
|  | import csv | ||||||
|  | import json | ||||||
|  | import xml.etree.ElementTree as ET | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | import toml | ||||||
|  | import yaml | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action_types import FileType | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LoadFileAction(BaseAction): | ||||||
|  |     """LoadFileAction allows loading and parsing files of various types.""" | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         file_path: str | Path | None = None, | ||||||
|  |         file_type: FileType | str = FileType.TEXT, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "file_path", | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||||
|  |         ) | ||||||
|  |         self._file_path = self._coerce_file_path(file_path) | ||||||
|  |         self._file_type = self._coerce_file_type(file_type) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_path(self) -> Path | None: | ||||||
|  |         """Get the file path as a Path object.""" | ||||||
|  |         return self._file_path | ||||||
|  |  | ||||||
|  |     @file_path.setter | ||||||
|  |     def file_path(self, value: str | Path): | ||||||
|  |         """Set the file path, converting to Path if necessary.""" | ||||||
|  |         self._file_path = self._coerce_file_path(value) | ||||||
|  |  | ||||||
|  |     def _coerce_file_path(self, file_path: str | Path | None) -> Path | None: | ||||||
|  |         """Coerce the file path to a Path object.""" | ||||||
|  |         if isinstance(file_path, Path): | ||||||
|  |             return file_path | ||||||
|  |         elif isinstance(file_path, str): | ||||||
|  |             return Path(file_path) | ||||||
|  |         elif file_path is None: | ||||||
|  |             return None | ||||||
|  |         else: | ||||||
|  |             raise TypeError("file_path must be a string or Path object") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_type(self) -> FileType: | ||||||
|  |         """Get the file type.""" | ||||||
|  |         return self._file_type | ||||||
|  |  | ||||||
|  |     @file_type.setter | ||||||
|  |     def file_type(self, value: FileType | str): | ||||||
|  |         """Set the file type, converting to FileType if necessary.""" | ||||||
|  |         self._file_type = self._coerce_file_type(value) | ||||||
|  |  | ||||||
|  |     def _coerce_file_type(self, file_type: FileType | str) -> FileType: | ||||||
|  |         """Coerce the file type to a FileType enum.""" | ||||||
|  |         if isinstance(file_type, FileType): | ||||||
|  |             return file_type | ||||||
|  |         elif isinstance(file_type, str): | ||||||
|  |             return FileType(file_type) | ||||||
|  |         else: | ||||||
|  |             raise TypeError("file_type must be a FileType enum or string") | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def load_file(self) -> Any: | ||||||
|  |         """Load and parse the file based on its type.""" | ||||||
|  |         if self.file_path is None: | ||||||
|  |             raise ValueError("file_path must be set before loading a file") | ||||||
|  |         elif not self.file_path.exists(): | ||||||
|  |             raise FileNotFoundError(f"File not found: {self.file_path}") | ||||||
|  |         elif not self.file_path.is_file(): | ||||||
|  |             raise ValueError(f"Path is not a regular file: {self.file_path}") | ||||||
|  |         value: Any = None | ||||||
|  |         try: | ||||||
|  |             if self.file_type == FileType.TEXT: | ||||||
|  |                 value = self.file_path.read_text(encoding="UTF-8") | ||||||
|  |             elif self.file_type == FileType.PATH: | ||||||
|  |                 value = self.file_path | ||||||
|  |             elif self.file_type == FileType.JSON: | ||||||
|  |                 value = json.loads(self.file_path.read_text(encoding="UTF-8")) | ||||||
|  |             elif self.file_type == FileType.TOML: | ||||||
|  |                 value = toml.loads(self.file_path.read_text(encoding="UTF-8")) | ||||||
|  |             elif self.file_type == FileType.YAML: | ||||||
|  |                 value = yaml.safe_load(self.file_path.read_text(encoding="UTF-8")) | ||||||
|  |             elif self.file_type == FileType.CSV: | ||||||
|  |                 with open(self.file_path, newline="", encoding="UTF-8") as csvfile: | ||||||
|  |                     reader = csv.reader(csvfile) | ||||||
|  |                     value = list(reader) | ||||||
|  |             elif self.file_type == FileType.TSV: | ||||||
|  |                 with open(self.file_path, newline="", encoding="UTF-8") as tsvfile: | ||||||
|  |                     reader = csv.reader(tsvfile, delimiter="\t") | ||||||
|  |                     value = list(reader) | ||||||
|  |             elif self.file_type == FileType.XML: | ||||||
|  |                 tree = ET.parse(self.file_path, parser=ET.XMLParser(encoding="UTF-8")) | ||||||
|  |                 root = tree.getroot() | ||||||
|  |                 value = ET.tostring(root, encoding="unicode") | ||||||
|  |             else: | ||||||
|  |                 raise ValueError(f"Unsupported return type: {self.file_type}") | ||||||
|  |  | ||||||
|  |         except Exception as error: | ||||||
|  |             logger.error("Failed to parse %s: %s", self.file_path.name, error) | ||||||
|  |         return value | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |  | ||||||
|  |             if "file_path" in kwargs: | ||||||
|  |                 self.file_path = kwargs["file_path"] | ||||||
|  |             elif self.inject_last_result and self.last_result: | ||||||
|  |                 self.file_path = self.last_result | ||||||
|  |  | ||||||
|  |             result = await self.load_file() | ||||||
|  |             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) | ||||||
|  |             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.GREEN}]📄 LoadFileAction[/] '{self.name}'" | ||||||
|  |         tree = parent.add(label) if parent else Tree(label) | ||||||
|  |  | ||||||
|  |         tree.add(f"[dim]Path:[/] {self.file_path}") | ||||||
|  |         tree.add(f"[dim]Type:[/] {self.file_type.name if self.file_type else 'None'}") | ||||||
|  |         if self.file_path is None: | ||||||
|  |             tree.add(f"[{OneColors.DARK_RED_b}]❌ File path is not set[/]") | ||||||
|  |         elif not self.file_path.exists(): | ||||||
|  |             tree.add(f"[{OneColors.DARK_RED_b}]❌ File does not exist[/]") | ||||||
|  |         elif not self.file_path.is_file(): | ||||||
|  |             tree.add(f"[{OneColors.LIGHT_YELLOW_b}]⚠️ Not a regular file[/]") | ||||||
|  |         else: | ||||||
|  |             try: | ||||||
|  |                 stat = self.file_path.stat() | ||||||
|  |                 tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes") | ||||||
|  |                 tree.add( | ||||||
|  |                     f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}" | ||||||
|  |                 ) | ||||||
|  |                 tree.add( | ||||||
|  |                     f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}" | ||||||
|  |                 ) | ||||||
|  |                 if self.file_type == FileType.TEXT: | ||||||
|  |                     preview_lines = self.file_path.read_text( | ||||||
|  |                         encoding="UTF-8" | ||||||
|  |                     ).splitlines()[:10] | ||||||
|  |                     content_tree = tree.add("[dim]Preview (first 10 lines):[/]") | ||||||
|  |                     for line in preview_lines: | ||||||
|  |                         content_tree.add(f"[dim]{line}[/]") | ||||||
|  |                 elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}: | ||||||
|  |                     raw = self.load_file() | ||||||
|  |                     if raw is not None: | ||||||
|  |                         preview_str = ( | ||||||
|  |                             json.dumps(raw, indent=2) | ||||||
|  |                             if isinstance(raw, dict) | ||||||
|  |                             else str(raw) | ||||||
|  |                         ) | ||||||
|  |                         preview_lines = preview_str.splitlines()[:10] | ||||||
|  |                         content_tree = tree.add("[dim]Parsed preview:[/]") | ||||||
|  |                         for line in preview_lines: | ||||||
|  |                             content_tree.add(f"[dim]{line}[/]") | ||||||
|  |             except Exception as e: | ||||||
|  |                 tree.add(f"[{OneColors.DARK_RED_b}]❌ Error reading file:[/] {e}") | ||||||
|  |  | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})" | ||||||
| @@ -3,11 +3,10 @@ | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from rich.console import Console |  | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| @@ -33,7 +32,6 @@ class MenuAction(BaseAction): | |||||||
|         default_selection: str = "", |         default_selection: str = "", | ||||||
|         inject_last_result: bool = False, |         inject_last_result: bool = False, | ||||||
|         inject_into: str = "last_result", |         inject_into: str = "last_result", | ||||||
|         console: Console | None = None, |  | ||||||
|         prompt_session: PromptSession | None = None, |         prompt_session: PromptSession | None = None, | ||||||
|         never_prompt: bool = False, |         never_prompt: bool = False, | ||||||
|         include_reserved: bool = True, |         include_reserved: bool = True, | ||||||
| @@ -51,7 +49,6 @@ class MenuAction(BaseAction): | |||||||
|         self.columns = columns |         self.columns = columns | ||||||
|         self.prompt_message = prompt_message |         self.prompt_message = prompt_message | ||||||
|         self.default_selection = default_selection |         self.default_selection = default_selection | ||||||
|         self.console = console or Console(color_system="auto") |  | ||||||
|         self.prompt_session = prompt_session or PromptSession() |         self.prompt_session = prompt_session or PromptSession() | ||||||
|         self.include_reserved = include_reserved |         self.include_reserved = include_reserved | ||||||
|         self.show_table = show_table |         self.show_table = show_table | ||||||
| @@ -73,6 +70,9 @@ class MenuAction(BaseAction): | |||||||
|             table.add_row(*row) |             table.add_row(*row) | ||||||
|         return table |         return table | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|         kwargs = self._maybe_inject_last_result(kwargs) |         kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
| @@ -105,15 +105,18 @@ class MenuAction(BaseAction): | |||||||
|             key = effective_default |             key = effective_default | ||||||
|             if not self.never_prompt: |             if not self.never_prompt: | ||||||
|                 table = self._build_table() |                 table = self._build_table() | ||||||
|                 key = await prompt_for_selection( |                 key_ = await prompt_for_selection( | ||||||
|                     self.menu_options.keys(), |                     self.menu_options.keys(), | ||||||
|                     table, |                     table, | ||||||
|                     default_selection=self.default_selection, |                     default_selection=self.default_selection, | ||||||
|                     console=self.console, |  | ||||||
|                     prompt_session=self.prompt_session, |                     prompt_session=self.prompt_session, | ||||||
|                     prompt_message=self.prompt_message, |                     prompt_message=self.prompt_message, | ||||||
|                     show_table=self.show_table, |                     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] |             option = self.menu_options[key] | ||||||
|             result = await option.action(*args, **kwargs) |             result = await option.action(*args, **kwargs) | ||||||
|             context.result = result |             context.result = result | ||||||
| @@ -121,10 +124,10 @@ class MenuAction(BaseAction): | |||||||
|             return result |             return result | ||||||
|  |  | ||||||
|         except BackSignal: |         except BackSignal: | ||||||
|             logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) |             logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name) | ||||||
|             return None |             return None | ||||||
|         except QuitSignal: |         except QuitSignal: | ||||||
|             logger.debug("[%s][QuitSignal] ← Exiting application", self.name) |             logger.debug("[%s][QuitSignal] <- Exiting application", self.name) | ||||||
|             raise |             raise | ||||||
|         except Exception as error: |         except Exception as error: | ||||||
|             context.exception = error |             context.exception = error | ||||||
|   | |||||||
							
								
								
									
										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_action 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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										169
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | |||||||
|  | # 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, Sequence | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.exceptions import EmptyPoolError | ||||||
|  | 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: Sequence[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: Sequence[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: | ||||||
|  |         if not self.actions: | ||||||
|  |             raise EmptyPoolError(f"[{self.name}] No actions to execute.") | ||||||
|  |         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." | ||||||
|  |                 ) | ||||||
|  |         updated_kwargs = self._maybe_inject_last_result(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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										131
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,131 @@ | |||||||
|  | # 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.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.menu import MenuOptionMap | ||||||
|  | from falyx.signals import BackSignal, QuitSignal | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PromptMenuAction(BaseAction): | ||||||
|  |     """PromptMenuAction class for creating prompt -> actions.""" | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         menu_options: MenuOptionMap, | ||||||
|  |         *, | ||||||
|  |         prompt_message: str = "Select > ", | ||||||
|  |         default_selection: str = "", | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |         prompt_session: PromptSession | None = None, | ||||||
|  |         never_prompt: bool = False, | ||||||
|  |         include_reserved: bool = True, | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |             never_prompt=never_prompt, | ||||||
|  |         ) | ||||||
|  |         self.menu_options = menu_options | ||||||
|  |         self.prompt_message = prompt_message | ||||||
|  |         self.default_selection = default_selection | ||||||
|  |         self.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'})" | ||||||
|  |         ) | ||||||
							
								
								
									
										247
									
								
								falyx/action/save_file_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								falyx/action/save_file_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,247 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """save_file_action.py""" | ||||||
|  | import csv | ||||||
|  | import json | ||||||
|  | import xml.etree.ElementTree as ET | ||||||
|  | from datetime import datetime | ||||||
|  | from pathlib import Path | ||||||
|  | from typing import Any, Literal | ||||||
|  |  | ||||||
|  | import toml | ||||||
|  | import yaml | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action_types import FileType | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SaveFileAction(BaseAction): | ||||||
|  |     """ | ||||||
|  |     SaveFileAction saves data to a file in the specified format (e.g., TEXT, JSON, YAML). | ||||||
|  |     Supports overwrite control and integrates with chaining workflows via inject_last_result. | ||||||
|  |  | ||||||
|  |     Supported types: TEXT, JSON, YAML, TOML, CSV, TSV, XML | ||||||
|  |  | ||||||
|  |     If the file exists and overwrite is False, the action will raise a FileExistsError. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         file_path: str, | ||||||
|  |         file_type: FileType | str = FileType.TEXT, | ||||||
|  |         mode: Literal["w", "a"] = "w", | ||||||
|  |         data: Any = None, | ||||||
|  |         overwrite: bool = True, | ||||||
|  |         create_dirs: bool = True, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "data", | ||||||
|  |     ): | ||||||
|  |         """ | ||||||
|  |         SaveFileAction allows saving data to a file. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             name (str): Name of the action. | ||||||
|  |             file_path (str | Path): Path to the file where data will be saved. | ||||||
|  |             file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML). | ||||||
|  |             mode (Literal["w", "a"]): File mode (default: "w"). | ||||||
|  |             data (Any): Data to be saved (if not using inject_last_result). | ||||||
|  |             overwrite (bool): Whether to overwrite the file if it exists. | ||||||
|  |             create_dirs (bool): Whether to create parent directories if they do not exist. | ||||||
|  |             inject_last_result (bool): Whether to inject result from previous action. | ||||||
|  |             inject_into (str): Kwarg name to inject the last result as. | ||||||
|  |         """ | ||||||
|  |         super().__init__( | ||||||
|  |             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||||
|  |         ) | ||||||
|  |         self._file_path = self._coerce_file_path(file_path) | ||||||
|  |         self._file_type = self._coerce_file_type(file_type) | ||||||
|  |         self.data = data | ||||||
|  |         self.overwrite = overwrite | ||||||
|  |         self.mode = mode | ||||||
|  |         self.create_dirs = create_dirs | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_path(self) -> Path | None: | ||||||
|  |         """Get the file path as a Path object.""" | ||||||
|  |         return self._file_path | ||||||
|  |  | ||||||
|  |     @file_path.setter | ||||||
|  |     def file_path(self, value: str | Path): | ||||||
|  |         """Set the file path, converting to Path if necessary.""" | ||||||
|  |         self._file_path = self._coerce_file_path(value) | ||||||
|  |  | ||||||
|  |     def _coerce_file_path(self, file_path: str | Path | None) -> Path | None: | ||||||
|  |         """Coerce the file path to a Path object.""" | ||||||
|  |         if isinstance(file_path, Path): | ||||||
|  |             return file_path | ||||||
|  |         elif isinstance(file_path, str): | ||||||
|  |             return Path(file_path) | ||||||
|  |         elif file_path is None: | ||||||
|  |             return None | ||||||
|  |         else: | ||||||
|  |             raise TypeError("file_path must be a string or Path object") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def file_type(self) -> FileType: | ||||||
|  |         """Get the file type.""" | ||||||
|  |         return self._file_type | ||||||
|  |  | ||||||
|  |     @file_type.setter | ||||||
|  |     def file_type(self, value: FileType | str): | ||||||
|  |         """Set the file type, converting to FileType if necessary.""" | ||||||
|  |         self._file_type = self._coerce_file_type(value) | ||||||
|  |  | ||||||
|  |     def _coerce_file_type(self, file_type: FileType | str) -> FileType: | ||||||
|  |         """Coerce the file type to a FileType enum.""" | ||||||
|  |         if isinstance(file_type, FileType): | ||||||
|  |             return file_type | ||||||
|  |         elif isinstance(file_type, str): | ||||||
|  |             return FileType(file_type) | ||||||
|  |         else: | ||||||
|  |             raise TypeError("file_type must be a FileType enum or string") | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     def _dict_to_xml(self, data: dict, root: ET.Element) -> None: | ||||||
|  |         """Convert a dictionary to XML format.""" | ||||||
|  |         for key, value in data.items(): | ||||||
|  |             if isinstance(value, dict): | ||||||
|  |                 sub_element = ET.SubElement(root, key) | ||||||
|  |                 self._dict_to_xml(value, sub_element) | ||||||
|  |             elif isinstance(value, list): | ||||||
|  |                 for item in value: | ||||||
|  |                     item_element = ET.SubElement(root, key) | ||||||
|  |                     if isinstance(item, dict): | ||||||
|  |                         self._dict_to_xml(item, item_element) | ||||||
|  |                     else: | ||||||
|  |                         item_element.text = str(item) | ||||||
|  |             else: | ||||||
|  |                 element = ET.SubElement(root, key) | ||||||
|  |                 element.text = str(value) | ||||||
|  |  | ||||||
|  |     async def save_file(self, data: Any) -> None: | ||||||
|  |         """Save data to the specified file in the desired format.""" | ||||||
|  |         if self.file_path is None: | ||||||
|  |             raise ValueError("file_path must be set before saving a file") | ||||||
|  |         elif self.file_path.exists() and not self.overwrite: | ||||||
|  |             raise FileExistsError(f"File already exists: {self.file_path}") | ||||||
|  |  | ||||||
|  |         if self.file_path.parent and not self.file_path.parent.exists(): | ||||||
|  |             if self.create_dirs: | ||||||
|  |                 self.file_path.parent.mkdir(parents=True, exist_ok=True) | ||||||
|  |             else: | ||||||
|  |                 raise FileNotFoundError( | ||||||
|  |                     f"Directory does not exist: {self.file_path.parent}" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             if self.file_type == FileType.TEXT: | ||||||
|  |                 self.file_path.write_text(data, encoding="UTF-8") | ||||||
|  |             elif self.file_type == FileType.JSON: | ||||||
|  |                 self.file_path.write_text(json.dumps(data, indent=4), encoding="UTF-8") | ||||||
|  |             elif self.file_type == FileType.TOML: | ||||||
|  |                 self.file_path.write_text(toml.dumps(data), encoding="UTF-8") | ||||||
|  |             elif self.file_type == FileType.YAML: | ||||||
|  |                 self.file_path.write_text(yaml.dump(data), encoding="UTF-8") | ||||||
|  |             elif self.file_type == FileType.CSV: | ||||||
|  |                 if not isinstance(data, list) or not all( | ||||||
|  |                     isinstance(row, list) for row in data | ||||||
|  |                 ): | ||||||
|  |                     raise ValueError( | ||||||
|  |                         f"{self.file_type.name} file type requires a list of lists" | ||||||
|  |                     ) | ||||||
|  |                 with open( | ||||||
|  |                     self.file_path, mode=self.mode, newline="", encoding="UTF-8" | ||||||
|  |                 ) as csvfile: | ||||||
|  |                     writer = csv.writer(csvfile) | ||||||
|  |                     writer.writerows(data) | ||||||
|  |             elif self.file_type == FileType.TSV: | ||||||
|  |                 if not isinstance(data, list) or not all( | ||||||
|  |                     isinstance(row, list) for row in data | ||||||
|  |                 ): | ||||||
|  |                     raise ValueError( | ||||||
|  |                         f"{self.file_type.name} file type requires a list of lists" | ||||||
|  |                     ) | ||||||
|  |                 with open( | ||||||
|  |                     self.file_path, mode=self.mode, newline="", encoding="UTF-8" | ||||||
|  |                 ) as tsvfile: | ||||||
|  |                     writer = csv.writer(tsvfile, delimiter="\t") | ||||||
|  |                     writer.writerows(data) | ||||||
|  |             elif self.file_type == FileType.XML: | ||||||
|  |                 if not isinstance(data, dict): | ||||||
|  |                     raise ValueError("XML file type requires data to be a dictionary") | ||||||
|  |                 root = ET.Element("root") | ||||||
|  |                 self._dict_to_xml(data, root) | ||||||
|  |                 tree = ET.ElementTree(root) | ||||||
|  |                 tree.write(self.file_path, encoding="UTF-8", xml_declaration=True) | ||||||
|  |             else: | ||||||
|  |                 raise ValueError(f"Unsupported file type: {self.file_type}") | ||||||
|  |  | ||||||
|  |         except Exception as error: | ||||||
|  |             logger.error("Failed to save %s: %s", self.file_path.name, error) | ||||||
|  |             raise | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs): | ||||||
|  |         combined_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         data = self.data or combined_kwargs.get(self.inject_into) | ||||||
|  |  | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, args=args, kwargs=combined_kwargs, action=self | ||||||
|  |         ) | ||||||
|  |         context.start_timer() | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |  | ||||||
|  |             await self.save_file(data) | ||||||
|  |             logger.debug("File saved successfully: %s", self.file_path) | ||||||
|  |  | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return str(self.file_path) | ||||||
|  |  | ||||||
|  |         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.CYAN}]💾 SaveFileAction[/] '{self.name}'" | ||||||
|  |         tree = parent.add(label) if parent else Tree(label) | ||||||
|  |  | ||||||
|  |         tree.add(f"[dim]Path:[/] {self.file_path}") | ||||||
|  |         tree.add(f"[dim]Type:[/] {self.file_type.name}") | ||||||
|  |         tree.add(f"[dim]Overwrite:[/] {self.overwrite}") | ||||||
|  |  | ||||||
|  |         if self.file_path and self.file_path.exists(): | ||||||
|  |             if self.overwrite: | ||||||
|  |                 tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]") | ||||||
|  |             else: | ||||||
|  |                 tree.add( | ||||||
|  |                     f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]" | ||||||
|  |                 ) | ||||||
|  |             stat = self.file_path.stat() | ||||||
|  |             tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes") | ||||||
|  |             tree.add( | ||||||
|  |                 f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}" | ||||||
|  |             ) | ||||||
|  |             tree.add( | ||||||
|  |                 f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})" | ||||||
| @@ -11,11 +11,10 @@ from typing import Any | |||||||
| import toml | import toml | ||||||
| import yaml | import yaml | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from rich.console import Console |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.action_types import FileType | ||||||
| from falyx.action.types import FileReturnType | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| @@ -25,6 +24,7 @@ from falyx.selection import ( | |||||||
|     prompt_for_selection, |     prompt_for_selection, | ||||||
|     render_selection_dict_table, |     render_selection_dict_table, | ||||||
| ) | ) | ||||||
|  | from falyx.signals import CancelSignal | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -49,8 +49,7 @@ class SelectFileAction(BaseAction): | |||||||
|         prompt_message (str): Message to display when prompting for selection. |         prompt_message (str): Message to display when prompting for selection. | ||||||
|         style (str): Style for the selection options. |         style (str): Style for the selection options. | ||||||
|         suffix_filter (str | None): Restrict to certain file types. |         suffix_filter (str | None): Restrict to certain file types. | ||||||
|         return_type (FileReturnType): What to return (path, content, parsed). |         return_type (FileType): What to return (path, content, parsed). | ||||||
|         console (Console | None): Console instance for output. |  | ||||||
|         prompt_session (PromptSession | None): Prompt session for user input. |         prompt_session (PromptSession | None): Prompt session for user input. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
| @@ -64,8 +63,10 @@ class SelectFileAction(BaseAction): | |||||||
|         prompt_message: str = "Choose > ", |         prompt_message: str = "Choose > ", | ||||||
|         style: str = OneColors.WHITE, |         style: str = OneColors.WHITE, | ||||||
|         suffix_filter: str | None = None, |         suffix_filter: str | None = None, | ||||||
|         return_type: FileReturnType | str = FileReturnType.PATH, |         return_type: FileType | str = FileType.PATH, | ||||||
|         console: Console | None = None, |         number_selections: int | str = 1, | ||||||
|  |         separator: str = ",", | ||||||
|  |         allow_duplicates: bool = False, | ||||||
|         prompt_session: PromptSession | None = None, |         prompt_session: PromptSession | None = None, | ||||||
|     ): |     ): | ||||||
|         super().__init__(name) |         super().__init__(name) | ||||||
| @@ -75,39 +76,59 @@ class SelectFileAction(BaseAction): | |||||||
|         self.prompt_message = prompt_message |         self.prompt_message = prompt_message | ||||||
|         self.suffix_filter = suffix_filter |         self.suffix_filter = suffix_filter | ||||||
|         self.style = style |         self.style = style | ||||||
|         self.console = console or Console(color_system="auto") |         self.number_selections = number_selections | ||||||
|  |         self.separator = separator | ||||||
|  |         self.allow_duplicates = allow_duplicates | ||||||
|         self.prompt_session = prompt_session or PromptSession() |         self.prompt_session = prompt_session or PromptSession() | ||||||
|         self.return_type = self._coerce_return_type(return_type) |         self.return_type = self._coerce_return_type(return_type) | ||||||
|  |  | ||||||
|     def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: |     @property | ||||||
|         if isinstance(return_type, FileReturnType): |     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: FileType | str) -> FileType: | ||||||
|  |         if isinstance(return_type, FileType): | ||||||
|             return return_type |             return return_type | ||||||
|         return FileReturnType(return_type) |         elif isinstance(return_type, str): | ||||||
|  |             return FileType(return_type) | ||||||
|  |         else: | ||||||
|  |             raise TypeError("return_type must be a FileType enum or string") | ||||||
|  |  | ||||||
|     def get_options(self, files: list[Path]) -> dict[str, SelectionOption]: |     def get_options(self, files: list[Path]) -> dict[str, SelectionOption]: | ||||||
|         value: Any |         value: Any | ||||||
|         options = {} |         options = {} | ||||||
|         for index, file in enumerate(files): |         for index, file in enumerate(files): | ||||||
|             try: |             try: | ||||||
|                 if self.return_type == FileReturnType.TEXT: |                 if self.return_type == FileType.TEXT: | ||||||
|                     value = file.read_text(encoding="UTF-8") |                     value = file.read_text(encoding="UTF-8") | ||||||
|                 elif self.return_type == FileReturnType.PATH: |                 elif self.return_type == FileType.PATH: | ||||||
|                     value = file |                     value = file | ||||||
|                 elif self.return_type == FileReturnType.JSON: |                 elif self.return_type == FileType.JSON: | ||||||
|                     value = json.loads(file.read_text(encoding="UTF-8")) |                     value = json.loads(file.read_text(encoding="UTF-8")) | ||||||
|                 elif self.return_type == FileReturnType.TOML: |                 elif self.return_type == FileType.TOML: | ||||||
|                     value = toml.loads(file.read_text(encoding="UTF-8")) |                     value = toml.loads(file.read_text(encoding="UTF-8")) | ||||||
|                 elif self.return_type == FileReturnType.YAML: |                 elif self.return_type == FileType.YAML: | ||||||
|                     value = yaml.safe_load(file.read_text(encoding="UTF-8")) |                     value = yaml.safe_load(file.read_text(encoding="UTF-8")) | ||||||
|                 elif self.return_type == FileReturnType.CSV: |                 elif self.return_type == FileType.CSV: | ||||||
|                     with open(file, newline="", encoding="UTF-8") as csvfile: |                     with open(file, newline="", encoding="UTF-8") as csvfile: | ||||||
|                         reader = csv.reader(csvfile) |                         reader = csv.reader(csvfile) | ||||||
|                         value = list(reader) |                         value = list(reader) | ||||||
|                 elif self.return_type == FileReturnType.TSV: |                 elif self.return_type == FileType.TSV: | ||||||
|                     with open(file, newline="", encoding="UTF-8") as tsvfile: |                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||||
|                         reader = csv.reader(tsvfile, delimiter="\t") |                         reader = csv.reader(tsvfile, delimiter="\t") | ||||||
|                         value = list(reader) |                         value = list(reader) | ||||||
|                 elif self.return_type == FileReturnType.XML: |                 elif self.return_type == FileType.XML: | ||||||
|                     tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) |                     tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) | ||||||
|                     root = tree.getroot() |                     root = tree.getroot() | ||||||
|                     value = ET.tostring(root, encoding="unicode") |                     value = ET.tostring(root, encoding="unicode") | ||||||
| @@ -118,39 +139,70 @@ class SelectFileAction(BaseAction): | |||||||
|                     description=file.name, value=value, style=self.style |                     description=file.name, value=value, style=self.style | ||||||
|                 ) |                 ) | ||||||
|             except Exception as error: |             except Exception as error: | ||||||
|                 logger.warning("[ERROR] Failed to parse %s: %s", file.name, error) |                 logger.error("Failed to parse %s: %s", file.name, error) | ||||||
|         return options |         return options | ||||||
|  |  | ||||||
|  |     def _find_cancel_key(self, options) -> str: | ||||||
|  |         """Return first numeric value not already used in the selection dict.""" | ||||||
|  |         for index in range(len(options)): | ||||||
|  |             if str(index) not in options: | ||||||
|  |                 return str(index) | ||||||
|  |         return str(len(options)) | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) |         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
|         try: |         try: | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |  | ||||||
|  |             if not self.directory.exists(): | ||||||
|  |                 raise FileNotFoundError(f"Directory {self.directory} does not exist.") | ||||||
|  |             elif not self.directory.is_dir(): | ||||||
|  |                 raise NotADirectoryError(f"{self.directory} is not a directory.") | ||||||
|  |  | ||||||
|             files = [ |             files = [ | ||||||
|                 f |                 file | ||||||
|                 for f in self.directory.iterdir() |                 for file in self.directory.iterdir() | ||||||
|                 if f.is_file() |                 if file.is_file() | ||||||
|                 and (self.suffix_filter is None or f.suffix == self.suffix_filter) |                 and (self.suffix_filter is None or file.suffix == self.suffix_filter) | ||||||
|             ] |             ] | ||||||
|             if not files: |             if not files: | ||||||
|                 raise FileNotFoundError("No files found in directory.") |                 raise FileNotFoundError("No files found in directory.") | ||||||
|  |  | ||||||
|             options = self.get_options(files) |             options = self.get_options(files) | ||||||
|  |  | ||||||
|  |             cancel_key = self._find_cancel_key(options) | ||||||
|  |             cancel_option = { | ||||||
|  |                 cancel_key: SelectionOption( | ||||||
|  |                     description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|             table = render_selection_dict_table( |             table = render_selection_dict_table( | ||||||
|                 title=self.title, selections=options, columns=self.columns |                 title=self.title, selections=options | cancel_option, columns=self.columns | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             key = await prompt_for_selection( |             keys = await prompt_for_selection( | ||||||
|                 options.keys(), |                 (options | cancel_option).keys(), | ||||||
|                 table, |                 table, | ||||||
|                 console=self.console, |  | ||||||
|                 prompt_session=self.prompt_session, |                 prompt_session=self.prompt_session, | ||||||
|                 prompt_message=self.prompt_message, |                 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 |             context.result = result | ||||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|             return result |             return result | ||||||
| @@ -176,11 +228,11 @@ class SelectFileAction(BaseAction): | |||||||
|         try: |         try: | ||||||
|             files = list(self.directory.iterdir()) |             files = list(self.directory.iterdir()) | ||||||
|             if self.suffix_filter: |             if self.suffix_filter: | ||||||
|                 files = [f for f in files if f.suffix == self.suffix_filter] |                 files = [file for file in files if file.suffix == self.suffix_filter] | ||||||
|             sample = files[:10] |             sample = files[:10] | ||||||
|             file_list = tree.add("[dim]Files:[/]") |             file_list = tree.add("[dim]Files:[/]") | ||||||
|             for f in sample: |             for file in sample: | ||||||
|                 file_list.add(f"[dim]{f.name}[/]") |                 file_list.add(f"[dim]{file.name}[/]") | ||||||
|             if len(files) > 10: |             if len(files) > 10: | ||||||
|                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") |                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") | ||||||
|         except Exception as error: |         except Exception as error: | ||||||
|   | |||||||
| @@ -3,23 +3,24 @@ | |||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from rich.console import Console |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.action_types import SelectionReturnType | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.selection import ( | from falyx.selection import ( | ||||||
|     SelectionOption, |     SelectionOption, | ||||||
|  |     SelectionOptionMap, | ||||||
|     prompt_for_index, |     prompt_for_index, | ||||||
|     prompt_for_selection, |     prompt_for_selection, | ||||||
|     render_selection_dict_table, |     render_selection_dict_table, | ||||||
|     render_selection_indexed_table, |     render_selection_indexed_table, | ||||||
| ) | ) | ||||||
|  | from falyx.signals import CancelSignal | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SelectionAction(BaseAction): | class SelectionAction(BaseAction): | ||||||
| @@ -34,16 +35,24 @@ class SelectionAction(BaseAction): | |||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         name: str, |         name: str, | ||||||
|         selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption], |         selections: ( | ||||||
|  |             list[str] | ||||||
|  |             | set[str] | ||||||
|  |             | tuple[str, ...] | ||||||
|  |             | dict[str, SelectionOption] | ||||||
|  |             | dict[str, Any] | ||||||
|  |         ), | ||||||
|         *, |         *, | ||||||
|         title: str = "Select an option", |         title: str = "Select an option", | ||||||
|         columns: int = 5, |         columns: int = 5, | ||||||
|         prompt_message: str = "Select > ", |         prompt_message: str = "Select > ", | ||||||
|         default_selection: str = "", |         default_selection: str = "", | ||||||
|  |         number_selections: int | str = 1, | ||||||
|  |         separator: str = ",", | ||||||
|  |         allow_duplicates: bool = False, | ||||||
|         inject_last_result: bool = False, |         inject_last_result: bool = False, | ||||||
|         inject_into: str = "last_result", |         inject_into: str = "last_result", | ||||||
|         return_key: bool = False, |         return_type: SelectionReturnType | str = "value", | ||||||
|         console: Console | None = None, |  | ||||||
|         prompt_session: PromptSession | None = None, |         prompt_session: PromptSession | None = None, | ||||||
|         never_prompt: bool = False, |         never_prompt: bool = False, | ||||||
|         show_table: bool = True, |         show_table: bool = True, | ||||||
| @@ -55,18 +64,42 @@ class SelectionAction(BaseAction): | |||||||
|             never_prompt=never_prompt, |             never_prompt=never_prompt, | ||||||
|         ) |         ) | ||||||
|         # Setter normalizes to correct type, mypy can't infer that |         # Setter normalizes to correct type, mypy can't infer that | ||||||
|         self.selections: list[str] | CaseInsensitiveDict = selections  # type: ignore[assignment] |         self.selections: list[str] | SelectionOptionMap = selections  # type: ignore[assignment] | ||||||
|         self.return_key = return_key |         self.return_type: SelectionReturnType = self._coerce_return_type(return_type) | ||||||
|         self.title = title |         self.title = title | ||||||
|         self.columns = columns |         self.columns = columns | ||||||
|         self.console = console or Console(color_system="auto") |  | ||||||
|         self.prompt_session = prompt_session or PromptSession() |         self.prompt_session = prompt_session or PromptSession() | ||||||
|         self.default_selection = default_selection |         self.default_selection = default_selection | ||||||
|  |         self.number_selections = number_selections | ||||||
|  |         self.separator = separator | ||||||
|  |         self.allow_duplicates = allow_duplicates | ||||||
|         self.prompt_message = prompt_message |         self.prompt_message = prompt_message | ||||||
|         self.show_table = show_table |         self.show_table = show_table | ||||||
|  |  | ||||||
|     @property |     @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 |         return self._selections | ||||||
|  |  | ||||||
|     @selections.setter |     @selections.setter | ||||||
| @@ -74,17 +107,101 @@ class SelectionAction(BaseAction): | |||||||
|         self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] |         self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] | ||||||
|     ): |     ): | ||||||
|         if isinstance(value, (list, tuple, set)): |         if isinstance(value, (list, tuple, set)): | ||||||
|             self._selections: list[str] | CaseInsensitiveDict = list(value) |             self._selections: list[str] | SelectionOptionMap = list(value) | ||||||
|         elif isinstance(value, dict): |         elif isinstance(value, dict): | ||||||
|             cid = CaseInsensitiveDict() |             som = SelectionOptionMap() | ||||||
|             cid.update(value) |             if all(isinstance(key, str) for key in value) and all( | ||||||
|             self._selections = cid |                 not isinstance(value[key], SelectionOption) for key in value | ||||||
|  |             ): | ||||||
|  |                 som.update( | ||||||
|  |                     { | ||||||
|  |                         str(index): SelectionOption(key, option) | ||||||
|  |                         for index, (key, option) in enumerate(value.items()) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             elif all(isinstance(key, str) for key in value) and all( | ||||||
|  |                 isinstance(value[key], SelectionOption) for key in value | ||||||
|  |             ): | ||||||
|  |                 som.update(value) | ||||||
|  |             else: | ||||||
|  |                 raise ValueError("Invalid dictionary format. Keys must be strings") | ||||||
|  |             self._selections = som | ||||||
|         else: |         else: | ||||||
|             raise TypeError( |             raise TypeError( | ||||||
|                 "'selections' must be a list[str] or dict[str, SelectionOption], " |                 "'selections' must be a list[str] or dict[str, SelectionOption], " | ||||||
|                 f"got {type(value).__name__}" |                 f"got {type(value).__name__}" | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |     def _find_cancel_key(self) -> str: | ||||||
|  |         """Find the cancel key in the selections.""" | ||||||
|  |         if isinstance(self.selections, dict): | ||||||
|  |             for index in range(len(self.selections) + 1): | ||||||
|  |                 if str(index) not in self.selections: | ||||||
|  |                     return str(index) | ||||||
|  |         return str(len(self.selections)) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def cancel_key(self) -> str: | ||||||
|  |         return self._cancel_key | ||||||
|  |  | ||||||
|  |     @cancel_key.setter | ||||||
|  |     def cancel_key(self, value: str) -> None: | ||||||
|  |         """Set the cancel key for the selection.""" | ||||||
|  |         if not isinstance(value, str): | ||||||
|  |             raise TypeError("Cancel key must be a string.") | ||||||
|  |         if isinstance(self.selections, dict) and value in self.selections: | ||||||
|  |             raise ValueError( | ||||||
|  |                 "Cancel key cannot be one of the selection keys. " | ||||||
|  |                 f"Current selections: {self.selections}" | ||||||
|  |             ) | ||||||
|  |         if isinstance(self.selections, list): | ||||||
|  |             if not value.isdigit() or int(value) > len(self.selections): | ||||||
|  |                 raise ValueError( | ||||||
|  |                     "cancel_key must be a digit and not greater than the number of selections." | ||||||
|  |                 ) | ||||||
|  |         self._cancel_key = value | ||||||
|  |  | ||||||
|  |     def cancel_formatter(self, index: int, selection: str) -> str: | ||||||
|  |         """Format the cancel option for display.""" | ||||||
|  |         if self.cancel_key == str(index): | ||||||
|  |             return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]" | ||||||
|  |         return f"[{index}] {selection}" | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     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: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|         kwargs = self._maybe_inject_last_result(kwargs) |         kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
| @@ -120,51 +237,85 @@ class SelectionAction(BaseAction): | |||||||
|         if self.never_prompt and not effective_default: |         if self.never_prompt and not effective_default: | ||||||
|             raise ValueError( |             raise ValueError( | ||||||
|                 f"[{self.name}] 'never_prompt' is True but no valid default_selection " |                 f"[{self.name}] 'never_prompt' is True but no valid default_selection " | ||||||
|                 "was provided." |                 "or usable last_result was available." | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
|         try: |         try: | ||||||
|  |             self.cancel_key = self._find_cancel_key() | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|             if isinstance(self.selections, list): |             if isinstance(self.selections, list): | ||||||
|                 table = render_selection_indexed_table( |                 table = render_selection_indexed_table( | ||||||
|                     title=self.title, |                     title=self.title, | ||||||
|                     selections=self.selections, |                     selections=self.selections + ["Cancel"], | ||||||
|  |                     columns=self.columns, | ||||||
|  |                     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, | ||||||
|  |                         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, |                     columns=self.columns, | ||||||
|                 ) |                 ) | ||||||
|                 if not self.never_prompt: |                 if not self.never_prompt: | ||||||
|                     index = await prompt_for_index( |                     keys = await prompt_for_selection( | ||||||
|                         len(self.selections) - 1, |                         (self.selections | cancel_option).keys(), | ||||||
|                         table, |                         table, | ||||||
|                         default_selection=effective_default, |                         default_selection=effective_default, | ||||||
|                         console=self.console, |  | ||||||
|                         prompt_session=self.prompt_session, |                         prompt_session=self.prompt_session, | ||||||
|                         prompt_message=self.prompt_message, |                         prompt_message=self.prompt_message, | ||||||
|                         show_table=self.show_table, |                         show_table=self.show_table, | ||||||
|  |                         number_selections=self.number_selections, | ||||||
|  |                         separator=self.separator, | ||||||
|  |                         allow_duplicates=self.allow_duplicates, | ||||||
|  |                         cancel_key=self.cancel_key, | ||||||
|                     ) |                     ) | ||||||
|                 else: |                 else: | ||||||
|                     index = effective_default |                     keys = effective_default | ||||||
|                 result = self.selections[int(index)] |                 if keys == self.cancel_key: | ||||||
|             elif isinstance(self.selections, dict): |                     raise CancelSignal("User cancelled the selection.") | ||||||
|                 table = render_selection_dict_table( |  | ||||||
|                     title=self.title, selections=self.selections, columns=self.columns |                 result = self._get_result_from_keys(keys) | ||||||
|                 ) |  | ||||||
|                 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 |  | ||||||
|             else: |             else: | ||||||
|                 raise TypeError( |                 raise TypeError( | ||||||
|                     "'selections' must be a list[str] or dict[str, tuple[str, Any]], " |                     "'selections' must be a list[str] or dict[str, Any], " | ||||||
|                     f"got {type(self.selections).__name__}" |                     f"got {type(self.selections).__name__}" | ||||||
|                 ) |                 ) | ||||||
|             context.result = result |             context.result = result | ||||||
| @@ -203,7 +354,7 @@ class SelectionAction(BaseAction): | |||||||
|             return |             return | ||||||
|  |  | ||||||
|         tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") |         tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") | ||||||
|         tree.add(f"[dim]Return:[/] {'Key' if self.return_key else 'Value'}") |         tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}") | ||||||
|         tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") |         tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") | ||||||
|  |  | ||||||
|         if not parent: |         if not parent: | ||||||
| @@ -218,6 +369,6 @@ class SelectionAction(BaseAction): | |||||||
|         return ( |         return ( | ||||||
|             f"SelectionAction(name={self.name!r}, type={selection_type}, " |             f"SelectionAction(name={self.name!r}, type={selection_type}, " | ||||||
|             f"default_selection={self.default_selection!r}, " |             f"default_selection={self.default_selection!r}, " | ||||||
|             f"return_key={self.return_key}, " |             f"return_type={self.return_type!r}, " | ||||||
|             f"prompt={'off' if self.never_prompt else 'on'})" |             f"prompt={'off' if self.never_prompt else 'on'})" | ||||||
|         ) |         ) | ||||||
|   | |||||||
							
								
								
									
										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})" | ||||||
|  |         ) | ||||||
| @@ -14,7 +14,7 @@ class SignalAction(Action): | |||||||
|     Useful for exiting a menu, going back, or halting execution gracefully. |     Useful for exiting a menu, going back, or halting execution gracefully. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, name: str, signal: Exception): |     def __init__(self, name: str, signal: FlowSignal): | ||||||
|         self.signal = signal |         self.signal = signal | ||||||
|         super().__init__(name, action=self.raise_signal) |         super().__init__(name, action=self.raise_signal) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,37 +0,0 @@ | |||||||
| from __future__ import annotations |  | ||||||
|  |  | ||||||
| from enum import Enum |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FileReturnType(Enum): |  | ||||||
|     """Enum for file return types.""" |  | ||||||
|  |  | ||||||
|     TEXT = "text" |  | ||||||
|     PATH = "path" |  | ||||||
|     JSON = "json" |  | ||||||
|     TOML = "toml" |  | ||||||
|     YAML = "yaml" |  | ||||||
|     CSV = "csv" |  | ||||||
|     TSV = "tsv" |  | ||||||
|     XML = "xml" |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def _get_alias(cls, value: str) -> str: |  | ||||||
|         aliases = { |  | ||||||
|             "yml": "yaml", |  | ||||||
|             "txt": "text", |  | ||||||
|             "file": "path", |  | ||||||
|             "filepath": "path", |  | ||||||
|         } |  | ||||||
|         return aliases.get(value, value) |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def _missing_(cls, value: object) -> FileReturnType: |  | ||||||
|         if isinstance(value, str): |  | ||||||
|             normalized = value.lower() |  | ||||||
|             alias = cls._get_alias(normalized) |  | ||||||
|             for member in cls: |  | ||||||
|                 if member.value == alias: |  | ||||||
|                     return member |  | ||||||
|         valid = ", ".join(member.value for member in cls) |  | ||||||
|         raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}") |  | ||||||
| @@ -1,9 +1,10 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """user_input_action.py""" | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from prompt_toolkit.validation import Validator | from prompt_toolkit.validation import Validator | ||||||
| from rich.console import Console |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action import BaseAction | from falyx.action.base_action import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| @@ -18,7 +19,6 @@ class UserInputAction(BaseAction): | |||||||
|         name (str): Action name. |         name (str): Action name. | ||||||
|         prompt_text (str): Prompt text (can include '{last_result}' for interpolation). |         prompt_text (str): Prompt text (can include '{last_result}' for interpolation). | ||||||
|         validator (Validator, optional): Prompt Toolkit validator. |         validator (Validator, optional): Prompt Toolkit validator. | ||||||
|         console (Console, optional): Rich console for rendering. |  | ||||||
|         prompt_session (PromptSession, optional): Reusable prompt session. |         prompt_session (PromptSession, optional): Reusable prompt session. | ||||||
|         inject_last_result (bool): Whether to inject last_result into prompt. |         inject_last_result (bool): Whether to inject last_result into prompt. | ||||||
|         inject_into (str): Key to use for injection (default: 'last_result'). |         inject_into (str): Key to use for injection (default: 'last_result'). | ||||||
| @@ -29,8 +29,8 @@ class UserInputAction(BaseAction): | |||||||
|         name: str, |         name: str, | ||||||
|         *, |         *, | ||||||
|         prompt_text: str = "Input > ", |         prompt_text: str = "Input > ", | ||||||
|  |         default_text: str = "", | ||||||
|         validator: Validator | None = None, |         validator: Validator | None = None, | ||||||
|         console: Console | None = None, |  | ||||||
|         prompt_session: PromptSession | None = None, |         prompt_session: PromptSession | None = None, | ||||||
|         inject_last_result: bool = False, |         inject_last_result: bool = False, | ||||||
|     ): |     ): | ||||||
| @@ -40,8 +40,11 @@ class UserInputAction(BaseAction): | |||||||
|         ) |         ) | ||||||
|         self.prompt_text = prompt_text |         self.prompt_text = prompt_text | ||||||
|         self.validator = validator |         self.validator = validator | ||||||
|         self.console = console or Console(color_system="auto") |  | ||||||
|         self.prompt_session = prompt_session or PromptSession() |         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: |     async def _run(self, *args, **kwargs) -> str: | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
| @@ -61,6 +64,7 @@ class UserInputAction(BaseAction): | |||||||
|             answer = await self.prompt_session.prompt_async( |             answer = await self.prompt_session.prompt_async( | ||||||
|                 prompt_text, |                 prompt_text, | ||||||
|                 validator=self.validator, |                 validator=self.validator, | ||||||
|  |                 default=kwargs.get("default_text", self.default_text), | ||||||
|             ) |             ) | ||||||
|             context.result = answer |             context.result = answer | ||||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from prompt_toolkit.formatted_text import HTML, merge_formatted_text | |||||||
| from prompt_toolkit.key_binding import KeyBindings | from prompt_toolkit.key_binding import KeyBindings | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
|  |  | ||||||
|  | from falyx.console import console | ||||||
| from falyx.options_manager import OptionsManager | from falyx.options_manager import OptionsManager | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict, chunks | from falyx.utils import CaseInsensitiveDict, chunks | ||||||
| @@ -30,7 +31,7 @@ class BottomBar: | |||||||
|         key_validator: Callable[[str], bool] | None = None, |         key_validator: Callable[[str], bool] | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.columns = columns |         self.columns = columns | ||||||
|         self.console = Console(color_system="auto") |         self.console: Console = console | ||||||
|         self._named_items: dict[str, Callable[[], HTML]] = {} |         self._named_items: dict[str, Callable[[], HTML]] = {} | ||||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() |         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||||
|         self.toggle_keys: list[str] = [] |         self.toggle_keys: list[str] = [] | ||||||
|   | |||||||
							
								
								
									
										146
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										146
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -19,33 +19,23 @@ in building robust interactive menus. | |||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| import shlex | import shlex | ||||||
| from functools import cached_property | from typing import Any, Awaitable, Callable | ||||||
| from typing import Any, Callable |  | ||||||
|  |  | ||||||
| from prompt_toolkit.formatted_text import FormattedText | from prompt_toolkit.formatted_text import FormattedText | ||||||
| from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | ||||||
| from rich.console import Console |  | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action.action import ( | from falyx.action.action import Action | ||||||
|     Action, | from falyx.action.base_action import BaseAction | ||||||
|     ActionGroup, | from falyx.console import console | ||||||
|     BaseAction, |  | ||||||
|     ChainedAction, |  | ||||||
|     ProcessAction, |  | ||||||
| ) |  | ||||||
| from falyx.action.io_action import BaseIOAction |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.debug import register_debug_hooks | from falyx.debug import register_debug_hooks | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.options_manager import OptionsManager | from falyx.options_manager import OptionsManager | ||||||
| from falyx.parsers import ( | from falyx.parser.command_argument_parser import CommandArgumentParser | ||||||
|     CommandArgumentParser, | from falyx.parser.signature import infer_args_from_func | ||||||
|     infer_args_from_func, |  | ||||||
|     same_argument_definitions, |  | ||||||
| ) |  | ||||||
| from falyx.prompt_utils import confirm_async, should_prompt_user | from falyx.prompt_utils import confirm_async, should_prompt_user | ||||||
| from falyx.protocols import ArgParserProtocol | from falyx.protocols import ArgParserProtocol | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| @@ -54,8 +44,6 @@ from falyx.signals import CancelSignal | |||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import ensure_async | from falyx.utils import ensure_async | ||||||
|  |  | ||||||
| console = Console(color_system="auto") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseModel): | class Command(BaseModel): | ||||||
|     """ |     """ | ||||||
| @@ -99,9 +87,14 @@ class Command(BaseModel): | |||||||
|         retry_policy (RetryPolicy): Retry behavior configuration. |         retry_policy (RetryPolicy): Retry behavior configuration. | ||||||
|         tags (list[str]): Organizational tags for the command. |         tags (list[str]): Organizational tags for the command. | ||||||
|         logging_hooks (bool): Whether to attach logging hooks automatically. |         logging_hooks (bool): Whether to attach logging hooks automatically. | ||||||
|         requires_input (bool | None): Indicates if the action needs input. |  | ||||||
|         options_manager (OptionsManager): Manages global command-line options. |         options_manager (OptionsManager): Manages global command-line options. | ||||||
|         arg_parser (CommandArgumentParser): Parses command arguments. |         arg_parser (CommandArgumentParser): Parses command arguments. | ||||||
|  |         arguments (list[dict[str, Any]]): Argument definitions for the command. | ||||||
|  |         argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments | ||||||
|  |             for the command parser. | ||||||
|  |         arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, | ||||||
|  |             such as help text or choices. | ||||||
|  |         simple_help_signature (bool): Whether to use a simplified help signature. | ||||||
|         custom_parser (ArgParserProtocol | None): Custom argument parser. |         custom_parser (ArgParserProtocol | None): Custom argument parser. | ||||||
|         custom_help (Callable[[], str | None] | None): Custom help message generator. |         custom_help (Callable[[], str | None] | None): Custom help message generator. | ||||||
|         auto_args (bool): Automatically infer arguments from the action. |         auto_args (bool): Automatically infer arguments from the action. | ||||||
| @@ -116,13 +109,13 @@ class Command(BaseModel): | |||||||
|  |  | ||||||
|     key: str |     key: str | ||||||
|     description: str |     description: str | ||||||
|     action: BaseAction | Callable[[Any], Any] |     action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]] | ||||||
|     args: tuple = () |     args: tuple = () | ||||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) |     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||||
|     hidden: bool = False |     hidden: bool = False | ||||||
|     aliases: list[str] = Field(default_factory=list) |     aliases: list[str] = Field(default_factory=list) | ||||||
|     help_text: str = "" |     help_text: str = "" | ||||||
|     help_epilogue: str = "" |     help_epilog: str = "" | ||||||
|     style: str = OneColors.WHITE |     style: str = OneColors.WHITE | ||||||
|     confirm: bool = False |     confirm: bool = False | ||||||
|     confirm_message: str = "Are you sure?" |     confirm_message: str = "Are you sure?" | ||||||
| @@ -138,24 +131,24 @@ class Command(BaseModel): | |||||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) |     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||||
|     tags: list[str] = Field(default_factory=list) |     tags: list[str] = Field(default_factory=list) | ||||||
|     logging_hooks: bool = False |     logging_hooks: bool = False | ||||||
|     requires_input: bool | None = None |  | ||||||
|     options_manager: OptionsManager = Field(default_factory=OptionsManager) |     options_manager: OptionsManager = Field(default_factory=OptionsManager) | ||||||
|     arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser) |     arg_parser: CommandArgumentParser | None = None | ||||||
|     arguments: list[dict[str, Any]] = Field(default_factory=list) |     arguments: list[dict[str, Any]] = Field(default_factory=list) | ||||||
|     argument_config: Callable[[CommandArgumentParser], None] | None = None |     argument_config: Callable[[CommandArgumentParser], None] | None = None | ||||||
|     custom_parser: ArgParserProtocol | None = None |     custom_parser: ArgParserProtocol | None = None | ||||||
|     custom_help: Callable[[], str | None] | None = None |     custom_help: Callable[[], str | None] | None = None | ||||||
|     auto_args: bool = False |     auto_args: bool = True | ||||||
|     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) |     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) | ||||||
|  |     simple_help_signature: bool = False | ||||||
|  |  | ||||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) |     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
|     def parse_args( |     async def parse_args( | ||||||
|         self, raw_args: list[str] | str, from_validate: bool = False |         self, raw_args: list[str] | str, from_validate: bool = False | ||||||
|     ) -> tuple[tuple, dict]: |     ) -> tuple[tuple, dict]: | ||||||
|         if self.custom_parser: |         if callable(self.custom_parser): | ||||||
|             if isinstance(raw_args, str): |             if isinstance(raw_args, str): | ||||||
|                 try: |                 try: | ||||||
|                     raw_args = shlex.split(raw_args) |                     raw_args = shlex.split(raw_args) | ||||||
| @@ -178,7 +171,15 @@ class Command(BaseModel): | |||||||
|                     raw_args, |                     raw_args, | ||||||
|                 ) |                 ) | ||||||
|                 return ((), {}) |                 return ((), {}) | ||||||
|         return self.arg_parser.parse_args_split(raw_args, from_validate=from_validate) |         if not isinstance(self.arg_parser, CommandArgumentParser): | ||||||
|  |             logger.warning( | ||||||
|  |                 "[Command:%s] No argument parser configured, using default parsing.", | ||||||
|  |                 self.key, | ||||||
|  |             ) | ||||||
|  |             return ((), {}) | ||||||
|  |         return await self.arg_parser.parse_args_split( | ||||||
|  |             raw_args, from_validate=from_validate | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     @field_validator("action", mode="before") |     @field_validator("action", mode="before") | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -192,28 +193,17 @@ class Command(BaseModel): | |||||||
|     def get_argument_definitions(self) -> list[dict[str, Any]]: |     def get_argument_definitions(self) -> list[dict[str, Any]]: | ||||||
|         if self.arguments: |         if self.arguments: | ||||||
|             return self.arguments |             return self.arguments | ||||||
|         elif self.argument_config: |         elif callable(self.argument_config) and isinstance( | ||||||
|  |             self.arg_parser, CommandArgumentParser | ||||||
|  |         ): | ||||||
|             self.argument_config(self.arg_parser) |             self.argument_config(self.arg_parser) | ||||||
|         elif self.auto_args: |         elif self.auto_args: | ||||||
|             if isinstance(self.action, (Action, ProcessAction)): |             if isinstance(self.action, BaseAction): | ||||||
|                 return infer_args_from_func(self.action.action, self.arg_metadata) |                 infer_target, maybe_metadata = self.action.get_infer_target() | ||||||
|             elif isinstance(self.action, ChainedAction): |                 # merge metadata with the action's metadata if not already in self.arg_metadata | ||||||
|                 if self.action.actions: |                 if maybe_metadata: | ||||||
|                     action = self.action.actions[0] |                     self.arg_metadata = {**maybe_metadata, **self.arg_metadata} | ||||||
|                     if isinstance(action, Action): |                 return infer_args_from_func(infer_target, self.arg_metadata) | ||||||
|                         return infer_args_from_func(action.action, self.arg_metadata) |  | ||||||
|                     elif callable(action): |  | ||||||
|                         return infer_args_from_func(action, self.arg_metadata) |  | ||||||
|             elif isinstance(self.action, ActionGroup): |  | ||||||
|                 arg_defs = same_argument_definitions( |  | ||||||
|                     self.action.actions, self.arg_metadata |  | ||||||
|                 ) |  | ||||||
|                 if arg_defs: |  | ||||||
|                     return arg_defs |  | ||||||
|                 logger.debug( |  | ||||||
|                     "[Command:%s] auto_args disabled: mismatched ActionGroup arguments", |  | ||||||
|                     self.key, |  | ||||||
|                 ) |  | ||||||
|             elif callable(self.action): |             elif callable(self.action): | ||||||
|                 return infer_args_from_func(self.action, self.arg_metadata) |                 return infer_args_from_func(self.action, self.arg_metadata) | ||||||
|         return [] |         return [] | ||||||
| @@ -241,29 +231,17 @@ class Command(BaseModel): | |||||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): |         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||||
|             register_debug_hooks(self.action.hooks) |             register_debug_hooks(self.action.hooks) | ||||||
|  |  | ||||||
|         if self.requires_input is None and self.detect_requires_input: |         if self.arg_parser is None and not self.custom_parser: | ||||||
|             self.requires_input = True |             self.arg_parser = CommandArgumentParser( | ||||||
|             self.hidden = True |                 command_key=self.key, | ||||||
|         elif self.requires_input is None: |                 command_description=self.description, | ||||||
|             self.requires_input = False |                 command_style=self.style, | ||||||
|  |                 help_text=self.help_text, | ||||||
|         for arg_def in self.get_argument_definitions(): |                 help_epilog=self.help_epilog, | ||||||
|             self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) |                 aliases=self.aliases, | ||||||
|  |  | ||||||
|     @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 |  | ||||||
|             ) |             ) | ||||||
|         elif isinstance(self.action, ActionGroup): |             for arg_def in self.get_argument_definitions(): | ||||||
|             return any(isinstance(action, BaseIOAction) for action in self.action.actions) |                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||||
|         return False |  | ||||||
|  |  | ||||||
|     def _inject_options_manager(self) -> None: |     def _inject_options_manager(self) -> None: | ||||||
|         """Inject the options manager into the action if applicable.""" |         """Inject the options manager into the action if applicable.""" | ||||||
| @@ -290,7 +268,7 @@ class Command(BaseModel): | |||||||
|             if self.preview_before_confirm: |             if self.preview_before_confirm: | ||||||
|                 await self.preview() |                 await self.preview() | ||||||
|             if not await confirm_async(self.confirmation_prompt): |             if not await confirm_async(self.confirmation_prompt): | ||||||
|                 logger.info("[Command:%s] ❌ Cancelled by user.", self.key) |                 logger.info("[Command:%s] Cancelled by user.", self.key) | ||||||
|                 raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") |                 raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") | ||||||
|  |  | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
| @@ -351,13 +329,39 @@ class Command(BaseModel): | |||||||
|  |  | ||||||
|         return FormattedText(prompt) |         return FormattedText(prompt) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def usage(self) -> str: | ||||||
|  |         """Generate a help string for the command arguments.""" | ||||||
|  |         if not self.arg_parser: | ||||||
|  |             return "No arguments defined." | ||||||
|  |  | ||||||
|  |         command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True) | ||||||
|  |         options_text = self.arg_parser.get_options_text(plain_text=True) | ||||||
|  |         return f"  {command_keys_text:<20}  {options_text} " | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def help_signature(self) -> str: | ||||||
|  |         """Generate a help signature for the command.""" | ||||||
|  |         if self.arg_parser and not self.simple_help_signature: | ||||||
|  |             signature = [self.arg_parser.get_usage()] | ||||||
|  |             signature.append(f"  {self.help_text or self.description}") | ||||||
|  |             if self.tags: | ||||||
|  |                 signature.append(f"  [dim]Tags: {', '.join(self.tags)}[/dim]") | ||||||
|  |             return "\n".join(signature).strip() | ||||||
|  |  | ||||||
|  |         command_keys = " | ".join( | ||||||
|  |             [f"[{self.style}]{self.key}[/{self.style}]"] | ||||||
|  |             + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] | ||||||
|  |         ) | ||||||
|  |         return f"{command_keys}  {self.description}" | ||||||
|  |  | ||||||
|     def log_summary(self) -> None: |     def log_summary(self) -> None: | ||||||
|         if self._context: |         if self._context: | ||||||
|             self._context.log_summary() |             self._context.log_summary() | ||||||
|  |  | ||||||
|     def show_help(self) -> bool: |     def show_help(self) -> bool: | ||||||
|         """Display the help message for the command.""" |         """Display the help message for the command.""" | ||||||
|         if self.custom_help: |         if callable(self.custom_help): | ||||||
|             output = self.custom_help() |             output = self.custom_help() | ||||||
|             if output: |             if output: | ||||||
|                 console.print(output) |                 console.print(output) | ||||||
|   | |||||||
							
								
								
									
										67
									
								
								falyx/completer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								falyx/completer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import shlex | ||||||
|  | from typing import TYPE_CHECKING, Iterable | ||||||
|  |  | ||||||
|  | from prompt_toolkit.completion import Completer, Completion | ||||||
|  | from prompt_toolkit.document import Document | ||||||
|  |  | ||||||
|  | if TYPE_CHECKING: | ||||||
|  |     from falyx import Falyx | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FalyxCompleter(Completer): | ||||||
|  |     """Completer for Falyx commands.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, falyx: "Falyx"): | ||||||
|  |         self.falyx = falyx | ||||||
|  |  | ||||||
|  |     def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: | ||||||
|  |         text = document.text_before_cursor | ||||||
|  |         try: | ||||||
|  |             tokens = shlex.split(text) | ||||||
|  |             cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t")) | ||||||
|  |         except ValueError: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): | ||||||
|  |             # Suggest command keys and aliases | ||||||
|  |             yield from self._suggest_commands(tokens[0] if tokens else "") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # Identify command | ||||||
|  |         command_key = tokens[0].upper() | ||||||
|  |         command = self.falyx._name_map.get(command_key) | ||||||
|  |         if not command or not command.arg_parser: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it | ||||||
|  |         parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1] | ||||||
|  |         stub = "" if cursor_at_end_of_token else tokens[-1] | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             suggestions = command.arg_parser.suggest_next( | ||||||
|  |                 parsed_args + ([stub] if stub else []) | ||||||
|  |             ) | ||||||
|  |             for suggestion in suggestions: | ||||||
|  |                 if suggestion.startswith(stub): | ||||||
|  |                     yield Completion(suggestion, start_position=-len(stub)) | ||||||
|  |         except Exception: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |     def _suggest_commands(self, prefix: str) -> Iterable[Completion]: | ||||||
|  |         prefix = prefix.upper() | ||||||
|  |         keys = [self.falyx.exit_command.key] | ||||||
|  |         keys.extend(self.falyx.exit_command.aliases) | ||||||
|  |         if self.falyx.history_command: | ||||||
|  |             keys.append(self.falyx.history_command.key) | ||||||
|  |             keys.extend(self.falyx.history_command.aliases) | ||||||
|  |         if self.falyx.help_command: | ||||||
|  |             keys.append(self.falyx.help_command.key) | ||||||
|  |             keys.extend(self.falyx.help_command.aliases) | ||||||
|  |         for cmd in self.falyx.commands.values(): | ||||||
|  |             keys.append(cmd.key) | ||||||
|  |             keys.extend(cmd.aliases) | ||||||
|  |         for key in keys: | ||||||
|  |             if key.upper().startswith(prefix): | ||||||
|  |                 yield Completion(key, start_position=-len(prefix)) | ||||||
| @@ -11,17 +11,16 @@ from typing import Any, Callable | |||||||
| import toml | import toml | ||||||
| import yaml | import yaml | ||||||
| from pydantic import BaseModel, Field, field_validator, model_validator | from pydantic import BaseModel, Field, field_validator, model_validator | ||||||
| from rich.console import Console |  | ||||||
|  |  | ||||||
| from falyx.action.action import Action, BaseAction | from falyx.action.action import Action | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
| from falyx.command import Command | from falyx.command import Command | ||||||
|  | from falyx.console import console | ||||||
| from falyx.falyx import Falyx | from falyx.falyx import Falyx | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
|  |  | ||||||
| console = Console(color_system="auto") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||||
|     if isinstance(obj, (BaseAction, Command)): |     if isinstance(obj, (BaseAction, Command)): | ||||||
| @@ -98,9 +97,9 @@ class RawCommand(BaseModel): | |||||||
|     retry: bool = False |     retry: bool = False | ||||||
|     retry_all: bool = False |     retry_all: bool = False | ||||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) |     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||||
|     requires_input: bool | None = None |  | ||||||
|     hidden: bool = False |     hidden: bool = False | ||||||
|     help_text: str = "" |     help_text: str = "" | ||||||
|  |     help_epilog: str = "" | ||||||
|  |  | ||||||
|     @field_validator("retry_policy") |     @field_validator("retry_policy") | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -126,6 +125,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | |||||||
|                 } |                 } | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     return commands |     return commands | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								falyx/console.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								falyx/console.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | from rich.console import Console | ||||||
|  |  | ||||||
|  | from falyx.themes import get_nord_theme | ||||||
|  |  | ||||||
|  | console = Console(color_system="truecolor", theme=get_nord_theme()) | ||||||
| @@ -24,6 +24,8 @@ from typing import Any | |||||||
| from pydantic import BaseModel, ConfigDict, Field | from pydantic import BaseModel, ConfigDict, Field | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
|  |  | ||||||
|  | from falyx.console import console | ||||||
|  |  | ||||||
|  |  | ||||||
| class ExecutionContext(BaseModel): | class ExecutionContext(BaseModel): | ||||||
|     """ |     """ | ||||||
| @@ -40,7 +42,7 @@ class ExecutionContext(BaseModel): | |||||||
|         kwargs (dict): Keyword arguments passed to the action. |         kwargs (dict): Keyword arguments passed to the action. | ||||||
|         action (BaseAction | Callable): The action instance being executed. |         action (BaseAction | Callable): The action instance being executed. | ||||||
|         result (Any | None): The result of the action, if successful. |         result (Any | None): The result of the action, if successful. | ||||||
|         exception (Exception | None): The exception raised, if execution failed. |         exception (BaseException | None): The exception raised, if execution failed. | ||||||
|         start_time (float | None): High-resolution performance start time. |         start_time (float | None): High-resolution performance start time. | ||||||
|         end_time (float | None): High-resolution performance end time. |         end_time (float | None): High-resolution performance end time. | ||||||
|         start_wall (datetime | None): Wall-clock timestamp when execution began. |         start_wall (datetime | None): Wall-clock timestamp when execution began. | ||||||
| @@ -70,18 +72,20 @@ class ExecutionContext(BaseModel): | |||||||
|  |  | ||||||
|     name: str |     name: str | ||||||
|     args: tuple = () |     args: tuple = () | ||||||
|     kwargs: dict = {} |     kwargs: dict = Field(default_factory=dict) | ||||||
|     action: Any |     action: Any | ||||||
|     result: Any | None = None |     result: Any | None = None | ||||||
|     exception: Exception | None = None |     exception: BaseException | None = None | ||||||
|  |  | ||||||
|     start_time: float | None = None |     start_time: float | None = None | ||||||
|     end_time: float | None = None |     end_time: float | None = None | ||||||
|     start_wall: datetime | None = None |     start_wall: datetime | None = None | ||||||
|     end_wall: datetime | None = None |     end_wall: datetime | None = None | ||||||
|  |  | ||||||
|  |     index: int | None = None | ||||||
|  |  | ||||||
|     extra: dict[str, Any] = Field(default_factory=dict) |     extra: dict[str, Any] = Field(default_factory=dict) | ||||||
|     console: Console = Field(default_factory=lambda: Console(color_system="auto")) |     console: Console = console | ||||||
|  |  | ||||||
|     shared_context: SharedContext | None = None |     shared_context: SharedContext | None = None | ||||||
|  |  | ||||||
| @@ -118,6 +122,17 @@ class ExecutionContext(BaseModel): | |||||||
|     def status(self) -> str: |     def status(self) -> str: | ||||||
|         return "OK" if self.success else "ERROR" |         return "OK" if self.success else "ERROR" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def signature(self) -> str: | ||||||
|  |         """ | ||||||
|  |         Returns a string representation of the action signature, including | ||||||
|  |         its name and arguments. | ||||||
|  |         """ | ||||||
|  |         args = ", ".join(map(repr, self.args)) | ||||||
|  |         kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) | ||||||
|  |         signature = ", ".join(filter(None, [args, kwargs])) | ||||||
|  |         return f"{self.action} ({signature})" | ||||||
|  |  | ||||||
|     def as_dict(self) -> dict: |     def as_dict(self) -> dict: | ||||||
|         return { |         return { | ||||||
|             "name": self.name, |             "name": self.name, | ||||||
| @@ -140,9 +155,9 @@ class ExecutionContext(BaseModel): | |||||||
|         message.append(f"Duration: {summary['duration']:.3f}s | ") |         message.append(f"Duration: {summary['duration']:.3f}s | ") | ||||||
|  |  | ||||||
|         if summary["exception"]: |         if summary["exception"]: | ||||||
|             message.append(f"❌ Exception: {summary['exception']}") |             message.append(f"Exception: {summary['exception']}") | ||||||
|         else: |         else: | ||||||
|             message.append(f"✅ Result: {summary['result']}") |             message.append(f"Result: {summary['result']}") | ||||||
|         (logger or self.console.print)("".join(message)) |         (logger or self.console.print)("".join(message)) | ||||||
|  |  | ||||||
|     def to_log_line(self) -> str: |     def to_log_line(self) -> str: | ||||||
| @@ -192,7 +207,7 @@ class SharedContext(BaseModel): | |||||||
|     Attributes: |     Attributes: | ||||||
|         name (str): Identifier for the context (usually the parent action name). |         name (str): Identifier for the context (usually the parent action name). | ||||||
|         results (list[Any]): Captures results from each action, in order of execution. |         results (list[Any]): Captures results from each action, in order of execution. | ||||||
|         errors (list[tuple[int, Exception]]): Indexed list of errors from failed actions. |         errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions. | ||||||
|         current_index (int): Index of the currently executing action (used in chains). |         current_index (int): Index of the currently executing action (used in chains). | ||||||
|         is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). |         is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). | ||||||
|         shared_result (Any | None): Optional shared value available to all actions in |         shared_result (Any | None): Optional shared value available to all actions in | ||||||
| @@ -217,7 +232,7 @@ class SharedContext(BaseModel): | |||||||
|     name: str |     name: str | ||||||
|     action: Any |     action: Any | ||||||
|     results: list[Any] = Field(default_factory=list) |     results: list[Any] = Field(default_factory=list) | ||||||
|     errors: list[tuple[int, Exception]] = Field(default_factory=list) |     errors: list[tuple[int, BaseException]] = Field(default_factory=list) | ||||||
|     current_index: int = -1 |     current_index: int = -1 | ||||||
|     is_parallel: bool = False |     is_parallel: bool = False | ||||||
|     shared_result: Any | None = None |     shared_result: Any | None = None | ||||||
| @@ -229,7 +244,7 @@ class SharedContext(BaseModel): | |||||||
|     def add_result(self, result: Any) -> None: |     def add_result(self, result: Any) -> None: | ||||||
|         self.results.append(result) |         self.results.append(result) | ||||||
|  |  | ||||||
|     def add_error(self, index: int, error: Exception) -> None: |     def add_error(self, index: int, error: BaseException) -> None: | ||||||
|         self.errors.append((index, error)) |         self.errors.append((index, error)) | ||||||
|  |  | ||||||
|     def set_shared_result(self, result: Any) -> None: |     def set_shared_result(self, result: Any) -> None: | ||||||
|   | |||||||
| @@ -8,9 +8,9 @@ from falyx.logger import logger | |||||||
| def log_before(context: ExecutionContext): | def log_before(context: ExecutionContext): | ||||||
|     """Log the start of an action.""" |     """Log the start of an action.""" | ||||||
|     args = ", ".join(map(repr, context.args)) |     args = ", ".join(map(repr, context.args)) | ||||||
|     kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) |     kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items()) | ||||||
|     signature = ", ".join(filter(None, [args, kwargs])) |     signature = ", ".join(filter(None, [args, kwargs])) | ||||||
|     logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) |     logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_success(context: ExecutionContext): | def log_success(context: ExecutionContext): | ||||||
| @@ -18,18 +18,18 @@ def log_success(context: ExecutionContext): | |||||||
|     result_str = repr(context.result) |     result_str = repr(context.result) | ||||||
|     if len(result_str) > 100: |     if len(result_str) > 100: | ||||||
|         result_str = f"{result_str[:100]} ..." |         result_str = f"{result_str[:100]} ..." | ||||||
|     logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) |     logger.debug("[%s] Success -> Result: %s", context.name, result_str) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_after(context: ExecutionContext): | def log_after(context: ExecutionContext): | ||||||
|     """Log the completion of an action, regardless of success or failure.""" |     """Log the completion of an action, regardless of success or failure.""" | ||||||
|     logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) |     logger.debug("[%s] Finished in %.3fs", context.name, context.duration) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_error(context: ExecutionContext): | def log_error(context: ExecutionContext): | ||||||
|     """Log an error that occurred during the action.""" |     """Log an error that occurred during the action.""" | ||||||
|     logger.error( |     logger.error( | ||||||
|         "[%s] ❌ Error (%s): %s", |         "[%s] Error (%s): %s", | ||||||
|         context.name, |         context.name, | ||||||
|         type(context.exception).__name__, |         type(context.exception).__name__, | ||||||
|         context.exception, |         context.exception, | ||||||
|   | |||||||
| @@ -30,5 +30,13 @@ class EmptyChainError(FalyxError): | |||||||
|     """Exception raised when the chain is empty.""" |     """Exception raised when the chain is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EmptyGroupError(FalyxError): | ||||||
|  |     """Exception raised when the chain is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EmptyPoolError(FalyxError): | ||||||
|  |     """Exception raised when the chain is empty.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class CommandArgumentError(FalyxError): | class CommandArgumentError(FalyxError): | ||||||
|     """Exception raised when there is an error in the command argument parser.""" |     """Exception raised when there is an error in the command argument parser.""" | ||||||
|   | |||||||
| @@ -29,12 +29,14 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from collections import defaultdict | from collections import defaultdict | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import Dict, List | from threading import Lock | ||||||
|  | from typing import Literal | ||||||
|  |  | ||||||
| from rich import box | from rich import box | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
|  |  | ||||||
|  | from falyx.console import console | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| @@ -70,23 +72,30 @@ class ExecutionRegistry: | |||||||
|         ExecutionRegistry.summary() |         ExecutionRegistry.summary() | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) |     _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) | ||||||
|     _store_all: List[ExecutionContext] = [] |     _store_by_index: dict[int, ExecutionContext] = {} | ||||||
|     _console = Console(color_system="auto") |     _store_all: list[ExecutionContext] = [] | ||||||
|  |     _console = Console(color_system="truecolor") | ||||||
|  |     _index = 0 | ||||||
|  |     _lock = Lock() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def record(cls, context: ExecutionContext): |     def record(cls, context: ExecutionContext): | ||||||
|         """Record an execution context.""" |         """Record an execution context.""" | ||||||
|         logger.debug(context.to_log_line()) |         logger.debug(context.to_log_line()) | ||||||
|  |         with cls._lock: | ||||||
|  |             context.index = cls._index | ||||||
|  |             cls._store_by_index[cls._index] = context | ||||||
|  |             cls._index += 1 | ||||||
|         cls._store_by_name[context.name].append(context) |         cls._store_by_name[context.name].append(context) | ||||||
|         cls._store_all.append(context) |         cls._store_all.append(context) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_all(cls) -> List[ExecutionContext]: |     def get_all(cls) -> list[ExecutionContext]: | ||||||
|         return cls._store_all |         return cls._store_all | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_by_name(cls, name: str) -> List[ExecutionContext]: |     def get_by_name(cls, name: str) -> list[ExecutionContext]: | ||||||
|         return cls._store_by_name.get(name, []) |         return cls._store_by_name.get(name, []) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -97,11 +106,79 @@ class ExecutionRegistry: | |||||||
|     def clear(cls): |     def clear(cls): | ||||||
|         cls._store_by_name.clear() |         cls._store_by_name.clear() | ||||||
|         cls._store_all.clear() |         cls._store_all.clear() | ||||||
|  |         cls._store_by_index.clear() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def summary(cls): |     def summary( | ||||||
|         table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE) |         cls, | ||||||
|  |         name: str = "", | ||||||
|  |         index: int | None = None, | ||||||
|  |         result_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("Name", style="bold cyan") | ||||||
|         table.add_column("Start", justify="right", style="dim") |         table.add_column("Start", justify="right", style="dim") | ||||||
|         table.add_column("End", justify="right", style="dim") |         table.add_column("End", justify="right", style="dim") | ||||||
| @@ -109,7 +186,7 @@ class ExecutionRegistry: | |||||||
|         table.add_column("Status", style="bold") |         table.add_column("Status", style="bold") | ||||||
|         table.add_column("Result / Exception", overflow="fold") |         table.add_column("Result / Exception", overflow="fold") | ||||||
|  |  | ||||||
|         for ctx in cls.get_all(): |         for ctx in contexts: | ||||||
|             start = ( |             start = ( | ||||||
|                 datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") |                 datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") | ||||||
|                 if ctx.start_time |                 if ctx.start_time | ||||||
| @@ -122,15 +199,19 @@ class ExecutionRegistry: | |||||||
|             ) |             ) | ||||||
|             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" |             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||||
|  |  | ||||||
|             if ctx.exception: |             if ctx.exception and status.lower() in ["all", "error"]: | ||||||
|                 status = f"[{OneColors.DARK_RED}]❌ Error" |                 final_status = f"[{OneColors.DARK_RED}]❌ Error" | ||||||
|                 result = repr(ctx.exception) |                 final_result = repr(ctx.exception) | ||||||
|  |             elif status.lower() in ["all", "success"]: | ||||||
|  |                 final_status = f"[{OneColors.GREEN}]✅ Success" | ||||||
|  |                 final_result = repr(ctx.result) | ||||||
|  |                 if len(final_result) > 1000: | ||||||
|  |                     final_result = f"{final_result[:1000]}..." | ||||||
|             else: |             else: | ||||||
|                 status = f"[{OneColors.GREEN}]✅ Success" |                 continue | ||||||
|                 result = repr(ctx.result) |  | ||||||
|                 if len(result) > 1000: |  | ||||||
|                     result = f"{result[:1000]}..." |  | ||||||
|  |  | ||||||
|             table.add_row(ctx.name, start, end, duration, status, result) |             table.add_row( | ||||||
|  |                 str(ctx.index), ctx.name, start, end, duration, final_status, final_result | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         cls._console.print(table) |         cls._console.print(table) | ||||||
|   | |||||||
							
								
								
									
										475
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										475
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -1,7 +1,5 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """falyx.py | """Main class for constructing and running Falyx CLI menus. | ||||||
|  |  | ||||||
| Main class for constructing and running Falyx CLI menus. |  | ||||||
|  |  | ||||||
| Falyx provides a structured, customizable interactive menu system | Falyx provides a structured, customizable interactive menu system | ||||||
| for running commands, actions, and workflows. It supports: | for running commands, actions, and workflows. It supports: | ||||||
| @@ -25,14 +23,13 @@ import asyncio | |||||||
| import logging | import logging | ||||||
| import shlex | import shlex | ||||||
| import sys | import sys | ||||||
| from argparse import Namespace | from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||||
| from difflib import get_close_matches | from difflib import get_close_matches | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from functools import cached_property | from functools import cached_property | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
|  |  | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from prompt_toolkit.completion import WordCompleter |  | ||||||
| from prompt_toolkit.formatted_text import AnyFormattedText | from prompt_toolkit.formatted_text import AnyFormattedText | ||||||
| from prompt_toolkit.key_binding import KeyBindings | from prompt_toolkit.key_binding import KeyBindings | ||||||
| from prompt_toolkit.patch_stdout import patch_stdout | from prompt_toolkit.patch_stdout import patch_stdout | ||||||
| @@ -42,9 +39,12 @@ from rich.console import Console | |||||||
| from rich.markdown import Markdown | from rich.markdown import Markdown | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
|  |  | ||||||
| from falyx.action.action import Action, BaseAction | from falyx.action.action import Action | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
| from falyx.bottom_bar import BottomBar | from falyx.bottom_bar import BottomBar | ||||||
| from falyx.command import Command | from falyx.command import Command | ||||||
|  | from falyx.completer import FalyxCompleter | ||||||
|  | from falyx.console import console | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.debug import log_after, log_before, log_error, log_success | from falyx.debug import log_after, log_before, log_error, log_success | ||||||
| from falyx.exceptions import ( | from falyx.exceptions import ( | ||||||
| @@ -58,12 +58,12 @@ from falyx.execution_registry import ExecutionRegistry as er | |||||||
| from falyx.hook_manager import Hook, HookManager, HookType | from falyx.hook_manager import Hook, HookManager, HookType | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| from falyx.options_manager import OptionsManager | from falyx.options_manager import OptionsManager | ||||||
| from falyx.parsers import CommandArgumentParser, get_arg_parsers | from falyx.parser import CommandArgumentParser, FalyxParsers, get_arg_parsers | ||||||
| from falyx.protocols import ArgParserProtocol | from falyx.protocols import ArgParserProtocol | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| from falyx.signals import BackSignal, CancelSignal, FlowSignal, HelpSignal, QuitSignal | from falyx.signals import BackSignal, CancelSignal, HelpSignal, QuitSignal | ||||||
| from falyx.themes import OneColors, get_nord_theme | from falyx.themes import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict, _noop, chunks, get_program_invocation | from falyx.utils import CaseInsensitiveDict, _noop, chunks | ||||||
| from falyx.version import __version__ | from falyx.version import __version__ | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -83,14 +83,26 @@ class CommandValidator(Validator): | |||||||
|         self.error_message = error_message |         self.error_message = error_message | ||||||
|  |  | ||||||
|     def validate(self, document) -> None: |     def validate(self, document) -> None: | ||||||
|  |         if not document.text: | ||||||
|  |             raise ValidationError( | ||||||
|  |                 message=self.error_message, | ||||||
|  |                 cursor_position=len(document.text), | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     async def validate_async(self, document) -> None: | ||||||
|         text = document.text |         text = document.text | ||||||
|         is_preview, choice, _, __ = self.falyx.get_command(text, from_validate=True) |         if not text: | ||||||
|  |             raise ValidationError( | ||||||
|  |                 message=self.error_message, | ||||||
|  |                 cursor_position=len(text), | ||||||
|  |             ) | ||||||
|  |         is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) | ||||||
|         if is_preview: |         if is_preview: | ||||||
|             return None |             return None | ||||||
|         if not choice: |         if not choice: | ||||||
|             raise ValidationError( |             raise ValidationError( | ||||||
|                 message=self.error_message, |                 message=self.error_message, | ||||||
|                 cursor_position=document.get_end_of_document_position(), |                 cursor_position=len(text), | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -111,6 +123,8 @@ class Falyx: | |||||||
|     - Submenu nesting and action chaining |     - Submenu nesting and action chaining | ||||||
|     - History tracking, help generation, and run key execution modes |     - History tracking, help generation, and run key execution modes | ||||||
|     - Seamless CLI argument parsing and integration via argparse |     - Seamless CLI argument parsing and integration via argparse | ||||||
|  |     - Declarative option management with OptionsManager | ||||||
|  |     - Command level argument parsing and validation | ||||||
|     - Extensible with user-defined hooks, bottom bars, and custom layouts |     - Extensible with user-defined hooks, bottom bars, and custom layouts | ||||||
|  |  | ||||||
|     Args: |     Args: | ||||||
| @@ -126,7 +140,7 @@ class Falyx: | |||||||
|         never_prompt (bool): Seed default for `OptionsManager["never_prompt"]` |         never_prompt (bool): Seed default for `OptionsManager["never_prompt"]` | ||||||
|         force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` |         force_confirm (bool): Seed default for `OptionsManager["force_confirm"]` | ||||||
|         cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. |         cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. | ||||||
|         options (OptionsManager | None): Declarative option mappings. |         options (OptionsManager | None): Declarative option mappings for global state. | ||||||
|         custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table |         custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table | ||||||
|                                                                 generator. |                                                                 generator. | ||||||
|  |  | ||||||
| @@ -146,6 +160,12 @@ class Falyx: | |||||||
|         self, |         self, | ||||||
|         title: str | Markdown = "Menu", |         title: str | Markdown = "Menu", | ||||||
|         *, |         *, | ||||||
|  |         program: str | None = "falyx", | ||||||
|  |         usage: str | None = None, | ||||||
|  |         description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||||
|  |         epilog: str | None = None, | ||||||
|  |         version: str = __version__, | ||||||
|  |         version_style: str = OneColors.BLUE_b, | ||||||
|         prompt: str | AnyFormattedText = "> ", |         prompt: str | AnyFormattedText = "> ", | ||||||
|         columns: int = 3, |         columns: int = 3, | ||||||
|         bottom_bar: BottomBar | str | Callable[[], Any] | None = None, |         bottom_bar: BottomBar | str | Callable[[], Any] | None = None, | ||||||
| @@ -158,11 +178,18 @@ class Falyx: | |||||||
|         force_confirm: bool = False, |         force_confirm: bool = False, | ||||||
|         cli_args: Namespace | None = None, |         cli_args: Namespace | None = None, | ||||||
|         options: OptionsManager | None = None, |         options: OptionsManager | None = None, | ||||||
|         render_menu: Callable[["Falyx"], None] | None = None, |         render_menu: Callable[[Falyx], None] | None = None, | ||||||
|         custom_table: Callable[["Falyx"], Table] | Table | None = None, |         custom_table: Callable[[Falyx], Table] | Table | None = None, | ||||||
|  |         hide_menu_table: bool = False, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Initializes the Falyx object.""" |         """Initializes the Falyx object.""" | ||||||
|         self.title: str | Markdown = title |         self.title: str | Markdown = title | ||||||
|  |         self.program: str | None = program | ||||||
|  |         self.usage: str | None = usage | ||||||
|  |         self.description: str | None = description | ||||||
|  |         self.epilog: str | None = epilog | ||||||
|  |         self.version: str = version | ||||||
|  |         self.version_style: str = version_style | ||||||
|         self.prompt: str | AnyFormattedText = prompt |         self.prompt: str | AnyFormattedText = prompt | ||||||
|         self.columns: int = columns |         self.columns: int = columns | ||||||
|         self.commands: dict[str, Command] = CaseInsensitiveDict() |         self.commands: dict[str, Command] = CaseInsensitiveDict() | ||||||
| @@ -173,7 +200,7 @@ class Falyx: | |||||||
|         self.help_command: Command | None = ( |         self.help_command: Command | None = ( | ||||||
|             self._get_help_command() if include_help_command else None |             self._get_help_command() if include_help_command else None | ||||||
|         ) |         ) | ||||||
|         self.console: Console = Console(color_system="auto", theme=get_nord_theme()) |         self.console: Console = console | ||||||
|         self.welcome_message: str | Markdown | dict[str, Any] = welcome_message |         self.welcome_message: str | Markdown | dict[str, Any] = welcome_message | ||||||
|         self.exit_message: str | Markdown | dict[str, Any] = exit_message |         self.exit_message: str | Markdown | dict[str, Any] = exit_message | ||||||
|         self.hooks: HookManager = HookManager() |         self.hooks: HookManager = HookManager() | ||||||
| @@ -183,8 +210,9 @@ class Falyx: | |||||||
|         self._never_prompt: bool = never_prompt |         self._never_prompt: bool = never_prompt | ||||||
|         self._force_confirm: bool = force_confirm |         self._force_confirm: bool = force_confirm | ||||||
|         self.cli_args: Namespace | None = cli_args |         self.cli_args: Namespace | None = cli_args | ||||||
|         self.render_menu: Callable[["Falyx"], None] | None = render_menu |         self.render_menu: Callable[[Falyx], None] | None = render_menu | ||||||
|         self.custom_table: Callable[["Falyx"], Table] | Table | None = custom_table |         self.custom_table: Callable[[Falyx], Table] | Table | None = custom_table | ||||||
|  |         self._hide_menu_table: bool = hide_menu_table | ||||||
|         self.validate_options(cli_args, options) |         self.validate_options(cli_args, options) | ||||||
|         self._prompt_session: PromptSession | None = None |         self._prompt_session: PromptSession | None = None | ||||||
|         self.mode = FalyxMode.MENU |         self.mode = FalyxMode.MENU | ||||||
| @@ -266,88 +294,127 @@ class Falyx: | |||||||
|             action=Action("Exit", action=_noop), |             action=Action("Exit", action=_noop), | ||||||
|             aliases=["EXIT", "QUIT"], |             aliases=["EXIT", "QUIT"], | ||||||
|             style=OneColors.DARK_RED, |             style=OneColors.DARK_RED, | ||||||
|  |             simple_help_signature=True, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def _get_history_command(self) -> Command: |     def _get_history_command(self) -> Command: | ||||||
|         """Returns the history command for the menu.""" |         """Returns the history command for the menu.""" | ||||||
|  |         parser = CommandArgumentParser( | ||||||
|  |             command_key="Y", | ||||||
|  |             command_description="History", | ||||||
|  |             command_style=OneColors.DARK_YELLOW, | ||||||
|  |             aliases=["HISTORY"], | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-n", | ||||||
|  |             "--name", | ||||||
|  |             help="Filter by execution name.", | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-i", | ||||||
|  |             "--index", | ||||||
|  |             type=int, | ||||||
|  |             help="Filter by execution index (0-based).", | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-s", | ||||||
|  |             "--status", | ||||||
|  |             choices=["all", "success", "error"], | ||||||
|  |             default="all", | ||||||
|  |             help="Filter by execution status (default: all).", | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-c", | ||||||
|  |             "--clear", | ||||||
|  |             action="store_true", | ||||||
|  |             help="Clear the Execution History.", | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-r", | ||||||
|  |             "--result", | ||||||
|  |             type=int, | ||||||
|  |             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( |         return Command( | ||||||
|             key="Y", |             key="Y", | ||||||
|             description="History", |             description="History", | ||||||
|             aliases=["HISTORY"], |             aliases=["HISTORY"], | ||||||
|             action=Action(name="View Execution History", action=er.summary), |             action=Action(name="View Execution History", action=er.summary), | ||||||
|             style=OneColors.DARK_YELLOW, |             style=OneColors.DARK_YELLOW, | ||||||
|  |             arg_parser=parser, | ||||||
|  |             help_text="View the execution history of commands.", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     async def _show_help(self): |     async def _show_help(self, tag: str = "") -> None: | ||||||
|         table = Table(title="[bold cyan]Help Menu[/]", box=box.SIMPLE) |         if tag: | ||||||
|         table.add_column("Key", style="bold", no_wrap=True) |             table = Table( | ||||||
|         table.add_column("Aliases", style="dim", no_wrap=True) |                 title=tag.upper(), | ||||||
|         table.add_column("Description", style="dim", overflow="fold") |                 title_justify="left", | ||||||
|         table.add_column("Tags", style="dim", no_wrap=True) |                 show_header=False, | ||||||
|  |                 box=box.SIMPLE, | ||||||
|         for command in self.commands.values(): |                 show_footer=False, | ||||||
|             help_text = command.help_text or command.description |  | ||||||
|             if command.requires_input: |  | ||||||
|                 help_text += " [dim](requires input)[/dim]" |  | ||||||
|             table.add_row( |  | ||||||
|                 f"[{command.style}]{command.key}[/]", |  | ||||||
|                 ", ".join(command.aliases) if command.aliases else "", |  | ||||||
|                 help_text, |  | ||||||
|                 ", ".join(command.tags) if command.tags else "", |  | ||||||
|             ) |             ) | ||||||
|  |             tag_lower = tag.lower() | ||||||
|         table.add_row( |             commands = [ | ||||||
|             f"[{self.exit_command.style}]{self.exit_command.key}[/]", |                 command | ||||||
|             ", ".join(self.exit_command.aliases), |                 for command in self.commands.values() | ||||||
|             "Exit this menu or program", |                 if any(tag_lower == tag.lower() for tag in command.tags) | ||||||
|         ) |             ] | ||||||
|  |             for command in commands: | ||||||
|         if self.history_command: |                 table.add_row(command.help_signature) | ||||||
|             table.add_row( |             self.console.print(table) | ||||||
|                 f"[{self.history_command.style}]{self.history_command.key}[/]", |             return | ||||||
|                 ", ".join(self.history_command.aliases), |         else: | ||||||
|                 "History of executed actions", |             table = Table( | ||||||
|  |                 title="Help", | ||||||
|  |                 title_justify="left", | ||||||
|  |                 title_style=OneColors.LIGHT_YELLOW_b, | ||||||
|  |                 show_header=False, | ||||||
|  |                 show_footer=False, | ||||||
|  |                 box=box.SIMPLE, | ||||||
|             ) |             ) | ||||||
|  |             for command in self.commands.values(): | ||||||
|  |                 table.add_row(command.help_signature) | ||||||
|         if self.help_command: |         if self.help_command: | ||||||
|             table.add_row( |             table.add_row(self.help_command.help_signature) | ||||||
|                 f"[{self.help_command.style}]{self.help_command.key}[/]", |         if self.history_command: | ||||||
|                 ", ".join(self.help_command.aliases), |             table.add_row(self.history_command.help_signature) | ||||||
|                 "Show this help menu", |         table.add_row(self.exit_command.help_signature) | ||||||
|             ) |         table.add_row(f"Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command ") | ||||||
|  |         self.console.print(table) | ||||||
|         self.console.print(table, justify="center") |  | ||||||
|         if self.mode == FalyxMode.MENU: |  | ||||||
|             self.console.print( |  | ||||||
|                 f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command " |  | ||||||
|                 "before running it.\n", |  | ||||||
|                 justify="center", |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _get_help_command(self) -> Command: |     def _get_help_command(self) -> Command: | ||||||
|         """Returns the help command for the menu.""" |         """Returns the help command for the menu.""" | ||||||
|  |         parser = CommandArgumentParser( | ||||||
|  |             command_key="H", | ||||||
|  |             command_description="Help", | ||||||
|  |             command_style=OneColors.LIGHT_YELLOW, | ||||||
|  |             aliases=["?", "HELP", "LIST"], | ||||||
|  |         ) | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-t", | ||||||
|  |             "--tag", | ||||||
|  |             nargs="?", | ||||||
|  |             default="", | ||||||
|  |             help="Optional tag to filter commands by.", | ||||||
|  |         ) | ||||||
|         return Command( |         return Command( | ||||||
|             key="H", |             key="H", | ||||||
|             aliases=["HELP", "?"], |             aliases=["?", "HELP", "LIST"], | ||||||
|             description="Help", |             description="Help", | ||||||
|  |             help_text="Show this help menu", | ||||||
|             action=Action("Help", self._show_help), |             action=Action("Help", self._show_help), | ||||||
|             style=OneColors.LIGHT_YELLOW, |             style=OneColors.LIGHT_YELLOW, | ||||||
|  |             arg_parser=parser, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def _get_completer(self) -> WordCompleter: |     def _get_completer(self) -> FalyxCompleter: | ||||||
|         """Completer to provide auto-completion for the menu commands.""" |         """Completer to provide auto-completion for the menu commands.""" | ||||||
|         keys = [self.exit_command.key] |         return FalyxCompleter(self) | ||||||
|         keys.extend(self.exit_command.aliases) |  | ||||||
|         if self.history_command: |  | ||||||
|             keys.append(self.history_command.key) |  | ||||||
|             keys.extend(self.history_command.aliases) |  | ||||||
|         if self.help_command: |  | ||||||
|             keys.append(self.help_command.key) |  | ||||||
|             keys.extend(self.help_command.aliases) |  | ||||||
|         for cmd in self.commands.values(): |  | ||||||
|             keys.append(cmd.key) |  | ||||||
|             keys.extend(cmd.aliases) |  | ||||||
|         return WordCompleter(keys, ignore_case=True) |  | ||||||
|  |  | ||||||
|     def _get_validator_error_message(self) -> str: |     def _get_validator_error_message(self) -> str: | ||||||
|         """Validator to check if the input is a valid command or toggle key.""" |         """Validator to check if the input is a valid command or toggle key.""" | ||||||
| @@ -440,12 +507,12 @@ class Falyx: | |||||||
|                 message=self.prompt, |                 message=self.prompt, | ||||||
|                 multiline=False, |                 multiline=False, | ||||||
|                 completer=self._get_completer(), |                 completer=self._get_completer(), | ||||||
|                 reserve_space_for_menu=1, |  | ||||||
|                 validator=CommandValidator(self, self._get_validator_error_message()), |                 validator=CommandValidator(self, self._get_validator_error_message()), | ||||||
|                 bottom_toolbar=self._get_bottom_bar_render(), |                 bottom_toolbar=self._get_bottom_bar_render(), | ||||||
|                 key_bindings=self.key_bindings, |                 key_bindings=self.key_bindings, | ||||||
|                 validate_while_typing=False, |                 validate_while_typing=True, | ||||||
|                 interrupt_exception=FlowSignal, |                 interrupt_exception=QuitSignal, | ||||||
|  |                 eof_exception=QuitSignal, | ||||||
|             ) |             ) | ||||||
|         return self._prompt_session |         return self._prompt_session | ||||||
|  |  | ||||||
| @@ -526,7 +593,7 @@ class Falyx: | |||||||
|         key: str = "X", |         key: str = "X", | ||||||
|         description: str = "Exit", |         description: str = "Exit", | ||||||
|         aliases: list[str] | None = None, |         aliases: list[str] | None = None, | ||||||
|         action: Callable[[Any], Any] | None = None, |         action: Callable[..., Any] | None = None, | ||||||
|         style: str = OneColors.DARK_RED, |         style: str = OneColors.DARK_RED, | ||||||
|         confirm: bool = False, |         confirm: bool = False, | ||||||
|         confirm_message: str = "Are you sure?", |         confirm_message: str = "Are you sure?", | ||||||
| @@ -553,7 +620,9 @@ class Falyx: | |||||||
|         if not isinstance(submenu, Falyx): |         if not isinstance(submenu, Falyx): | ||||||
|             raise NotAFalyxError("submenu must be an instance of Falyx.") |             raise NotAFalyxError("submenu must be an instance of Falyx.") | ||||||
|         self._validate_command_key(key) |         self._validate_command_key(key) | ||||||
|         self.add_command(key, description, submenu.menu, style=style) |         self.add_command( | ||||||
|  |             key, description, submenu.menu, style=style, simple_help_signature=True | ||||||
|  |         ) | ||||||
|         if submenu.exit_command.key == "X": |         if submenu.exit_command.key == "X": | ||||||
|             submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) |             submenu.update_exit_command(key="B", description="Back", aliases=["BACK"]) | ||||||
|  |  | ||||||
| @@ -580,14 +649,14 @@ class Falyx: | |||||||
|         self, |         self, | ||||||
|         key: str, |         key: str, | ||||||
|         description: str, |         description: str, | ||||||
|         action: BaseAction | Callable[[Any], Any], |         action: BaseAction | Callable[..., Any], | ||||||
|         *, |         *, | ||||||
|         args: tuple = (), |         args: tuple = (), | ||||||
|         kwargs: dict[str, Any] | None = None, |         kwargs: dict[str, Any] | None = None, | ||||||
|         hidden: bool = False, |         hidden: bool = False, | ||||||
|         aliases: list[str] | None = None, |         aliases: list[str] | None = None, | ||||||
|         help_text: str = "", |         help_text: str = "", | ||||||
|         help_epilogue: str = "", |         help_epilog: str = "", | ||||||
|         style: str = OneColors.WHITE, |         style: str = OneColors.WHITE, | ||||||
|         confirm: bool = False, |         confirm: bool = False, | ||||||
|         confirm_message: str = "Are you sure?", |         confirm_message: str = "Are you sure?", | ||||||
| @@ -608,14 +677,14 @@ class Falyx: | |||||||
|         retry: bool = False, |         retry: bool = False, | ||||||
|         retry_all: bool = False, |         retry_all: bool = False, | ||||||
|         retry_policy: RetryPolicy | None = None, |         retry_policy: RetryPolicy | None = None, | ||||||
|         requires_input: bool | None = None, |  | ||||||
|         arg_parser: CommandArgumentParser | None = None, |         arg_parser: CommandArgumentParser | None = None, | ||||||
|         arguments: list[dict[str, Any]] | None = None, |         arguments: list[dict[str, Any]] | None = None, | ||||||
|         argument_config: Callable[[CommandArgumentParser], None] | None = None, |         argument_config: Callable[[CommandArgumentParser], None] | None = None, | ||||||
|         custom_parser: ArgParserProtocol | None = None, |         custom_parser: ArgParserProtocol | None = None, | ||||||
|         custom_help: Callable[[], str | None] | None = None, |         custom_help: Callable[[], str | None] | None = None, | ||||||
|         auto_args: bool = False, |         auto_args: bool = True, | ||||||
|         arg_metadata: dict[str, str | dict[str, Any]] | None = None, |         arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||||
|  |         simple_help_signature: bool = False, | ||||||
|     ) -> Command: |     ) -> Command: | ||||||
|         """Adds an command to the menu, preventing duplicates.""" |         """Adds an command to the menu, preventing duplicates.""" | ||||||
|         self._validate_command_key(key) |         self._validate_command_key(key) | ||||||
| @@ -626,15 +695,6 @@ class Falyx: | |||||||
|                     "arg_parser must be an instance of CommandArgumentParser." |                     "arg_parser must be an instance of CommandArgumentParser." | ||||||
|                 ) |                 ) | ||||||
|             arg_parser = arg_parser |             arg_parser = arg_parser | ||||||
|         else: |  | ||||||
|             arg_parser = CommandArgumentParser( |  | ||||||
|                 command_key=key, |  | ||||||
|                 command_description=description, |  | ||||||
|                 command_style=style, |  | ||||||
|                 help_text=help_text, |  | ||||||
|                 help_epilogue=help_epilogue, |  | ||||||
|                 aliases=aliases, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         command = Command( |         command = Command( | ||||||
|             key=key, |             key=key, | ||||||
| @@ -645,7 +705,7 @@ class Falyx: | |||||||
|             hidden=hidden, |             hidden=hidden, | ||||||
|             aliases=aliases if aliases else [], |             aliases=aliases if aliases else [], | ||||||
|             help_text=help_text, |             help_text=help_text, | ||||||
|             help_epilogue=help_epilogue, |             help_epilog=help_epilog, | ||||||
|             style=style, |             style=style, | ||||||
|             confirm=confirm, |             confirm=confirm, | ||||||
|             confirm_message=confirm_message, |             confirm_message=confirm_message, | ||||||
| @@ -660,7 +720,6 @@ class Falyx: | |||||||
|             retry=retry, |             retry=retry, | ||||||
|             retry_all=retry_all, |             retry_all=retry_all, | ||||||
|             retry_policy=retry_policy or RetryPolicy(), |             retry_policy=retry_policy or RetryPolicy(), | ||||||
|             requires_input=requires_input, |  | ||||||
|             options_manager=self.options, |             options_manager=self.options, | ||||||
|             arg_parser=arg_parser, |             arg_parser=arg_parser, | ||||||
|             arguments=arguments or [], |             arguments=arguments or [], | ||||||
| @@ -669,6 +728,7 @@ class Falyx: | |||||||
|             custom_help=custom_help, |             custom_help=custom_help, | ||||||
|             auto_args=auto_args, |             auto_args=auto_args, | ||||||
|             arg_metadata=arg_metadata or {}, |             arg_metadata=arg_metadata or {}, | ||||||
|  |             simple_help_signature=simple_help_signature, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         if hooks: |         if hooks: | ||||||
| @@ -693,16 +753,16 @@ class Falyx: | |||||||
|     def get_bottom_row(self) -> list[str]: |     def get_bottom_row(self) -> list[str]: | ||||||
|         """Returns the bottom row of the table for displaying additional commands.""" |         """Returns the bottom row of the table for displaying additional commands.""" | ||||||
|         bottom_row = [] |         bottom_row = [] | ||||||
|         if self.history_command: |  | ||||||
|             bottom_row.append( |  | ||||||
|                 f"[{self.history_command.key}] [{self.history_command.style}]" |  | ||||||
|                 f"{self.history_command.description}" |  | ||||||
|             ) |  | ||||||
|         if self.help_command: |         if self.help_command: | ||||||
|             bottom_row.append( |             bottom_row.append( | ||||||
|                 f"[{self.help_command.key}] [{self.help_command.style}]" |                 f"[{self.help_command.key}] [{self.help_command.style}]" | ||||||
|                 f"{self.help_command.description}" |                 f"{self.help_command.description}" | ||||||
|             ) |             ) | ||||||
|  |         if self.history_command: | ||||||
|  |             bottom_row.append( | ||||||
|  |                 f"[{self.history_command.key}] [{self.history_command.style}]" | ||||||
|  |                 f"{self.history_command.description}" | ||||||
|  |             ) | ||||||
|         bottom_row.append( |         bottom_row.append( | ||||||
|             f"[{self.exit_command.key}] [{self.exit_command.style}]" |             f"[{self.exit_command.key}] [{self.exit_command.style}]" | ||||||
|             f"{self.exit_command.description}" |             f"{self.exit_command.description}" | ||||||
| @@ -714,7 +774,7 @@ class Falyx: | |||||||
|         Build the standard table layout. Developers can subclass or call this |         Build the standard table layout. Developers can subclass or call this | ||||||
|         in custom tables. |         in custom tables. | ||||||
|         """ |         """ | ||||||
|         table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True)  # type: ignore[arg-type] |         table = Table(title=self.title, show_header=False, box=box.SIMPLE)  # type: ignore[arg-type] | ||||||
|         visible_commands = [item for item in self.commands.items() if not item[1].hidden] |         visible_commands = [item for item in self.commands.items() if not item[1].hidden] | ||||||
|         for chunk in chunks(visible_commands, self.columns): |         for chunk in chunks(visible_commands, self.columns): | ||||||
|             row = [] |             row = [] | ||||||
| @@ -730,7 +790,12 @@ class Falyx: | |||||||
|     def table(self) -> Table: |     def table(self) -> Table: | ||||||
|         """Creates or returns a custom table to display the menu commands.""" |         """Creates or returns a custom table to display the menu commands.""" | ||||||
|         if callable(self.custom_table): |         if callable(self.custom_table): | ||||||
|             return self.custom_table(self) |             custom_table = self.custom_table(self) | ||||||
|  |             if not isinstance(custom_table, Table): | ||||||
|  |                 raise FalyxError( | ||||||
|  |                     "custom_table must return an instance of rich.table.Table." | ||||||
|  |                 ) | ||||||
|  |             return custom_table | ||||||
|         elif isinstance(self.custom_table, Table): |         elif isinstance(self.custom_table, Table): | ||||||
|             return self.custom_table |             return self.custom_table | ||||||
|         else: |         else: | ||||||
| @@ -741,7 +806,7 @@ class Falyx: | |||||||
|             return True, input_str[1:].strip() |             return True, input_str[1:].strip() | ||||||
|         return False, input_str.strip() |         return False, input_str.strip() | ||||||
|  |  | ||||||
|     def get_command( |     async def get_command( | ||||||
|         self, raw_choices: str, from_validate=False |         self, raw_choices: str, from_validate=False | ||||||
|     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: |     ) -> tuple[bool, Command | None, tuple, dict[str, Any]]: | ||||||
|         """ |         """ | ||||||
| @@ -759,7 +824,7 @@ class Falyx: | |||||||
|             is_preview = False |             is_preview = False | ||||||
|             choice = "?" |             choice = "?" | ||||||
|         elif is_preview and not choice: |         elif is_preview and not choice: | ||||||
|             # No help command enabled |             # No help (list) command enabled | ||||||
|             if not from_validate: |             if not from_validate: | ||||||
|                 self.console.print( |                 self.console.print( | ||||||
|                     f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode." |                     f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode." | ||||||
| @@ -768,31 +833,39 @@ class Falyx: | |||||||
|  |  | ||||||
|         choice = choice.upper() |         choice = choice.upper() | ||||||
|         name_map = self._name_map |         name_map = self._name_map | ||||||
|         if choice in name_map: |         run_command = None | ||||||
|             if not from_validate: |         if name_map.get(choice): | ||||||
|                 logger.info("Command '%s' selected.", choice) |             run_command = name_map[choice] | ||||||
|             if input_args and name_map[choice].arg_parser: |         else: | ||||||
|                 try: |             prefix_matches = [ | ||||||
|                     args, kwargs = name_map[choice].parse_args(input_args, from_validate) |                 cmd for key, cmd in name_map.items() if key.startswith(choice) | ||||||
|                 except CommandArgumentError as error: |             ] | ||||||
|                     if not from_validate: |             if len(prefix_matches) == 1: | ||||||
|                         if not name_map[choice].show_help(): |                 run_command = prefix_matches[0] | ||||||
|                             self.console.print( |  | ||||||
|                                 f"[{OneColors.DARK_RED}]❌ Invalid arguments for '{choice}': {error}" |  | ||||||
|                             ) |  | ||||||
|                     else: |  | ||||||
|                         name_map[choice].show_help() |  | ||||||
|                         raise ValidationError( |  | ||||||
|                             message=str(error), cursor_position=len(raw_choices) |  | ||||||
|                         ) |  | ||||||
|                     return is_preview, None, args, kwargs |  | ||||||
|                 except HelpSignal: |  | ||||||
|                     return True, None, args, kwargs |  | ||||||
|             return is_preview, name_map[choice], args, kwargs |  | ||||||
|  |  | ||||||
|         prefix_matches = [cmd for key, cmd in name_map.items() if key.startswith(choice)] |         if run_command: | ||||||
|         if len(prefix_matches) == 1: |             if not from_validate: | ||||||
|             return is_preview, prefix_matches[0], args, kwargs |                 logger.info("Command '%s' selected.", run_command.key) | ||||||
|  |             if is_preview: | ||||||
|  |                 return True, run_command, args, kwargs | ||||||
|  |             elif self.mode in {FalyxMode.RUN, FalyxMode.RUN_ALL, FalyxMode.PREVIEW}: | ||||||
|  |                 return False, run_command, args, kwargs | ||||||
|  |             try: | ||||||
|  |                 args, kwargs = await run_command.parse_args(input_args, from_validate) | ||||||
|  |             except (CommandArgumentError, Exception) as error: | ||||||
|  |                 if not from_validate: | ||||||
|  |                     run_command.show_help() | ||||||
|  |                     self.console.print( | ||||||
|  |                         f"[{OneColors.DARK_RED}]❌ [{run_command.key}]: {error}" | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     raise ValidationError( | ||||||
|  |                         message=str(error), cursor_position=len(raw_choices) | ||||||
|  |                     ) | ||||||
|  |                 return is_preview, None, args, kwargs | ||||||
|  |             except HelpSignal: | ||||||
|  |                 return True, None, args, kwargs | ||||||
|  |             return is_preview, run_command, args, kwargs | ||||||
|  |  | ||||||
|         fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) |         fuzzy_matches = get_close_matches(choice, list(name_map.keys()), n=3, cutoff=0.7) | ||||||
|         if fuzzy_matches: |         if fuzzy_matches: | ||||||
| @@ -801,22 +874,35 @@ class Falyx: | |||||||
|                     f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. " |                     f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'. " | ||||||
|                     "Did you mean:" |                     "Did you mean:" | ||||||
|                 ) |                 ) | ||||||
|             for match in fuzzy_matches: |                 for match in fuzzy_matches: | ||||||
|                 cmd = name_map[match] |                     cmd = name_map[match] | ||||||
|                 self.console.print(f"  • [bold]{match}[/] → {cmd.description}") |                     self.console.print(f"  • [bold]{match}[/] → {cmd.description}") | ||||||
|  |             else: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     message=f"Unknown command '{choice}'. Did you mean: " | ||||||
|  |                     f"{', '.join(fuzzy_matches)}?", | ||||||
|  |                     cursor_position=len(raw_choices), | ||||||
|  |                 ) | ||||||
|         else: |         else: | ||||||
|             if not from_validate: |             if not from_validate: | ||||||
|                 self.console.print( |                 self.console.print( | ||||||
|                     f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]" |                     f"[{OneColors.LIGHT_YELLOW}]⚠️ Unknown command '{choice}'[/]" | ||||||
|                 ) |                 ) | ||||||
|  |             else: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     message=f"Unknown command '{choice}'.", | ||||||
|  |                     cursor_position=len(raw_choices), | ||||||
|  |                 ) | ||||||
|         return is_preview, None, args, kwargs |         return is_preview, None, args, kwargs | ||||||
|  |  | ||||||
|     def _create_context(self, selected_command: Command) -> ExecutionContext: |     def _create_context( | ||||||
|         """Creates a context dictionary for the selected command.""" |         self, selected_command: Command, args: tuple, kwargs: dict[str, Any] | ||||||
|  |     ) -> ExecutionContext: | ||||||
|  |         """Creates an ExecutionContext object for the selected command.""" | ||||||
|         return ExecutionContext( |         return ExecutionContext( | ||||||
|             name=selected_command.description, |             name=selected_command.description, | ||||||
|             args=tuple(), |             args=args, | ||||||
|             kwargs={}, |             kwargs=kwargs, | ||||||
|             action=selected_command, |             action=selected_command, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -834,7 +920,7 @@ class Falyx: | |||||||
|         """Processes the action of the selected command.""" |         """Processes the action of the selected command.""" | ||||||
|         with patch_stdout(raw=True): |         with patch_stdout(raw=True): | ||||||
|             choice = await self.prompt_session.prompt_async() |             choice = await self.prompt_session.prompt_async() | ||||||
|         is_preview, selected_command, args, kwargs = self.get_command(choice) |         is_preview, selected_command, args, kwargs = await self.get_command(choice) | ||||||
|         if not selected_command: |         if not selected_command: | ||||||
|             logger.info("Invalid command '%s'.", choice) |             logger.info("Invalid command '%s'.", choice) | ||||||
|             return True |             return True | ||||||
| @@ -844,22 +930,13 @@ class Falyx: | |||||||
|             await selected_command.preview() |             await selected_command.preview() | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
|         if selected_command.requires_input: |  | ||||||
|             program = get_program_invocation() |  | ||||||
|             self.console.print( |  | ||||||
|                 f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires" |  | ||||||
|                 f" input and must be run via [{OneColors.MAGENTA}]'{program} run" |  | ||||||
|                 f"'[{OneColors.LIGHT_YELLOW}] with proper piping or arguments.[/]" |  | ||||||
|             ) |  | ||||||
|             return True |  | ||||||
|  |  | ||||||
|         self.last_run_command = selected_command |         self.last_run_command = selected_command | ||||||
|  |  | ||||||
|         if selected_command == self.exit_command: |         if selected_command == self.exit_command: | ||||||
|             logger.info("🔙 Back selected: exiting %s", self.get_title()) |             logger.info("Back selected: exiting %s", self.get_title()) | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|         context = self._create_context(selected_command) |         context = self._create_context(selected_command, args, kwargs) | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
|         try: |         try: | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
| @@ -885,7 +962,7 @@ class Falyx: | |||||||
|     ) -> Any: |     ) -> Any: | ||||||
|         """Run a command by key without displaying the menu (non-interactive mode).""" |         """Run a command by key without displaying the menu (non-interactive mode).""" | ||||||
|         self.debug_hooks() |         self.debug_hooks() | ||||||
|         is_preview, selected_command, _, __ = self.get_command(command_key) |         is_preview, selected_command, _, __ = await self.get_command(command_key) | ||||||
|         kwargs = kwargs or {} |         kwargs = kwargs or {} | ||||||
|  |  | ||||||
|         self.last_run_command = selected_command |         self.last_run_command = selected_command | ||||||
| @@ -899,12 +976,12 @@ class Falyx: | |||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         logger.info( |         logger.info( | ||||||
|             "[run_key] 🚀 Executing: %s — %s", |             "[run_key] Executing: %s — %s", | ||||||
|             selected_command.key, |             selected_command.key, | ||||||
|             selected_command.description, |             selected_command.description, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         context = self._create_context(selected_command) |         context = self._create_context(selected_command, args, kwargs) | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
|         try: |         try: | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
| @@ -912,10 +989,10 @@ class Falyx: | |||||||
|             context.result = result |             context.result = result | ||||||
|  |  | ||||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|             logger.info("[run_key] ✅ '%s' complete.", selected_command.description) |             logger.info("[run_key] '%s' complete.", selected_command.description) | ||||||
|         except (KeyboardInterrupt, EOFError) as error: |         except (KeyboardInterrupt, EOFError) as error: | ||||||
|             logger.warning( |             logger.warning( | ||||||
|                 "[run_key] ⚠️ Interrupted by user: %s", selected_command.description |                 "[run_key] Interrupted by user: %s", selected_command.description | ||||||
|             ) |             ) | ||||||
|             raise FalyxError( |             raise FalyxError( | ||||||
|                 f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." |                 f"[run_key] ⚠️ '{selected_command.description}' interrupted by user." | ||||||
| @@ -924,7 +1001,7 @@ class Falyx: | |||||||
|             context.exception = error |             context.exception = error | ||||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|             logger.error( |             logger.error( | ||||||
|                 "[run_key] ❌ Failed: %s — %s: %s", |                 "[run_key] Failed: %s — %s: %s", | ||||||
|                 selected_command.description, |                 selected_command.description, | ||||||
|                 type(error).__name__, |                 type(error).__name__, | ||||||
|                 error, |                 error, | ||||||
| @@ -978,16 +1055,17 @@ class Falyx: | |||||||
|  |  | ||||||
|     async def menu(self) -> None: |     async def menu(self) -> None: | ||||||
|         """Runs the menu and handles user input.""" |         """Runs the menu and handles user input.""" | ||||||
|         logger.info("Running menu: %s", self.get_title()) |         logger.info("Starting menu: %s", self.get_title()) | ||||||
|         self.debug_hooks() |         self.debug_hooks() | ||||||
|         if self.welcome_message: |         if self.welcome_message: | ||||||
|             self.print_message(self.welcome_message) |             self.print_message(self.welcome_message) | ||||||
|         try: |         try: | ||||||
|             while True: |             while True: | ||||||
|                 if callable(self.render_menu): |                 if not self.options.get("hide_menu_table", self._hide_menu_table): | ||||||
|                     self.render_menu(self) |                     if callable(self.render_menu): | ||||||
|                 else: |                         self.render_menu(self) | ||||||
|                     self.console.print(self.table, justify="center") |                     else: | ||||||
|  |                         self.console.print(self.table, justify="center") | ||||||
|                 try: |                 try: | ||||||
|                     task = asyncio.create_task(self.process_command()) |                     task = asyncio.create_task(self.process_command()) | ||||||
|                     should_continue = await task |                     should_continue = await task | ||||||
| @@ -997,47 +1075,77 @@ class Falyx: | |||||||
|                     logger.info("EOF or KeyboardInterrupt. Exiting menu.") |                     logger.info("EOF or KeyboardInterrupt. Exiting menu.") | ||||||
|                     break |                     break | ||||||
|                 except QuitSignal: |                 except QuitSignal: | ||||||
|                     logger.info("QuitSignal received. Exiting menu.") |                     logger.info("[QuitSignal]. <- Exiting menu.") | ||||||
|                     break |                     break | ||||||
|                 except BackSignal: |                 except BackSignal: | ||||||
|                     logger.info("BackSignal received.") |                     logger.info("[BackSignal]. <- Returning to the menu.") | ||||||
|                 except CancelSignal: |                 except CancelSignal: | ||||||
|                     logger.info("CancelSignal received.") |                     logger.info("[CancelSignal]. <- Returning to the menu.") | ||||||
|         finally: |         finally: | ||||||
|             logger.info("Exiting menu: %s", self.get_title()) |             logger.info("Exiting menu: %s", self.get_title()) | ||||||
|             if self.exit_message: |             if self.exit_message: | ||||||
|                 self.print_message(self.exit_message) |                 self.print_message(self.exit_message) | ||||||
|  |  | ||||||
|     async def run(self) -> None: |     async def run( | ||||||
|  |         self, | ||||||
|  |         falyx_parsers: FalyxParsers | None = None, | ||||||
|  |         root_parser: ArgumentParser | None = None, | ||||||
|  |         subparsers: _SubParsersAction | None = None, | ||||||
|  |         callback: Callable[..., Any] | None = None, | ||||||
|  |     ) -> None: | ||||||
|         """Run Falyx CLI with structured subcommands.""" |         """Run Falyx CLI with structured subcommands.""" | ||||||
|         if not self.cli_args: |         if self.cli_args: | ||||||
|             self.cli_args = get_arg_parsers().root.parse_args() |             raise FalyxError( | ||||||
|  |                 "Run is incompatible with CLI arguments. Use 'run_key' instead." | ||||||
|  |             ) | ||||||
|  |         if falyx_parsers: | ||||||
|  |             if not isinstance(falyx_parsers, FalyxParsers): | ||||||
|  |                 raise FalyxError("falyx_parsers must be an instance of FalyxParsers.") | ||||||
|  |         else: | ||||||
|  |             falyx_parsers = get_arg_parsers( | ||||||
|  |                 self.program, | ||||||
|  |                 self.usage, | ||||||
|  |                 self.description, | ||||||
|  |                 self.epilog, | ||||||
|  |                 commands=self.commands, | ||||||
|  |                 root_parser=root_parser, | ||||||
|  |                 subparsers=subparsers, | ||||||
|  |             ) | ||||||
|  |         self.cli_args = falyx_parsers.parse_args() | ||||||
|         self.options.from_namespace(self.cli_args, "cli_args") |         self.options.from_namespace(self.cli_args, "cli_args") | ||||||
|  |  | ||||||
|  |         if callback: | ||||||
|  |             if not callable(callback): | ||||||
|  |                 raise FalyxError("Callback must be a callable function.") | ||||||
|  |             callback(self.cli_args) | ||||||
|  |  | ||||||
|         if not self.options.get("never_prompt"): |         if not self.options.get("never_prompt"): | ||||||
|             self.options.set("never_prompt", self._never_prompt) |             self.options.set("never_prompt", self._never_prompt) | ||||||
|  |  | ||||||
|         if not self.options.get("force_confirm"): |         if not self.options.get("force_confirm"): | ||||||
|             self.options.set("force_confirm", self._force_confirm) |             self.options.set("force_confirm", self._force_confirm) | ||||||
|  |  | ||||||
|  |         if not self.options.get("hide_menu_table"): | ||||||
|  |             self.options.set("hide_menu_table", self._hide_menu_table) | ||||||
|  |  | ||||||
|         if self.cli_args.verbose: |         if self.cli_args.verbose: | ||||||
|             logging.getLogger("falyx").setLevel(logging.DEBUG) |             logging.getLogger("falyx").setLevel(logging.DEBUG) | ||||||
|  |  | ||||||
|         if self.cli_args.debug_hooks: |         if self.cli_args.debug_hooks: | ||||||
|             logger.debug("✅ Enabling global debug hooks for all commands") |             logger.debug("Enabling global debug hooks for all commands") | ||||||
|             self.register_all_with_debug_hooks() |             self.register_all_with_debug_hooks() | ||||||
|  |  | ||||||
|         if self.cli_args.command == "list": |         if self.cli_args.command == "list": | ||||||
|             await self._show_help() |             await self._show_help(tag=self.cli_args.tag) | ||||||
|             sys.exit(0) |             sys.exit(0) | ||||||
|  |  | ||||||
|         if self.cli_args.command == "version" or self.cli_args.version: |         if self.cli_args.command == "version" or self.cli_args.version: | ||||||
|             self.console.print(f"[{OneColors.BLUE_b}]Falyx CLI v{__version__}[/]") |             self.console.print(f"[{self.version_style}]{self.program} v{self.version}[/]") | ||||||
|             sys.exit(0) |             sys.exit(0) | ||||||
|  |  | ||||||
|         if self.cli_args.command == "preview": |         if self.cli_args.command == "preview": | ||||||
|             self.mode = FalyxMode.PREVIEW |             self.mode = FalyxMode.PREVIEW | ||||||
|             _, command, args, kwargs = self.get_command(self.cli_args.name) |             _, command, args, kwargs = await self.get_command(self.cli_args.name) | ||||||
|             if not command: |             if not command: | ||||||
|                 self.console.print( |                 self.console.print( | ||||||
|                     f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." |                     f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found." | ||||||
| @@ -1051,7 +1159,7 @@ class Falyx: | |||||||
|  |  | ||||||
|         if self.cli_args.command == "run": |         if self.cli_args.command == "run": | ||||||
|             self.mode = FalyxMode.RUN |             self.mode = FalyxMode.RUN | ||||||
|             is_preview, command, _, __ = self.get_command(self.cli_args.name) |             is_preview, command, _, __ = await self.get_command(self.cli_args.name) | ||||||
|             if is_preview: |             if is_preview: | ||||||
|                 if command is None: |                 if command is None: | ||||||
|                     sys.exit(1) |                     sys.exit(1) | ||||||
| @@ -1062,14 +1170,27 @@ class Falyx: | |||||||
|                 sys.exit(1) |                 sys.exit(1) | ||||||
|             self._set_retry_policy(command) |             self._set_retry_policy(command) | ||||||
|             try: |             try: | ||||||
|                 args, kwargs = command.parse_args(self.cli_args.command_args) |                 args, kwargs = await command.parse_args(self.cli_args.command_args) | ||||||
|             except HelpSignal: |             except HelpSignal: | ||||||
|                 sys.exit(0) |                 sys.exit(0) | ||||||
|  |             except CommandArgumentError as error: | ||||||
|  |                 self.console.print(f"[{OneColors.DARK_RED}]❌ ['{command.key}'] {error}") | ||||||
|  |                 command.show_help() | ||||||
|  |                 sys.exit(1) | ||||||
|             try: |             try: | ||||||
|                 await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) |                 await self.run_key(self.cli_args.name, args=args, kwargs=kwargs) | ||||||
|             except FalyxError as error: |             except FalyxError as error: | ||||||
|                 self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") |                 self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") | ||||||
|                 sys.exit(1) |                 sys.exit(1) | ||||||
|  |             except QuitSignal: | ||||||
|  |                 logger.info("[QuitSignal]. <- Exiting run.") | ||||||
|  |                 sys.exit(0) | ||||||
|  |             except BackSignal: | ||||||
|  |                 logger.info("[BackSignal]. <- Exiting run.") | ||||||
|  |                 sys.exit(0) | ||||||
|  |             except CancelSignal: | ||||||
|  |                 logger.info("[CancelSignal]. <- Exiting run.") | ||||||
|  |                 sys.exit(0) | ||||||
|  |  | ||||||
|             if self.cli_args.summary: |             if self.cli_args.summary: | ||||||
|                 er.summary() |                 er.summary() | ||||||
| @@ -1093,9 +1214,23 @@ class Falyx: | |||||||
|                 f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] " |                 f"[{OneColors.CYAN_b}]🚀 Running all commands with tag:[/] " | ||||||
|                 f"{self.cli_args.tag}" |                 f"{self.cli_args.tag}" | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|             for cmd in matching: |             for cmd in matching: | ||||||
|                 self._set_retry_policy(cmd) |                 self._set_retry_policy(cmd) | ||||||
|                 await self.run_key(cmd.key) |                 try: | ||||||
|  |                     await self.run_key(cmd.key) | ||||||
|  |                 except FalyxError as error: | ||||||
|  |                     self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]") | ||||||
|  |                     sys.exit(1) | ||||||
|  |                 except QuitSignal: | ||||||
|  |                     logger.info("[QuitSignal]. <- Exiting run.") | ||||||
|  |                     sys.exit(0) | ||||||
|  |                 except BackSignal: | ||||||
|  |                     logger.info("[BackSignal]. <- Exiting run.") | ||||||
|  |                     sys.exit(0) | ||||||
|  |                 except CancelSignal: | ||||||
|  |                     logger.info("[CancelSignal]. <- Exiting run.") | ||||||
|  |                     sys.exit(0) | ||||||
|  |  | ||||||
|             if self.cli_args.summary: |             if self.cli_args.summary: | ||||||
|                 er.summary() |                 er.summary() | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| import inspect | import inspect | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import Awaitable, Callable, Dict, List, Optional, Union | from typing import Awaitable, Callable, Union | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.logger import logger | from falyx.logger import logger | ||||||
| @@ -24,7 +24,7 @@ class HookType(Enum): | |||||||
|     ON_TEARDOWN = "on_teardown" |     ON_TEARDOWN = "on_teardown" | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def choices(cls) -> List[HookType]: |     def choices(cls) -> list[HookType]: | ||||||
|         """Return a list of all hook type choices.""" |         """Return a list of all hook type choices.""" | ||||||
|         return list(cls) |         return list(cls) | ||||||
|  |  | ||||||
| @@ -37,16 +37,17 @@ class HookManager: | |||||||
|     """HookManager""" |     """HookManager""" | ||||||
|  |  | ||||||
|     def __init__(self) -> None: |     def __init__(self) -> None: | ||||||
|         self._hooks: Dict[HookType, List[Hook]] = { |         self._hooks: dict[HookType, list[Hook]] = { | ||||||
|             hook_type: [] for hook_type in HookType |             hook_type: [] for hook_type in HookType | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def register(self, hook_type: HookType, hook: Hook): |     def register(self, hook_type: HookType | str, hook: Hook): | ||||||
|         if hook_type not in HookType: |         """Raises ValueError if the hook type is not supported.""" | ||||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") |         if not isinstance(hook_type, HookType): | ||||||
|  |             hook_type = HookType(hook_type) | ||||||
|         self._hooks[hook_type].append(hook) |         self._hooks[hook_type].append(hook) | ||||||
|  |  | ||||||
|     def clear(self, hook_type: Optional[HookType] = None): |     def clear(self, hook_type: HookType | None = None): | ||||||
|         if hook_type: |         if hook_type: | ||||||
|             self._hooks[hook_type] = [] |             self._hooks[hook_type] = [] | ||||||
|         else: |         else: | ||||||
| @@ -64,7 +65,7 @@ class HookManager: | |||||||
|                     hook(context) |                     hook(context) | ||||||
|             except Exception as hook_error: |             except Exception as hook_error: | ||||||
|                 logger.warning( |                 logger.warning( | ||||||
|                     "⚠️ Hook '%s' raised an exception during '%s' for '%s': %s", |                     "[Hook:%s] raised an exception during '%s' for '%s': %s", | ||||||
|                     hook.__name__, |                     hook.__name__, | ||||||
|                     hook_type, |                     hook_type, | ||||||
|                     context.name, |                     context.name, | ||||||
|   | |||||||
| @@ -56,10 +56,10 @@ class CircuitBreaker: | |||||||
|         if self.open_until: |         if self.open_until: | ||||||
|             if time.time() < self.open_until: |             if time.time() < self.open_until: | ||||||
|                 raise CircuitBreakerOpen( |                 raise CircuitBreakerOpen( | ||||||
|                     f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." |                     f"Circuit open for '{name}' until {time.ctime(self.open_until)}." | ||||||
|                 ) |                 ) | ||||||
|             else: |             else: | ||||||
|                 logger.info("🟢 Circuit closed again for '%s'.") |                 logger.info("Circuit closed again for '%s'.") | ||||||
|                 self.failures = 0 |                 self.failures = 0 | ||||||
|                 self.open_until = None |                 self.open_until = None | ||||||
|  |  | ||||||
| @@ -67,7 +67,7 @@ class CircuitBreaker: | |||||||
|         name = context.name |         name = context.name | ||||||
|         self.failures += 1 |         self.failures += 1 | ||||||
|         logger.warning( |         logger.warning( | ||||||
|             "⚠️ CircuitBreaker: '%s' failure %s/%s.", |             "CircuitBreaker: '%s' failure %s/%s.", | ||||||
|             name, |             name, | ||||||
|             self.failures, |             self.failures, | ||||||
|             self.max_failures, |             self.max_failures, | ||||||
| @@ -75,7 +75,7 @@ class CircuitBreaker: | |||||||
|         if self.failures >= self.max_failures: |         if self.failures >= self.max_failures: | ||||||
|             self.open_until = time.time() + self.reset_timeout |             self.open_until = time.time() + self.reset_timeout | ||||||
|             logger.error( |             logger.error( | ||||||
|                 "🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) |                 "Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|     def after_hook(self, _: ExecutionContext): |     def after_hook(self, _: ExecutionContext): | ||||||
| @@ -87,4 +87,4 @@ class CircuitBreaker: | |||||||
|     def reset(self): |     def reset(self): | ||||||
|         self.failures = 0 |         self.failures = 0 | ||||||
|         self.open_until = None |         self.open_until = None | ||||||
|         logger.info("🔄 Circuit reset.") |         logger.info("Circuit reset.") | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
| """init.py""" | """init.py""" | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
| from rich.console import Console | from falyx.console import console | ||||||
|  |  | ||||||
| TEMPLATE_TASKS = """\ | TEMPLATE_TASKS = """\ | ||||||
| # This file is used by falyx.yaml to define CLI actions. | # This file is used by falyx.yaml to define CLI actions. | ||||||
| @@ -11,9 +11,7 @@ TEMPLATE_TASKS = """\ | |||||||
| import asyncio | import asyncio | ||||||
| import json | import json | ||||||
|  |  | ||||||
| from falyx.action import Action, ChainedAction | from falyx.action import Action, ChainedAction, ShellAction, SelectionAction | ||||||
| from falyx.io_action import ShellAction |  | ||||||
| from falyx.selection_action import SelectionAction |  | ||||||
|  |  | ||||||
|  |  | ||||||
| post_ids = ["1", "2", "3", "4", "5"] | post_ids = ["1", "2", "3", "4", "5"] | ||||||
| @@ -100,10 +98,8 @@ commands: | |||||||
|     aliases: [clean, cleanup] |     aliases: [clean, cleanup] | ||||||
| """ | """ | ||||||
|  |  | ||||||
| console = Console(color_system="auto") |  | ||||||
|  |  | ||||||
|  | def init_project(name: str) -> None: | ||||||
| def init_project(name: str = ".") -> None: |  | ||||||
|     target = Path(name).resolve() |     target = Path(name).resolve() | ||||||
|     target.mkdir(parents=True, exist_ok=True) |     target.mkdir(parents=True, exist_ok=True) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,4 +2,4 @@ | |||||||
| """logger.py""" | """logger.py""" | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| logger = logging.getLogger("falyx") | logger: logging.Logger = logging.getLogger("falyx") | ||||||
|   | |||||||
| @@ -2,7 +2,9 @@ from __future__ import annotations | |||||||
|  |  | ||||||
| from dataclasses import dataclass | from dataclasses import dataclass | ||||||
|  |  | ||||||
| from falyx.action import BaseAction | from prompt_toolkit.formatted_text import FormattedText | ||||||
|  |  | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
| from falyx.signals import BackSignal, QuitSignal | from falyx.signals import BackSignal, QuitSignal | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict | from falyx.utils import CaseInsensitiveDict | ||||||
| @@ -26,6 +28,12 @@ class MenuOption: | |||||||
|         """Render the menu option for display.""" |         """Render the menu option for display.""" | ||||||
|         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" |         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" | ||||||
|  |  | ||||||
|  |     def render_prompt(self, key: str) -> FormattedText: | ||||||
|  |         """Render the menu option for prompt display.""" | ||||||
|  |         return FormattedText( | ||||||
|  |             [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class MenuOptionMap(CaseInsensitiveDict): | class MenuOptionMap(CaseInsensitiveDict): | ||||||
|     """ |     """ | ||||||
| @@ -33,7 +41,7 @@ class MenuOptionMap(CaseInsensitiveDict): | |||||||
|     and special signal entries like Quit and Back. |     and special signal entries like Quit and Back. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     RESERVED_KEYS = {"Q", "B"} |     RESERVED_KEYS = {"B", "X"} | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
| @@ -49,14 +57,14 @@ class MenuOptionMap(CaseInsensitiveDict): | |||||||
|     def _inject_reserved_defaults(self): |     def _inject_reserved_defaults(self): | ||||||
|         from falyx.action import SignalAction |         from falyx.action import SignalAction | ||||||
|  |  | ||||||
|         self._add_reserved( |  | ||||||
|             "Q", |  | ||||||
|             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), |  | ||||||
|         ) |  | ||||||
|         self._add_reserved( |         self._add_reserved( | ||||||
|             "B", |             "B", | ||||||
|             MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), |             MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), | ||||||
|         ) |         ) | ||||||
|  |         self._add_reserved( | ||||||
|  |             "X", | ||||||
|  |             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def _add_reserved(self, key: str, option: MenuOption) -> None: |     def _add_reserved(self, key: str, option: MenuOption) -> None: | ||||||
|         """Add a reserved key, bypassing validation.""" |         """Add a reserved key, bypassing validation.""" | ||||||
| @@ -78,8 +86,20 @@ class MenuOptionMap(CaseInsensitiveDict): | |||||||
|             raise ValueError(f"Cannot delete reserved option '{key}'.") |             raise ValueError(f"Cannot delete reserved option '{key}'.") | ||||||
|         super().__delitem__(key) |         super().__delitem__(key) | ||||||
|  |  | ||||||
|  |     def update(self, other=None, **kwargs): | ||||||
|  |         """Update the selection options with another dictionary.""" | ||||||
|  |         if other: | ||||||
|  |             for key, option in other.items(): | ||||||
|  |                 if not isinstance(option, MenuOption): | ||||||
|  |                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||||
|  |                 self[key] = option | ||||||
|  |         for key, option in kwargs.items(): | ||||||
|  |             if not isinstance(option, MenuOption): | ||||||
|  |                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||||
|  |             self[key] = option | ||||||
|  |  | ||||||
|     def items(self, include_reserved: bool = True): |     def items(self, include_reserved: bool = True): | ||||||
|         for k, v in super().items(): |         for key, option in super().items(): | ||||||
|             if not include_reserved and k in self.RESERVED_KEYS: |             if not include_reserved and key in self.RESERVED_KEYS: | ||||||
|                 continue |                 continue | ||||||
|             yield k, v |             yield key, option | ||||||
|   | |||||||
							
								
								
									
										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", | ||||||
|  | ] | ||||||
							
								
								
									
										118
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,118 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """argument.py""" | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
|  | from falyx.parser.argument_action import ArgumentAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class Argument: | ||||||
|  |     """ | ||||||
|  |     Represents a command-line argument. | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         flags (tuple[str, ...]): Short and long flags for the argument. | ||||||
|  |         dest (str): The destination name for the argument. | ||||||
|  |         action (ArgumentAction): The action to be taken when the argument is encountered. | ||||||
|  |         type (Any): The type of the argument (e.g., str, int, float) or a callable that converts the argument value. | ||||||
|  |         default (Any): The default value if the argument is not provided. | ||||||
|  |         choices (list[str] | None): A list of valid choices for the argument. | ||||||
|  |         required (bool): True if the argument is required, False otherwise. | ||||||
|  |         help (str): Help text for the argument. | ||||||
|  |         nargs (int | str | None): Number of arguments expected. Can be an int, '?', '*', '+', or None. | ||||||
|  |         positional (bool): True if the argument is positional (no leading - or -- in flags), False otherwise. | ||||||
|  |         resolver (BaseAction | None): | ||||||
|  |             An action object that resolves the argument, if applicable. | ||||||
|  |         lazy_resolver (bool): True if the resolver should be called lazily, False otherwise | ||||||
|  |         suggestions (list[str] | None): A list of suggestions for the argument. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     flags: tuple[str, ...] | ||||||
|  |     dest: str | ||||||
|  |     action: ArgumentAction = ArgumentAction.STORE | ||||||
|  |     type: Any = str | ||||||
|  |     default: Any = None | ||||||
|  |     choices: list[str] | None = None | ||||||
|  |     required: bool = False | ||||||
|  |     help: str = "" | ||||||
|  |     nargs: int | str | None = None | ||||||
|  |     positional: bool = False | ||||||
|  |     resolver: BaseAction | None = None | ||||||
|  |     lazy_resolver: bool = False | ||||||
|  |     suggestions: list[str] | None = None | ||||||
|  |  | ||||||
|  |     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, | ||||||
|  |                 ArgumentAction.ACTION, | ||||||
|  |             ) | ||||||
|  |             and not self.positional | ||||||
|  |         ): | ||||||
|  |             choice_text = self.dest.upper() | ||||||
|  |         elif self.action in ( | ||||||
|  |             ArgumentAction.STORE, | ||||||
|  |             ArgumentAction.APPEND, | ||||||
|  |             ArgumentAction.EXTEND, | ||||||
|  |             ArgumentAction.ACTION, | ||||||
|  |         ) 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, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
							
								
								
									
										28
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | # 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" | ||||||
|  |     STORE_BOOL_OPTIONAL = "store_bool_optional" | ||||||
|  |     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 | ||||||
							
								
								
									
										1154
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1154
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										15
									
								
								falyx/parser/parser_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								falyx/parser/parser_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """parser_types.py""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def true_none(value: Any) -> bool | None: | ||||||
|  |     if value is None: | ||||||
|  |         return None | ||||||
|  |     return True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def false_none(value: Any) -> bool | None: | ||||||
|  |     if value is None: | ||||||
|  |         return None | ||||||
|  |     return False | ||||||
							
								
								
									
										383
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										383
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,383 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """parsers.py | ||||||
|  | This module contains the argument parsers used for the Falyx CLI. | ||||||
|  | """ | ||||||
|  | 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: | ||||||
|  |     """Defines the argument parsers for the Falyx CLI.""" | ||||||
|  |  | ||||||
|  |     root: ArgumentParser | ||||||
|  |     subparsers: _SubParsersAction | ||||||
|  |     run: ArgumentParser | ||||||
|  |     run_all: ArgumentParser | ||||||
|  |     preview: ArgumentParser | ||||||
|  |     list: ArgumentParser | ||||||
|  |     version: ArgumentParser | ||||||
|  |  | ||||||
|  |     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: | ||||||
|  |         """Parse the command line arguments.""" | ||||||
|  |         return self.root.parse_args(args) | ||||||
|  |  | ||||||
|  |     def as_dict(self) -> dict[str, ArgumentParser]: | ||||||
|  |         """Convert the FalyxParsers instance to a dictionary.""" | ||||||
|  |         return asdict(self) | ||||||
|  |  | ||||||
|  |     def get_parser(self, name: str) -> ArgumentParser | None: | ||||||
|  |         """Get the parser by name.""" | ||||||
|  |         return self.as_dict().get(name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_root_parser( | ||||||
|  |     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, | ||||||
|  | ) -> ArgumentParser: | ||||||
|  |     """ | ||||||
|  |     Construct the root-level ArgumentParser for the Falyx CLI. | ||||||
|  |  | ||||||
|  |     This parser handles global arguments shared across subcommands and can serve | ||||||
|  |     as the base parser for the Falyx CLI or standalone applications. It includes | ||||||
|  |     options for verbosity, debug logging, and version output. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         prog (str | None): Name of the program (e.g., 'falyx'). | ||||||
|  |         usage (str | None): Optional custom usage string. | ||||||
|  |         description (str | None): Description shown in the CLI help. | ||||||
|  |         epilog (str | None): Message displayed at the end of help output. | ||||||
|  |         parents (Sequence[ArgumentParser] | None): Optional parent parsers. | ||||||
|  |         prefix_chars (str): Characters to denote optional arguments (default: "-"). | ||||||
|  |         fromfile_prefix_chars (str | None): Prefix to indicate argument file input. | ||||||
|  |         argument_default (Any): Global default value for arguments. | ||||||
|  |         conflict_handler (str): Strategy to resolve conflicting argument names. | ||||||
|  |         add_help (bool): Whether to include help (`-h/--help`) in this parser. | ||||||
|  |         allow_abbrev (bool): Allow abbreviated long options. | ||||||
|  |         exit_on_error (bool): Exit immediately on error or raise an exception. | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         ArgumentParser: The root parser with global options attached. | ||||||
|  |  | ||||||
|  |     Notes: | ||||||
|  |         ``` | ||||||
|  |         Includes the following arguments: | ||||||
|  |             --never-prompt       : Run in non-interactive mode. | ||||||
|  |             -v / --verbose       : Enable debug logging. | ||||||
|  |             --debug-hooks        : Enable hook lifecycle debug logs. | ||||||
|  |             --version            : Print the Falyx version. | ||||||
|  |         ``` | ||||||
|  |     """ | ||||||
|  |     parser = ArgumentParser( | ||||||
|  |         prog=prog, | ||||||
|  |         usage=usage, | ||||||
|  |         description=description, | ||||||
|  |         epilog=epilog, | ||||||
|  |         parents=parents if parents else [], | ||||||
|  |         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, | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--never-prompt", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Run in non-interactive mode with all prompts bypassed.", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-v", "--verbose", action="store_true", help=f"Enable debug logging for {prog}." | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug-hooks", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Enable default lifecycle debug logging", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument("--version", action="store_true", help=f"Show {prog} version") | ||||||
|  |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_subparsers( | ||||||
|  |     parser: ArgumentParser, | ||||||
|  |     title: str = "Falyx Commands", | ||||||
|  |     description: str | None = "Available commands for the Falyx CLI.", | ||||||
|  | ) -> _SubParsersAction: | ||||||
|  |     """ | ||||||
|  |     Create and return a subparsers object for registering Falyx CLI subcommands. | ||||||
|  |  | ||||||
|  |     This function adds a `subparsers` block to the given root parser, enabling | ||||||
|  |     structured subcommands such as `run`, `run-all`, `preview`, etc. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         parser (ArgumentParser): The root parser to attach the subparsers to. | ||||||
|  |         title (str): Title used in help output to group subcommands. | ||||||
|  |         description (str | None): Optional text describing the group of subcommands. | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         _SubParsersAction: The subparsers object that can be used to add new CLI subcommands. | ||||||
|  |  | ||||||
|  |     Raises: | ||||||
|  |         TypeError: If `parser` is not an instance of `ArgumentParser`. | ||||||
|  |  | ||||||
|  |     Example: | ||||||
|  |         ```python | ||||||
|  |         >>> parser = get_root_parser() | ||||||
|  |         >>> subparsers = get_subparsers(parser, title="Available Commands") | ||||||
|  |         >>> subparsers.add_parser("run", help="Run a Falyx command") | ||||||
|  |         ``` | ||||||
|  |     """ | ||||||
|  |     if not isinstance(parser, ArgumentParser): | ||||||
|  |         raise TypeError("parser must be an instance of ArgumentParser") | ||||||
|  |     subparsers = parser.add_subparsers( | ||||||
|  |         title=title, | ||||||
|  |         description=description, | ||||||
|  |         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: | ||||||
|  |     """ | ||||||
|  |     Create and return the full suite of argument parsers used by the Falyx CLI. | ||||||
|  |  | ||||||
|  |     This function builds the root parser and all subcommand parsers used for structured | ||||||
|  |     CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, | ||||||
|  |     `preview`, `list`, and `version`, and integrates with registered `Command` objects | ||||||
|  |     to populate dynamic help and usage documentation. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         prog (str | None): Program name to display in help and usage messages. | ||||||
|  |         usage (str | None): Optional usage message to override the default. | ||||||
|  |         description (str | None): Description for the CLI root parser. | ||||||
|  |         epilog (str | None): Epilog message shown after the help text. | ||||||
|  |         parents (Sequence[ArgumentParser] | None): Optional parent parsers. | ||||||
|  |         prefix_chars (str): Characters that prefix optional arguments. | ||||||
|  |         fromfile_prefix_chars (str | None): Prefix character for reading args from file. | ||||||
|  |         argument_default (Any): Default value for arguments if not specified. | ||||||
|  |         conflict_handler (str): Strategy for resolving conflicting arguments. | ||||||
|  |         add_help (bool): Whether to add the `-h/--help` option to the root parser. | ||||||
|  |         allow_abbrev (bool): Whether to allow abbreviated long options. | ||||||
|  |         exit_on_error (bool): Whether the parser exits on error or raises. | ||||||
|  |         commands (dict[str, Command] | None): Optional dictionary of registered commands | ||||||
|  |             to populate help and subcommand descriptions dynamically. | ||||||
|  |         root_parser (ArgumentParser | None): Custom root parser to use instead of building one. | ||||||
|  |         subparsers (_SubParsersAction | None): Optional existing subparser object to extend. | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         FalyxParsers: A structured container of all parsers, including `run`, `run-all`, | ||||||
|  |                       `preview`, `list`, `version`, and the root parser. | ||||||
|  |  | ||||||
|  |     Raises: | ||||||
|  |         TypeError: If `root_parser` is not an instance of ArgumentParser or | ||||||
|  |                    `subparsers` is not an instance of _SubParsersAction. | ||||||
|  |  | ||||||
|  |     Example: | ||||||
|  |         ```python | ||||||
|  |         >>> parsers = get_arg_parsers(commands=my_command_dict) | ||||||
|  |         >>> args = parsers.root.parse_args() | ||||||
|  |         ``` | ||||||
|  |  | ||||||
|  |     Notes: | ||||||
|  |         - This function integrates dynamic command usage and descriptions if the | ||||||
|  |           `commands` argument is provided. | ||||||
|  |         - The `run` parser supports additional options for retry logic and confirmation | ||||||
|  |           prompts. | ||||||
|  |         - The `run-all` parser executes all commands matching a tag. | ||||||
|  |         - Use `falyx run ?[COMMAND]` from the CLI to preview a command. | ||||||
|  |     """ | ||||||
|  |     if epilog is None: | ||||||
|  |         epilog = f"Tip: Use '{prog} run ?[COMMAND]' to preview any command from 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: | ||||||
|  |         if prog == "falyx": | ||||||
|  |             subparsers = get_subparsers( | ||||||
|  |                 parser, | ||||||
|  |                 title="Falyx Commands", | ||||||
|  |                 description="Available commands for the Falyx CLI.", | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             subparsers = get_subparsers(parser, title="subcommands", description=None) | ||||||
|  |     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.help_text or command.description | ||||||
|  |             run_description.append(f"{' '*24}{command_description}") | ||||||
|  |     run_epilog = ( | ||||||
|  |         f"Tip: Use '{prog} 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", | ||||||
|  |         help="Print an execution summary after command completes", | ||||||
|  |     ) | ||||||
|  |     run_parser.add_argument( | ||||||
|  |         "--retries", type=int, help="Number of retries on failure", default=0 | ||||||
|  |     ) | ||||||
|  |     run_parser.add_argument( | ||||||
|  |         "--retry-delay", | ||||||
|  |         type=float, | ||||||
|  |         help="Initial delay between retries in (seconds)", | ||||||
|  |         default=0, | ||||||
|  |     ) | ||||||
|  |     run_parser.add_argument( | ||||||
|  |         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||||
|  |     ) | ||||||
|  |     run_group = run_parser.add_mutually_exclusive_group(required=False) | ||||||
|  |     run_group.add_argument( | ||||||
|  |         "-c", | ||||||
|  |         "--confirm", | ||||||
|  |         dest="force_confirm", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Force confirmation prompts", | ||||||
|  |     ) | ||||||
|  |     run_group.add_argument( | ||||||
|  |         "-s", | ||||||
|  |         "--skip-confirm", | ||||||
|  |         dest="skip_confirm", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Skip confirmation prompts", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     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( | ||||||
|  |         "run-all", help="Run all commands with a given tag" | ||||||
|  |     ) | ||||||
|  |     run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") | ||||||
|  |     run_all_parser.add_argument( | ||||||
|  |         "--summary", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Print a summary after all tagged commands run", | ||||||
|  |     ) | ||||||
|  |     run_all_parser.add_argument( | ||||||
|  |         "--retries", type=int, help="Number of retries on failure", default=0 | ||||||
|  |     ) | ||||||
|  |     run_all_parser.add_argument( | ||||||
|  |         "--retry-delay", | ||||||
|  |         type=float, | ||||||
|  |         help="Initial delay between retries in (seconds)", | ||||||
|  |         default=0, | ||||||
|  |     ) | ||||||
|  |     run_all_parser.add_argument( | ||||||
|  |         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||||
|  |     ) | ||||||
|  |     run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) | ||||||
|  |     run_all_group.add_argument( | ||||||
|  |         "-c", | ||||||
|  |         "--confirm", | ||||||
|  |         dest="force_confirm", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Force confirmation prompts", | ||||||
|  |     ) | ||||||
|  |     run_all_group.add_argument( | ||||||
|  |         "-s", | ||||||
|  |         "--skip-confirm", | ||||||
|  |         dest="skip_confirm", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Skip confirmation prompts", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     preview_parser = subparsers.add_parser( | ||||||
|  |         "preview", help="Preview a command without running it" | ||||||
|  |     ) | ||||||
|  |     preview_parser.add_argument("name", help="Key, alias, or description of the command") | ||||||
|  |  | ||||||
|  |     list_parser = subparsers.add_parser( | ||||||
|  |         "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=f"Show {prog} version") | ||||||
|  |  | ||||||
|  |     return FalyxParsers( | ||||||
|  |         root=parser, | ||||||
|  |         subparsers=subparsers, | ||||||
|  |         run=run_parser, | ||||||
|  |         run_all=run_all_parser, | ||||||
|  |         preview=preview_parser, | ||||||
|  |         list=list_parser, | ||||||
|  |         version=version_parser, | ||||||
|  |     ) | ||||||
| @@ -1,17 +1,21 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| import inspect | import inspect | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
| 
 | 
 | ||||||
| from falyx import logger | from falyx.logger import logger | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def infer_args_from_func( | def infer_args_from_func( | ||||||
|     func: Callable[[Any], Any], |     func: Callable[[Any], Any] | None, | ||||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, |     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||||
| ) -> list[dict[str, Any]]: | ) -> list[dict[str, Any]]: | ||||||
|     """ |     """ | ||||||
|     Infer argument definitions from a callable's signature. |     Infer argument definitions from a callable's signature. | ||||||
|     Returns a list of kwargs suitable for CommandArgumentParser.add_argument. |     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 {} |     arg_metadata = arg_metadata or {} | ||||||
|     signature = inspect.signature(func) |     signature = inspect.signature(func) | ||||||
|     arg_defs = [] |     arg_defs = [] | ||||||
| @@ -21,7 +25,6 @@ def infer_args_from_func( | |||||||
|         metadata = ( |         metadata = ( | ||||||
|             {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata |             {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata | ||||||
|         ) |         ) | ||||||
| 
 |  | ||||||
|         if param.kind not in ( |         if param.kind not in ( | ||||||
|             inspect.Parameter.POSITIONAL_ONLY, |             inspect.Parameter.POSITIONAL_ONLY, | ||||||
|             inspect.Parameter.POSITIONAL_OR_KEYWORD, |             inspect.Parameter.POSITIONAL_OR_KEYWORD, | ||||||
| @@ -29,9 +32,16 @@ def infer_args_from_func( | |||||||
|         ): |         ): | ||||||
|             continue |             continue | ||||||
| 
 | 
 | ||||||
|         arg_type = ( |         if metadata.get("type"): | ||||||
|             param.annotation if param.annotation is not inspect.Parameter.empty else str |             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 |         default = param.default if param.default is not inspect.Parameter.empty else None | ||||||
|         is_required = param.default is inspect.Parameter.empty |         is_required = param.default is inspect.Parameter.empty | ||||||
|         if is_required: |         if is_required: | ||||||
| @@ -39,13 +49,15 @@ def infer_args_from_func( | |||||||
|         else: |         else: | ||||||
|             flags = [f"--{name.replace('_', '-')}"] |             flags = [f"--{name.replace('_', '-')}"] | ||||||
|         action = "store" |         action = "store" | ||||||
|         nargs: int | str = 1 |         nargs: int | str | None = None | ||||||
| 
 | 
 | ||||||
|         if arg_type is bool: |         if arg_type is bool: | ||||||
|             if param.default is False: |             if param.default is False: | ||||||
|                 action = "store_true" |                 action = "store_true" | ||||||
|             else: |                 default = None | ||||||
|  |             elif param.default is True: | ||||||
|                 action = "store_false" |                 action = "store_false" | ||||||
|  |                 default = None | ||||||
| 
 | 
 | ||||||
|         if arg_type is list: |         if arg_type is list: | ||||||
|             action = "append" |             action = "append" | ||||||
| @@ -65,6 +77,7 @@ def infer_args_from_func( | |||||||
|                 "action": action, |                 "action": action, | ||||||
|                 "help": metadata.get("help", ""), |                 "help": metadata.get("help", ""), | ||||||
|                 "choices": metadata.get("choices"), |                 "choices": metadata.get("choices"), | ||||||
|  |                 "suggestions": metadata.get("suggestions"), | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
							
								
								
									
										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_action 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", "t", "1", "yes", "on"}: | ||||||
|  |         return True | ||||||
|  |     elif value in {"false", "f", "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) | ||||||
|  |     try: | ||||||
|  |         coerced_value = base_type(value) | ||||||
|  |         return enum_type(coerced_value) | ||||||
|  |     except (ValueError, TypeError): | ||||||
|  |         values = [str(enum.value) for enum in enum_type] | ||||||
|  |         raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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}") | ||||||
|  |  | ||||||
|  |     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 | ||||||
| @@ -1,21 +0,0 @@ | |||||||
| """ |  | ||||||
| Falyx CLI Framework |  | ||||||
|  |  | ||||||
| Copyright (c) 2025 rtj.dev LLC. |  | ||||||
| Licensed under the MIT License. See LICENSE file for details. |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| from .argparse import Argument, ArgumentAction, CommandArgumentParser |  | ||||||
| from .parsers import FalyxParsers, get_arg_parsers |  | ||||||
| from .signature import infer_args_from_func |  | ||||||
| from .utils import same_argument_definitions |  | ||||||
|  |  | ||||||
| __all__ = [ |  | ||||||
|     "Argument", |  | ||||||
|     "ArgumentAction", |  | ||||||
|     "CommandArgumentParser", |  | ||||||
|     "get_arg_parsers", |  | ||||||
|     "FalyxParsers", |  | ||||||
|     "infer_args_from_func", |  | ||||||
|     "same_argument_definitions", |  | ||||||
| ] |  | ||||||
| @@ -1,756 +0,0 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed |  | ||||||
| from copy import deepcopy |  | ||||||
| from dataclasses import dataclass |  | ||||||
| from enum import Enum |  | ||||||
| from typing import Any, Iterable |  | ||||||
|  |  | ||||||
| from rich.console import Console |  | ||||||
| from rich.markup import escape |  | ||||||
| from rich.text import Text |  | ||||||
|  |  | ||||||
| from falyx.exceptions import CommandArgumentError |  | ||||||
| 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 |  | ||||||
|  |  | ||||||
|     def get_positional_text(self) -> str: |  | ||||||
|         """Get the positional text for the argument.""" |  | ||||||
|         text = "" |  | ||||||
|         if self.positional: |  | ||||||
|             if self.choices: |  | ||||||
|                 text = f"{{{','.join([str(choice) for choice in self.choices])}}}" |  | ||||||
|             else: |  | ||||||
|                 text = self.dest |  | ||||||
|         return text |  | ||||||
|  |  | ||||||
|     def get_choice_text(self) -> str: |  | ||||||
|         """Get the choice text for the argument.""" |  | ||||||
|         choice_text = "" |  | ||||||
|         if self.choices: |  | ||||||
|             choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}" |  | ||||||
|         elif ( |  | ||||||
|             self.action |  | ||||||
|             in ( |  | ||||||
|                 ArgumentAction.STORE, |  | ||||||
|                 ArgumentAction.APPEND, |  | ||||||
|                 ArgumentAction.EXTEND, |  | ||||||
|             ) |  | ||||||
|             and not self.positional |  | ||||||
|         ): |  | ||||||
|             choice_text = self.dest.upper() |  | ||||||
|         elif isinstance(self.nargs, str): |  | ||||||
|             choice_text = self.dest |  | ||||||
|  |  | ||||||
|         if self.nargs == "?": |  | ||||||
|             choice_text = f"[{choice_text}]" |  | ||||||
|         elif self.nargs == "*": |  | ||||||
|             choice_text = f"[{choice_text} ...]" |  | ||||||
|         elif self.nargs == "+": |  | ||||||
|             choice_text = f"{choice_text} [{choice_text} ...]" |  | ||||||
|         return choice_text |  | ||||||
|  |  | ||||||
|     def __eq__(self, other: object) -> bool: |  | ||||||
|         if not isinstance(other, Argument): |  | ||||||
|             return False |  | ||||||
|         return ( |  | ||||||
|             self.flags == other.flags |  | ||||||
|             and self.dest == other.dest |  | ||||||
|             and self.action == other.action |  | ||||||
|             and self.type == other.type |  | ||||||
|             and self.choices == other.choices |  | ||||||
|             and self.required == other.required |  | ||||||
|             and self.nargs == other.nargs |  | ||||||
|             and self.positional == other.positional |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def __hash__(self) -> int: |  | ||||||
|         return hash( |  | ||||||
|             ( |  | ||||||
|                 tuple(self.flags), |  | ||||||
|                 self.dest, |  | ||||||
|                 self.action, |  | ||||||
|                 self.type, |  | ||||||
|                 tuple(self.choices or []), |  | ||||||
|                 self.required, |  | ||||||
|                 self.nargs, |  | ||||||
|                 self.positional, |  | ||||||
|             ) |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class CommandArgumentParser: |  | ||||||
|     """ |  | ||||||
|     Custom argument parser for Falyx Commands. |  | ||||||
|     It is used to create a command-line interface for Falyx |  | ||||||
|     commands, allowing users to specify options and arguments |  | ||||||
|     when executing commands. |  | ||||||
|     It is not intended to be a full-featured replacement for |  | ||||||
|     argparse, but rather a lightweight alternative for specific use |  | ||||||
|     cases within the Falyx framework. |  | ||||||
|  |  | ||||||
|     Features: |  | ||||||
|     - Customizable argument parsing. |  | ||||||
|     - Type coercion for arguments. |  | ||||||
|     - Support for positional and keyword arguments. |  | ||||||
|     - Support for default values. |  | ||||||
|     - Support for boolean flags. |  | ||||||
|     - Exception handling for invalid arguments. |  | ||||||
|     - Render Help using Rich library. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def __init__( |  | ||||||
|         self, |  | ||||||
|         command_key: str = "", |  | ||||||
|         command_description: str = "", |  | ||||||
|         command_style: str = "bold", |  | ||||||
|         help_text: str = "", |  | ||||||
|         help_epilogue: str = "", |  | ||||||
|         aliases: list[str] | None = None, |  | ||||||
|     ) -> None: |  | ||||||
|         """Initialize the CommandArgumentParser.""" |  | ||||||
|         self.command_key: str = command_key |  | ||||||
|         self.command_description: str = command_description |  | ||||||
|         self.command_style: str = command_style |  | ||||||
|         self.help_text: str = help_text |  | ||||||
|         self.help_epilogue: str = help_epilogue |  | ||||||
|         self.aliases: list[str] = aliases or [] |  | ||||||
|         self._arguments: list[Argument] = [] |  | ||||||
|         self._positional: list[Argument] = [] |  | ||||||
|         self._keyword: list[Argument] = [] |  | ||||||
|         self._flag_map: dict[str, Argument] = {} |  | ||||||
|         self._dest_set: set[str] = set() |  | ||||||
|         self._add_help() |  | ||||||
|         self.console = Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     def _add_help(self): |  | ||||||
|         """Add help argument to the parser.""" |  | ||||||
|         self.add_argument( |  | ||||||
|             "-h", |  | ||||||
|             "--help", |  | ||||||
|             action=ArgumentAction.HELP, |  | ||||||
|             help="Show this help message.", |  | ||||||
|             dest="help", |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def _is_positional(self, flags: tuple[str, ...]) -> bool: |  | ||||||
|         """Check if the flags are positional.""" |  | ||||||
|         positional = False |  | ||||||
|         if any(not flag.startswith("-") for flag in flags): |  | ||||||
|             positional = True |  | ||||||
|  |  | ||||||
|         if positional and len(flags) > 1: |  | ||||||
|             raise CommandArgumentError("Positional arguments cannot have multiple flags") |  | ||||||
|         return positional |  | ||||||
|  |  | ||||||
|     def _get_dest_from_flags( |  | ||||||
|         self, flags: tuple[str, ...], dest: str | None |  | ||||||
|     ) -> str | None: |  | ||||||
|         """Convert flags to a destination name.""" |  | ||||||
|         if dest: |  | ||||||
|             if not dest.replace("_", "").isalnum(): |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     "dest must be a valid identifier (letters, digits, and underscores only)" |  | ||||||
|                 ) |  | ||||||
|             if dest[0].isdigit(): |  | ||||||
|                 raise CommandArgumentError("dest must not start with a digit") |  | ||||||
|             return dest |  | ||||||
|         dest = None |  | ||||||
|         for flag in flags: |  | ||||||
|             if flag.startswith("--"): |  | ||||||
|                 dest = flag.lstrip("-").replace("-", "_").lower() |  | ||||||
|                 break |  | ||||||
|             elif flag.startswith("-"): |  | ||||||
|                 dest = flag.lstrip("-").replace("-", "_").lower() |  | ||||||
|             else: |  | ||||||
|                 dest = flag.replace("-", "_").lower() |  | ||||||
|         assert dest is not None, "dest should not be None" |  | ||||||
|         if not dest.replace("_", "").isalnum(): |  | ||||||
|             raise CommandArgumentError( |  | ||||||
|                 "dest must be a valid identifier (letters, digits, and underscores only)" |  | ||||||
|             ) |  | ||||||
|         if dest[0].isdigit(): |  | ||||||
|             raise CommandArgumentError("dest must not start with a digit") |  | ||||||
|         return dest |  | ||||||
|  |  | ||||||
|     def _determine_required( |  | ||||||
|         self, required: bool, positional: bool, nargs: int | str |  | ||||||
|     ) -> bool: |  | ||||||
|         """Determine if the argument is required.""" |  | ||||||
|         if required: |  | ||||||
|             return True |  | ||||||
|         if positional: |  | ||||||
|             if isinstance(nargs, int): |  | ||||||
|                 return nargs > 0 |  | ||||||
|             elif isinstance(nargs, str): |  | ||||||
|                 if nargs in ("+"): |  | ||||||
|                     return True |  | ||||||
|                 elif nargs in ("*", "?"): |  | ||||||
|                     return False |  | ||||||
|                 else: |  | ||||||
|                     raise CommandArgumentError(f"Invalid nargs value: {nargs}") |  | ||||||
|  |  | ||||||
|         return required |  | ||||||
|  |  | ||||||
|     def _validate_nargs(self, nargs: int | str) -> int | str: |  | ||||||
|         allowed_nargs = ("?", "*", "+") |  | ||||||
|         if isinstance(nargs, int): |  | ||||||
|             if nargs <= 0: |  | ||||||
|                 raise CommandArgumentError("nargs must be a positive integer") |  | ||||||
|         elif isinstance(nargs, str): |  | ||||||
|             if nargs not in allowed_nargs: |  | ||||||
|                 raise CommandArgumentError(f"Invalid nargs value: {nargs}") |  | ||||||
|         else: |  | ||||||
|             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]: |  | ||||||
|         if choices is not None: |  | ||||||
|             if isinstance(choices, dict): |  | ||||||
|                 raise CommandArgumentError("choices cannot be a dict") |  | ||||||
|             try: |  | ||||||
|                 choices = list(choices) |  | ||||||
|             except TypeError: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     "choices must be iterable (like list, tuple, or set)" |  | ||||||
|                 ) |  | ||||||
|         else: |  | ||||||
|             choices = [] |  | ||||||
|         for choice in choices: |  | ||||||
|             if not isinstance(choice, expected_type): |  | ||||||
|                 try: |  | ||||||
|                     expected_type(choice) |  | ||||||
|                 except Exception: |  | ||||||
|                     raise CommandArgumentError( |  | ||||||
|                         f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}" |  | ||||||
|                     ) |  | ||||||
|         return choices |  | ||||||
|  |  | ||||||
|     def _validate_default_type( |  | ||||||
|         self, default: Any, expected_type: type, dest: str |  | ||||||
|     ) -> None: |  | ||||||
|         """Validate the default value type.""" |  | ||||||
|         if default is not None and not isinstance(default, expected_type): |  | ||||||
|             try: |  | ||||||
|                 expected_type(default) |  | ||||||
|             except Exception: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def _validate_default_list_type( |  | ||||||
|         self, default: list[Any], expected_type: type, dest: str |  | ||||||
|     ) -> None: |  | ||||||
|         if isinstance(default, list): |  | ||||||
|             for item in default: |  | ||||||
|                 if not isinstance(item, expected_type): |  | ||||||
|                     try: |  | ||||||
|                         expected_type(item) |  | ||||||
|                     except Exception: |  | ||||||
|                         raise CommandArgumentError( |  | ||||||
|                             f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" |  | ||||||
|                         ) |  | ||||||
|  |  | ||||||
|     def _resolve_default( |  | ||||||
|         self, action: ArgumentAction, default: Any, nargs: str | int |  | ||||||
|     ) -> Any: |  | ||||||
|         """Get the default value for the argument.""" |  | ||||||
|         if default is None: |  | ||||||
|             if action == ArgumentAction.STORE_TRUE: |  | ||||||
|                 return False |  | ||||||
|             elif action == ArgumentAction.STORE_FALSE: |  | ||||||
|                 return True |  | ||||||
|             elif action == ArgumentAction.COUNT: |  | ||||||
|                 return 0 |  | ||||||
|             elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): |  | ||||||
|                 return [] |  | ||||||
|             elif nargs in ("+", "*"): |  | ||||||
|                 return [] |  | ||||||
|             else: |  | ||||||
|                 return None |  | ||||||
|         return default |  | ||||||
|  |  | ||||||
|     def _validate_flags(self, flags: tuple[str, ...]) -> None: |  | ||||||
|         """Validate the flags provided for the argument.""" |  | ||||||
|         if not flags: |  | ||||||
|             raise CommandArgumentError("No flags provided") |  | ||||||
|         for flag in flags: |  | ||||||
|             if not isinstance(flag, str): |  | ||||||
|                 raise CommandArgumentError(f"Flag '{flag}' must be a string") |  | ||||||
|             if flag.startswith("--") and len(flag) < 3: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Flag '{flag}' must be at least 3 characters long" |  | ||||||
|                 ) |  | ||||||
|             if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Flag '{flag}' must be a single character or start with '--'" |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|     def add_argument(self, *flags, **kwargs): |  | ||||||
|         """Add an argument to the parser. |  | ||||||
|         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. |  | ||||||
|             nargs: The number of arguments expected. |  | ||||||
|             default: The default value if the argument is not provided. |  | ||||||
|             type: The type to which the command-line argument should be converted. |  | ||||||
|             choices: A container of the allowable values for the argument. |  | ||||||
|             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(). |  | ||||||
|         """ |  | ||||||
|         self._validate_flags(flags) |  | ||||||
|         positional = self._is_positional(flags) |  | ||||||
|         dest = self._get_dest_from_flags(flags, kwargs.get("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) |  | ||||||
|         if ( |  | ||||||
|             action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) |  | ||||||
|             and default is not None |  | ||||||
|         ): |  | ||||||
|             if isinstance(default, list): |  | ||||||
|                 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) |  | ||||||
|         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 |  | ||||||
|         ) |  | ||||||
|         argument = Argument( |  | ||||||
|             flags=flags, |  | ||||||
|             dest=dest, |  | ||||||
|             action=action, |  | ||||||
|             type=expected_type, |  | ||||||
|             default=default, |  | ||||||
|             choices=choices, |  | ||||||
|             required=required, |  | ||||||
|             help=kwargs.get("help", ""), |  | ||||||
|             nargs=nargs, |  | ||||||
|             positional=positional, |  | ||||||
|         ) |  | ||||||
|         for flag in flags: |  | ||||||
|             if flag in self._flag_map: |  | ||||||
|                 existing = self._flag_map[flag] |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Flag '{flag}' is already used by argument '{existing.dest}'" |  | ||||||
|                 ) |  | ||||||
|             self._flag_map[flag] = argument |  | ||||||
|         self._arguments.append(argument) |  | ||||||
|         if positional: |  | ||||||
|             self._positional.append(argument) |  | ||||||
|         else: |  | ||||||
|             self._keyword.append(argument) |  | ||||||
|  |  | ||||||
|     def get_argument(self, dest: str) -> Argument | None: |  | ||||||
|         return next((a for a in self._arguments if a.dest == dest), None) |  | ||||||
|  |  | ||||||
|     def to_definition_list(self) -> list[dict[str, Any]]: |  | ||||||
|         defs = [] |  | ||||||
|         for arg in self._arguments: |  | ||||||
|             defs.append( |  | ||||||
|                 { |  | ||||||
|                     "flags": arg.flags, |  | ||||||
|                     "dest": arg.dest, |  | ||||||
|                     "action": arg.action, |  | ||||||
|                     "type": arg.type, |  | ||||||
|                     "choices": arg.choices, |  | ||||||
|                     "required": arg.required, |  | ||||||
|                     "nargs": arg.nargs, |  | ||||||
|                     "positional": arg.positional, |  | ||||||
|                 } |  | ||||||
|             ) |  | ||||||
|         return defs |  | ||||||
|  |  | ||||||
|     def _consume_nargs( |  | ||||||
|         self, args: list[str], start: int, spec: Argument |  | ||||||
|     ) -> tuple[list[str], int]: |  | ||||||
|         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 == "+": |  | ||||||
|             if i >= len(args): |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Expected at least one value for '{spec.dest}'" |  | ||||||
|                 ) |  | ||||||
|             while i < len(args) and not args[i].startswith("-"): |  | ||||||
|                 values.append(args[i]) |  | ||||||
|                 i += 1 |  | ||||||
|             assert values, "Expected at least one value for '+' nargs: shouldn't happen" |  | ||||||
|             return values, i |  | ||||||
|         elif spec.nargs == "*": |  | ||||||
|             while i < len(args) and not args[i].startswith("-"): |  | ||||||
|                 values.append(args[i]) |  | ||||||
|                 i += 1 |  | ||||||
|             return values, i |  | ||||||
|         elif spec.nargs == "?": |  | ||||||
|             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" |  | ||||||
|  |  | ||||||
|     def _consume_all_positional_args( |  | ||||||
|         self, |  | ||||||
|         args: list[str], |  | ||||||
|         result: dict[str, Any], |  | ||||||
|         positional_args: list[Argument], |  | ||||||
|         consumed_positional_indicies: set[int], |  | ||||||
|     ) -> int: |  | ||||||
|         remaining_positional_args = [ |  | ||||||
|             (j, spec) |  | ||||||
|             for j, spec in enumerate(positional_args) |  | ||||||
|             if j not in consumed_positional_indicies |  | ||||||
|         ] |  | ||||||
|         i = 0 |  | ||||||
|  |  | ||||||
|         for j, spec in remaining_positional_args: |  | ||||||
|             # estimate how many args the remaining specs might need |  | ||||||
|             is_last = j == len(positional_args) - 1 |  | ||||||
|             remaining = len(args) - i |  | ||||||
|             min_required = 0 |  | ||||||
|             for next_spec in positional_args[j + 1 :]: |  | ||||||
|                 if isinstance(next_spec.nargs, int): |  | ||||||
|                     min_required += next_spec.nargs |  | ||||||
|                 elif next_spec.nargs == "+": |  | ||||||
|                     min_required += 1 |  | ||||||
|                 elif next_spec.nargs == "?": |  | ||||||
|                     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: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|             if spec.action == ArgumentAction.APPEND: |  | ||||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" |  | ||||||
|                 if spec.nargs in (None, 1): |  | ||||||
|                     result[spec.dest].append(typed[0]) |  | ||||||
|                 else: |  | ||||||
|                     result[spec.dest].append(typed) |  | ||||||
|             elif spec.action == ArgumentAction.EXTEND: |  | ||||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" |  | ||||||
|                 result[spec.dest].extend(typed) |  | ||||||
|             elif spec.nargs in (None, 1, "?"): |  | ||||||
|                 result[spec.dest] = typed[0] if len(typed) == 1 else typed |  | ||||||
|             else: |  | ||||||
|                 result[spec.dest] = typed |  | ||||||
|  |  | ||||||
|             if spec.nargs not in ("*", "+"): |  | ||||||
|                 consumed_positional_indicies.add(j) |  | ||||||
|  |  | ||||||
|         if i < len(args): |  | ||||||
|             raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}") |  | ||||||
|  |  | ||||||
|         return i |  | ||||||
|  |  | ||||||
|     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 = [] |  | ||||||
|  |  | ||||||
|         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] |  | ||||||
|                 action = spec.action |  | ||||||
|  |  | ||||||
|                 if action == ArgumentAction.HELP: |  | ||||||
|                     if not from_validate: |  | ||||||
|                         self.render_help() |  | ||||||
|                     raise HelpSignal() |  | ||||||
|                 elif action == ArgumentAction.STORE_TRUE: |  | ||||||
|                     result[spec.dest] = True |  | ||||||
|                     consumed_indices.add(i) |  | ||||||
|                     i += 1 |  | ||||||
|                 elif action == ArgumentAction.STORE_FALSE: |  | ||||||
|                     result[spec.dest] = False |  | ||||||
|                     consumed_indices.add(i) |  | ||||||
|                     i += 1 |  | ||||||
|                 elif action == ArgumentAction.COUNT: |  | ||||||
|                     result[spec.dest] = result.get(spec.dest, 0) + 1 |  | ||||||
|                     consumed_indices.add(i) |  | ||||||
|                     i += 1 |  | ||||||
|                 elif action == ArgumentAction.APPEND: |  | ||||||
|                     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: |  | ||||||
|                         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__}" |  | ||||||
|                             ) |  | ||||||
|                     else: |  | ||||||
|                         result[spec.dest].append(typed_values) |  | ||||||
|                     consumed_indices.update(range(i, new_i)) |  | ||||||
|                     i = new_i |  | ||||||
|                 elif action == ArgumentAction.EXTEND: |  | ||||||
|                     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: |  | ||||||
|                         raise CommandArgumentError( |  | ||||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" |  | ||||||
|                         ) |  | ||||||
|                     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: |  | ||||||
|                         raise CommandArgumentError( |  | ||||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" |  | ||||||
|                         ) |  | ||||||
|                     if ( |  | ||||||
|                         spec.nargs in (None, 1, "?") |  | ||||||
|                         and spec.action != ArgumentAction.APPEND |  | ||||||
|                     ): |  | ||||||
|                         result[spec.dest] = ( |  | ||||||
|                             typed_values[0] if len(typed_values) == 1 else typed_values |  | ||||||
|                         ) |  | ||||||
|                     else: |  | ||||||
|                         result[spec.dest] = typed_values |  | ||||||
|                     consumed_indices.update(range(i, new_i)) |  | ||||||
|                     i = new_i |  | ||||||
|             else: |  | ||||||
|                 # Get the next flagged argument index if it exists |  | ||||||
|                 next_flagged_index = -1 |  | ||||||
|                 for index, arg in enumerate(args[i:], start=i): |  | ||||||
|                     if arg.startswith("-"): |  | ||||||
|                         next_flagged_index = index |  | ||||||
|                         break |  | ||||||
|                 if next_flagged_index == -1: |  | ||||||
|                     next_flagged_index = len(args) |  | ||||||
|  |  | ||||||
|                 args_consumed = self._consume_all_positional_args( |  | ||||||
|                     args[i:next_flagged_index], |  | ||||||
|                     result, |  | ||||||
|                     positional_args, |  | ||||||
|                     consumed_positional_indices, |  | ||||||
|                 ) |  | ||||||
|                 i += args_consumed |  | ||||||
|  |  | ||||||
|         # Required validation |  | ||||||
|         for spec in self._arguments: |  | ||||||
|             if spec.dest == "help": |  | ||||||
|                 continue |  | ||||||
|             if spec.required and not result.get(spec.dest): |  | ||||||
|                 raise CommandArgumentError(f"Missing required argument: {spec.dest}") |  | ||||||
|  |  | ||||||
|             if spec.choices and result.get(spec.dest) not in spec.choices: |  | ||||||
|                 raise CommandArgumentError( |  | ||||||
|                     f"Invalid value for {spec.dest}: must be one of {spec.choices}" |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|             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" |  | ||||||
|                     ) |  | ||||||
|                 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}" |  | ||||||
|                         ) |  | ||||||
|                 elif len(result[spec.dest]) != spec.nargs: |  | ||||||
|                     raise CommandArgumentError( |  | ||||||
|                         f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}" |  | ||||||
|                     ) |  | ||||||
|  |  | ||||||
|         result.pop("help", None) |  | ||||||
|         return result |  | ||||||
|  |  | ||||||
|     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, from_validate) |  | ||||||
|         args_list = [] |  | ||||||
|         kwargs_dict = {} |  | ||||||
|         for arg in self._arguments: |  | ||||||
|             if arg.dest == "help": |  | ||||||
|                 continue |  | ||||||
|             if arg.positional: |  | ||||||
|                 args_list.append(parsed[arg.dest]) |  | ||||||
|             else: |  | ||||||
|                 kwargs_dict[arg.dest] = parsed[arg.dest] |  | ||||||
|         return tuple(args_list), kwargs_dict |  | ||||||
|  |  | ||||||
|     def render_help(self) -> None: |  | ||||||
|         # Options |  | ||||||
|         # Add all keyword arguments to the options list |  | ||||||
|         options_list = [] |  | ||||||
|         for arg in self._keyword: |  | ||||||
|             choice_text = arg.get_choice_text() |  | ||||||
|             if choice_text: |  | ||||||
|                 options_list.extend([f"[{arg.flags[0]} {choice_text}]"]) |  | ||||||
|             else: |  | ||||||
|                 options_list.extend([f"[{arg.flags[0]}]"]) |  | ||||||
|  |  | ||||||
|         # Add positional arguments to the options list |  | ||||||
|         for arg in self._positional: |  | ||||||
|             choice_text = arg.get_choice_text() |  | ||||||
|             if isinstance(arg.nargs, int): |  | ||||||
|                 choice_text = " ".join([choice_text] * arg.nargs) |  | ||||||
|             options_list.append(escape(choice_text)) |  | ||||||
|  |  | ||||||
|         options_text = " ".join(options_list) |  | ||||||
|         command_keys = " | ".join( |  | ||||||
|             [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"] |  | ||||||
|             + [ |  | ||||||
|                 f"[{self.command_style}]{alias}[/{self.command_style}]" |  | ||||||
|                 for alias in self.aliases |  | ||||||
|             ] |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         usage = f"usage: {command_keys} {options_text}" |  | ||||||
|         self.console.print(f"[bold]{usage}[/bold]\n") |  | ||||||
|  |  | ||||||
|         # Description |  | ||||||
|         if self.help_text: |  | ||||||
|             self.console.print(self.help_text + "\n") |  | ||||||
|  |  | ||||||
|         # Arguments |  | ||||||
|         if self._arguments: |  | ||||||
|             if self._positional: |  | ||||||
|                 self.console.print("[bold]positional:[/bold]") |  | ||||||
|                 for arg in self._positional: |  | ||||||
|                     flags = arg.get_positional_text() |  | ||||||
|                     arg_line = Text(f"  {flags:<30} ") |  | ||||||
|                     help_text = arg.help or "" |  | ||||||
|                     arg_line.append(help_text) |  | ||||||
|                     self.console.print(arg_line) |  | ||||||
|             self.console.print("[bold]options:[/bold]") |  | ||||||
|             for arg in self._keyword: |  | ||||||
|                 flags = ", ".join(arg.flags) |  | ||||||
|                 flags_choice = f"{flags} {arg.get_choice_text()}" |  | ||||||
|                 arg_line = Text(f"  {flags_choice:<30} ") |  | ||||||
|                 help_text = arg.help or "" |  | ||||||
|                 arg_line.append(help_text) |  | ||||||
|                 self.console.print(arg_line) |  | ||||||
|  |  | ||||||
|         # Epilogue |  | ||||||
|         if self.help_epilogue: |  | ||||||
|             self.console.print("\n" + self.help_epilogue, style="dim") |  | ||||||
|  |  | ||||||
|     def __eq__(self, other: object) -> bool: |  | ||||||
|         if not isinstance(other, CommandArgumentParser): |  | ||||||
|             return False |  | ||||||
|  |  | ||||||
|         def sorted_args(parser): |  | ||||||
|             return sorted(parser._arguments, key=lambda a: a.dest) |  | ||||||
|  |  | ||||||
|         return sorted_args(self) == sorted_args(other) |  | ||||||
|  |  | ||||||
|     def __hash__(self) -> int: |  | ||||||
|         return hash(tuple(sorted(self._arguments, key=lambda a: a.dest))) |  | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |  | ||||||
|         positional = sum(arg.positional for arg in self._arguments) |  | ||||||
|         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})" |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def __repr__(self) -> str: |  | ||||||
|         return str(self) |  | ||||||
| @@ -1,179 +0,0 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed |  | ||||||
| """parsers.py |  | ||||||
| This module contains the argument parsers used for the Falyx CLI. |  | ||||||
| """ |  | ||||||
| from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction |  | ||||||
| from dataclasses import asdict, dataclass |  | ||||||
| from typing import Any, Sequence |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass |  | ||||||
| class FalyxParsers: |  | ||||||
|     """Defines the argument parsers for the Falyx CLI.""" |  | ||||||
|  |  | ||||||
|     root: ArgumentParser |  | ||||||
|     subparsers: _SubParsersAction |  | ||||||
|     run: ArgumentParser |  | ||||||
|     run_all: ArgumentParser |  | ||||||
|     preview: ArgumentParser |  | ||||||
|     list: ArgumentParser |  | ||||||
|     version: ArgumentParser |  | ||||||
|  |  | ||||||
|     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: |  | ||||||
|         """Parse the command line arguments.""" |  | ||||||
|         return self.root.parse_args(args) |  | ||||||
|  |  | ||||||
|     def as_dict(self) -> dict[str, ArgumentParser]: |  | ||||||
|         """Convert the FalyxParsers instance to a dictionary.""" |  | ||||||
|         return asdict(self) |  | ||||||
|  |  | ||||||
|     def get_parser(self, name: str) -> ArgumentParser | None: |  | ||||||
|         """Get the parser by name.""" |  | ||||||
|         return self.as_dict().get(name) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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, |  | ||||||
| ) -> FalyxParsers: |  | ||||||
|     """Returns the argument parser for the CLI.""" |  | ||||||
|     parser = ArgumentParser( |  | ||||||
|         prog=prog, |  | ||||||
|         usage=usage, |  | ||||||
|         description=description, |  | ||||||
|         epilog=epilog, |  | ||||||
|         parents=parents if parents else [], |  | ||||||
|         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, |  | ||||||
|     ) |  | ||||||
|     parser.add_argument( |  | ||||||
|         "--never-prompt", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Run in non-interactive mode with all prompts bypassed.", |  | ||||||
|     ) |  | ||||||
|     parser.add_argument( |  | ||||||
|         "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." |  | ||||||
|     ) |  | ||||||
|     parser.add_argument( |  | ||||||
|         "--debug-hooks", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Enable default lifecycle debug logging", |  | ||||||
|     ) |  | ||||||
|     parser.add_argument("--version", action="store_true", help="Show Falyx version") |  | ||||||
|     subparsers = parser.add_subparsers(dest="command") |  | ||||||
|  |  | ||||||
|     run_parser = subparsers.add_parser("run", help="Run a specific command") |  | ||||||
|     run_parser.add_argument("name", help="Key, alias, or description of the command") |  | ||||||
|     run_parser.add_argument( |  | ||||||
|         "--summary", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Print an execution summary after command completes", |  | ||||||
|     ) |  | ||||||
|     run_parser.add_argument( |  | ||||||
|         "--retries", type=int, help="Number of retries on failure", default=0 |  | ||||||
|     ) |  | ||||||
|     run_parser.add_argument( |  | ||||||
|         "--retry-delay", |  | ||||||
|         type=float, |  | ||||||
|         help="Initial delay between retries in (seconds)", |  | ||||||
|         default=0, |  | ||||||
|     ) |  | ||||||
|     run_parser.add_argument( |  | ||||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 |  | ||||||
|     ) |  | ||||||
|     run_group = run_parser.add_mutually_exclusive_group(required=False) |  | ||||||
|     run_group.add_argument( |  | ||||||
|         "-c", |  | ||||||
|         "--confirm", |  | ||||||
|         dest="force_confirm", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Force confirmation prompts", |  | ||||||
|     ) |  | ||||||
|     run_group.add_argument( |  | ||||||
|         "-s", |  | ||||||
|         "--skip-confirm", |  | ||||||
|         dest="skip_confirm", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Skip confirmation prompts", |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     run_group.add_argument( |  | ||||||
|         "command_args", |  | ||||||
|         nargs=REMAINDER, |  | ||||||
|         help="Arguments to pass to the command (if applicable)", |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     run_all_parser = subparsers.add_parser( |  | ||||||
|         "run-all", help="Run all commands with a given tag" |  | ||||||
|     ) |  | ||||||
|     run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") |  | ||||||
|     run_all_parser.add_argument( |  | ||||||
|         "--summary", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Print a summary after all tagged commands run", |  | ||||||
|     ) |  | ||||||
|     run_all_parser.add_argument( |  | ||||||
|         "--retries", type=int, help="Number of retries on failure", default=0 |  | ||||||
|     ) |  | ||||||
|     run_all_parser.add_argument( |  | ||||||
|         "--retry-delay", |  | ||||||
|         type=float, |  | ||||||
|         help="Initial delay between retries in (seconds)", |  | ||||||
|         default=0, |  | ||||||
|     ) |  | ||||||
|     run_all_parser.add_argument( |  | ||||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 |  | ||||||
|     ) |  | ||||||
|     run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) |  | ||||||
|     run_all_group.add_argument( |  | ||||||
|         "-c", |  | ||||||
|         "--confirm", |  | ||||||
|         dest="force_confirm", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Force confirmation prompts", |  | ||||||
|     ) |  | ||||||
|     run_all_group.add_argument( |  | ||||||
|         "-s", |  | ||||||
|         "--skip-confirm", |  | ||||||
|         dest="skip_confirm", |  | ||||||
|         action="store_true", |  | ||||||
|         help="Skip confirmation prompts", |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     preview_parser = subparsers.add_parser( |  | ||||||
|         "preview", help="Preview a command without running it" |  | ||||||
|     ) |  | ||||||
|     preview_parser.add_argument("name", help="Key, alias, or description of the command") |  | ||||||
|  |  | ||||||
|     list_parser = subparsers.add_parser( |  | ||||||
|         "list", help="List all available commands with tags" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     version_parser = subparsers.add_parser("version", help="Show the Falyx version") |  | ||||||
|  |  | ||||||
|     return FalyxParsers( |  | ||||||
|         root=parser, |  | ||||||
|         subparsers=subparsers, |  | ||||||
|         run=run_parser, |  | ||||||
|         run_all=run_all_parser, |  | ||||||
|         preview=preview_parser, |  | ||||||
|         list=list_parser, |  | ||||||
|         version=version_parser, |  | ||||||
|     ) |  | ||||||
| @@ -1,33 +0,0 @@ | |||||||
| from typing import Any |  | ||||||
|  |  | ||||||
| from falyx import logger |  | ||||||
| from falyx.action.action import Action, ChainedAction, ProcessAction |  | ||||||
| from falyx.parsers.signature import infer_args_from_func |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def same_argument_definitions( |  | ||||||
|     actions: list[Any], |  | ||||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, |  | ||||||
| ) -> list[dict[str, Any]] | None: |  | ||||||
|     arg_sets = [] |  | ||||||
|     for action in actions: |  | ||||||
|         if isinstance(action, (Action, ProcessAction)): |  | ||||||
|             arg_defs = infer_args_from_func(action.action, arg_metadata) |  | ||||||
|         elif isinstance(action, ChainedAction): |  | ||||||
|             if action.actions: |  | ||||||
|                 action = action.actions[0] |  | ||||||
|                 if isinstance(action, Action): |  | ||||||
|                     arg_defs = infer_args_from_func(action.action, arg_metadata) |  | ||||||
|                 elif callable(action): |  | ||||||
|                     arg_defs = infer_args_from_func(action, arg_metadata) |  | ||||||
|         elif callable(action): |  | ||||||
|             arg_defs = infer_args_from_func(action, arg_metadata) |  | ||||||
|         else: |  | ||||||
|             logger.debug("Auto args unsupported for action: %s", action) |  | ||||||
|             return None |  | ||||||
|         arg_sets.append(arg_defs) |  | ||||||
|  |  | ||||||
|     first = arg_sets[0] |  | ||||||
|     if all(arg_set == first for arg_set in arg_sets[1:]): |  | ||||||
|         return first |  | ||||||
|     return None |  | ||||||
| @@ -2,14 +2,16 @@ | |||||||
| """protocols.py""" | """protocols.py""" | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| from typing import Any, Awaitable, Protocol, runtime_checkable | from typing import Any, Awaitable, Callable, Protocol, runtime_checkable | ||||||
|  |  | ||||||
| from falyx.action.action import BaseAction | from falyx.action.base_action import BaseAction | ||||||
|  |  | ||||||
|  |  | ||||||
| @runtime_checkable | @runtime_checkable | ||||||
| class ActionFactoryProtocol(Protocol): | class ActionFactoryProtocol(Protocol): | ||||||
|     async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ... |     async def __call__( | ||||||
|  |         self, *args: Any, **kwargs: Any | ||||||
|  |     ) -> Callable[..., Awaitable[BaseAction]]: ... | ||||||
|  |  | ||||||
|  |  | ||||||
| @runtime_checkable | @runtime_checkable | ||||||
|   | |||||||
| @@ -53,7 +53,7 @@ class RetryHandler: | |||||||
|         self.policy.delay = delay |         self.policy.delay = delay | ||||||
|         self.policy.backoff = backoff |         self.policy.backoff = backoff | ||||||
|         self.policy.jitter = jitter |         self.policy.jitter = jitter | ||||||
|         logger.info("🔄 Retry policy enabled: %s", self.policy) |         logger.info("Retry policy enabled: %s", self.policy) | ||||||
|  |  | ||||||
|     async def retry_on_error(self, context: ExecutionContext) -> None: |     async def retry_on_error(self, context: ExecutionContext) -> None: | ||||||
|         from falyx.action import Action |         from falyx.action import Action | ||||||
| @@ -67,21 +67,21 @@ class RetryHandler: | |||||||
|         last_error = error |         last_error = error | ||||||
|  |  | ||||||
|         if not target: |         if not target: | ||||||
|             logger.warning("[%s] ⚠️ No action target. Cannot retry.", name) |             logger.warning("[%s] No action target. Cannot retry.", name) | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         if not isinstance(target, Action): |         if not isinstance(target, Action): | ||||||
|             logger.warning( |             logger.warning( | ||||||
|                 "[%s] ❌ RetryHandler only supports only supports Action objects.", name |                 "[%s] RetryHandler only supports only supports Action objects.", name | ||||||
|             ) |             ) | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         if not getattr(target, "is_retryable", False): |         if not getattr(target, "is_retryable", False): | ||||||
|             logger.warning("[%s] ❌ Not retryable.", name) |             logger.warning("[%s] Not retryable.", name) | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         if not self.policy.enabled: |         if not self.policy.enabled: | ||||||
|             logger.warning("[%s] ❌ Retry policy is disabled.", name) |             logger.warning("[%s] Retry policy is disabled.", name) | ||||||
|             return None |             return None | ||||||
|  |  | ||||||
|         while retries_done < self.policy.max_retries: |         while retries_done < self.policy.max_retries: | ||||||
| @@ -92,7 +92,7 @@ class RetryHandler: | |||||||
|                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) |                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||||
|  |  | ||||||
|             logger.info( |             logger.info( | ||||||
|                 "[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...", |                 "[%s] Retrying (%s/%s) in %ss due to '%s'...", | ||||||
|                 name, |                 name, | ||||||
|                 retries_done, |                 retries_done, | ||||||
|                 self.policy.max_retries, |                 self.policy.max_retries, | ||||||
| @@ -104,13 +104,13 @@ class RetryHandler: | |||||||
|                 result = await target.action(*context.args, **context.kwargs) |                 result = await target.action(*context.args, **context.kwargs) | ||||||
|                 context.result = result |                 context.result = result | ||||||
|                 context.exception = None |                 context.exception = None | ||||||
|                 logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done) |                 logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done) | ||||||
|                 return None |                 return None | ||||||
|             except Exception as retry_error: |             except Exception as retry_error: | ||||||
|                 last_error = retry_error |                 last_error = retry_error | ||||||
|                 current_delay *= self.policy.backoff |                 current_delay *= self.policy.backoff | ||||||
|                 logger.warning( |                 logger.warning( | ||||||
|                     "[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.", |                     "[%s] Retry attempt %s/%s failed due to '%s'.", | ||||||
|                     name, |                     name, | ||||||
|                     retries_done, |                     retries_done, | ||||||
|                     self.policy.max_retries, |                     self.policy.max_retries, | ||||||
| @@ -118,4 +118,4 @@ class RetryHandler: | |||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|         context.exception = last_error |         context.exception = last_error | ||||||
|         logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries) |         logger.error("[%s] All %s retries failed.", name, self.policy.max_retries) | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """retry_utils.py""" | """retry_utils.py""" | ||||||
| from falyx.action.action import Action, BaseAction | from falyx.action.action import Action | ||||||
|  | from falyx.action.base_action import BaseAction | ||||||
| from falyx.hook_manager import HookType | from falyx.hook_manager import HookType | ||||||
| from falyx.retry import RetryHandler, RetryPolicy | from falyx.retry import RetryHandler, RetryPolicy | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,13 +5,13 @@ from typing import Any, Callable, KeysView, Sequence | |||||||
|  |  | ||||||
| from prompt_toolkit import PromptSession | from prompt_toolkit import PromptSession | ||||||
| from rich import box | from rich import box | ||||||
| from rich.console import Console |  | ||||||
| from rich.markup import escape | from rich.markup import escape | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
|  |  | ||||||
|  | from falyx.console import console | ||||||
| from falyx.themes import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import chunks | from falyx.utils import CaseInsensitiveDict, chunks | ||||||
| from falyx.validators import int_range_validator, key_validator | from falyx.validators import MultiIndexValidator, MultiKeyValidator | ||||||
|  |  | ||||||
|  |  | ||||||
| @dataclass | @dataclass | ||||||
| @@ -32,6 +32,62 @@ class SelectionOption: | |||||||
|         return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" |         return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SelectionOptionMap(CaseInsensitiveDict): | ||||||
|  |     """ | ||||||
|  |     Manages selection options including validation and reserved key protection. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     RESERVED_KEYS: set[str] = set() | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         options: dict[str, SelectionOption] | None = None, | ||||||
|  |         allow_reserved: bool = False, | ||||||
|  |     ): | ||||||
|  |         super().__init__() | ||||||
|  |         self.allow_reserved = allow_reserved | ||||||
|  |         if options: | ||||||
|  |             self.update(options) | ||||||
|  |  | ||||||
|  |     def _add_reserved(self, key: str, option: SelectionOption) -> None: | ||||||
|  |         """Add a reserved key, bypassing validation.""" | ||||||
|  |         norm_key = key.upper() | ||||||
|  |         super().__setitem__(norm_key, option) | ||||||
|  |  | ||||||
|  |     def __setitem__(self, key: str, option: SelectionOption) -> None: | ||||||
|  |         if not isinstance(option, SelectionOption): | ||||||
|  |             raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||||
|  |         norm_key = key.upper() | ||||||
|  |         if norm_key in self.RESERVED_KEYS and not self.allow_reserved: | ||||||
|  |             raise ValueError( | ||||||
|  |                 f"Key '{key}' is reserved and cannot be used in SelectionOptionMap." | ||||||
|  |             ) | ||||||
|  |         super().__setitem__(norm_key, option) | ||||||
|  |  | ||||||
|  |     def __delitem__(self, key: str) -> None: | ||||||
|  |         if key.upper() in self.RESERVED_KEYS and not self.allow_reserved: | ||||||
|  |             raise ValueError(f"Cannot delete reserved option '{key}'.") | ||||||
|  |         super().__delitem__(key) | ||||||
|  |  | ||||||
|  |     def update(self, other=None, **kwargs): | ||||||
|  |         """Update the selection options with another dictionary.""" | ||||||
|  |         if other: | ||||||
|  |             for key, option in other.items(): | ||||||
|  |                 if not isinstance(option, SelectionOption): | ||||||
|  |                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||||
|  |                 self[key] = option | ||||||
|  |         for key, option in kwargs.items(): | ||||||
|  |             if not isinstance(option, SelectionOption): | ||||||
|  |                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||||
|  |             self[key] = option | ||||||
|  |  | ||||||
|  |     def items(self, include_reserved: bool = True): | ||||||
|  |         for k, v in super().items(): | ||||||
|  |             if not include_reserved and k in self.RESERVED_KEYS: | ||||||
|  |                 continue | ||||||
|  |             yield k, v | ||||||
|  |  | ||||||
|  |  | ||||||
| def render_table_base( | def render_table_base( | ||||||
|     title: str, |     title: str, | ||||||
|     *, |     *, | ||||||
| @@ -211,23 +267,37 @@ async def prompt_for_index( | |||||||
|     *, |     *, | ||||||
|     min_index: int = 0, |     min_index: int = 0, | ||||||
|     default_selection: str = "", |     default_selection: str = "", | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     show_table: bool = True, |     show_table: bool = True, | ||||||
| ): |     number_selections: int | str = 1, | ||||||
|  |     separator: str = ",", | ||||||
|  |     allow_duplicates: bool = False, | ||||||
|  |     cancel_key: str = "", | ||||||
|  | ) -> int | list[int]: | ||||||
|     prompt_session = prompt_session or PromptSession() |     prompt_session = prompt_session or PromptSession() | ||||||
|     console = console or Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     if show_table: |     if show_table: | ||||||
|         console.print(table, justify="center") |         console.print(table, justify="center") | ||||||
|  |  | ||||||
|     selection = await prompt_session.prompt_async( |     selection = await prompt_session.prompt_async( | ||||||
|         message=prompt_message, |         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, |         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( | async def prompt_for_selection( | ||||||
| @@ -235,35 +305,46 @@ async def prompt_for_selection( | |||||||
|     table: Table, |     table: Table, | ||||||
|     *, |     *, | ||||||
|     default_selection: str = "", |     default_selection: str = "", | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     show_table: bool = True, |     show_table: bool = True, | ||||||
| ) -> 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 the user to select a key from a set of options. Return the selected key.""" | ||||||
|     prompt_session = prompt_session or PromptSession() |     prompt_session = prompt_session or PromptSession() | ||||||
|     console = console or Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     if show_table: |     if show_table: | ||||||
|         console.print(table, justify="center") |         console.print(table, justify="center") | ||||||
|  |  | ||||||
|     selected = await prompt_session.prompt_async( |     selected = await prompt_session.prompt_async( | ||||||
|         message=prompt_message, |         message=prompt_message, | ||||||
|         validator=key_validator(keys), |         validator=MultiKeyValidator( | ||||||
|  |             keys, number_selections, separator, allow_duplicates, cancel_key | ||||||
|  |         ), | ||||||
|         default=default_selection, |         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( | async def select_value_from_list( | ||||||
|     title: str, |     title: str, | ||||||
|     selections: Sequence[str], |     selections: Sequence[str], | ||||||
|     *, |     *, | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     default_selection: str = "", |     default_selection: str = "", | ||||||
|  |     number_selections: int | str = 1, | ||||||
|  |     separator: str = ",", | ||||||
|  |     allow_duplicates: bool = False, | ||||||
|  |     cancel_key: str = "", | ||||||
|     columns: int = 4, |     columns: int = 4, | ||||||
|     caption: str = "", |     caption: str = "", | ||||||
|     box_style: box.Box = box.SIMPLE, |     box_style: box.Box = box.SIMPLE, | ||||||
| @@ -276,7 +357,7 @@ async def select_value_from_list( | |||||||
|     title_style: str = "", |     title_style: str = "", | ||||||
|     caption_style: str = "", |     caption_style: str = "", | ||||||
|     highlight: bool = False, |     highlight: bool = False, | ||||||
| ): | ) -> str | list[str]: | ||||||
|     """Prompt for a selection. Return the selected item.""" |     """Prompt for a selection. Return the selected item.""" | ||||||
|     table = render_selection_indexed_table( |     table = render_selection_indexed_table( | ||||||
|         title=title, |         title=title, | ||||||
| @@ -295,17 +376,21 @@ async def select_value_from_list( | |||||||
|         highlight=highlight, |         highlight=highlight, | ||||||
|     ) |     ) | ||||||
|     prompt_session = prompt_session or PromptSession() |     prompt_session = prompt_session or PromptSession() | ||||||
|     console = console or Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     selection_index = await prompt_for_index( |     selection_index = await prompt_for_index( | ||||||
|         len(selections) - 1, |         len(selections) - 1, | ||||||
|         table, |         table, | ||||||
|         default_selection=default_selection, |         default_selection=default_selection, | ||||||
|         console=console, |  | ||||||
|         prompt_session=prompt_session, |         prompt_session=prompt_session, | ||||||
|         prompt_message=prompt_message, |         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] |     return selections[selection_index] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -313,14 +398,16 @@ async def select_key_from_dict( | |||||||
|     selections: dict[str, SelectionOption], |     selections: dict[str, SelectionOption], | ||||||
|     table: Table, |     table: Table, | ||||||
|     *, |     *, | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     default_selection: str = "", |     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 for a key from a dict, returns the key.""" | ||||||
|     prompt_session = prompt_session or PromptSession() |     prompt_session = prompt_session or PromptSession() | ||||||
|     console = console or Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     console.print(table, justify="center") |     console.print(table, justify="center") | ||||||
|  |  | ||||||
| @@ -328,9 +415,12 @@ async def select_key_from_dict( | |||||||
|         selections.keys(), |         selections.keys(), | ||||||
|         table, |         table, | ||||||
|         default_selection=default_selection, |         default_selection=default_selection, | ||||||
|         console=console, |  | ||||||
|         prompt_session=prompt_session, |         prompt_session=prompt_session, | ||||||
|         prompt_message=prompt_message, |         prompt_message=prompt_message, | ||||||
|  |         number_selections=number_selections, | ||||||
|  |         separator=separator, | ||||||
|  |         allow_duplicates=allow_duplicates, | ||||||
|  |         cancel_key=cancel_key, | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -338,14 +428,16 @@ async def select_value_from_dict( | |||||||
|     selections: dict[str, SelectionOption], |     selections: dict[str, SelectionOption], | ||||||
|     table: Table, |     table: Table, | ||||||
|     *, |     *, | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     default_selection: str = "", |     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 for a key from a dict, but return the value.""" | ||||||
|     prompt_session = prompt_session or PromptSession() |     prompt_session = prompt_session or PromptSession() | ||||||
|     console = console or Console(color_system="auto") |  | ||||||
|  |  | ||||||
|     console.print(table, justify="center") |     console.print(table, justify="center") | ||||||
|  |  | ||||||
| @@ -353,11 +445,16 @@ async def select_value_from_dict( | |||||||
|         selections.keys(), |         selections.keys(), | ||||||
|         table, |         table, | ||||||
|         default_selection=default_selection, |         default_selection=default_selection, | ||||||
|         console=console, |  | ||||||
|         prompt_session=prompt_session, |         prompt_session=prompt_session, | ||||||
|         prompt_message=prompt_message, |         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 |     return selections[selection_key].value | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -365,11 +462,14 @@ async def get_selection_from_dict_menu( | |||||||
|     title: str, |     title: str, | ||||||
|     selections: dict[str, SelectionOption], |     selections: dict[str, SelectionOption], | ||||||
|     *, |     *, | ||||||
|     console: Console | None = None, |  | ||||||
|     prompt_session: PromptSession | None = None, |     prompt_session: PromptSession | None = None, | ||||||
|     prompt_message: str = "Select an option > ", |     prompt_message: str = "Select an option > ", | ||||||
|     default_selection: str = "", |     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.""" |     """Prompt for a key from a dict, but return the value.""" | ||||||
|     table = render_selection_dict_table( |     table = render_selection_dict_table( | ||||||
|         title, |         title, | ||||||
| @@ -379,8 +479,11 @@ async def get_selection_from_dict_menu( | |||||||
|     return await select_value_from_dict( |     return await select_value_from_dict( | ||||||
|         selections=selections, |         selections=selections, | ||||||
|         table=table, |         table=table, | ||||||
|         console=console, |  | ||||||
|         prompt_session=prompt_session, |         prompt_session=prompt_session, | ||||||
|         prompt_message=prompt_message, |         prompt_message=prompt_message, | ||||||
|         default_selection=default_selection, |         default_selection=default_selection, | ||||||
|  |         number_selections=number_selections, | ||||||
|  |         separator=separator, | ||||||
|  |         allow_duplicates=allow_duplicates, | ||||||
|  |         cancel_key=cancel_key, | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -10,6 +10,13 @@ class FlowSignal(BaseException): | |||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BreakChainSignal(FlowSignal): | ||||||
|  |     """Raised to break the current action chain and return to the previous context.""" | ||||||
|  |  | ||||||
|  |     def __init__(self, message: str = "Break chain signal received."): | ||||||
|  |         super().__init__(message) | ||||||
|  |  | ||||||
|  |  | ||||||
| class QuitSignal(FlowSignal): | class QuitSignal(FlowSignal): | ||||||
|     """Raised to signal an immediate exit from the CLI framework.""" |     """Raised to signal an immediate exit from the CLI framework.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -184,7 +184,7 @@ def setup_logging( | |||||||
|     console_handler.setLevel(console_log_level) |     console_handler.setLevel(console_log_level) | ||||||
|     root.addHandler(console_handler) |     root.addHandler(console_handler) | ||||||
|  |  | ||||||
|     file_handler = logging.FileHandler(log_filename) |     file_handler = logging.FileHandler(log_filename, "a", "UTF-8") | ||||||
|     file_handler.setLevel(file_log_level) |     file_handler.setLevel(file_log_level) | ||||||
|     if json_log_to_file: |     if json_log_to_file: | ||||||
|         file_handler.setFormatter( |         file_handler.setFormatter( | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ | |||||||
| """validators.py""" | """validators.py""" | ||||||
| from typing import KeysView, Sequence | 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: | def int_range_validator(minimum: int, maximum: int) -> Validator: | ||||||
| @@ -44,4 +44,119 @@ def yes_no_validator() -> Validator: | |||||||
|             return False |             return False | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
|     return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.") |     return Validator.from_callable(validate, error_message="Enter 'Y', 'y' or 'N', 'n'.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def words_validator( | ||||||
|  |     keys: Sequence[str] | KeysView[str], error_message: str | None = None | ||||||
|  | ) -> Validator: | ||||||
|  |     """Validator for specific word inputs.""" | ||||||
|  |  | ||||||
|  |     def validate(text: str) -> bool: | ||||||
|  |         if text.upper() not in [key.upper() for key in keys]: | ||||||
|  |             return False | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     if error_message is None: | ||||||
|  |         error_message = f"Invalid input. Choices: {{{', '.join(keys)}}}." | ||||||
|  |  | ||||||
|  |     return Validator.from_callable(validate, error_message=error_message) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def word_validator(word: str) -> Validator: | ||||||
|  |     """Validator for specific word inputs.""" | ||||||
|  |  | ||||||
|  |     def validate(text: str) -> bool: | ||||||
|  |         if text.upper().strip() == "N": | ||||||
|  |             return True | ||||||
|  |         return text.upper().strip() == word.upper() | ||||||
|  |  | ||||||
|  |     return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N', '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.29" | __version__ = "0.1.63" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.29" | version = "0.1.63" | ||||||
| description = "Reliable and introspectable async CLI action framework." | description = "Reliable and introspectable async CLI action framework." | ||||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||||
| license = "MIT" | license = "MIT" | ||||||
| @@ -16,6 +16,7 @@ python-json-logger = "^3.3.0" | |||||||
| toml = "^0.10" | toml = "^0.10" | ||||||
| pyyaml = "^6.0" | pyyaml = "^6.0" | ||||||
| aiohttp = "^3.11" | aiohttp = "^3.11" | ||||||
|  | python-dateutil = "^2.8" | ||||||
|  |  | ||||||
| [tool.poetry.group.dev.dependencies] | [tool.poetry.group.dev.dependencies] | ||||||
| pytest = "^8.3.5" | pytest = "^8.3.5" | ||||||
| @@ -26,6 +27,10 @@ black = { version = "^25.0", allow-prereleases = true } | |||||||
| mypy = { version = "^1.0", allow-prereleases = true } | mypy = { version = "^1.0", allow-prereleases = true } | ||||||
| isort = { version = "^5.0", allow-prereleases = true } | isort = { version = "^5.0", allow-prereleases = true } | ||||||
| pytest-cov = "^4.0" | pytest-cov = "^4.0" | ||||||
|  | mkdocs = "^1.6.1" | ||||||
|  | mkdocs-material = "^9.6.14" | ||||||
|  | mkdocstrings = {extras = ["python"], version = "^0.29.1"} | ||||||
|  | mike = "^2.1.3" | ||||||
|  |  | ||||||
| [tool.poetry.scripts] | [tool.poetry.scripts] | ||||||
| falyx = "falyx.__main__:main" | falyx = "falyx.__main__:main" | ||||||
|   | |||||||
| @@ -38,13 +38,14 @@ async def test_action_async_callable(): | |||||||
|     action = Action("test_action", async_callable) |     action = Action("test_action", async_callable) | ||||||
|     result = await action() |     result = await action() | ||||||
|     assert result == "Hello, World!" |     assert result == "Hello, World!" | ||||||
|  |     print(action) | ||||||
|     assert ( |     assert ( | ||||||
|         str(action) |         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 ( |     assert ( | ||||||
|         repr(action) |         repr(action) | ||||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" |         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ActionFactory, 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 = ActionFactory(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 | # test_command.py | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction | from falyx.action import Action, BaseIOAction, ChainedAction | ||||||
| from falyx.command import Command | from falyx.command import Command | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| @@ -50,108 +50,13 @@ def test_command_str(): | |||||||
|     """Test if Command string representation is correct.""" |     """Test if Command string representation is correct.""" | ||||||
|     action = Action("test_action", dummy_action) |     action = Action("test_action", dummy_action) | ||||||
|     cmd = Command(key="TEST", description="Test Command", action=action) |     cmd = Command(key="TEST", description="Test Command", action=action) | ||||||
|  |     print(cmd) | ||||||
|     assert ( |     assert ( | ||||||
|         str(cmd) |         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(): | def test_enable_retry(): | ||||||
|     """Command should enable retry if action is an Action and  retry is set to True.""" |     """Command should enable retry if action is an Action and  retry is set to True.""" | ||||||
|     cmd = Command( |     cmd = Command( | ||||||
| @@ -193,13 +98,17 @@ def test_enable_retry_not_action(): | |||||||
|     cmd = Command( |     cmd = Command( | ||||||
|         key="C", |         key="C", | ||||||
|         description="Retry action", |         description="Retry action", | ||||||
|         action=DummyInputAction, |         action=DummyInputAction( | ||||||
|  |             name="dummy_input_action", | ||||||
|  |         ), | ||||||
|         retry=True, |         retry=True, | ||||||
|     ) |     ) | ||||||
|     assert cmd.retry is True |     assert cmd.retry is True | ||||||
|     with pytest.raises(Exception) as exc_info: |     with pytest.raises(Exception) as exc_info: | ||||||
|         assert cmd.action.retry_policy.enabled is False |         assert cmd.action.retry_policy.enabled is False | ||||||
|     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) |     assert "'DummyInputAction' object has no attribute 'retry_policy'" in str( | ||||||
|  |         exc_info.value | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_chain_retry_all(): | def test_chain_retry_all(): | ||||||
| @@ -229,13 +138,17 @@ def test_chain_retry_all_not_base_action(): | |||||||
|     cmd = Command( |     cmd = Command( | ||||||
|         key="E", |         key="E", | ||||||
|         description="Chain with retry", |         description="Chain with retry", | ||||||
|         action=DummyInputAction, |         action=DummyInputAction( | ||||||
|  |             name="dummy_input_action", | ||||||
|  |         ), | ||||||
|         retry_all=True, |         retry_all=True, | ||||||
|     ) |     ) | ||||||
|     assert cmd.retry_all is True |     assert cmd.retry_all is True | ||||||
|     with pytest.raises(Exception) as exc_info: |     with pytest.raises(Exception) as exc_info: | ||||||
|         assert cmd.action.retry_policy.enabled is False |         assert cmd.action.retry_policy.enabled is False | ||||||
|     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) |     assert "'DummyInputAction' object has no attribute 'retry_policy'" in str( | ||||||
|  |         exc_info.value | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
|   | |||||||
| @@ -1,102 +1,113 @@ | |||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from falyx.exceptions import CommandArgumentError | from falyx.exceptions import CommandArgumentError | ||||||
| from falyx.parsers import ArgumentAction, CommandArgumentParser | from falyx.parser import ArgumentAction, CommandArgumentParser | ||||||
| from falyx.signals import HelpSignal | from falyx.signals import HelpSignal | ||||||
|  |  | ||||||
|  |  | ||||||
| def build_parser_and_parse(args, config): | async def build_parser_and_parse(args, config): | ||||||
|     cap = CommandArgumentParser() |     cap = CommandArgumentParser() | ||||||
|     config(cap) |     config(cap) | ||||||
|     return cap.parse_args(args) |     return await cap.parse_args(args) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_none(): | @pytest.mark.asyncio | ||||||
|  | async def test_none(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--foo", type=str) |         parser.add_argument("--foo", type=str) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(None, config) |     parsed = await build_parser_and_parse(None, config) | ||||||
|     assert parsed["foo"] is None |     assert parsed["foo"] is None | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_append_multiple_flags(): | @pytest.mark.asyncio | ||||||
|  | async def test_append_multiple_flags(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) |         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config) |     parsed = await build_parser_and_parse( | ||||||
|  |         ["--tag", "a", "--tag", "b", "--tag", "c"], config | ||||||
|  |     ) | ||||||
|     assert parsed["tag"] == ["a", "b", "c"] |     assert parsed["tag"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_positional_nargs_plus_and_single(): | @pytest.mark.asyncio | ||||||
|  | async def test_positional_nargs_plus_and_single(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("files", nargs="+", type=str) |         parser.add_argument("files", nargs="+", type=str) | ||||||
|         parser.add_argument("mode", nargs=1) |         parser.add_argument("mode", nargs=1) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["a", "b", "c", "prod"], config) |     parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||||
|     assert parsed["files"] == ["a", "b", "c"] |     assert parsed["files"] == ["a", "b", "c"] | ||||||
|     assert parsed["mode"] == "prod" |     assert parsed["mode"] == "prod" | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_type_validation_failure(): | @pytest.mark.asyncio | ||||||
|  | async def test_type_validation_failure(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--count", type=int) |         parser.add_argument("--count", type=int) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         build_parser_and_parse(["--count", "abc"], config) |         await build_parser_and_parse(["--count", "abc"], config) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_required_field_missing(): | @pytest.mark.asyncio | ||||||
|  | async def test_required_field_missing(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--env", type=str, required=True) |         parser.add_argument("--env", type=str, required=True) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         build_parser_and_parse([], config) |         await build_parser_and_parse([], config) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_choices_enforced(): | @pytest.mark.asyncio | ||||||
|  | async def test_choices_enforced(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--mode", choices=["dev", "prod"]) |         parser.add_argument("--mode", choices=["dev", "prod"]) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         build_parser_and_parse(["--mode", "staging"], config) |         await build_parser_and_parse(["--mode", "staging"], config) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_boolean_flags(): | @pytest.mark.asyncio | ||||||
|  | async def test_boolean_flags(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) |         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) | ||||||
|         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) |         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["--debug", "--no-debug"], config) |     parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) | ||||||
|     assert parsed["debug"] is True |     assert parsed["debug"] is True | ||||||
|     assert parsed["no_debug"] is False |     assert parsed["no_debug"] is False | ||||||
|     parsed = build_parser_and_parse([], config) |     parsed = await build_parser_and_parse([], config) | ||||||
|     print(parsed) |  | ||||||
|     assert parsed["debug"] is False |     assert parsed["debug"] is False | ||||||
|     assert parsed["no_debug"] is True |     assert parsed["no_debug"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_count_action(): | @pytest.mark.asyncio | ||||||
|  | async def test_count_action(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("-v", action=ArgumentAction.COUNT) |         parser.add_argument("-v", action=ArgumentAction.COUNT) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["-v", "-v", "-v"], config) |     parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) | ||||||
|     assert parsed["v"] == 3 |     assert parsed["v"] == 3 | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_nargs_star(): | @pytest.mark.asyncio | ||||||
|  | async def test_nargs_star(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("args", nargs="*", type=str) |         parser.add_argument("args", nargs="*", type=str) | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["one", "two", "three"], config) |     parsed = await build_parser_and_parse(["one", "two", "three"], config) | ||||||
|     assert parsed["args"] == ["one", "two", "three"] |     assert parsed["args"] == ["one", "two", "three"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_flag_and_positional_mix(): | @pytest.mark.asyncio | ||||||
|  | async def test_flag_and_positional_mix(): | ||||||
|     def config(parser): |     def config(parser): | ||||||
|         parser.add_argument("--env", type=str) |         parser.add_argument("--env", type=str) | ||||||
|         parser.add_argument("tasks", nargs="+") |         parser.add_argument("tasks", nargs="+") | ||||||
|  |  | ||||||
|     parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config) |     parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||||
|     assert parsed["env"] == "prod" |     assert parsed["env"] == "prod" | ||||||
|     assert parsed["tasks"] == ["build", "test"] |     assert parsed["tasks"] == ["build", "test"] | ||||||
|  |  | ||||||
| @@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest(): | |||||||
|     parser.add_argument("-f", "--falyx") |     parser.add_argument("-f", "--falyx") | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["-f", "--falyx"] |     assert arg.flags == ("-f", "--falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_flag_dest_conflict(): | def test_add_argument_flag_dest_conflict(): | ||||||
| @@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest(): | |||||||
|     parser.add_argument("-f", "--falyx", "--test", dest="falyx") |     parser.add_argument("-f", "--falyx", "--test", dest="falyx") | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["-f", "--falyx", "--test"] |     assert arg.flags == ("-f", "--falyx", "--test") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_multiple_flags_dest(): | def test_add_argument_multiple_flags_dest(): | ||||||
| @@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest(): | |||||||
|     parser.add_argument("-f", "--falyx", "--test") |     parser.add_argument("-f", "--falyx", "--test") | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["-f", "--falyx", "--test"] |     assert arg.flags == ("-f", "--falyx", "--test") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_single_flag_dest(): | def test_add_argument_single_flag_dest(): | ||||||
| @@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest(): | |||||||
|     parser.add_argument("-f") |     parser.add_argument("-f") | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "f" |     assert arg.dest == "f" | ||||||
|     assert arg.flags == ["-f"] |     assert arg.flags == ("-f",) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_bad_dest(): | def test_add_argument_bad_dest(): | ||||||
| @@ -257,7 +268,7 @@ def test_add_argument_default_value(): | |||||||
|     parser.add_argument("--falyx", default="default_value") |     parser.add_argument("--falyx", default="default_value") | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["--falyx"] |     assert arg.flags == ("--falyx",) | ||||||
|     assert arg.default == "default_value" |     assert arg.default == "default_value" | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices(): | |||||||
|         parser.add_argument("--falyx", choices=["a", "b"], default="c") |         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_choices(): | @pytest.mark.asyncio | ||||||
|  | async def test_add_argument_choices(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|     # ✅ Choices provided |     # ✅ Choices provided | ||||||
|     parser.add_argument("--falyx", choices=["a", "b", "c"]) |     parser.add_argument("--falyx", choices=["a", "b", "c"]) | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["--falyx"] |     assert arg.flags == ("--falyx",) | ||||||
|     assert arg.choices == ["a", "b", "c"] |     assert arg.choices == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--falyx", "a"]) |     args = await parser.parse_args(["--falyx", "a"]) | ||||||
|     assert args["falyx"] == "a" |     assert args["falyx"] == "a" | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--falyx", "d"]) |         await parser.parse_args(["--falyx", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_choices_invalid(): | def test_add_argument_choices_invalid(): | ||||||
| @@ -333,26 +345,28 @@ def test_add_argument_choices_invalid(): | |||||||
| def test_add_argument_bad_nargs(): | def test_add_argument_bad_nargs(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|     # ❌ Invalid nargs value |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.add_argument("--falyx", nargs="invalid") |         parser.add_argument("--falyx", nargs="invalid") | ||||||
|  |  | ||||||
|     # ❌ Invalid nargs type |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.add_argument("--falyx", nargs=123) |         parser.add_argument("--foo", nargs="123") | ||||||
|  |  | ||||||
|     # ❌ Invalid nargs type |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.add_argument("--falyx", nargs=None) |         parser.add_argument("--foo", nargs=[1, 2]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--too", action="count", nargs=5) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("falyx", action="store_true", nargs=5) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_add_argument_nargs(): | def test_add_argument_nargs(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     # ✅ Valid nargs value |  | ||||||
|     parser.add_argument("--falyx", nargs=2) |     parser.add_argument("--falyx", nargs=2) | ||||||
|     arg = parser._arguments[-1] |     arg = parser._arguments[-1] | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["--falyx"] |     assert arg.flags == ("--falyx",) | ||||||
|     assert arg.nargs == 2 |     assert arg.nargs == 2 | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -377,56 +391,63 @@ def test_get_argument(): | |||||||
|     parser.add_argument("--falyx", type=str, default="default_value") |     parser.add_argument("--falyx", type=str, default="default_value") | ||||||
|     arg = parser.get_argument("falyx") |     arg = parser.get_argument("falyx") | ||||||
|     assert arg.dest == "falyx" |     assert arg.dest == "falyx" | ||||||
|     assert arg.flags == ["--falyx"] |     assert arg.flags == ("--falyx",) | ||||||
|     assert arg.default == "default_value" |     assert arg.default == "default_value" | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="+", type=str) |     parser.add_argument("files", nargs="+", type=str) | ||||||
|     parser.add_argument("mode", nargs=1) |     parser.add_argument("mode", nargs=1) | ||||||
|  |     parser.add_argument("--action", action="store_true") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c", "--action"]) | ||||||
|  |     assert args["files"] == ["a", "b"] | ||||||
|  |     assert args["mode"] == "c" | ||||||
|  |     args = await parser.parse_args(["--action", "a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b"] |     assert args["files"] == ["a", "b"] | ||||||
|     assert args["mode"] == "c" |     assert args["mode"] == "c" | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_plus(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_plus(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="+", type=str) |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a"]) |     args = await parser.parse_args(["a"]) | ||||||
|     assert args["files"] == ["a"] |     assert args["files"] == ["a"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_flagged_nargs_plus(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_flagged_nargs_plus(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--files", nargs="+", type=str) |     parser.add_argument("--files", nargs="+", type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--files", "a", "b", "c"]) |     args = await parser.parse_args(["--files", "a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--files", "a"]) |     args = await parser.parse_args(["--files", "a"]) | ||||||
|     print(args) |     print(args) | ||||||
|     assert args["files"] == ["a"] |     assert args["files"] == ["a"] | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args([]) | ||||||
|     assert args["files"] == [] |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_numbered_nargs(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_numbered_nargs(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs=2, type=str) |     parser.add_argument("files", nargs=2, type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b"]) |     args = await parser.parse_args(["a", "b"]) | ||||||
|     assert args["files"] == ["a", "b"] |     assert args["files"] == ["a", "b"] | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         args = parser.parse_args(["a"]) |         args = await parser.parse_args(["a"]) | ||||||
|         print(args) |         print(args) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -436,48 +457,53 @@ def test_parse_args_nargs_zero(): | |||||||
|         parser.add_argument("files", nargs=0, type=str) |         parser.add_argument("files", nargs=0, type=str) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_more_than_expected(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_more_than_expected(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs=2, type=str) |     parser.add_argument("files", nargs=2, type=str) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["a", "b", "c", "d"]) |         await parser.parse_args(["a", "b", "c", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_one_or_none(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_one_or_none(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="?", type=str) |     parser.add_argument("files", nargs="?", type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a"]) |     args = await parser.parse_args(["a"]) | ||||||
|     assert args["files"] == "a" |     assert args["files"] == "a" | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args([]) | ||||||
|     assert args["files"] is None |     assert args["files"] is None | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_positional(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_positional(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="*", type=str) |     parser.add_argument("files", nargs="*", type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args([]) | ||||||
|     assert args["files"] == [] |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_positional_plus(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_positional_plus(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="+", type=str) |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         args = parser.parse_args([]) |         args = await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_multiple_positional(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_multiple_positional(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", nargs="+", type=str) |     parser.add_argument("files", nargs="+", type=str) | ||||||
|     parser.add_argument("mode", nargs=1) |     parser.add_argument("mode", nargs=1) | ||||||
| @@ -485,7 +511,7 @@ def test_parse_args_nargs_multiple_positional(): | |||||||
|     parser.add_argument("target", nargs="*") |     parser.add_argument("target", nargs="*") | ||||||
|     parser.add_argument("extra", nargs="+") |     parser.add_argument("extra", nargs="+") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c", "d", "e"]) |     args = await parser.parse_args(["a", "b", "c", "d", "e"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|     assert args["mode"] == "d" |     assert args["mode"] == "d" | ||||||
|     assert args["action"] == [] |     assert args["action"] == [] | ||||||
| @@ -493,186 +519,311 @@ def test_parse_args_nargs_multiple_positional(): | |||||||
|     assert args["extra"] == ["e"] |     assert args["extra"] == ["e"] | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args([]) |         await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_invalid_positional_arguments(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_none(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", type=int) | ||||||
|  |     parser.add_argument("mode") | ||||||
|  |  | ||||||
|  |     await parser.parse_args(["1", "2"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_invalid_positional_arguments(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("numbers", nargs="*", type=int) |     parser.add_argument("numbers", nargs="*", type=int) | ||||||
|     parser.add_argument("mode", nargs=1) |     parser.add_argument("mode", nargs=1) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["1", "2", "c", "d"]) |         await parser.parse_args(["1", "2", "c", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_append(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_append(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) |     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||||
|     assert args["numbers"] == [1, 2, 3] |     assert args["numbers"] == [1, 2, 3] | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--numbers", "1"]) |     args = await parser.parse_args(["--numbers", "1"]) | ||||||
|     assert args["numbers"] == [1] |     assert args["numbers"] == [1] | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args([]) | ||||||
|     assert args["numbers"] == [] |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_nargs_append(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_int_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||||
|  |     assert args["numbers"] == [[1], [2], [3]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1"]) | ||||||
|  |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_append(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") | ||||||
|     parser.add_argument("--mode") |     parser.add_argument("--mode") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) |     args = await parser.parse_args(["1"]) | ||||||
|     assert args["numbers"] == [[1, 2, 3], [4, 5]] |  | ||||||
|  |  | ||||||
|     args = parser.parse_args(["1"]) |  | ||||||
|     assert args["numbers"] == [[1]] |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||||
|  |     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||||
|  |     assert args["mode"] == "numbers" | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1", "2", "3"]) | ||||||
|  |     assert args["numbers"] == [[1, 2, 3]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|     assert args["numbers"] == [] |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_append_flagged_invalid_type(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_int_optional_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1"]) | ||||||
|  |     assert args["numbers"] == [1] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_int_optional_append_multiple_values(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["1", "2"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_int_positional_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1"]) | ||||||
|  |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["1", "2", "3"]) | ||||||
|  |  | ||||||
|  |     parser2 = CommandArgumentParser() | ||||||
|  |     parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2) | ||||||
|  |  | ||||||
|  |     args = await parser2.parse_args(["1", "2"]) | ||||||
|  |     assert args["numbers"] == [[1, 2]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser2.parse_args(["1", "2", "3"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_append_flagged_invalid_type(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--numbers", "a"]) |         await parser.parse_args(["--numbers", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_append_groups_nargs(): | @pytest.mark.asyncio | ||||||
|  | async def test_append_groups_nargs(): | ||||||
|     cap = CommandArgumentParser() |     cap = CommandArgumentParser() | ||||||
|     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) |     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) | ||||||
|  |  | ||||||
|     parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) |     parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||||
|     assert parsed["item"] == [["a", "b"], ["c", "d"]] |     assert parsed["item"] == [["a", "b"], ["c", "d"]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await cap.parse_args(["--item", "a", "b", "--item", "c"]) | ||||||
|  |  | ||||||
| def test_extend_flattened(): |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_flattened(): | ||||||
|     cap = CommandArgumentParser() |     cap = CommandArgumentParser() | ||||||
|     cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) |     cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) | ||||||
|  |  | ||||||
|     parsed = cap.parse_args(["--value", "x", "--value", "y"]) |     parsed = await cap.parse_args(["--value", "x", "--value", "y"]) | ||||||
|     assert parsed["value"] == ["x", "y"] |     assert parsed["value"] == ["x", "y"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args_split_order(): | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_split_order(): | ||||||
|     cap = CommandArgumentParser() |     cap = CommandArgumentParser() | ||||||
|     cap.add_argument("a") |     cap.add_argument("a") | ||||||
|     cap.add_argument("--x") |     cap.add_argument("--x") | ||||||
|     cap.add_argument("b", nargs="*") |     cap.add_argument("b", nargs="*") | ||||||
|     args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"]) |     args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) | ||||||
|     assert args == ("1", ["2"]) |     assert args == ("1", ["2"]) | ||||||
|     assert kwargs == {"x": "100"} |     assert kwargs == {"x": "100"} | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_help_signal_triggers(): | @pytest.mark.asyncio | ||||||
|  | async def test_help_signal_triggers(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--foo") |     parser.add_argument("--foo") | ||||||
|     with pytest.raises(HelpSignal): |     with pytest.raises(HelpSignal): | ||||||
|         parser.parse_args(["--help"]) |         await parser.parse_args(["--help"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_empty_parser_defaults(): | @pytest.mark.asyncio | ||||||
|  | async def test_empty_parser_defaults(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     with pytest.raises(HelpSignal): |     with pytest.raises(HelpSignal): | ||||||
|         parser.parse_args(["--help"]) |         await parser.parse_args(["--help"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_basic(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_basic(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) |     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) |     args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||||
|     assert args["tag"] == ["a", "b", "c"] |     assert args["tag"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_nargs_2(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_2(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) |     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) |     args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||||
|     assert args["pair"] == ["a", "b", "c", "d"] |     assert args["pair"] == ["a", "b", "c", "d"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_nargs_star(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_star(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") |     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--files", "x", "y", "z"]) |     args = await parser.parse_args(["--files", "x", "y", "z"]) | ||||||
|     assert args["files"] == ["x", "y", "z"] |     assert args["files"] == ["x", "y", "z"] | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--files"]) |     args = await parser.parse_args(["--files"]) | ||||||
|     assert args["files"] == [] |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_nargs_plus(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_plus(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") |     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) |     args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||||
|     assert args["inputs"] == [1, 2, 3, 4] |     assert args["inputs"] == [1, 2, 3, 4] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_invalid_type(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_invalid_type(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) |     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--nums", "a"]) |         await parser.parse_args(["--nums", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_greedy_invalid_type(): | @pytest.mark.asyncio | ||||||
|  | async def test_greedy_invalid_type(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--nums", nargs="*", type=int) |     parser.add_argument("--nums", nargs="*", type=int) | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--nums", "a"]) |         await parser.parse_args(["--nums", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_append_vs_extend_behavior(): | @pytest.mark.asyncio | ||||||
|  | async def test_append_vs_extend_behavior(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) |     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) |     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||||
|  |  | ||||||
|     args = parser.parse_args( |     args = await parser.parse_args( | ||||||
|         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] |         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] | ||||||
|     ) |     ) | ||||||
|     assert args["x"] == [["a", "b"], ["c", "d"]] |     assert args["x"] == [["a", "b"], ["c", "d"]] | ||||||
|     assert args["y"] == ["1", "2", "3", "4"] |     assert args["y"] == ["1", "2", "3", "4"] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_append_vs_extend_behavior_error(): | @pytest.mark.asyncio | ||||||
|  | async def test_append_vs_extend_behavior_error(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) |     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) |     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||||
|  |  | ||||||
|     # This should raise an error because the last argument is not a valid pair |     # This should raise an error because the last argument is not a valid pair | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]) |         await parser.parse_args( | ||||||
|  |             ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]) |         await parser.parse_args( | ||||||
|  |             ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_positional(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_positional(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") |     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     args = parser.parse_args([]) |     args = await parser.parse_args([]) | ||||||
|     assert args["files"] == [] |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_extend_positional_nargs(): | @pytest.mark.asyncio | ||||||
|  | async def test_extend_positional_nargs(): | ||||||
|     parser = CommandArgumentParser() |     parser = CommandArgumentParser() | ||||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") |     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") | ||||||
|  |  | ||||||
|     args = parser.parse_args(["a", "b", "c"]) |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|     assert args["files"] == ["a", "b", "c"] |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|     with pytest.raises(CommandArgumentError): |     with pytest.raises(CommandArgumentError): | ||||||
|         parser.parse_args([]) |         await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_argument_parser_equality(): | ||||||
|  |     parser1 = CommandArgumentParser() | ||||||
|  |     parser2 = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     parser1.add_argument("--foo", type=str) | ||||||
|  |     parser2.add_argument("--foo", type=str) | ||||||
|  |  | ||||||
|  |     assert parser1 == parser2 | ||||||
|  |  | ||||||
|  |     parser1.add_argument("--bar", type=int) | ||||||
|  |     assert parser1 != parser2 | ||||||
|  |  | ||||||
|  |     parser2.add_argument("--bar", type=int) | ||||||
|  |     assert parser1 == parser2 | ||||||
|  |  | ||||||
|  |     assert parser1 != "not a parser" | ||||||
|  |     assert parser1 is not None | ||||||
|  |     assert parser1 != object() | ||||||
|  |  | ||||||
|  |     assert parser1.to_definition_list() == parser2.to_definition_list() | ||||||
|  |     assert hash(parser1) == hash(parser2) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_render_help(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--foo", type=str, help="Foo help") | ||||||
|  |     parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help") | ||||||
|  |  | ||||||
|  |     assert parser.render_help() is None | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ from pathlib import Path | |||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from falyx.__main__ import bootstrap, find_falyx_config, get_falyx_parsers, run | from falyx.__main__ import bootstrap, find_falyx_config, main | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_find_falyx_config(): | def test_find_falyx_config(): | ||||||
| @@ -50,63 +50,3 @@ def test_bootstrap_with_global_config(): | |||||||
|     assert str(config_file.parent) in sys.path |     assert str(config_file.parent) in sys.path | ||||||
|     config_file.unlink() |     config_file.unlink() | ||||||
|     sys.path = sys_path_before |     sys.path = sys_path_before | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_args(): |  | ||||||
|     """Test if the parse_args function works correctly.""" |  | ||||||
|     falyx_parsers = get_falyx_parsers() |  | ||||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) |  | ||||||
|  |  | ||||||
|     assert args.command == "init" |  | ||||||
|     assert args.name == "test_project" |  | ||||||
|  |  | ||||||
|     args = falyx_parsers.parse_args(["init-global"]) |  | ||||||
|     assert args.command == "init-global" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_run(): |  | ||||||
|     """Test if the run function works correctly.""" |  | ||||||
|     falyx_parsers = get_falyx_parsers() |  | ||||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) |  | ||||||
|     run(args) |  | ||||||
|     assert args.command == "init" |  | ||||||
|     assert args.name == "test_project" |  | ||||||
|     # Check if the project directory was created |  | ||||||
|     assert Path("test_project").exists() |  | ||||||
|     # Clean up |  | ||||||
|     (Path("test_project") / "falyx.yaml").unlink() |  | ||||||
|     (Path("test_project") / "tasks.py").unlink() |  | ||||||
|     Path("test_project").rmdir() |  | ||||||
|     # Test init-global |  | ||||||
|     args = falyx_parsers.parse_args(["init-global"]) |  | ||||||
|     run(args) |  | ||||||
|     # Check if the global config directory was created |  | ||||||
|     assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists() |  | ||||||
|     # Clean up |  | ||||||
|     (Path.home() / ".config" / "falyx" / "falyx.yaml").unlink() |  | ||||||
|     (Path.home() / ".config" / "falyx" / "tasks.py").unlink() |  | ||||||
|     (Path.home() / ".config" / "falyx").rmdir() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_no_bootstrap(): |  | ||||||
|     """Test if the main function works correctly when no config file is found.""" |  | ||||||
|     falyx_parsers = get_falyx_parsers() |  | ||||||
|     args = falyx_parsers.parse_args(["list"]) |  | ||||||
|     assert run(args) is None |  | ||||||
|     # Check if the task was run |  | ||||||
|     assert args.command == "list" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_run_test_project(): |  | ||||||
|     """Test if the main function works correctly with a test project.""" |  | ||||||
|     falyx_parsers = get_falyx_parsers() |  | ||||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) |  | ||||||
|     run(args) |  | ||||||
|  |  | ||||||
|     args = falyx_parsers.parse_args(["run", "B"]) |  | ||||||
|     os.chdir("test_project") |  | ||||||
|     with pytest.raises(SystemExit): |  | ||||||
|         assert run(args) == "Build complete!" |  | ||||||
|     os.chdir("..") |  | ||||||
|     shutil.rmtree("test_project") |  | ||||||
|     assert not Path("test_project").exists() |  | ||||||
|   | |||||||
							
								
								
									
										219
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action | ||||||
|  | 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"]) | ||||||
							
								
								
									
										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()) == 9 | ||||||
							
								
								
									
										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 | ||||||
							
								
								
									
										18
									
								
								tests/test_parsers/test_completions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								tests/test_parsers/test_completions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.parser.command_argument_parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.parametrize( | ||||||
|  |     "input_tokens, expected", | ||||||
|  |     [ | ||||||
|  |         ([""], ["--help", "--tag", "-h"]), | ||||||
|  |         (["--ta"], ["--tag"]), | ||||||
|  |         (["--tag"], ["analytics", "build"]), | ||||||
|  |     ], | ||||||
|  | ) | ||||||
|  | async def test_suggest_next(input_tokens, expected): | ||||||
|  |     parser = CommandArgumentParser(...) | ||||||
|  |     parser.add_argument("--tag", choices=["analytics", "build"]) | ||||||
|  |     assert sorted(parser.suggest_next(input_tokens)) == sorted(expected) | ||||||
							
								
								
									
										40
									
								
								tests/test_parsers/test_multiple_positional.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								tests/test_parsers/test_multiple_positional.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_multiple_positional(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+") | ||||||
|  |     parser.add_argument("mode", choices=["edit", "view"]) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c", "edit"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |     assert args["mode"] == "edit" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_multiple_positional_with_default(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+") | ||||||
|  |     parser.add_argument("mode", choices=["edit", "view"], default="edit") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |     assert args["mode"] == "edit" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_multiple_positional_with_double_default(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+", default=["a", "b", "c"]) | ||||||
|  |     parser.add_argument("mode", choices=["edit", "view"], default="edit") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args() | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |     assert args["mode"] == "edit" | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b"]) | ||||||
|  |     assert args["files"] == ["a", "b"] | ||||||
|  |     assert args["mode"] == "edit" | ||||||
							
								
								
									
										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"]) | ||||||
							
								
								
									
										26
									
								
								tests/test_parsers/test_negative_numbers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								tests/test_parsers/test_negative_numbers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.exceptions import CommandArgumentError | ||||||
|  | from falyx.parser import CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_negative_integer(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--number", type=int, required=True, help="A negative integer") | ||||||
|  |     args = await parser.parse_args(["--number", "-42"]) | ||||||
|  |     assert args["number"] == -42 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_negative_float(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--value", type=float, required=True, help="A negative float") | ||||||
|  |     args = await parser.parse_args(["--value", "-3.14"]) | ||||||
|  |     assert args["value"] == -3.14 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_parse_number_flag(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("-1", type=int, required=True, help="A negative number flag") | ||||||
							
								
								
									
										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"]) | ||||||
							
								
								
									
										83
									
								
								tests/test_parsers/test_store_bool_optional.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								tests/test_parsers/test_store_bool_optional.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.exceptions import CommandArgumentError | ||||||
|  | from falyx.parser import ArgumentAction, CommandArgumentParser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_store_bool_optional_true(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug", | ||||||
|  |         action=ArgumentAction.STORE_BOOL_OPTIONAL, | ||||||
|  |         help="Enable debug mode.", | ||||||
|  |     ) | ||||||
|  |     args = await parser.parse_args(["--debug"]) | ||||||
|  |     assert args["debug"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_store_bool_optional_false(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug", | ||||||
|  |         action=ArgumentAction.STORE_BOOL_OPTIONAL, | ||||||
|  |         help="Enable debug mode.", | ||||||
|  |     ) | ||||||
|  |     args = await parser.parse_args(["--no-debug"]) | ||||||
|  |     assert args["debug"] is False | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_store_bool_optional_default_none(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug", | ||||||
|  |         action=ArgumentAction.STORE_BOOL_OPTIONAL, | ||||||
|  |         help="Enable debug mode.", | ||||||
|  |     ) | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["debug"] is None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_store_bool_optional_flag_order(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--dry-run", | ||||||
|  |         action=ArgumentAction.STORE_BOOL_OPTIONAL, | ||||||
|  |         help="Run without making changes.", | ||||||
|  |     ) | ||||||
|  |     args = await parser.parse_args(["--dry-run"]) | ||||||
|  |     assert args["dry_run"] is True | ||||||
|  |     args = await parser.parse_args(["--no-dry-run"]) | ||||||
|  |     assert args["dry_run"] is False | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_store_bool_optional_requires_long_flag(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument( | ||||||
|  |             "-d", action=ArgumentAction.STORE_BOOL_OPTIONAL, help="Invalid" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_store_bool_optional_disallows_multiple_flags(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--debug", "-d", action=ArgumentAction.STORE_BOOL_OPTIONAL) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_store_bool_optional_duplicate_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug", | ||||||
|  |         action=ArgumentAction.STORE_BOOL_OPTIONAL, | ||||||
|  |         help="Enable debug mode.", | ||||||
|  |     ) | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument( | ||||||
|  |             "--debug", | ||||||
|  |             action=ArgumentAction.STORE_TRUE, | ||||||
|  |             help="Conflicting debug option.", | ||||||
|  |         ) | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user