Compare commits
	
		
			75 Commits
		
	
	
		
			pipes
			...
			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 | |||
| afa47b0bac | |||
| 70a527358d | |||
| 62276debd5 | |||
| b14004c989 | |||
| bba473047c | |||
| 2bdca72e04 | |||
| 87a56ac40b | |||
| e999ad5e1c | |||
| 5c09f86b9b | |||
| 9351ae658c | |||
| 76e542cfce | |||
| ad803e01be | |||
| 53729f089f | |||
| 7616216c26 | |||
| 26aab7f2d5 | |||
| 880d86d47d | |||
| 05a7f982f2 | |||
| 5d96d6d3d9 | |||
| b5da6b9647 | |||
| 2fee87ade9 | |||
| 6f159810b2 | |||
| a90c447d5c | |||
| f9cb9ebaef | |||
| 91c4d5481f | |||
| 69b629eb08 | |||
| f6316599d4 | |||
| b51c4ba4f7 | |||
| 2d879561c9 | |||
| e91654ca27 | |||
| 4b1a9ef718 | |||
| b9529d85ce | |||
| fe9758adbf | |||
| bc1637143c | |||
| 80de941335 | |||
| e9fdd9cec6 | |||
| 1fe0cd2675 | |||
| 18163edab9 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -15,4 +15,3 @@ build/ | ||||
| .vscode/ | ||||
| coverage.xml | ||||
| .coverage | ||||
|  | ||||
|   | ||||
							
								
								
									
										23
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.pre-commit-config.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| repos: | ||||
| -   repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v5.0.0 | ||||
|     hooks: | ||||
|     -   id: trailing-whitespace | ||||
|     -   id: end-of-file-fixer | ||||
| -   repo: https://github.com/pycqa/isort | ||||
|     rev: 5.13.2 | ||||
|     hooks: | ||||
|     -   id: isort | ||||
|         args: [--profile, black] | ||||
| -   repo: https://github.com/psf/black-pre-commit-mirror | ||||
|     rev: 25.1.0 | ||||
|     hooks: | ||||
|     -   id: black | ||||
|         args: [-l, "90"] | ||||
| -   repo: local | ||||
|     hooks: | ||||
|     - id: sync-version | ||||
|       name: Sync version from pyproject.toml | ||||
|       entry: python scripts/sync_version.py | ||||
|       language: system | ||||
|       files: ^pyproject\.toml$ | ||||
| @@ -52,7 +52,8 @@ poetry install | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Falyx, Action, ChainedAction | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
|  | ||||
| # A flaky async step that fails randomly | ||||
| async def flaky_step(): | ||||
| @@ -62,8 +63,8 @@ async def flaky_step(): | ||||
|     return "ok" | ||||
|  | ||||
| # Create the actions | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
| step1 = Action(name="step_1", action=flaky_step) | ||||
| step2 = Action(name="step_2", action=flaky_step) | ||||
|  | ||||
| # Chain the actions | ||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||
| @@ -74,9 +75,9 @@ falyx.add_command( | ||||
|     key="R", | ||||
|     description="Run My Pipeline", | ||||
|     action=chain, | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
|     retry_all=True, | ||||
| ) | ||||
|  | ||||
| # Entry point | ||||
|   | ||||
| @@ -1,29 +1,33 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ActionGroup, ChainedAction | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
|  | ||||
|  | ||||
| # Actions can be defined as synchronous functions | ||||
| # Falyx will automatically convert them to async functions | ||||
| def hello() -> None: | ||||
|     print("Hello, world!") | ||||
|  | ||||
| hello = Action(name="hello_action", action=hello) | ||||
|  | ||||
| hello_action = Action(name="hello_action", action=hello) | ||||
|  | ||||
| # Actions can be run by themselves or as part of a command or pipeline | ||||
| asyncio.run(hello()) | ||||
| asyncio.run(hello_action()) | ||||
|  | ||||
|  | ||||
| # Actions are designed to be asynchronous first | ||||
| async def goodbye() -> None: | ||||
|     print("Goodbye!") | ||||
|  | ||||
| goodbye = Action(name="goodbye_action", action=goodbye) | ||||
|  | ||||
| goodbye_action = Action(name="goodbye_action", action=goodbye) | ||||
|  | ||||
| asyncio.run(goodbye()) | ||||
|  | ||||
| # Actions can be run in parallel | ||||
| group = ActionGroup(name="greeting_group", actions=[hello, goodbye]) | ||||
| group = ActionGroup(name="greeting_group", actions=[hello_action, goodbye_action]) | ||||
| asyncio.run(group()) | ||||
|  | ||||
| # Actions can be run in a chain | ||||
| chain = ChainedAction(name="greeting_chain", actions=[hello, goodbye]) | ||||
| chain = ChainedAction(name="greeting_chain", actions=[hello_action, goodbye_action]) | ||||
| asyncio.run(chain()) | ||||
|   | ||||
							
								
								
									
										48
									
								
								examples/action_factory_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								examples/action_factory_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction | ||||
|  | ||||
| # Selection of a post ID to fetch (just an example set) | ||||
| post_selector = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=["15", "25", "35", "45", "55"], | ||||
|     title="Choose a Post ID to submit", | ||||
|     prompt_message="Post ID > ", | ||||
|     show_table=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| # Factory that builds and executes the actual HTTP POST request | ||||
| async def build_post_action(post_id) -> HTTPAction: | ||||
|     print(f"Building HTTPAction for Post ID: {post_id}") | ||||
|     return HTTPAction( | ||||
|         name=f"POST to /posts (id={post_id})", | ||||
|         method="POST", | ||||
|         url="https://jsonplaceholder.typicode.com/posts", | ||||
|         json={"title": "foo", "body": "bar", "userId": int(post_id)}, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| post_factory = ActionFactory( | ||||
|     name="Build HTTPAction from Post ID", | ||||
|     factory=build_post_action, | ||||
|     inject_last_result=True, | ||||
|     inject_into="post_id", | ||||
|     preview_kwargs={"post_id": "100"}, | ||||
| ) | ||||
|  | ||||
| # Wrap in a ChainedAction | ||||
| chain = ChainedAction( | ||||
|     name="Submit Post Flow", | ||||
|     actions=[post_selector, post_factory], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| flx = Falyx() | ||||
| flx.add_command( | ||||
|     key="S", | ||||
|     description="Submit a Post", | ||||
|     action=chain, | ||||
| ) | ||||
| asyncio.run(flx.run()) | ||||
							
								
								
									
										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()) | ||||
							
								
								
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup | ||||
|  | ||||
|  | ||||
| # Define a shared async function | ||||
| async def say_hello(name: str, excited: bool = False): | ||||
|     if excited: | ||||
|         print(f"Hello, {name}!!!") | ||||
|     else: | ||||
|         print(f"Hello, {name}.") | ||||
|  | ||||
|  | ||||
| # Wrap the same callable in multiple Actions | ||||
| action1 = Action("say_hello_1", action=say_hello) | ||||
| action2 = Action("say_hello_2", action=say_hello) | ||||
| action3 = Action("say_hello_3", action=say_hello) | ||||
|  | ||||
| # Combine into an ActionGroup | ||||
| group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) | ||||
|  | ||||
| flx = Falyx("Test Group") | ||||
| flx.add_command( | ||||
|     key="G", | ||||
|     description="Greet someone with multiple variations.", | ||||
|     aliases=["greet", "hello"], | ||||
|     action=group, | ||||
|     arg_metadata={ | ||||
|         "name": { | ||||
|             "help": "The name of the person to greet.", | ||||
|         }, | ||||
|         "excited": { | ||||
|             "help": "Whether to greet excitedly.", | ||||
|         }, | ||||
|     }, | ||||
| ) | ||||
| asyncio.run(flx.run()) | ||||
							
								
								
									
										62
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|  | ||||
|  | ||||
| async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str: | ||||
|     if verbose: | ||||
|         print(f"Deploying {service} to {region}...") | ||||
|     await asyncio.sleep(2) | ||||
|     if verbose: | ||||
|         print(f"{service} deployed successfully!") | ||||
|     return f"{service} deployed to {region}" | ||||
|  | ||||
|  | ||||
| flx = Falyx("Deployment CLI") | ||||
|  | ||||
| flx.add_command( | ||||
|     key="D", | ||||
|     aliases=["deploy"], | ||||
|     description="Deploy", | ||||
|     help_text="Deploy a service to a specified region.", | ||||
|     action=Action( | ||||
|         name="deploy_service", | ||||
|         action=deploy, | ||||
|     ), | ||||
|     arg_metadata={ | ||||
|         "service": "Service name", | ||||
|         "region": { | ||||
|             "help": "Deployment region", | ||||
|             "choices": ["us-east-1", "us-west-2", "eu-west-1"], | ||||
|         }, | ||||
|         "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()) | ||||
							
								
								
									
										10
									
								
								examples/config_loading.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								examples/config_loading.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| """config_loading.py""" | ||||
|  | ||||
| from falyx.config import loader | ||||
|  | ||||
| flx = loader("falyx.yaml") | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     import asyncio | ||||
|  | ||||
|     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()) | ||||
							
								
								
									
										32
									
								
								examples/falyx.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								examples/falyx.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| commands: | ||||
|   - key: P | ||||
|     description: Pipeline Demo | ||||
|     action: pipeline_demo.pipeline | ||||
|     tags: [pipeline, demo] | ||||
|     help_text: Run Deployment Pipeline with retries. | ||||
|  | ||||
|   - key: G | ||||
|     description: Run HTTP Action Group | ||||
|     action: http_demo.action_group | ||||
|     tags: [http, demo] | ||||
|     confirm: true | ||||
|  | ||||
|   - key: S | ||||
|     description: Select a file | ||||
|     action: file_select.sf | ||||
|     tags: [file, select, demo] | ||||
|  | ||||
|   - key: M | ||||
|     description: Menu Demo | ||||
|     action: menu_demo.menu | ||||
|     tags: [menu, demo] | ||||
|     help_text: Run a menu demo with multiple options. | ||||
|  | ||||
| submenus: | ||||
|   - key: C | ||||
|     description: Process Menu (From Config) | ||||
|     config: process.yaml | ||||
|  | ||||
|   - key: U | ||||
|     description: Submenu From Python | ||||
|     submenu: submenu.submenu | ||||
							
								
								
									
										162
									
								
								examples/falyx_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								examples/falyx_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.version import __version__ | ||||
|  | ||||
|  | ||||
| class Foo: | ||||
|     def __init__(self, flx: Falyx) -> None: | ||||
|         self.flx = flx | ||||
|  | ||||
|     async def build(self): | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Build complete!") | ||||
|         return "Build complete!" | ||||
|  | ||||
|     async def test(self): | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Tests passed!") | ||||
|         return "Tests passed!" | ||||
|  | ||||
|     async def deploy(self): | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Deployment complete!") | ||||
|         return "Deployment complete!" | ||||
|  | ||||
|     async def clean(self): | ||||
|         print("🧹 Cleaning...") | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Clean complete!") | ||||
|         return "Clean complete!" | ||||
|  | ||||
|     async def build_package(self): | ||||
|         print("🔨 Building...") | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Build finished!") | ||||
|         return "Build finished!" | ||||
|  | ||||
|     async def package(self): | ||||
|         print("📦 Packaging...") | ||||
|         await asyncio.sleep(1) | ||||
|         print("✅ Package complete!") | ||||
|         return "Package complete!" | ||||
|  | ||||
|     async def run_tests(self): | ||||
|         print("🧪 Running tests...") | ||||
|         await asyncio.sleep(random.randint(1, 3)) | ||||
|         print("✅ Tests passed!") | ||||
|         return "Tests passed!" | ||||
|  | ||||
|     async def run_integration_tests(self): | ||||
|         print("🔗 Running integration tests...") | ||||
|         await asyncio.sleep(random.randint(1, 3)) | ||||
|         print("✅ Integration tests passed!") | ||||
|         return "Integration tests passed!" | ||||
|  | ||||
|     async def run_linter(self): | ||||
|         print("🧹 Running linter...") | ||||
|         await asyncio.sleep(random.randint(1, 3)) | ||||
|         print("✅ Linter passed!") | ||||
|         return "Linter passed!" | ||||
|  | ||||
|     async def run(self): | ||||
|         await self.flx.run() | ||||
|  | ||||
|  | ||||
| async def main() -> None: | ||||
|     """Build and return a Falyx instance with all your commands.""" | ||||
|     flx = Falyx( | ||||
|         title="🚀 Falyx CLI", | ||||
|         columns=5, | ||||
|         welcome_message="Welcome to Falyx CLI!", | ||||
|         exit_message="Goodbye!", | ||||
|     ) | ||||
|     foo = Foo(flx) | ||||
|  | ||||
|     # --- Bottom bar info --- | ||||
|     flx.bottom_bar.columns = 3 | ||||
|     flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose") | ||||
|     flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks") | ||||
|     flx.bottom_bar.add_static("Version", f"Falyx v{__version__}") | ||||
|  | ||||
|     # --- Command actions --- | ||||
|  | ||||
|     # --- Single Actions --- | ||||
|     flx.add_command( | ||||
|         key="B", | ||||
|         description="Build project", | ||||
|         action=Action("Build", foo.build), | ||||
|         tags=["build"], | ||||
|         spinner=True, | ||||
|         spinner_message="📦 Building...", | ||||
|     ) | ||||
|     flx.add_command( | ||||
|         key="T", | ||||
|         description="Run tests", | ||||
|         action=Action("Test", foo.test), | ||||
|         tags=["test"], | ||||
|         spinner=True, | ||||
|         spinner_message="🧪 Running tests...", | ||||
|     ) | ||||
|     flx.add_command( | ||||
|         key="D", | ||||
|         description="Deploy project", | ||||
|         action=Action("Deploy", foo.deploy), | ||||
|         tags=["deploy"], | ||||
|         spinner=True, | ||||
|         spinner_message="🚀 Deploying...", | ||||
|     ) | ||||
|  | ||||
|     # --- Build pipeline (ChainedAction) --- | ||||
|     pipeline = ChainedAction( | ||||
|         name="Full Build Pipeline", | ||||
|         actions=[ | ||||
|             Action("Clean", foo.clean), | ||||
|             Action("Build", foo.build_package), | ||||
|             Action("Package", foo.package), | ||||
|         ], | ||||
|     ) | ||||
|     flx.add_command( | ||||
|         key="P", | ||||
|         description="Run Build Pipeline", | ||||
|         action=pipeline, | ||||
|         tags=["build", "pipeline"], | ||||
|         spinner=True, | ||||
|         spinner_message="🔨 Running build pipeline...", | ||||
|         spinner_type="line", | ||||
|     ) | ||||
|  | ||||
|     # --- Test suite (ActionGroup) --- | ||||
|     test_suite = ActionGroup( | ||||
|         name="Test Suite", | ||||
|         actions=[ | ||||
|             Action("Unit Tests", foo.run_tests), | ||||
|             Action("Integration Tests", foo.run_integration_tests), | ||||
|             Action("Lint", foo.run_linter), | ||||
|         ], | ||||
|     ) | ||||
|     flx.add_command( | ||||
|         key="G", | ||||
|         description="Run All Tests", | ||||
|         action=test_suite, | ||||
|         tags=["test", "parallel"], | ||||
|         spinner=True, | ||||
|         spinner_type="line", | ||||
|     ) | ||||
|     await foo.run() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     try: | ||||
|         asyncio.run(main()) | ||||
|     except (KeyboardInterrupt, EOFError): | ||||
|         pass | ||||
							
								
								
									
										32
									
								
								examples/file_select.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								examples/file_select.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import SelectFileAction | ||||
| from falyx.action.action_types import FileType | ||||
|  | ||||
| sf = SelectFileAction( | ||||
|     name="select_file", | ||||
|     suffix_filter=".yaml", | ||||
|     title="Select a YAML file", | ||||
|     prompt_message="Choose 2 > ", | ||||
|     return_type=FileType.TEXT, | ||||
|     columns=3, | ||||
|     number_selections=2, | ||||
| ) | ||||
|  | ||||
| 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( | ||||
|     key="S", | ||||
|     description="Select a file", | ||||
|     action=sf, | ||||
|     help_text="Select a file from the current directory", | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
							
								
								
									
										6
									
								
								examples/http.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								examples/http.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| commands: | ||||
|   - key: T | ||||
|     description: HTTP Test | ||||
|     action: single_http.http_action | ||||
|     tags: [http, demo] | ||||
|     help_text: Run HTTP test. | ||||
							
								
								
									
										66
									
								
								examples/http_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								examples/http_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import asyncio | ||||
|  | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ActionGroup, HTTPAction | ||||
| from falyx.hooks import ResultReporter | ||||
|  | ||||
| console = Console() | ||||
|  | ||||
|  | ||||
| action_group = ActionGroup( | ||||
|     "HTTP Group", | ||||
|     actions=[ | ||||
|         HTTPAction( | ||||
|             name="Get Example", | ||||
|             method="GET", | ||||
|             url="https://jsonplaceholder.typicode.com/posts/1", | ||||
|             headers={"Accept": "application/json"}, | ||||
|             retry=True, | ||||
|         ), | ||||
|         HTTPAction( | ||||
|             name="Post Example", | ||||
|             method="POST", | ||||
|             url="https://jsonplaceholder.typicode.com/posts", | ||||
|             headers={"Content-Type": "application/json"}, | ||||
|             json={"title": "foo", "body": "bar", "userId": 1}, | ||||
|             retry=True, | ||||
|         ), | ||||
|         HTTPAction( | ||||
|             name="Put Example", | ||||
|             method="PUT", | ||||
|             url="https://jsonplaceholder.typicode.com/posts/1", | ||||
|             headers={"Content-Type": "application/json"}, | ||||
|             json={"id": 1, "title": "foo", "body": "bar", "userId": 1}, | ||||
|             retry=True, | ||||
|         ), | ||||
|         HTTPAction( | ||||
|             name="Delete Example", | ||||
|             method="DELETE", | ||||
|             url="https://jsonplaceholder.typicode.com/posts/1", | ||||
|             headers={"Content-Type": "application/json"}, | ||||
|             retry=True, | ||||
|         ), | ||||
|     ], | ||||
| ) | ||||
|  | ||||
| reporter = ResultReporter() | ||||
|  | ||||
| action_group.hooks.register( | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
| flx = Falyx("HTTP Demo") | ||||
|  | ||||
| flx.add_command( | ||||
|     key="G", | ||||
|     description="Run HTTP Action Group", | ||||
|     action=action_group, | ||||
|     spinner=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
							
								
								
									
										136
									
								
								examples/menu_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								examples/menu_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| import asyncio | ||||
| import time | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     MenuAction, | ||||
|     ProcessAction, | ||||
|     PromptMenuAction, | ||||
| ) | ||||
| from falyx.menu import MenuOption, MenuOptionMap | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| # Basic coroutine for Action | ||||
| async def greet_user(): | ||||
|     print("👋 Hello from a regular Action!") | ||||
|     await asyncio.sleep(0.5) | ||||
|     return "Greeted user." | ||||
|  | ||||
|  | ||||
| # Chain of tasks | ||||
| async def fetch_data(): | ||||
|     print("📡 Fetching data...") | ||||
|     await asyncio.sleep(1) | ||||
|     return "data123" | ||||
|  | ||||
|  | ||||
| async def process_data(last_result): | ||||
|     print(f"⚙️ Processing: {last_result}") | ||||
|     await asyncio.sleep(1) | ||||
|     return f"processed({last_result})" | ||||
|  | ||||
|  | ||||
| async def save_data(last_result): | ||||
|     print(f"💾 Saving: {last_result}") | ||||
|     await asyncio.sleep(1) | ||||
|     return f"saved({last_result})" | ||||
|  | ||||
|  | ||||
| # Parallel tasks | ||||
| async def fetch_users(): | ||||
|     print("👥 Fetching users...") | ||||
|     await asyncio.sleep(1) | ||||
|     return ["alice", "bob", "carol"] | ||||
|  | ||||
|  | ||||
| async def fetch_logs(): | ||||
|     print("📝 Fetching logs...") | ||||
|     await asyncio.sleep(2) | ||||
|     return ["log1", "log2"] | ||||
|  | ||||
|  | ||||
| # CPU-bound task (simulate via blocking sleep) | ||||
| def heavy_computation(): | ||||
|     print("🧠 Starting heavy computation...") | ||||
|     time.sleep(3) | ||||
|     print("✅ Finished computation.") | ||||
|     return 42 | ||||
|  | ||||
|  | ||||
| # Define actions | ||||
|  | ||||
| basic_action = Action("greet", greet_user) | ||||
|  | ||||
| chained = ChainedAction( | ||||
|     name="data-pipeline", | ||||
|     actions=[ | ||||
|         Action("fetch", fetch_data), | ||||
|         Action("process", process_data, inject_last_result=True), | ||||
|         Action("save", save_data, inject_last_result=True), | ||||
|     ], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| parallel = ActionGroup( | ||||
|     name="parallel-fetch", | ||||
|     actions=[ | ||||
|         Action("fetch-users", fetch_users), | ||||
|         Action("fetch-logs", fetch_logs), | ||||
|     ], | ||||
| ) | ||||
|  | ||||
| process = ProcessAction(name="compute", action=heavy_computation) | ||||
|  | ||||
| menu_options = MenuOptionMap( | ||||
|     { | ||||
|         "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), | ||||
|         "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), | ||||
|         "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), | ||||
|         "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| # Menu setup | ||||
|  | ||||
| menu = MenuAction( | ||||
|     name="main-menu", | ||||
|     title="Choose a task to run", | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
|  | ||||
| prompt_menu = PromptMenuAction( | ||||
|     name="select-user", | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
| flx = Falyx( | ||||
|     title="🚀 Falyx Menu Demo", | ||||
|     welcome_message="Welcome to the Menu Demo!", | ||||
|     exit_message="Goodbye!", | ||||
|     columns=2, | ||||
|     never_prompt=False, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="M", | ||||
|     description="Show Menu", | ||||
|     action=menu, | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="P", | ||||
|     description="Show Prompt Menu", | ||||
|     action=prompt_menu, | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
							
								
								
									
										76
									
								
								examples/pipeline_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								examples/pipeline_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import ExecutionRegistry as er | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
|  | ||||
|  | ||||
| # Step 1: Fast I/O-bound setup (standard Action) | ||||
| async def checkout_code(): | ||||
|     print("📥 Checking out code...") | ||||
|     await asyncio.sleep(0.5) | ||||
|  | ||||
|  | ||||
| # Step 2: CPU-bound task (ProcessAction) | ||||
| def run_static_analysis(): | ||||
|     print("🧠 Running static analysis (CPU-bound)...") | ||||
|     total = 0 | ||||
|     for i in range(10_000_000): | ||||
|         total += i % 3 | ||||
|     return total | ||||
|  | ||||
|  | ||||
| # Step 3: Simulated flaky test with retry | ||||
| async def flaky_tests(): | ||||
|     import random | ||||
|  | ||||
|     await asyncio.sleep(0.3) | ||||
|     if random.random() < 0.3: | ||||
|         raise RuntimeError("❌ Random test failure!") | ||||
|     print("🧪 Tests passed.") | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| # Step 4: Multiple deploy targets (parallel ActionGroup) | ||||
| async def deploy_to(target: str): | ||||
|     print(f"🚀 Deploying to {target}...") | ||||
|     await asyncio.sleep(0.2) | ||||
|     return f"{target} complete" | ||||
|  | ||||
|  | ||||
| def build_pipeline(): | ||||
|     retry_handler = RetryHandler(RetryPolicy(max_retries=3, delay=0.5)) | ||||
|  | ||||
|     # Base actions | ||||
|     checkout = Action("Checkout", checkout_code) | ||||
|     analysis = ProcessAction("Static Analysis", run_static_analysis) | ||||
|     tests = Action("Run Tests", flaky_tests) | ||||
|     tests.hooks.register("on_error", retry_handler.retry_on_error) | ||||
|  | ||||
|     # Parallel deploys | ||||
|     deploy_group = ActionGroup( | ||||
|         "Deploy to All", | ||||
|         [ | ||||
|             Action("Deploy US", deploy_to, args=("us-west",)), | ||||
|             Action("Deploy EU", deploy_to, args=("eu-central",)), | ||||
|             Action("Deploy Asia", deploy_to, args=("asia-east",)), | ||||
|         ], | ||||
|     ) | ||||
|  | ||||
|     # Full pipeline | ||||
|     return ChainedAction("CI/CD Pipeline", [checkout, analysis, tests, deploy_group]) | ||||
|  | ||||
|  | ||||
| pipeline = build_pipeline() | ||||
|  | ||||
|  | ||||
| # Run the pipeline | ||||
| async def main(): | ||||
|     pipeline = build_pipeline() | ||||
|     await pipeline() | ||||
|     er.summary() | ||||
|     await pipeline.preview() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(main()) | ||||
							
								
								
									
										11
									
								
								examples/process.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								examples/process.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| commands: | ||||
|   - key: P | ||||
|     description: Pipeline Demo | ||||
|     action: pipeline_demo.pipeline | ||||
|     tags: [pipeline, demo] | ||||
|     help_text: Run Demployment Pipeline with retries. | ||||
|  | ||||
| submenus: | ||||
|   - key: C | ||||
|     description: HTTP Test (Nested From Config) | ||||
|     config: http.yaml | ||||
| @@ -1,22 +1,36 @@ | ||||
| from falyx import Falyx, ProcessAction | ||||
| from falyx.themes.colors import NordColors as nc | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ProcessPoolAction | ||||
| from falyx.action.process_pool_action import ProcessTask | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.themes import NordColors as nc | ||||
|  | ||||
| console = Console() | ||||
| falyx = Falyx(title="🚀 Process Pool Demo") | ||||
|  | ||||
| def generate_primes(n): | ||||
|     primes = [] | ||||
|     for num in range(2, n): | ||||
|  | ||||
| def generate_primes(start: int = 2, end: int = 100_000) -> list[int]: | ||||
|     primes: list[int] = [] | ||||
|     console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW) | ||||
|     for num in range(start, end): | ||||
|         if all(num % p != 0 for p in primes): | ||||
|             primes.append(num) | ||||
|     console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) | ||||
|     console.print( | ||||
|         f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN | ||||
|     ) | ||||
|     return primes | ||||
|  | ||||
| # Will not block the event loop | ||||
| heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,)) | ||||
|  | ||||
| falyx.add_command("R", "Generate Primes", heavy_action, spinner=True) | ||||
| actions = [ProcessTask(task=generate_primes)] | ||||
|  | ||||
| # 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__": | ||||
|   | ||||
							
								
								
									
										32
									
								
								examples/run_key.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								examples/run_key.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action | ||||
|  | ||||
|  | ||||
| async def main(): | ||||
|     state = {"count": 0} | ||||
|  | ||||
|     async def flaky(): | ||||
|         if not state["count"]: | ||||
|             state["count"] += 1 | ||||
|             print("Flaky step failed, retrying...") | ||||
|             raise RuntimeError("Random failure!") | ||||
|         return "ok" | ||||
|  | ||||
|     # Add a command that raises an exception | ||||
|     falyx.add_command( | ||||
|         key="E", | ||||
|         description="Error Command", | ||||
|         action=Action("flaky", flaky), | ||||
|         retry=True, | ||||
|     ) | ||||
|  | ||||
|     result = await falyx.run_key("E") | ||||
|     print(result) | ||||
|     assert result == "ok" | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     falyx = Falyx("Headless Recovery Test") | ||||
|     asyncio.run(main()) | ||||
							
								
								
									
										70
									
								
								examples/selection_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								examples/selection_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import asyncio | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
| from falyx.signals import CancelSignal | ||||
|  | ||||
| selections = { | ||||
|     "1": SelectionOption( | ||||
|         description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac" | ||||
|     ), | ||||
|     "2": SelectionOption( | ||||
|         description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac" | ||||
|     ), | ||||
| } | ||||
|  | ||||
|  | ||||
| select = SelectionAction( | ||||
|     name="Select Deployment", | ||||
|     selections=selections, | ||||
|     title="Select a Deployment", | ||||
|     columns=2, | ||||
|     prompt_message="> ", | ||||
|     return_type="value", | ||||
|     show_table=True, | ||||
| ) | ||||
|  | ||||
| list_selections = [uuid4() for _ in range(10)] | ||||
|  | ||||
| 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()) | ||||
							
								
								
									
										89
									
								
								examples/shell_example.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										89
									
								
								examples/shell_example.py
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| #!/usr/bin/env python | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction, ShellAction | ||||
| from falyx.hooks import ResultReporter | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| # Setup logging | ||||
| setup_logging() | ||||
|  | ||||
|  | ||||
| fx = Falyx("🚀 Falyx Demo") | ||||
|  | ||||
| e = ShellAction("Shell", "echo Hello, {}!") | ||||
|  | ||||
| fx.add_command( | ||||
|     key="R", | ||||
|     description="Echo a message", | ||||
|     action=e, | ||||
| ) | ||||
|  | ||||
| s = ShellAction("Ping", "ping -c 1 {}") | ||||
|  | ||||
| fx.add_command( | ||||
|     key="P", | ||||
|     description="Ping a host", | ||||
|     action=s, | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def a1(last_result): | ||||
|     return f"Hello, {last_result}" | ||||
|  | ||||
|  | ||||
| async def a2(last_result): | ||||
|     return f"World! {last_result}" | ||||
|  | ||||
|  | ||||
| reporter = ResultReporter() | ||||
|  | ||||
| a1 = Action("a1", a1, inject_last_result=True) | ||||
| a1.hooks.register( | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
| a2 = Action("a2", a2, inject_last_result=True) | ||||
| a2.hooks.register( | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def normal(): | ||||
|     print("Normal") | ||||
|     return "Normal" | ||||
|  | ||||
|  | ||||
| async def annotate(last_result): | ||||
|     return f"Annotated: {last_result}" | ||||
|  | ||||
|  | ||||
| async def whisper(last_result): | ||||
|     return last_result.lower() | ||||
|  | ||||
|  | ||||
| c1 = ChainedAction( | ||||
|     name="ShellDemo", | ||||
|     actions=[ | ||||
|         # host, | ||||
|         ShellAction("Ping", "ping -c 1 {}"), | ||||
|         Action("Annotate", annotate), | ||||
|         Action("Whisper", whisper), | ||||
|     ], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| fx.add_command( | ||||
|     key="C", | ||||
|     description="Run a chain of actions", | ||||
|     action=c1, | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def main(): | ||||
|     await fx.run() | ||||
|  | ||||
|  | ||||
| asyncio.run(main()) | ||||
| @@ -1,18 +1,22 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Falyx, Action, ChainedAction | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|  | ||||
|  | ||||
| # A flaky async step that fails randomly | ||||
| async def flaky_step(): | ||||
| async def flaky_step() -> str: | ||||
|     await asyncio.sleep(0.2) | ||||
|     if random.random() < 0.5: | ||||
|     if random.random() < 0.3: | ||||
|         raise RuntimeError("Random failure!") | ||||
|     print("Flaky step succeeded!") | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| # Create a retry handler | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
|   | ||||
							
								
								
									
										14
									
								
								examples/single_http.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								examples/single_http.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx.action import HTTPAction | ||||
|  | ||||
| http_action = HTTPAction( | ||||
|     name="Get Example", | ||||
|     method="GET", | ||||
|     url="https://jsonplaceholder.typicode.com/posts/1", | ||||
|     headers={"Accept": "application/json"}, | ||||
|     retry=True, | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(http_action()) | ||||
							
								
								
									
										53
									
								
								examples/submenu.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								examples/submenu.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,53 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|  | ||||
|  | ||||
| # A flaky async step that fails randomly | ||||
| async def flaky_step(): | ||||
|     await asyncio.sleep(0.2) | ||||
|     if random.random() < 0.5: | ||||
|         raise RuntimeError("Random failure!") | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
|  | ||||
| # Chain the actions | ||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||
|  | ||||
| # Create the CLI menu | ||||
| falyx = Falyx("🚀 Falyx Demo") | ||||
| falyx.add_command( | ||||
|     key="R", | ||||
|     description="Run My Pipeline", | ||||
|     action=chain, | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
| ) | ||||
|  | ||||
| # Create a submenu | ||||
| submenu = Falyx("Submenu") | ||||
| submenu.add_command( | ||||
|     key="T", | ||||
|     description="Test", | ||||
|     action=lambda: "test", | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
| ) | ||||
| falyx.add_submenu( | ||||
|     key="S", | ||||
|     description="Submenu", | ||||
|     submenu=submenu, | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(falyx.run()) | ||||
							
								
								
									
										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()) | ||||
							
								
								
									
										38
									
								
								examples/user_input_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								examples/user_input_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import asyncio | ||||
|  | ||||
| from prompt_toolkit.validation import Validator | ||||
|  | ||||
| from falyx.action import Action, ChainedAction, UserInputAction | ||||
|  | ||||
|  | ||||
| def validate_alpha() -> Validator: | ||||
|     def validate(text: str) -> bool: | ||||
|         return text.isalpha() | ||||
|  | ||||
|     return Validator.from_callable( | ||||
|         validate, | ||||
|         error_message="Please enter only alphabetic characters.", | ||||
|         move_cursor_to_end=True, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| chain = ChainedAction( | ||||
|     name="Demo Chain", | ||||
|     actions=[ | ||||
|         "Name", | ||||
|         UserInputAction( | ||||
|             name="User Input", | ||||
|             prompt_text="Enter your {last_result}: ", | ||||
|             validator=validate_alpha(), | ||||
|         ), | ||||
|         Action( | ||||
|             name="Display Name", | ||||
|             action=lambda last_result: print(f"Hello, {last_result}!"), | ||||
|         ), | ||||
|     ], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(chain.preview()) | ||||
|     asyncio.run(chain()) | ||||
| @@ -1,23 +1,19 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| import logging | ||||
|  | ||||
| from .action import Action, ActionGroup, ChainedAction, ProcessAction | ||||
| from .command import Command | ||||
| from .context import ExecutionContext, ResultsContext | ||||
| from .execution_registry import ExecutionRegistry | ||||
| from .falyx import Falyx | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
|  | ||||
| __version__ = "0.1.0" | ||||
|  | ||||
| __all__ = [ | ||||
|     "Action", | ||||
|     "ChainedAction", | ||||
|     "ActionGroup", | ||||
|     "ProcessAction", | ||||
|     "Falyx", | ||||
|     "Command", | ||||
|     "ExecutionContext", | ||||
|     "ResultsContext", | ||||
|     "ExecutionRegistry", | ||||
| ] | ||||
|   | ||||
| @@ -1,42 +1,120 @@ | ||||
| # falyx/__main__.py | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| import asyncio | ||||
| import logging | ||||
| import os | ||||
| import sys | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.action import Action | ||||
| from falyx.config import loader | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers | ||||
|  | ||||
|  | ||||
| def build_falyx() -> Falyx: | ||||
|     """Build and return a Falyx instance with all your commands.""" | ||||
|     app = Falyx(title="🚀 Falyx CLI") | ||||
| def find_falyx_config() -> Path | None: | ||||
|     candidates = [ | ||||
|         Path.cwd() / "falyx.yaml", | ||||
|         Path.cwd() / "falyx.toml", | ||||
|         Path.cwd() / ".falyx.yaml", | ||||
|         Path.cwd() / ".falyx.toml", | ||||
|         Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")), | ||||
|         Path.home() / ".config" / "falyx" / "falyx.yaml", | ||||
|         Path.home() / ".config" / "falyx" / "falyx.toml", | ||||
|         Path.home() / ".falyx.yaml", | ||||
|         Path.home() / ".falyx.toml", | ||||
|     ] | ||||
|     return next((p for p in candidates if p.exists()), None) | ||||
|  | ||||
|     # Example commands | ||||
|     app.add_command( | ||||
|         key="B", | ||||
|         description="Build project", | ||||
|         action=Action("Build", lambda: print("📦 Building...")), | ||||
|         tags=["build"] | ||||
|  | ||||
| def bootstrap() -> Path | None: | ||||
|     config_path = find_falyx_config() | ||||
|     if config_path and str(config_path.parent) not in sys.path: | ||||
|         sys.path.insert(0, str(config_path.parent)) | ||||
|     return config_path | ||||
|  | ||||
|  | ||||
| def init_config(parser: CommandArgumentParser) -> None: | ||||
|     parser.add_argument( | ||||
|         "name", | ||||
|         type=str, | ||||
|         help="Name of the new Falyx project", | ||||
|         default=".", | ||||
|         nargs="?", | ||||
|     ) | ||||
|  | ||||
|     app.add_command( | ||||
|         key="T", | ||||
|         description="Run tests", | ||||
|         action=Action("Test", lambda: print("🧪 Running tests...")), | ||||
|         tags=["test"] | ||||
|  | ||||
| def init_callback(args: Namespace) -> None: | ||||
|     """Callback for the init command.""" | ||||
|     if args.command == "init": | ||||
|         from falyx.init import init_project | ||||
|  | ||||
|         init_project(args.name) | ||||
|     elif args.command == "init_global": | ||||
|         from falyx.init import init_global | ||||
|  | ||||
|         init_global() | ||||
|  | ||||
|  | ||||
| def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]: | ||||
|     root_parser: ArgumentParser = get_root_parser() | ||||
|     subparsers = get_subparsers(root_parser) | ||||
|     init_parser = subparsers.add_parser( | ||||
|         "init", | ||||
|         help="Initialize a new Falyx project", | ||||
|         description="Create a new Falyx project with mock configuration files.", | ||||
|         epilog="If no name is provided, the current directory will be used.", | ||||
|     ) | ||||
|     init_parser.add_argument( | ||||
|         "name", | ||||
|         type=str, | ||||
|         help="Name of the new Falyx project", | ||||
|         default=".", | ||||
|         nargs="?", | ||||
|     ) | ||||
|     subparsers.add_parser( | ||||
|         "init-global", | ||||
|         help="Initialize Falyx global configuration", | ||||
|         description="Create a global Falyx configuration at ~/.config/falyx/.", | ||||
|     ) | ||||
|     return root_parser, subparsers | ||||
|  | ||||
|  | ||||
| def main() -> Any: | ||||
|     bootstrap_path = bootstrap() | ||||
|     if not bootstrap_path: | ||||
|         from falyx.init import init_global, init_project | ||||
|  | ||||
|         flx: Falyx = Falyx() | ||||
|         flx.add_command( | ||||
|             "I", | ||||
|             "Initialize a new Falyx project", | ||||
|             init_project, | ||||
|             aliases=["init"], | ||||
|             argument_config=init_config, | ||||
|             help_epilog="If no name is provided, the current directory will be used.", | ||||
|         ) | ||||
|         flx.add_command( | ||||
|             "G", | ||||
|             "Initialize Falyx global configuration", | ||||
|             init_global, | ||||
|             aliases=["init-global"], | ||||
|             help_text="Create a global Falyx configuration at ~/.config/falyx/.", | ||||
|         ) | ||||
|     else: | ||||
|         flx = loader(bootstrap_path) | ||||
|  | ||||
|     root_parser, subparsers = get_parsers() | ||||
|  | ||||
|     return asyncio.run( | ||||
|         flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback) | ||||
|     ) | ||||
|  | ||||
|     app.add_command( | ||||
|         key="D", | ||||
|         description="Deploy project", | ||||
|         action=Action("Deploy", lambda: print("🚀 Deploying...")), | ||||
|         tags=["deploy"] | ||||
|     ) | ||||
|  | ||||
|     return app | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     logging.basicConfig(level=logging.WARNING) | ||||
|     falyx = build_falyx() | ||||
|     asyncio.run(falyx.run()) | ||||
|  | ||||
|     main() | ||||
|   | ||||
							
								
								
									
										476
									
								
								falyx/action.py
									
									
									
									
									
								
							
							
						
						
									
										476
									
								
								falyx/action.py
									
									
									
									
									
								
							| @@ -1,476 +0,0 @@ | ||||
| """action.py | ||||
|  | ||||
| Any Action or Command is callable and supports the signature: | ||||
|     result = thing(*args, **kwargs) | ||||
|  | ||||
| This guarantees: | ||||
| - Hook lifecycle (before/after/error/teardown) | ||||
| - Timing | ||||
| - Consistent return values | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
| from abc import ABC, abstractmethod | ||||
| from concurrent.futures import ProcessPoolExecutor | ||||
| from functools import partial | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.context import ExecutionContext, ResultsContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import ensure_async, logger | ||||
|  | ||||
| console = Console() | ||||
|  | ||||
|  | ||||
| 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 Menu. | ||||
|  | ||||
|     inject_last_result (bool): Whether to inject the previous action's result into kwargs. | ||||
|     inject_last_result_as (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_last_result_as: str = "last_result", | ||||
|             logging_hooks: bool = False, | ||||
|     ) -> None: | ||||
|         self.name = name | ||||
|         self.hooks = hooks or HookManager() | ||||
|         self.is_retryable: bool = False | ||||
|         self.results_context: ResultsContext | None = None | ||||
|         self.inject_last_result: bool = inject_last_result | ||||
|         self.inject_last_result_as: str = inject_last_result_as | ||||
|  | ||||
|         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_results_context(self, results_context: ResultsContext): | ||||
|         self.results_context = results_context | ||||
|  | ||||
|     def prepare_for_chain(self, results_context: ResultsContext) -> BaseAction: | ||||
|         """ | ||||
|         Prepare the action specifically for sequential (ChainedAction) execution. | ||||
|         Can be overridden for chain-specific logic. | ||||
|         """ | ||||
|         self.set_results_context(results_context) | ||||
|         return self | ||||
|  | ||||
|     def prepare_for_group(self, results_context: ResultsContext) -> BaseAction: | ||||
|         """ | ||||
|         Prepare the action specifically for parallel (ActionGroup) execution. | ||||
|         Can be overridden for group-specific logic. | ||||
|         """ | ||||
|         self.set_results_context(results_context) | ||||
|         return self | ||||
|  | ||||
|     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: | ||||
|         if self.inject_last_result and self.results_context: | ||||
|             key = self.inject_last_result_as | ||||
|             if key in kwargs: | ||||
|                 logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key) | ||||
|             kwargs = dict(kwargs) | ||||
|             kwargs[key] = self.results_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) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"<{self.__class__.__name__} '{self.name}'>" | ||||
|  | ||||
|     def __repr__(self): | ||||
|         return str(self) | ||||
|  | ||||
|     @classmethod | ||||
|     def enable_retries_recursively(cls, action: BaseAction, policy: RetryPolicy | None): | ||||
|         if not policy: | ||||
|             policy = RetryPolicy(enabled=True) | ||||
|         if isinstance(action, Action): | ||||
|             action.retry_policy = policy | ||||
|             action.retry_policy.enabled = True | ||||
|             action.hooks.register("on_error", RetryHandler(policy).retry_on_error) | ||||
|  | ||||
|         if hasattr(action, "actions"): | ||||
|             for sub in action.actions: | ||||
|                 cls.enable_retries_recursively(sub, policy) | ||||
|  | ||||
|  | ||||
| class Action(BaseAction): | ||||
|     """A simple action that runs a callable. It can be a function or a coroutine.""" | ||||
|     def __init__( | ||||
|             self, | ||||
|             name: str, | ||||
|             action, | ||||
|             rollback=None, | ||||
|             args: tuple[Any, ...] = (), | ||||
|             kwargs: dict[str, Any] | None = None, | ||||
|             hooks: HookManager | None = None, | ||||
|             inject_last_result: bool = False, | ||||
|             inject_last_result_as: str = "last_result", | ||||
|             retry: bool = False, | ||||
|             retry_policy: RetryPolicy | None = None, | ||||
|     ) -> None: | ||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||
|         self.action = ensure_async(action) | ||||
|         self.rollback = rollback | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.is_retryable = True | ||||
|         self.retry_policy = retry_policy or RetryPolicy() | ||||
|         if retry or (retry_policy and retry_policy.enabled): | ||||
|             self.enable_retry() | ||||
|  | ||||
|     def enable_retry(self): | ||||
|         """Enable retry with the existing retry policy.""" | ||||
|         self.retry_policy.enabled = True | ||||
|         logger.debug(f"[Action:{self.name}] Registering retry handler") | ||||
|         handler = RetryHandler(self.retry_policy) | ||||
|         self.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
|  | ||||
|     def set_retry_policy(self, policy: RetryPolicy): | ||||
|         """Set a new retry policy and re-register the handler.""" | ||||
|         self.retry_policy = policy | ||||
|         self.enable_retry() | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         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, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             result = await self.action(*combined_args, **combined_kwargs) | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 logger.info("[%s] ✅ Recovered: %s", self.name, self.name) | ||||
|                 return context.result | ||||
|             raise error | ||||
|         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_b}]⚙ Action[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_last_result_as}')[/dim]") | ||||
|         if self.retry_policy.enabled: | ||||
|             label.append( | ||||
|                 f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, " | ||||
|                 f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x" | ||||
|             ) | ||||
|  | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             console.print(Tree("".join(label))) | ||||
|  | ||||
|  | ||||
| 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): | ||||
|     """A ChainedAction is a sequence of actions that are executed in order.""" | ||||
|     def __init__( | ||||
|             self, | ||||
|             name: str, | ||||
|             actions: list[BaseAction] | None = None, | ||||
|             hooks: HookManager | None = None, | ||||
|             inject_last_result: bool = False, | ||||
|             inject_last_result_as: str = "last_result", | ||||
|     ) -> None: | ||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||
|         ActionListMixin.__init__(self) | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> list[Any]: | ||||
|         results_context = ResultsContext(name=self.name) | ||||
|         if self.results_context: | ||||
|             results_context.add_result(self.results_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": []}, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             for index, action in enumerate(self.actions): | ||||
|                 results_context.current_index = index | ||||
|                 prepared = action.prepare_for_chain(results_context) | ||||
|                 result = await prepared(*args, **updated_kwargs) | ||||
|                 results_context.add_result(result) | ||||
|                 context.extra["results"].append(result) | ||||
|                 context.extra["rollback_stack"].append(prepared) | ||||
|  | ||||
|             context.result = context.extra["results"] | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|  | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             results_context.errors.append((results_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): | ||||
|         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) | ||||
|  | ||||
|     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_last_result_as}')[/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: | ||||
|             console.print(tree) | ||||
|  | ||||
|     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) | ||||
|  | ||||
|  | ||||
| class ActionGroup(BaseAction, ActionListMixin): | ||||
|     """An ActionGroup is a collection of actions that can be run in parallel.""" | ||||
|     def __init__( | ||||
|             self, | ||||
|             name: str, | ||||
|             actions: list[BaseAction] | None = None, | ||||
|             hooks: HookManager | None = None, | ||||
|             inject_last_result: bool = False, | ||||
|             inject_last_result_as: str = "last_result", | ||||
|     ): | ||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||
|         ActionListMixin.__init__(self) | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: | ||||
|         results_context = ResultsContext(name=self.name, is_parallel=True) | ||||
|         if self.results_context: | ||||
|             results_context.set_shared_result(self.results_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": []}, | ||||
|         ) | ||||
|         async def run_one(action: BaseAction): | ||||
|             try: | ||||
|                 prepared = action.prepare_for_group(results_context) | ||||
|                 result = await prepared(*args, **updated_kwargs) | ||||
|                 results_context.add_result((action.name, result)) | ||||
|                 context.extra["results"].append((action.name, result)) | ||||
|             except Exception as error: | ||||
|                 results_context.errors.append((results_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) | ||||
|  | ||||
|     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_last_result_as}')[/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: | ||||
|             console.print(tree) | ||||
|  | ||||
|     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) | ||||
|  | ||||
|  | ||||
| class ProcessAction(BaseAction): | ||||
|     """A ProcessAction runs a function in a separate process using ProcessPoolExecutor.""" | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         func: Callable[..., Any], | ||||
|         args: tuple = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         executor: ProcessPoolExecutor | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_last_result_as: str = "last_result", | ||||
|     ): | ||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||
|         self.func = func | ||||
|         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.results_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.func, *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) | ||||
|  | ||||
|     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_last_result_as}')[/dim]") | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             console.print(Tree("".join(label))) | ||||
|  | ||||
|     def _validate_pickleable(self, obj: Any) -> bool: | ||||
|         try: | ||||
|             import pickle | ||||
|             pickle.dumps(obj) | ||||
|             return True | ||||
|         except (pickle.PicklingError, TypeError): | ||||
|             return False | ||||
							
								
								
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										52
									
								
								falyx/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								falyx/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .action import Action | ||||
| from .action_factory import ActionFactory | ||||
| from .action_group import ActionGroup | ||||
| from .base_action import BaseAction | ||||
| from .chained_action import ChainedAction | ||||
| from .confirm_action import ConfirmAction | ||||
| from .fallback_action import FallbackAction | ||||
| from .http_action import HTTPAction | ||||
| from .io_action import BaseIOAction | ||||
| from .literal_input_action import LiteralInputAction | ||||
| from .load_file_action import LoadFileAction | ||||
| 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 .selection_action import SelectionAction | ||||
| from .shell_action import ShellAction | ||||
| from .signal_action import SignalAction | ||||
| from .user_input_action import UserInputAction | ||||
|  | ||||
| __all__ = [ | ||||
|     "Action", | ||||
|     "ActionGroup", | ||||
|     "BaseAction", | ||||
|     "ChainedAction", | ||||
|     "ProcessAction", | ||||
|     "ActionFactory", | ||||
|     "HTTPAction", | ||||
|     "BaseIOAction", | ||||
|     "ShellAction", | ||||
|     "SelectionAction", | ||||
|     "SelectFileAction", | ||||
|     "MenuAction", | ||||
|     "SignalAction", | ||||
|     "FallbackAction", | ||||
|     "LiteralInputAction", | ||||
|     "UserInputAction", | ||||
|     "PromptMenuAction", | ||||
|     "ProcessPoolAction", | ||||
|     "LoadFileAction", | ||||
|     "SaveFileAction", | ||||
|     "ConfirmAction", | ||||
| ] | ||||
							
								
								
									
										162
									
								
								falyx/action/action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								falyx/action/action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, 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.logger import logger | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class Action(BaseAction): | ||||
|     """ | ||||
|     Action wraps a simple function or coroutine into a standard executable unit. | ||||
|  | ||||
|     It supports: | ||||
|     - Optional retry logic. | ||||
|     - Hook lifecycle (before, success, error, after, teardown). | ||||
|     - Last result injection for chaining. | ||||
|     - Optional rollback handlers for undo logic. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         action (Callable): The function or coroutine to execute. | ||||
|         rollback (Callable, optional): Rollback function to undo the action. | ||||
|         args (tuple, optional): Static positional arguments. | ||||
|         kwargs (dict, optional): Static keyword arguments. | ||||
|         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||
|         inject_last_result (bool, optional): Enable last_result injection. | ||||
|         inject_into (str, optional): Name of injected key. | ||||
|         retry (bool, optional): Enable retry logic. | ||||
|         retry_policy (RetryPolicy, optional): Retry settings. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         action: Callable[..., Any] | Callable[..., Awaitable[Any]], | ||||
|         *, | ||||
|         rollback: 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", | ||||
|         retry: bool = False, | ||||
|         retry_policy: RetryPolicy | None = None, | ||||
|     ) -> None: | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.action = action | ||||
|         self.rollback = rollback | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.is_retryable = True | ||||
|         self.retry_policy = retry_policy or RetryPolicy() | ||||
|         if retry or (retry_policy and retry_policy.enabled): | ||||
|             self.enable_retry() | ||||
|  | ||||
|     @property | ||||
|     def action(self) -> Callable[..., Awaitable[Any]]: | ||||
|         return self._action | ||||
|  | ||||
|     @action.setter | ||||
|     def action(self, value: Callable[..., Awaitable[Any]]): | ||||
|         self._action = ensure_async(value) | ||||
|  | ||||
|     @property | ||||
|     def rollback(self) -> Callable[..., Awaitable[Any]] | None: | ||||
|         return self._rollback | ||||
|  | ||||
|     @rollback.setter | ||||
|     def rollback(self, value: Callable[..., Awaitable[Any]] | None): | ||||
|         if value is None: | ||||
|             self._rollback = None | ||||
|         else: | ||||
|             self._rollback = ensure_async(value) | ||||
|  | ||||
|     def enable_retry(self): | ||||
|         """Enable retry with the existing retry policy.""" | ||||
|         self.retry_policy.enable_policy() | ||||
|         logger.debug("[%s] Registering retry handler", self.name) | ||||
|         handler = RetryHandler(self.retry_policy) | ||||
|         self.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
|  | ||||
|     def set_retry_policy(self, policy: RetryPolicy): | ||||
|         """Set a new retry policy and re-register the handler.""" | ||||
|         self.retry_policy = policy | ||||
|         if policy.enabled: | ||||
|             self.enable_retry() | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||
|         """ | ||||
|         Returns the callable to be used for argument inference. | ||||
|         By default, it returns the action itself. | ||||
|         """ | ||||
|         return self.action, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||
|  | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=combined_args, | ||||
|             kwargs=combined_kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             result = await self.action(*combined_args, **combined_kwargs) | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 logger.info("[%s] Recovered: %s", self.name, self.name) | ||||
|                 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) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ Action[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         if self.retry_policy.enabled: | ||||
|             label.append( | ||||
|                 f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, " | ||||
|                 f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x" | ||||
|             ) | ||||
|  | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"Action(name={self.name!r}, action=" | ||||
|             f"{getattr(self._action, '__name__', repr(self._action))}, " | ||||
|             f"retry={self.retry_policy.enabled}, " | ||||
|             f"rollback={self.rollback is not None})" | ||||
|         ) | ||||
							
								
								
									
										135
									
								
								falyx/action/action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								falyx/action/action_factory.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_factory_action.py""" | ||||
| 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 HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.protocols import ActionFactoryProtocol | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class ActionFactory(BaseAction): | ||||
|     """ | ||||
|     Dynamically creates and runs another Action at runtime using a factory function. | ||||
|  | ||||
|     This is useful for generating context-specific behavior (e.g., dynamic HTTPActions) | ||||
|     where the structure of the next action depends on runtime values. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         factory (Callable): A function that returns a BaseAction given args/kwargs. | ||||
|         inject_last_result (bool): Whether to inject last_result into the factory. | ||||
|         inject_into (str): The name of the kwarg to inject last_result as. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         factory: ActionFactoryProtocol, | ||||
|         *, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         preview_args: tuple[Any, ...] = (), | ||||
|         preview_kwargs: dict[str, Any] | None = None, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name=name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.factory = factory | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.preview_args = preview_args | ||||
|         self.preview_kwargs = preview_kwargs or {} | ||||
|  | ||||
|     @property | ||||
|     def factory(self) -> ActionFactoryProtocol: | ||||
|         return self._factory  # type: ignore[return-value] | ||||
|  | ||||
|     @factory.setter | ||||
|     def factory(self, value: ActionFactoryProtocol): | ||||
|         self._factory = ensure_async(value) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||
|         return self.factory, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         args = (*self.args, *args) | ||||
|         kwargs = {**self.kwargs, **kwargs} | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=f"{self.name} (factory)", | ||||
|             args=args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             generated_action = await self.factory(*args, **updated_kwargs) | ||||
|             if not isinstance(generated_action, BaseAction): | ||||
|                 raise TypeError( | ||||
|                     f"[{self.name}] Factory must return a BaseAction, got " | ||||
|                     f"{type(generated_action).__name__}" | ||||
|                 ) | ||||
|             if self.shared_context: | ||||
|                 generated_action.set_shared_context(self.shared_context) | ||||
|                 if hasattr(generated_action, "register_teardown") and callable( | ||||
|                     generated_action.register_teardown | ||||
|                 ): | ||||
|                     generated_action.register_teardown(self.shared_context.action.hooks) | ||||
|                     logger.debug( | ||||
|                         "[%s] Registered teardown for %s", | ||||
|                         self.name, | ||||
|                         generated_action.name, | ||||
|                     ) | ||||
|             if self.options_manager: | ||||
|                 generated_action.set_options_manager(self.options_manager) | ||||
|             context.result = await generated_action() | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.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.CYAN_b}]🏗️ ActionFactory[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         try: | ||||
|             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): | ||||
|                 await generated.preview(parent=tree) | ||||
|             else: | ||||
|                 tree.add( | ||||
|                     f"[{OneColors.DARK_RED}]⚠️ Factory did not return a BaseAction[/]" | ||||
|                 ) | ||||
|         except Exception as error: | ||||
|             tree.add(f"[{OneColors.DARK_RED}]⚠️ Preview failed: {error}[/]") | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
							
								
								
									
										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})" | ||||
							
								
								
									
										159
									
								
								falyx/action/http_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								falyx/action/http_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """http_action.py | ||||
| Defines an Action subclass for making HTTP requests using aiohttp within Falyx workflows. | ||||
|  | ||||
| Features: | ||||
| - Automatic reuse of aiohttp.ClientSession via SharedContext | ||||
| - JSON, query param, header, and body support | ||||
| - Retry integration and last_result injection | ||||
| - Clean resource teardown using hooks | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| import aiohttp | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| async def close_shared_http_session(context: ExecutionContext) -> None: | ||||
|     try: | ||||
|         shared_context: SharedContext = context.get_shared_context() | ||||
|         session = shared_context.get("http_session") | ||||
|         should_close = shared_context.get("_session_should_close", False) | ||||
|         if session and should_close: | ||||
|             await session.close() | ||||
|     except Exception as error: | ||||
|         logger.warning("Error closing shared HTTP session: %s", error) | ||||
|  | ||||
|  | ||||
| class HTTPAction(Action): | ||||
|     """ | ||||
|     An Action for executing HTTP requests using aiohttp with shared session reuse. | ||||
|  | ||||
|     This action integrates seamlessly into Falyx pipelines, with automatic session | ||||
|     management, result injection, and lifecycle hook support. It is ideal for CLI-driven | ||||
|     API workflows where you need to call remote services and process their responses. | ||||
|  | ||||
|     Features: | ||||
|     - Uses aiohttp for asynchronous HTTP requests | ||||
|     - Reuses a shared session via SharedContext to reduce connection overhead | ||||
|     - Automatically closes the session at the end of an ActionGroup (if applicable) | ||||
|     - Supports GET, POST, PUT, DELETE, etc. with full header, query, body support | ||||
|     - Retry and result injection compatible | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         method (str): HTTP method (e.g., 'GET', 'POST'). | ||||
|         url (str): The request URL. | ||||
|         headers (dict[str, str], optional): Request headers. | ||||
|         params (dict[str, Any], optional): URL query parameters. | ||||
|         json (dict[str, Any], optional): JSON body to send. | ||||
|         data (Any, optional): Raw data or form-encoded body. | ||||
|         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||
|         inject_last_result (bool): Enable last_result injection. | ||||
|         inject_into (str): Name of injected key. | ||||
|         retry (bool): Enable retry logic. | ||||
|         retry_policy (RetryPolicy): Retry settings. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         method: str, | ||||
|         url: str, | ||||
|         *, | ||||
|         args: tuple[Any, ...] = (), | ||||
|         headers: dict[str, str] | None = None, | ||||
|         params: dict[str, Any] | None = None, | ||||
|         json: dict[str, Any] | None = None, | ||||
|         data: Any = None, | ||||
|         hooks=None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         retry: bool = False, | ||||
|         retry_policy=None, | ||||
|     ): | ||||
|         self.method = method.upper() | ||||
|         self.url = url | ||||
|         self.headers = headers | ||||
|         self.params = params | ||||
|         self.json = json | ||||
|         self.data = data | ||||
|  | ||||
|         super().__init__( | ||||
|             name=name, | ||||
|             action=self._request, | ||||
|             args=args, | ||||
|             kwargs={}, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             retry=retry, | ||||
|             retry_policy=retry_policy, | ||||
|         ) | ||||
|  | ||||
|     async def _request(self, *_, **__) -> dict[str, Any]: | ||||
|         if self.shared_context: | ||||
|             context: SharedContext = self.shared_context | ||||
|             session = context.get("http_session") | ||||
|             if session is None: | ||||
|                 session = aiohttp.ClientSession() | ||||
|                 context.set("http_session", session) | ||||
|                 context.set("_session_should_close", True) | ||||
|         else: | ||||
|             session = aiohttp.ClientSession() | ||||
|  | ||||
|         try: | ||||
|             async with session.request( | ||||
|                 self.method, | ||||
|                 self.url, | ||||
|                 headers=self.headers, | ||||
|                 params=self.params, | ||||
|                 json=self.json, | ||||
|                 data=self.data, | ||||
|             ) as response: | ||||
|                 body = await response.text() | ||||
|                 return { | ||||
|                     "status": response.status, | ||||
|                     "url": str(response.url), | ||||
|                     "headers": dict(response.headers), | ||||
|                     "body": body, | ||||
|                 } | ||||
|         finally: | ||||
|             if not self.shared_context: | ||||
|                 await session.close() | ||||
|  | ||||
|     def register_teardown(self, hooks: HookManager): | ||||
|         hooks.register(HookType.ON_TEARDOWN, close_shared_http_session) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [ | ||||
|             f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'", | ||||
|             f"\n[dim]Method:[/] {self.method}", | ||||
|             f"\n[dim]URL:[/] {self.url}", | ||||
|         ] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f"\n[dim]Injects:[/] '{self.inject_into}'") | ||||
|         if self.retry_policy and self.retry_policy.enabled: | ||||
|             label.append( | ||||
|                 f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, " | ||||
|                 f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x" | ||||
|             ) | ||||
|  | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"HTTPAction(name={self.name!r}, method={self.method!r}, url={self.url!r}, " | ||||
|             f"headers={self.headers!r}, params={self.params!r}, json={self.json!r}, " | ||||
|             f"data={self.data!r}, retry={self.retry_policy.enabled}, " | ||||
|             f"inject_last_result={self.inject_last_result})" | ||||
|         ) | ||||
							
								
								
									
										169
									
								
								falyx/action/io_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								falyx/action/io_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,169 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """io_action.py | ||||
| BaseIOAction: A base class for stream- or buffer-based IO-driven Actions. | ||||
|  | ||||
| This module defines `BaseIOAction`, a specialized variant of `BaseAction` | ||||
| that interacts with standard input and output, enabling command-line pipelines, | ||||
| text filters, and stream processing tasks. | ||||
|  | ||||
| Features: | ||||
| - Supports buffered or streaming input modes. | ||||
| - Reads from stdin and writes to stdout automatically. | ||||
| - Integrates with lifecycle hooks and retry logic. | ||||
| - Subclasses must implement `from_input()`, `to_output()`, and `_run()`. | ||||
|  | ||||
| Common usage includes shell-like filters, input transformers, or any tool that | ||||
| needs to consume input from another process or pipeline. | ||||
| """ | ||||
| import asyncio | ||||
| import sys | ||||
| 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 BaseIOAction(BaseAction): | ||||
|     """ | ||||
|     Base class for IO-driven Actions that operate on stdin/stdout input streams. | ||||
|  | ||||
|     Designed for use in shell pipelines or programmatic workflows that pass data | ||||
|     through chained commands. It handles reading input, transforming it, and | ||||
|     emitting output — either as a one-time buffered operation or line-by-line streaming. | ||||
|  | ||||
|     Core responsibilities: | ||||
|     - Reads input from stdin or previous action result. | ||||
|     - Supports buffered or streaming modes via `mode`. | ||||
|     - Parses input via `from_input()` and formats output via `to_output()`. | ||||
|     - Executes `_run()` with the parsed input. | ||||
|     - Writes output to stdout. | ||||
|  | ||||
|     Subclasses must implement: | ||||
|     - `from_input(raw)`: Convert raw stdin or injected data into typed input. | ||||
|     - `to_output(data)`: Convert result into output string or bytes. | ||||
|     - `_run(parsed_input, *args, **kwargs)`: Core execution logic. | ||||
|  | ||||
|     Attributes: | ||||
|         mode (str): Either "buffered" or "stream". Controls input behavior. | ||||
|         inject_last_result (bool): Whether to inject shared context input. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         mode: str = "buffered", | ||||
|         logging_hooks: bool = True, | ||||
|         inject_last_result: bool = True, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name=name, | ||||
|             hooks=hooks, | ||||
|             logging_hooks=logging_hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.mode = mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> Any: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def to_output(self, result: Any) -> str | bytes: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     async def _resolve_input( | ||||
|         self, args: tuple[Any], kwargs: dict[str, Any] | ||||
|     ) -> str | bytes: | ||||
|         data = await self._read_stdin() | ||||
|         if data: | ||||
|             return self.from_input(data) | ||||
|  | ||||
|         if len(args) == 1: | ||||
|             return self.from_input(args[0]) | ||||
|  | ||||
|         if self.inject_last_result and self.shared_context: | ||||
|             return self.shared_context.last_result() | ||||
|  | ||||
|         return "" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs): | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|         try: | ||||
|             if self.mode == "stream": | ||||
|                 line_gen = await self._read_stdin_stream() | ||||
|                 async for _ in self._stream_lines(line_gen, args, kwargs): | ||||
|                     pass | ||||
|                 result = getattr(self, "_last_result", None) | ||||
|             else: | ||||
|                 parsed_input = await self._resolve_input(args, kwargs) | ||||
|                 result = await self._run(parsed_input) | ||||
|                 output = self.to_output(result) | ||||
|                 await self._write_stdout(output) | ||||
|             context.result = result | ||||
|             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 _read_stdin(self) -> str: | ||||
|         if not sys.stdin.isatty(): | ||||
|             return await asyncio.to_thread(sys.stdin.read) | ||||
|         return "" | ||||
|  | ||||
|     async def _read_stdin_stream(self) -> Any: | ||||
|         """Returns a generator that yields lines from stdin in a background thread.""" | ||||
|         loop = asyncio.get_running_loop() | ||||
|         return await loop.run_in_executor(None, lambda: iter(sys.stdin)) | ||||
|  | ||||
|     async def _stream_lines(self, line_gen, args, kwargs): | ||||
|         for line in line_gen: | ||||
|             parsed = self.from_input(line) | ||||
|             result = await self._run(parsed, *args, **kwargs) | ||||
|             self._last_result = result | ||||
|             output = self.to_output(result) | ||||
|             await self._write_stdout(output) | ||||
|             yield result | ||||
|  | ||||
|     async def _write_stdout(self, data: str) -> None: | ||||
|         await asyncio.to_thread(sys.stdout.write, data) | ||||
|         await asyncio.to_thread(sys.stdout.flush) | ||||
|  | ||||
|     async def _run(self, parsed_input: Any, *args, **kwargs) -> Any: | ||||
|         """Subclasses should override this with actual logic.""" | ||||
|         raise NotImplementedError("Must implement _run()") | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"<{self.__class__.__name__} '{self.name}' IOAction>" | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ IOAction[/] '{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))) | ||||
							
								
								
									
										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})" | ||||
							
								
								
									
										159
									
								
								falyx/action/menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								falyx/action/menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """menu_action.py""" | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from rich.table import Table | ||||
| 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.selection import prompt_for_selection, render_table_base | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import chunks | ||||
|  | ||||
|  | ||||
| class MenuAction(BaseAction): | ||||
|     """MenuAction class for creating single use menu actions.""" | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         menu_options: MenuOptionMap, | ||||
|         *, | ||||
|         title: str = "Select an option", | ||||
|         columns: int = 2, | ||||
|         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, | ||||
|         show_table: bool = True, | ||||
|         custom_table: Table | None = None, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.menu_options = menu_options | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.include_reserved = include_reserved | ||||
|         self.show_table = show_table | ||||
|         self.custom_table = custom_table | ||||
|  | ||||
|     def _build_table(self) -> Table: | ||||
|         if self.custom_table: | ||||
|             return self.custom_table | ||||
|         table = render_table_base( | ||||
|             title=self.title, | ||||
|             columns=self.columns, | ||||
|         ) | ||||
|         for chunk in chunks( | ||||
|             self.menu_options.items(include_reserved=self.include_reserved), self.columns | ||||
|         ): | ||||
|             row = [] | ||||
|             for key, option in chunk: | ||||
|                 row.append(option.render(key)) | ||||
|             table.add_row(*row) | ||||
|         return table | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             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: | ||||
|                 table = self._build_table() | ||||
|                 key_ = await prompt_for_selection( | ||||
|                     self.menu_options.keys(), | ||||
|                     table, | ||||
|                     default_selection=self.default_selection, | ||||
|                     prompt_session=self.prompt_session, | ||||
|                     prompt_message=self.prompt_message, | ||||
|                     show_table=self.show_table, | ||||
|                 ) | ||||
|                 if isinstance(key_, str): | ||||
|                     key = key_ | ||||
|                 else: | ||||
|                     assert False, "Unreachable, MenuAction only supports single selection" | ||||
|             option = self.menu_options[key] | ||||
|             result = await option.action(*args, **kwargs) | ||||
|             context.result = result | ||||
|             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.DARK_YELLOW_b}]📋 MenuAction[/] '{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"MenuAction(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'})" | ||||
|         ) | ||||
							
								
								
									
										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})" | ||||
							
								
								
									
										248
									
								
								falyx/action/select_file_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								falyx/action/select_file_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """select_file_action.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import csv | ||||
| import json | ||||
| import xml.etree.ElementTree as ET | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| import toml | ||||
| import yaml | ||||
| from prompt_toolkit import PromptSession | ||||
| 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.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SelectFileAction(BaseAction): | ||||
|     """ | ||||
|     SelectFileAction allows users to select a file from a directory and return: | ||||
|     - file content (as text, JSON, CSV, etc.) | ||||
|     - or the file path itself. | ||||
|  | ||||
|     Supported formats: text, json, yaml, toml, csv, tsv, xml. | ||||
|  | ||||
|     Useful for: | ||||
|     - dynamically loading config files | ||||
|     - interacting with user-selected data | ||||
|     - chaining file contents into workflows | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         directory (Path | str): Where to search for files. | ||||
|         title (str): Title of the selection menu. | ||||
|         columns (int): Number of columns in the selection menu. | ||||
|         prompt_message (str): Message to display when prompting for selection. | ||||
|         style (str): Style for the selection options. | ||||
|         suffix_filter (str | None): Restrict to certain file types. | ||||
|         return_type (FileType): What to return (path, content, parsed). | ||||
|         prompt_session (PromptSession | None): Prompt session for user input. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         directory: Path | str = ".", | ||||
|         *, | ||||
|         title: str = "Select a file", | ||||
|         columns: int = 3, | ||||
|         prompt_message: str = "Choose > ", | ||||
|         style: str = OneColors.WHITE, | ||||
|         suffix_filter: str | None = None, | ||||
|         return_type: FileType | str = FileType.PATH, | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|     ): | ||||
|         super().__init__(name) | ||||
|         self.directory = Path(directory).resolve() | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.suffix_filter = suffix_filter | ||||
|         self.style = style | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.return_type = self._coerce_return_type(return_type) | ||||
|  | ||||
|     @property | ||||
|     def number_selections(self) -> int | str: | ||||
|         return self._number_selections | ||||
|  | ||||
|     @number_selections.setter | ||||
|     def number_selections(self, value: int | str): | ||||
|         if isinstance(value, int) and value > 0: | ||||
|             self._number_selections: int | str = value | ||||
|         elif isinstance(value, str): | ||||
|             if value not in ("*"): | ||||
|                 raise ValueError("number_selections string must be one of '*'") | ||||
|             self._number_selections = value | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or one of '*'") | ||||
|  | ||||
|     def _coerce_return_type(self, return_type: FileType | str) -> FileType: | ||||
|         if isinstance(return_type, FileType): | ||||
|             return 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]: | ||||
|         value: Any | ||||
|         options = {} | ||||
|         for index, file in enumerate(files): | ||||
|             try: | ||||
|                 if self.return_type == FileType.TEXT: | ||||
|                     value = file.read_text(encoding="UTF-8") | ||||
|                 elif self.return_type == FileType.PATH: | ||||
|                     value = file | ||||
|                 elif self.return_type == FileType.JSON: | ||||
|                     value = json.loads(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.TOML: | ||||
|                     value = toml.loads(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.YAML: | ||||
|                     value = yaml.safe_load(file.read_text(encoding="UTF-8")) | ||||
|                 elif self.return_type == FileType.CSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as csvfile: | ||||
|                         reader = csv.reader(csvfile) | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileType.TSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||
|                         reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                         value = list(reader) | ||||
|                 elif self.return_type == FileType.XML: | ||||
|                     tree = ET.parse(file, parser=ET.XMLParser(encoding="UTF-8")) | ||||
|                     root = tree.getroot() | ||||
|                     value = ET.tostring(root, encoding="unicode") | ||||
|                 else: | ||||
|                     raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|  | ||||
|                 options[str(index)] = SelectionOption( | ||||
|                     description=file.name, value=value, style=self.style | ||||
|                 ) | ||||
|             except Exception as error: | ||||
|                 logger.error("Failed to parse %s: %s", file.name, error) | ||||
|         return options | ||||
|  | ||||
|     def _find_cancel_key(self, options) -> str: | ||||
|         """Return first numeric value not already used in the selection dict.""" | ||||
|         for index in range(len(options)): | ||||
|             if str(index) not in options: | ||||
|                 return str(index) | ||||
|         return str(len(options)) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             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 = [ | ||||
|                 file | ||||
|                 for file in self.directory.iterdir() | ||||
|                 if file.is_file() | ||||
|                 and (self.suffix_filter is None or file.suffix == self.suffix_filter) | ||||
|             ] | ||||
|             if not files: | ||||
|                 raise FileNotFoundError("No files found in directory.") | ||||
|  | ||||
|             options = self.get_options(files) | ||||
|  | ||||
|             cancel_key = self._find_cancel_key(options) | ||||
|             cancel_option = { | ||||
|                 cancel_key: SelectionOption( | ||||
|                     description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             table = render_selection_dict_table( | ||||
|                 title=self.title, selections=options | cancel_option, columns=self.columns | ||||
|             ) | ||||
|  | ||||
|             keys = await prompt_for_selection( | ||||
|                 (options | cancel_option).keys(), | ||||
|                 table, | ||||
|                 prompt_session=self.prompt_session, | ||||
|                 prompt_message=self.prompt_message, | ||||
|                 number_selections=self.number_selections, | ||||
|                 separator=self.separator, | ||||
|                 allow_duplicates=self.allow_duplicates, | ||||
|                 cancel_key=cancel_key, | ||||
|             ) | ||||
|  | ||||
|             if isinstance(keys, str): | ||||
|                 if keys == cancel_key: | ||||
|                     raise CancelSignal("User canceled the selection.") | ||||
|                 result = options[keys].value | ||||
|             elif isinstance(keys, list): | ||||
|                 result = [options[key].value for key in keys] | ||||
|  | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
|         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}]📁 SelectFilesAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         tree.add(f"[dim]Directory:[/] {str(self.directory)}") | ||||
|         tree.add(f"[dim]Suffix filter:[/] {self.suffix_filter or 'None'}") | ||||
|         tree.add(f"[dim]Return type:[/] {self.return_type}") | ||||
|         tree.add(f"[dim]Prompt:[/] {self.prompt_message}") | ||||
|         tree.add(f"[dim]Columns:[/] {self.columns}") | ||||
|         try: | ||||
|             files = list(self.directory.iterdir()) | ||||
|             if self.suffix_filter: | ||||
|                 files = [file for file in files if file.suffix == self.suffix_filter] | ||||
|             sample = files[:10] | ||||
|             file_list = tree.add("[dim]Files:[/]") | ||||
|             for file in sample: | ||||
|                 file_list.add(f"[dim]{file.name}[/]") | ||||
|             if len(files) > 10: | ||||
|                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") | ||||
|         except Exception as error: | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]⚠️ Error scanning directory: {error}[/]") | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, " | ||||
|             f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})" | ||||
|         ) | ||||
							
								
								
									
										374
									
								
								falyx/action/selection_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										374
									
								
								falyx/action/selection_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,374 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection_action.py""" | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action_types import SelectionReturnType | ||||
| 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.selection import ( | ||||
|     SelectionOption, | ||||
|     SelectionOptionMap, | ||||
|     prompt_for_index, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
|     render_selection_indexed_table, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SelectionAction(BaseAction): | ||||
|     """ | ||||
|     A selection action that prompts the user to select an option from a list or | ||||
|     dictionary. The selected option is then returned as the result of the action. | ||||
|  | ||||
|     If return_key is True, the key of the selected option is returned instead of | ||||
|     the value. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         selections: ( | ||||
|             list[str] | ||||
|             | set[str] | ||||
|             | tuple[str, ...] | ||||
|             | dict[str, SelectionOption] | ||||
|             | dict[str, Any] | ||||
|         ), | ||||
|         *, | ||||
|         title: str = "Select an option", | ||||
|         columns: int = 5, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         return_type: SelectionReturnType | str = "value", | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
|         show_table: bool = True, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         # Setter normalizes to correct type, mypy can't infer that | ||||
|         self.selections: list[str] | SelectionOptionMap = selections  # type: ignore[assignment] | ||||
|         self.return_type: SelectionReturnType = self._coerce_return_type(return_type) | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.default_selection = default_selection | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_message = prompt_message | ||||
|         self.show_table = show_table | ||||
|  | ||||
|     @property | ||||
|     def number_selections(self) -> int | str: | ||||
|         return self._number_selections | ||||
|  | ||||
|     @number_selections.setter | ||||
|     def number_selections(self, value: int | str): | ||||
|         if isinstance(value, int) and value > 0: | ||||
|             self._number_selections: int | str = value | ||||
|         elif isinstance(value, str): | ||||
|             if value not in ("*"): | ||||
|                 raise ValueError("number_selections string must be '*'") | ||||
|             self._number_selections = value | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or '*'") | ||||
|  | ||||
|     def _coerce_return_type( | ||||
|         self, return_type: SelectionReturnType | str | ||||
|     ) -> SelectionReturnType: | ||||
|         if isinstance(return_type, SelectionReturnType): | ||||
|             return return_type | ||||
|         return SelectionReturnType(return_type) | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | SelectionOptionMap: | ||||
|         return self._selections | ||||
|  | ||||
|     @selections.setter | ||||
|     def selections( | ||||
|         self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] | ||||
|     ): | ||||
|         if isinstance(value, (list, tuple, set)): | ||||
|             self._selections: list[str] | SelectionOptionMap = list(value) | ||||
|         elif isinstance(value, dict): | ||||
|             som = SelectionOptionMap() | ||||
|             if all(isinstance(key, str) for key in value) and all( | ||||
|                 not isinstance(value[key], SelectionOption) for key in value | ||||
|             ): | ||||
|                 som.update( | ||||
|                     { | ||||
|                         str(index): SelectionOption(key, option) | ||||
|                         for index, (key, option) in enumerate(value.items()) | ||||
|                     } | ||||
|                 ) | ||||
|             elif all(isinstance(key, str) for key in value) and all( | ||||
|                 isinstance(value[key], SelectionOption) for key in value | ||||
|             ): | ||||
|                 som.update(value) | ||||
|             else: | ||||
|                 raise ValueError("Invalid dictionary format. Keys must be strings") | ||||
|             self._selections = som | ||||
|         else: | ||||
|             raise TypeError( | ||||
|                 "'selections' must be a list[str] or dict[str, SelectionOption], " | ||||
|                 f"got {type(value).__name__}" | ||||
|             ) | ||||
|  | ||||
|     def _find_cancel_key(self) -> str: | ||||
|         """Find the cancel key in the selections.""" | ||||
|         if isinstance(self.selections, dict): | ||||
|             for index in range(len(self.selections) + 1): | ||||
|                 if str(index) not in self.selections: | ||||
|                     return str(index) | ||||
|         return str(len(self.selections)) | ||||
|  | ||||
|     @property | ||||
|     def cancel_key(self) -> str: | ||||
|         return self._cancel_key | ||||
|  | ||||
|     @cancel_key.setter | ||||
|     def cancel_key(self, value: str) -> None: | ||||
|         """Set the cancel key for the selection.""" | ||||
|         if not isinstance(value, str): | ||||
|             raise TypeError("Cancel key must be a string.") | ||||
|         if isinstance(self.selections, dict) and value in self.selections: | ||||
|             raise ValueError( | ||||
|                 "Cancel key cannot be one of the selection keys. " | ||||
|                 f"Current selections: {self.selections}" | ||||
|             ) | ||||
|         if isinstance(self.selections, list): | ||||
|             if not value.isdigit() or int(value) > len(self.selections): | ||||
|                 raise ValueError( | ||||
|                     "cancel_key must be a digit and not greater than the number of selections." | ||||
|                 ) | ||||
|         self._cancel_key = value | ||||
|  | ||||
|     def cancel_formatter(self, index: int, selection: str) -> str: | ||||
|         """Format the cancel option for display.""" | ||||
|         if self.cancel_key == str(index): | ||||
|             return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]" | ||||
|         return f"[{index}] {selection}" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     def _get_result_from_keys(self, keys: str | list[str]) -> Any: | ||||
|         if not isinstance(self.selections, dict): | ||||
|             raise TypeError("Selections must be a dictionary to get result by keys.") | ||||
|         if self.return_type == SelectionReturnType.KEY: | ||||
|             result: Any = keys | ||||
|         elif self.return_type == SelectionReturnType.VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].value for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].value | ||||
|         elif self.return_type == SelectionReturnType.ITEMS: | ||||
|             if isinstance(keys, list): | ||||
|                 result = {key: self.selections[key] for key in keys} | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {keys: self.selections[keys]} | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].description for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].description | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = { | ||||
|                     self.selections[key].description: self.selections[key].value | ||||
|                     for key in keys | ||||
|                 } | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {self.selections[keys].description: self.selections[keys].value} | ||||
|         else: | ||||
|             raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|         return result | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|  | ||||
|         effective_default = str(self.default_selection) | ||||
|         maybe_result = str(self.last_result) | ||||
|         if isinstance(self.selections, dict): | ||||
|             if maybe_result in self.selections: | ||||
|                 effective_default = maybe_result | ||||
|             elif self.inject_last_result: | ||||
|                 logger.warning( | ||||
|                     "[%s] Injected last result '%s' not found in selections", | ||||
|                     self.name, | ||||
|                     maybe_result, | ||||
|                 ) | ||||
|         elif isinstance(self.selections, list): | ||||
|             if maybe_result.isdigit() and int(maybe_result) in range( | ||||
|                 len(self.selections) | ||||
|             ): | ||||
|                 effective_default = maybe_result | ||||
|             elif self.inject_last_result: | ||||
|                 logger.warning( | ||||
|                     "[%s] Injected last result '%s' not found in selections", | ||||
|                     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 " | ||||
|                 "or usable last_result was available." | ||||
|             ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             self.cancel_key = self._find_cancel_key() | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             if isinstance(self.selections, list): | ||||
|                 table = render_selection_indexed_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections + ["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, | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     keys = await prompt_for_selection( | ||||
|                         (self.selections | cancel_option).keys(), | ||||
|                         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: | ||||
|                     keys = effective_default | ||||
|                 if keys == self.cancel_key: | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|  | ||||
|                 result = self._get_result_from_keys(keys) | ||||
|             else: | ||||
|                 raise TypeError( | ||||
|                     "'selections' must be a list[str] or dict[str, Any], " | ||||
|                     f"got {type(self.selections).__name__}" | ||||
|                 ) | ||||
|             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) | ||||
|             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_RED}]🧭 SelectionAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         if isinstance(self.selections, list): | ||||
|             sub = tree.add(f"[dim]Type:[/] List[str] ({len(self.selections)} items)") | ||||
|             for i, item in enumerate(self.selections[:10]):  # limit to 10 | ||||
|                 sub.add(f"[dim]{i}[/]: {item}") | ||||
|             if len(self.selections) > 10: | ||||
|                 sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") | ||||
|         elif isinstance(self.selections, dict): | ||||
|             sub = tree.add( | ||||
|                 f"[dim]Type:[/] Dict[str, (str, Any)] ({len(self.selections)} items)" | ||||
|             ) | ||||
|             for i, (key, option) in enumerate(list(self.selections.items())[:10]): | ||||
|                 sub.add(f"[dim]{key}[/]: {option.description}") | ||||
|             if len(self.selections) > 10: | ||||
|                 sub.add(f"[dim]... ({len(self.selections) - 10} more)[/]") | ||||
|         else: | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]Invalid selections type[/]") | ||||
|             return | ||||
|  | ||||
|         tree.add(f"[dim]Default:[/] '{self.default_selection or self.last_result}'") | ||||
|         tree.add(f"[dim]Return:[/] {self.return_type.name.capitalize()}") | ||||
|         tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         selection_type = ( | ||||
|             "List" | ||||
|             if isinstance(self.selections, list) | ||||
|             else "Dict" if isinstance(self.selections, dict) else "Unknown" | ||||
|         ) | ||||
|         return ( | ||||
|             f"SelectionAction(name={self.name!r}, type={selection_type}, " | ||||
|             f"default_selection={self.default_selection!r}, " | ||||
|             f"return_type={self.return_type!r}, " | ||||
|             f"prompt={'off' if self.never_prompt else 'on'})" | ||||
|         ) | ||||
							
								
								
									
										105
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """shell_action.py | ||||
| Execute shell commands with input substitution.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.io_action import BaseIOAction | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ShellAction(BaseIOAction): | ||||
|     """ | ||||
|     ShellAction wraps a shell command template for CLI pipelines. | ||||
|  | ||||
|     This Action takes parsed input (from stdin, literal, or last_result), | ||||
|     substitutes it into the provided shell command template, and executes | ||||
|     the command asynchronously using subprocess. | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
|             raise TypeError( | ||||
|                 f"{self.name} expected str or bytes input, got {type(raw).__name__}" | ||||
|             ) | ||||
|         return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         if sys.stdin.isatty(): | ||||
|             return self._run, {"parsed_input": {"help": self.command_template}} | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, parsed_input: str) -> str: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             try: | ||||
|                 args = shlex.split(command) | ||||
|             except ValueError as error: | ||||
|                 raise FalyxError(f"Invalid command template: {error}") | ||||
|             result = subprocess.run(args, capture_output=True, text=True, check=True) | ||||
|         else: | ||||
|             result = subprocess.run( | ||||
|                 command, shell=True, text=True, capture_output=True, check=True | ||||
|             ) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|  | ||||
|     def to_output(self, result: str) -> str: | ||||
|         return result | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," | ||||
|             f" safe_mode={self.safe_mode})" | ||||
|         ) | ||||
							
								
								
									
										43
									
								
								falyx/action/signal_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								falyx/action/signal_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signal_action.py""" | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.signals import FlowSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SignalAction(Action): | ||||
|     """ | ||||
|     An action that raises a control flow signal when executed. | ||||
|  | ||||
|     Useful for exiting a menu, going back, or halting execution gracefully. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, name: str, signal: FlowSignal): | ||||
|         self.signal = signal | ||||
|         super().__init__(name, action=self.raise_signal) | ||||
|  | ||||
|     async def raise_signal(self, *args, **kwargs): | ||||
|         raise self.signal | ||||
|  | ||||
|     @property | ||||
|     def signal(self): | ||||
|         return self._signal | ||||
|  | ||||
|     @signal.setter | ||||
|     def signal(self, value: FlowSignal): | ||||
|         if not isinstance(value, FlowSignal): | ||||
|             raise TypeError( | ||||
|                 f"Signal must be an FlowSignal instance, got {type(value).__name__}" | ||||
|             ) | ||||
|         self._signal = value | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"SignalAction(name={self.name}, signal={self._signal.__class__.__name__})" | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = f"[{OneColors.LIGHT_RED}]⚡ SignalAction[/] '{self.signal.__class__.__name__}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
							
								
								
									
										98
									
								
								falyx/action/user_input_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/action/user_input_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """user_input_action.py""" | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.validation import Validator | ||||
| from rich.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.themes.colors import OneColors | ||||
|  | ||||
|  | ||||
| class UserInputAction(BaseAction): | ||||
|     """ | ||||
|     Prompts the user for input via PromptSession and returns the result. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Action name. | ||||
|         prompt_text (str): Prompt text (can include '{last_result}' for interpolation). | ||||
|         validator (Validator, optional): Prompt Toolkit validator. | ||||
|         prompt_session (PromptSession, optional): Reusable prompt session. | ||||
|         inject_last_result (bool): Whether to inject last_result into prompt. | ||||
|         inject_into (str): Key to use for injection (default: 'last_result'). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         prompt_text: str = "Input > ", | ||||
|         default_text: str = "", | ||||
|         validator: Validator | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name=name, | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.prompt_text = prompt_text | ||||
|         self.validator = validator | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.default_text = default_text | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> str: | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             prompt_text = self.prompt_text | ||||
|             if self.inject_last_result and self.last_result: | ||||
|                 prompt_text = prompt_text.format(last_result=self.last_result) | ||||
|  | ||||
|             answer = await self.prompt_session.prompt_async( | ||||
|                 prompt_text, | ||||
|                 validator=self.validator, | ||||
|                 default=kwargs.get("default_text", self.default_text), | ||||
|             ) | ||||
|             context.result = answer | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return answer | ||||
|         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.MAGENTA}]⌨ UserInputAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         prompt_text = ( | ||||
|             self.prompt_text.replace("{last_result}", "<last_result>") | ||||
|             if "{last_result}" in self.prompt_text | ||||
|             else self.prompt_text | ||||
|         ) | ||||
|         tree.add(f"[dim]Prompt:[/] {prompt_text}") | ||||
|         if self.validator: | ||||
|             tree.add("[dim]Validator:[/] Yes") | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"UserInputAction(name={self.name!r}, prompt={self.prompt!r})" | ||||
| @@ -1,3 +1,4 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """bottom_bar.py""" | ||||
|  | ||||
| from typing import Any, Callable | ||||
| @@ -6,9 +7,10 @@ from prompt_toolkit.formatted_text import HTML, merge_formatted_text | ||||
| from prompt_toolkit.key_binding import KeyBindings | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
|  | ||||
|  | ||||
| class BottomBar: | ||||
| @@ -29,8 +31,7 @@ class BottomBar: | ||||
|         key_validator: Callable[[str], bool] | None = None, | ||||
|     ) -> None: | ||||
|         self.columns = columns | ||||
|         self.console = Console() | ||||
|         self._items: list[Callable[[], HTML]] = [] | ||||
|         self.console: Console = console | ||||
|         self._named_items: dict[str, Callable[[], HTML]] = {} | ||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||
|         self.toggle_keys: list[str] = [] | ||||
| @@ -45,11 +46,7 @@ class BottomBar: | ||||
|     def space(self) -> int: | ||||
|         return self.console.width // self.columns | ||||
|  | ||||
|     def add_custom( | ||||
|         self, | ||||
|         name: str, | ||||
|         render_fn: Callable[[], HTML] | ||||
|     ) -> None: | ||||
|     def add_custom(self, name: str, render_fn: Callable[[], HTML]) -> None: | ||||
|         """Add a custom render function to the bottom bar.""" | ||||
|         if not callable(render_fn): | ||||
|             raise ValueError("`render_fn` must be callable") | ||||
| @@ -63,9 +60,7 @@ class BottomBar: | ||||
|         bg: str = OneColors.WHITE, | ||||
|     ) -> None: | ||||
|         def render(): | ||||
|             return HTML( | ||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" | ||||
|             ) | ||||
|             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(name, render) | ||||
|  | ||||
| @@ -85,9 +80,7 @@ class BottomBar: | ||||
|             get_value_ = self._value_getters[name] | ||||
|             current_ = get_value_() | ||||
|             text = f"{label}: {current_}" | ||||
|             return HTML( | ||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" | ||||
|             ) | ||||
|             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(name, render) | ||||
|  | ||||
| @@ -99,6 +92,7 @@ class BottomBar: | ||||
|         total: int, | ||||
|         fg: str = OneColors.BLACK, | ||||
|         bg: str = OneColors.WHITE, | ||||
|         enforce_total: bool = True, | ||||
|     ) -> None: | ||||
|         if not callable(get_current): | ||||
|             raise ValueError("`get_current` must be a callable returning int") | ||||
| @@ -108,14 +102,12 @@ class BottomBar: | ||||
|         def render(): | ||||
|             get_current_ = self._value_getters[name] | ||||
|             current_value = get_current_() | ||||
|             if current_value > total: | ||||
|             if current_value > total and enforce_total: | ||||
|                 raise ValueError( | ||||
|                     f"Current value {current_value} is greater than total value {total}" | ||||
|                 ) | ||||
|             text = f"{label}: {current_value}/{total}" | ||||
|             return HTML( | ||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" | ||||
|             ) | ||||
|             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(name, render) | ||||
|  | ||||
| @@ -137,7 +129,9 @@ class BottomBar: | ||||
|         if key in self.toggle_keys: | ||||
|             raise ValueError(f"Key {key} is already used as a toggle") | ||||
|         if self.key_validator and not self.key_validator(key): | ||||
|             raise ValueError(f"Key '{key}' conflicts with existing command, toggle, or reserved key.") | ||||
|             raise ValueError( | ||||
|                 f"Key '{key}' conflicts with existing command, toggle, or reserved key." | ||||
|             ) | ||||
|         self._value_getters[key] = get_state | ||||
|         self.toggle_keys.append(key) | ||||
|  | ||||
| @@ -146,16 +140,14 @@ class BottomBar: | ||||
|             color = bg_on if get_state_() else bg_off | ||||
|             status = "ON" if get_state_() else "OFF" | ||||
|             text = f"({key.upper()}) {label}: {status}" | ||||
|             return HTML( | ||||
|                 f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>" | ||||
|             ) | ||||
|             return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(key, render) | ||||
|  | ||||
|         for k in (key.upper(), key.lower()): | ||||
|  | ||||
|             @self.key_bindings.add(k) | ||||
|             def _(event): | ||||
|             def _(_): | ||||
|                 toggle_state() | ||||
|  | ||||
|     def add_toggle_from_option( | ||||
| @@ -169,6 +161,7 @@ class BottomBar: | ||||
|         bg_on: str = OneColors.GREEN, | ||||
|         bg_off: str = OneColors.DARK_RED, | ||||
|     ) -> None: | ||||
|         """Add a toggle to the bottom bar based on an option from OptionsManager.""" | ||||
|         self.add_toggle( | ||||
|             key=key, | ||||
|             label=label, | ||||
| @@ -185,15 +178,33 @@ class BottomBar: | ||||
|         return {label: getter() for label, getter in self._value_getters.items()} | ||||
|  | ||||
|     def get_value(self, name: str) -> Any: | ||||
|         """Get the current value of a registered item.""" | ||||
|         if name not in self._value_getters: | ||||
|             raise ValueError(f"No value getter registered under name: '{name}'") | ||||
|         return self._value_getters[name]() | ||||
|  | ||||
|     def remove_item(self, name: str) -> None: | ||||
|         """Remove an item from the bottom bar.""" | ||||
|         self._named_items.pop(name, None) | ||||
|         self._value_getters.pop(name, None) | ||||
|         if name in self.toggle_keys: | ||||
|             self.toggle_keys.remove(name) | ||||
|  | ||||
|     def clear(self) -> None: | ||||
|         """Clear all items from the bottom bar.""" | ||||
|         self._value_getters.clear() | ||||
|         self._named_items.clear() | ||||
|         self.toggle_keys.clear() | ||||
|  | ||||
|     def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None: | ||||
|         if name in self._named_items: | ||||
|             raise ValueError(f"Bottom bar item '{name}' already exists") | ||||
|         self._named_items[name] = render_fn | ||||
|         self._items = list(self._named_items.values()) | ||||
|  | ||||
|     def render(self): | ||||
|         return merge_formatted_text([fn() for fn in self._items]) | ||||
|         """Render the bottom bar.""" | ||||
|         lines = [] | ||||
|         for chunk in chunks(self._named_items.values(), self.columns): | ||||
|             lines.extend(list(chunk)) | ||||
|             lines.append(lambda: HTML("\n")) | ||||
|         return merge_formatted_text([fn() for fn in lines[:-1]]) | ||||
|   | ||||
							
								
								
									
										328
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										328
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -1,43 +1,122 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """command.py | ||||
| Any Action or Command is callable and supports the signature: | ||||
|     result = thing(*args, **kwargs) | ||||
|  | ||||
| This guarantees: | ||||
| - Hook lifecycle (before/after/error/teardown) | ||||
| - Timing | ||||
| - Consistent return values | ||||
| Defines the Command class for Falyx CLI. | ||||
|  | ||||
| Commands are callable units representing a menu option or CLI task, | ||||
| wrapping either a BaseAction or a simple function. They provide: | ||||
|  | ||||
| - Hook lifecycle (before, on_success, on_error, after, on_teardown) | ||||
| - Execution timing and duration tracking | ||||
| - Retry logic (single action or recursively through action trees) | ||||
| - Confirmation prompts and spinner integration | ||||
| - Result capturing and summary logging | ||||
| - Rich-based preview for CLI display | ||||
|  | ||||
| Every Command is self-contained, configurable, and plays a critical role | ||||
| in building robust interactive menus. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Callable | ||||
| import shlex | ||||
| from typing import Any, Awaitable, Callable | ||||
|  | ||||
| from prompt_toolkit.formatted_text import FormattedText | ||||
| from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action import Action, BaseAction | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.console import console | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.command_argument_parser import CommandArgumentParser | ||||
| from falyx.parser.signature import infer_args_from_func | ||||
| from falyx.prompt_utils import confirm_async, should_prompt_user | ||||
| from falyx.protocols import ArgParserProtocol | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import _noop, ensure_async, logger | ||||
|  | ||||
| console = Console() | ||||
| from falyx.retry_utils import enable_retries_recursively | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class Command(BaseModel): | ||||
|     """Class representing an command in the menu.""" | ||||
|     """ | ||||
|     Represents a selectable command in a Falyx menu system. | ||||
|  | ||||
|     A Command wraps an executable action (function, coroutine, or BaseAction) | ||||
|     and enhances it with: | ||||
|  | ||||
|     - Lifecycle hooks (before, success, error, after, teardown) | ||||
|     - Retry support (single action or recursive for chained/grouped actions) | ||||
|     - Confirmation prompts for safe execution | ||||
|     - Spinner visuals during execution | ||||
|     - Tagging for categorization and filtering | ||||
|     - Rich-based CLI previews | ||||
|     - Result tracking and summary reporting | ||||
|  | ||||
|     Commands are built to be flexible yet robust, enabling dynamic CLI workflows | ||||
|     without sacrificing control or reliability. | ||||
|  | ||||
|     Attributes: | ||||
|         key (str): Primary trigger key for the command. | ||||
|         description (str): Short description for the menu display. | ||||
|         hidden (bool): Toggles visibility in the menu. | ||||
|         aliases (list[str]): Alternate keys or phrases. | ||||
|         action (BaseAction | Callable): The executable logic. | ||||
|         args (tuple): Static positional arguments. | ||||
|         kwargs (dict): Static keyword arguments. | ||||
|         help_text (str): Additional help or guidance text. | ||||
|         style (str): Rich style for description. | ||||
|         confirm (bool): Whether to require confirmation before executing. | ||||
|         confirm_message (str): Custom confirmation prompt. | ||||
|         preview_before_confirm (bool): Whether to preview before confirming. | ||||
|         spinner (bool): Whether to show a spinner during execution. | ||||
|         spinner_message (str): Spinner text message. | ||||
|         spinner_type (str): Spinner style (e.g., dots, line, etc.). | ||||
|         spinner_style (str): Color or style of the spinner. | ||||
|         spinner_kwargs (dict): Extra spinner configuration. | ||||
|         hooks (HookManager): Hook manager for lifecycle events. | ||||
|         retry (bool): Enable retry on failure. | ||||
|         retry_all (bool): Enable retry across chained or grouped actions. | ||||
|         retry_policy (RetryPolicy): Retry behavior configuration. | ||||
|         tags (list[str]): Organizational tags for the command. | ||||
|         logging_hooks (bool): Whether to attach logging hooks automatically. | ||||
|         options_manager (OptionsManager): Manages global command-line options. | ||||
|         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_help (Callable[[], str | None] | None): Custom help message generator. | ||||
|         auto_args (bool): Automatically infer arguments from the action. | ||||
|  | ||||
|     Methods: | ||||
|         __call__(): Executes the command, respecting hooks and retries. | ||||
|         preview(): Rich tree preview of the command. | ||||
|         confirmation_prompt(): Formatted prompt for confirmation. | ||||
|         result: Property exposing the last result. | ||||
|         log_summary(): Summarizes execution details to the console. | ||||
|     """ | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     aliases: list[str] = Field(default_factory=list) | ||||
|     action: BaseAction | Callable[[], Any] = _noop | ||||
|     action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]] | ||||
|     args: tuple = () | ||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     hidden: bool = False | ||||
|     aliases: list[str] = Field(default_factory=list) | ||||
|     help_text: str = "" | ||||
|     color: str = OneColors.WHITE | ||||
|     help_epilog: str = "" | ||||
|     style: str = OneColors.WHITE | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
|     preview_before_confirm: bool = True | ||||
| @@ -52,24 +131,55 @@ class Command(BaseModel): | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     tags: list[str] = Field(default_factory=list) | ||||
|     logging_hooks: bool = False | ||||
|     options_manager: OptionsManager = Field(default_factory=OptionsManager) | ||||
|     arg_parser: CommandArgumentParser | None = None | ||||
|     arguments: list[dict[str, Any]] = Field(default_factory=list) | ||||
|     argument_config: Callable[[CommandArgumentParser], None] | None = None | ||||
|     custom_parser: ArgParserProtocol | None = None | ||||
|     custom_help: Callable[[], str | None] | None = None | ||||
|     auto_args: bool = True | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) | ||||
|     simple_help_signature: bool = False | ||||
|  | ||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
|     def model_post_init(self, __context: Any) -> None: | ||||
|         """Post-initialization to set up the action and hooks.""" | ||||
|         if self.retry and isinstance(self.action, Action): | ||||
|             self.action.enable_retry() | ||||
|         elif self.retry_policy and isinstance(self.action, Action): | ||||
|             self.action.set_retry_policy(self.retry_policy) | ||||
|         elif self.retry: | ||||
|             logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") | ||||
|         if self.retry_all: | ||||
|             self.action.enable_retries_recursively(self.action, self.retry_policy) | ||||
|     async def parse_args( | ||||
|         self, raw_args: list[str] | str, from_validate: bool = False | ||||
|     ) -> tuple[tuple, dict]: | ||||
|         if callable(self.custom_parser): | ||||
|             if isinstance(raw_args, str): | ||||
|                 try: | ||||
|                     raw_args = shlex.split(raw_args) | ||||
|                 except ValueError: | ||||
|                     logger.warning( | ||||
|                         "[Command:%s] Failed to split arguments: %s", | ||||
|                         self.key, | ||||
|                         raw_args, | ||||
|                     ) | ||||
|                     return ((), {}) | ||||
|             return self.custom_parser(raw_args) | ||||
|  | ||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||
|             register_debug_hooks(self.action.hooks) | ||||
|         if isinstance(raw_args, str): | ||||
|             try: | ||||
|                 raw_args = shlex.split(raw_args) | ||||
|             except ValueError: | ||||
|                 logger.warning( | ||||
|                     "[Command:%s] Failed to split arguments: %s", | ||||
|                     self.key, | ||||
|                     raw_args, | ||||
|                 ) | ||||
|                 return ((), {}) | ||||
|         if not isinstance(self.arg_parser, CommandArgumentParser): | ||||
|             logger.warning( | ||||
|                 "[Command:%s] No argument parser configured, using default parsing.", | ||||
|                 self.key, | ||||
|             ) | ||||
|             return ((), {}) | ||||
|         return await self.arg_parser.parse_args_split( | ||||
|             raw_args, from_validate=from_validate | ||||
|         ) | ||||
|  | ||||
|     @field_validator("action", mode="before") | ||||
|     @classmethod | ||||
| @@ -80,11 +190,70 @@ class Command(BaseModel): | ||||
|             return ensure_async(action) | ||||
|         raise TypeError("Action must be a callable or an instance of BaseAction") | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Command(key='{self.key}', description='{self.description}')" | ||||
|     def get_argument_definitions(self) -> list[dict[str, Any]]: | ||||
|         if self.arguments: | ||||
|             return self.arguments | ||||
|         elif callable(self.argument_config) and isinstance( | ||||
|             self.arg_parser, CommandArgumentParser | ||||
|         ): | ||||
|             self.argument_config(self.arg_parser) | ||||
|         elif self.auto_args: | ||||
|             if isinstance(self.action, BaseAction): | ||||
|                 infer_target, maybe_metadata = self.action.get_infer_target() | ||||
|                 # merge metadata with the action's metadata if not already in self.arg_metadata | ||||
|                 if maybe_metadata: | ||||
|                     self.arg_metadata = {**maybe_metadata, **self.arg_metadata} | ||||
|                 return infer_args_from_func(infer_target, self.arg_metadata) | ||||
|             elif callable(self.action): | ||||
|                 return infer_args_from_func(self.action, self.arg_metadata) | ||||
|         return [] | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs): | ||||
|         """Run the action with full hook lifecycle, timing, and error handling.""" | ||||
|     def model_post_init(self, _: Any) -> None: | ||||
|         """Post-initialization to set up the action and hooks.""" | ||||
|         if self.retry and isinstance(self.action, Action): | ||||
|             self.action.enable_retry() | ||||
|         elif self.retry_policy and isinstance(self.action, Action): | ||||
|             self.action.set_retry_policy(self.retry_policy) | ||||
|         elif self.retry: | ||||
|             logger.warning( | ||||
|                 "[Command:%s] Retry requested, but action is not an Action instance.", | ||||
|                 self.key, | ||||
|             ) | ||||
|         if self.retry_all and isinstance(self.action, BaseAction): | ||||
|             self.retry_policy.enabled = True | ||||
|             enable_retries_recursively(self.action, self.retry_policy) | ||||
|         elif self.retry_all: | ||||
|             logger.warning( | ||||
|                 "[Command:%s] Retry all requested, but action is not a BaseAction.", | ||||
|                 self.key, | ||||
|             ) | ||||
|  | ||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||
|             register_debug_hooks(self.action.hooks) | ||||
|  | ||||
|         if self.arg_parser is None and not self.custom_parser: | ||||
|             self.arg_parser = CommandArgumentParser( | ||||
|                 command_key=self.key, | ||||
|                 command_description=self.description, | ||||
|                 command_style=self.style, | ||||
|                 help_text=self.help_text, | ||||
|                 help_epilog=self.help_epilog, | ||||
|                 aliases=self.aliases, | ||||
|             ) | ||||
|             for arg_def in self.get_argument_definitions(): | ||||
|                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||
|  | ||||
|     def _inject_options_manager(self) -> None: | ||||
|         """Inject the options manager into the action if applicable.""" | ||||
|         if isinstance(self.action, BaseAction): | ||||
|             self.action.set_options_manager(self.options_manager) | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs) -> Any: | ||||
|         """ | ||||
|         Run the action with full hook lifecycle, timing, error handling, | ||||
|         confirmation prompts, preview, and spinner integration. | ||||
|         """ | ||||
|         self._inject_options_manager() | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = {**self.kwargs, **kwargs} | ||||
|         context = ExecutionContext( | ||||
| @@ -94,20 +263,35 @@ class Command(BaseModel): | ||||
|             action=self, | ||||
|         ) | ||||
|         self._context = context | ||||
|  | ||||
|         if should_prompt_user(confirm=self.confirm, options=self.options_manager): | ||||
|             if self.preview_before_confirm: | ||||
|                 await self.preview() | ||||
|             if not await confirm_async(self.confirmation_prompt): | ||||
|                 logger.info("[Command:%s] Cancelled by user.", self.key) | ||||
|                 raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") | ||||
|  | ||||
|         context.start_timer() | ||||
|  | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             result = await self.action(*combined_args, **combined_kwargs) | ||||
|             if self.spinner: | ||||
|                 with console.status( | ||||
|                     self.spinner_message, | ||||
|                     spinner=self.spinner_type, | ||||
|                     spinner_style=self.spinner_style, | ||||
|                     **self.spinner_kwargs, | ||||
|                 ): | ||||
|                     result = await self.action(*combined_args, **combined_kwargs) | ||||
|             else: | ||||
|                 result = await self.action(*combined_args, **combined_kwargs) | ||||
|  | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 logger.info(f"✅ Recovered: {self.key}") | ||||
|                 return context.result | ||||
|             raise error | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
| @@ -124,9 +308,7 @@ class Command(BaseModel): | ||||
|     def confirmation_prompt(self) -> FormattedText: | ||||
|         """Generate a styled prompt_toolkit FormattedText confirmation message.""" | ||||
|         if self.confirm_message and self.confirm_message != "Are you sure?": | ||||
|             return FormattedText([ | ||||
|                 ("class:confirm", self.confirm_message) | ||||
|             ]) | ||||
|             return FormattedText([("class:confirm", self.confirm_message)]) | ||||
|  | ||||
|         action_name = getattr(self.action, "__name__", None) | ||||
|         if isinstance(self.action, BaseAction): | ||||
| @@ -141,27 +323,81 @@ class Command(BaseModel): | ||||
|             prompt.append(("class:confirm", f"(calls `{action_name}`) ")) | ||||
|  | ||||
|         if self.args or self.kwargs: | ||||
|             prompt.append((OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ")) | ||||
|             prompt.append( | ||||
|                 (OneColors.DARK_YELLOW, f"with args={self.args}, kwargs={self.kwargs} ") | ||||
|             ) | ||||
|  | ||||
|         return FormattedText(prompt) | ||||
|  | ||||
|     def log_summary(self): | ||||
|     @property | ||||
|     def usage(self) -> str: | ||||
|         """Generate a help string for the command arguments.""" | ||||
|         if not self.arg_parser: | ||||
|             return "No arguments defined." | ||||
|  | ||||
|         command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True) | ||||
|         options_text = self.arg_parser.get_options_text(plain_text=True) | ||||
|         return f"  {command_keys_text:<20}  {options_text} " | ||||
|  | ||||
|     @property | ||||
|     def help_signature(self) -> str: | ||||
|         """Generate a help signature for the command.""" | ||||
|         if self.arg_parser and not self.simple_help_signature: | ||||
|             signature = [self.arg_parser.get_usage()] | ||||
|             signature.append(f"  {self.help_text or self.description}") | ||||
|             if self.tags: | ||||
|                 signature.append(f"  [dim]Tags: {', '.join(self.tags)}[/dim]") | ||||
|             return "\n".join(signature).strip() | ||||
|  | ||||
|         command_keys = " | ".join( | ||||
|             [f"[{self.style}]{self.key}[/{self.style}]"] | ||||
|             + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] | ||||
|         ) | ||||
|         return f"{command_keys}  {self.description}" | ||||
|  | ||||
|     def log_summary(self) -> None: | ||||
|         if self._context: | ||||
|             self._context.log_summary() | ||||
|  | ||||
|     async def preview(self): | ||||
|     def show_help(self) -> bool: | ||||
|         """Display the help message for the command.""" | ||||
|         if callable(self.custom_help): | ||||
|             output = self.custom_help() | ||||
|             if output: | ||||
|                 console.print(output) | ||||
|             return True | ||||
|         if isinstance(self.arg_parser, CommandArgumentParser): | ||||
|             self.arg_parser.render_help() | ||||
|             return True | ||||
|         return False | ||||
|  | ||||
|     async def preview(self) -> None: | ||||
|         label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}" | ||||
|  | ||||
|         if hasattr(self.action, "preview") and callable(self.action.preview): | ||||
|             tree = Tree(label) | ||||
|             await self.action.preview(parent=tree) | ||||
|             if self.help_text: | ||||
|                 tree.add(f"[dim]💡 {self.help_text}[/dim]") | ||||
|             console.print(tree) | ||||
|         elif callable(self.action): | ||||
|         elif callable(self.action) and not isinstance(self.action, BaseAction): | ||||
|             console.print(f"{label}") | ||||
|             if self.help_text: | ||||
|                 console.print(f"[dim]💡 {self.help_text}[/dim]") | ||||
|             console.print( | ||||
|                 f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__} " | ||||
|                 f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}" | ||||
|                 f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" | ||||
|             ) | ||||
|         else: | ||||
|             console.print(f"{label}") | ||||
|             console.print(f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]") | ||||
|             if self.help_text: | ||||
|                 console.print(f"[dim]💡 {self.help_text}[/dim]") | ||||
|             console.print( | ||||
|                 f"[{OneColors.DARK_RED}]⚠️ No preview available for this action.[/]" | ||||
|             ) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"Command(key='{self.key}', description='{self.description}' " | ||||
|             f"action='{self.action}')" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										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)) | ||||
							
								
								
									
										273
									
								
								falyx/config.py
									
									
									
									
									
								
							
							
						
						
									
										273
									
								
								falyx/config.py
									
									
									
									
									
								
							| @@ -1,16 +1,25 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """config.py | ||||
| Configuration loader for Falyx CLI commands.""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import importlib | ||||
| import sys | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
| from typing import Any, Callable | ||||
|  | ||||
| import toml | ||||
| import yaml | ||||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||||
|  | ||||
| from falyx.action import Action, BaseAction | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.command import Command | ||||
| from falyx.console import console | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.logger import logger | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||
| @@ -20,8 +29,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||
|         return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) | ||||
|     else: | ||||
|         raise TypeError( | ||||
|             f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " | ||||
|             "It must be a callable or an instance of BaseAction." | ||||
|             f"Cannot wrap object of type '{type(obj).__name__}'. " | ||||
|             "Expected a function or BaseAction." | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -29,14 +38,193 @@ def import_action(dotted_path: str) -> Any: | ||||
|     """Dynamically imports a callable from a dotted path like 'my.module.func'.""" | ||||
|     module_path, _, attr = dotted_path.rpartition(".") | ||||
|     if not module_path: | ||||
|         raise ValueError(f"Invalid action path: {dotted_path}") | ||||
|     module = importlib.import_module(module_path) | ||||
|     return getattr(module, attr) | ||||
|         console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}") | ||||
|         sys.exit(1) | ||||
|     try: | ||||
|         module = importlib.import_module(module_path) | ||||
|     except ModuleNotFoundError as error: | ||||
|         logger.error("Failed to import module '%s': %s", module_path, error) | ||||
|         console.print( | ||||
|             f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n" | ||||
|             f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable " | ||||
|             "via PYTHONPATH." | ||||
|         ) | ||||
|         sys.exit(1) | ||||
|     try: | ||||
|         action = getattr(module, attr) | ||||
|     except AttributeError as error: | ||||
|         logger.error( | ||||
|             "Module '%s' does not have attribute '%s': %s", module_path, attr, error | ||||
|         ) | ||||
|         console.print( | ||||
|             f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute " | ||||
|             f"'{attr}': {error}[/]" | ||||
|         ) | ||||
|         sys.exit(1) | ||||
|     return action | ||||
|  | ||||
|  | ||||
| def loader(file_path: str) -> list[dict[str, Any]]: | ||||
| class RawCommand(BaseModel): | ||||
|     """Raw command model for Falyx CLI configuration.""" | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     action: str | ||||
|  | ||||
|     args: tuple[Any, ...] = Field(default_factory=tuple) | ||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     aliases: list[str] = Field(default_factory=list) | ||||
|     tags: list[str] = Field(default_factory=list) | ||||
|     style: str = OneColors.WHITE | ||||
|  | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
|     preview_before_confirm: bool = True | ||||
|  | ||||
|     spinner: bool = False | ||||
|     spinner_message: str = "Processing..." | ||||
|     spinner_type: str = "dots" | ||||
|     spinner_style: str = OneColors.CYAN | ||||
|     spinner_kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|  | ||||
|     before_hooks: list[Callable] = Field(default_factory=list) | ||||
|     success_hooks: list[Callable] = Field(default_factory=list) | ||||
|     error_hooks: list[Callable] = Field(default_factory=list) | ||||
|     after_hooks: list[Callable] = Field(default_factory=list) | ||||
|     teardown_hooks: list[Callable] = Field(default_factory=list) | ||||
|  | ||||
|     logging_hooks: bool = False | ||||
|     retry: bool = False | ||||
|     retry_all: bool = False | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     hidden: bool = False | ||||
|     help_text: str = "" | ||||
|     help_epilog: str = "" | ||||
|  | ||||
|     @field_validator("retry_policy") | ||||
|     @classmethod | ||||
|     def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy: | ||||
|         if isinstance(value, RetryPolicy): | ||||
|             return value | ||||
|         if not isinstance(value, dict): | ||||
|             raise ValueError("retry_policy must be a dictionary.") | ||||
|         return RetryPolicy(**value) | ||||
|  | ||||
|  | ||||
| def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | ||||
|     commands = [] | ||||
|     for entry in raw_commands: | ||||
|         raw_command = RawCommand(**entry) | ||||
|         commands.append( | ||||
|             Command.model_validate( | ||||
|                 { | ||||
|                     **raw_command.model_dump(exclude={"action"}), | ||||
|                     "action": wrap_if_needed( | ||||
|                         import_action(raw_command.action), name=raw_command.description | ||||
|                     ), | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     return commands | ||||
|  | ||||
|  | ||||
| def convert_submenus( | ||||
|     raw_submenus: list[dict[str, Any]], *, parent_path: Path | None = None, depth: int = 0 | ||||
| ) -> list[dict[str, Any]]: | ||||
|     submenus: list[dict[str, Any]] = [] | ||||
|     for raw_submenu in raw_submenus: | ||||
|         if raw_submenu.get("config"): | ||||
|             config_path = Path(raw_submenu["config"]) | ||||
|             if parent_path: | ||||
|                 config_path = (parent_path.parent / config_path).resolve() | ||||
|             submenu = loader(config_path, _depth=depth + 1) | ||||
|         else: | ||||
|             submenu_module_path = raw_submenu.get("submenu") | ||||
|             if not isinstance(submenu_module_path, str): | ||||
|                 console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ Invalid submenu path:[/] {submenu_module_path}" | ||||
|                 ) | ||||
|                 sys.exit(1) | ||||
|             submenu = import_action(submenu_module_path) | ||||
|         if not isinstance(submenu, Falyx): | ||||
|             console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu:[/] {submenu}") | ||||
|             sys.exit(1) | ||||
|  | ||||
|         key = raw_submenu.get("key") | ||||
|         if not isinstance(key, str): | ||||
|             console.print(f"[{OneColors.DARK_RED}]❌ Invalid submenu key:[/] {key}") | ||||
|             sys.exit(1) | ||||
|  | ||||
|         description = raw_submenu.get("description") | ||||
|         if not isinstance(description, str): | ||||
|             console.print( | ||||
|                 f"[{OneColors.DARK_RED}]❌ Invalid submenu description:[/] {description}" | ||||
|             ) | ||||
|             sys.exit(1) | ||||
|  | ||||
|         submenus.append( | ||||
|             Submenu( | ||||
|                 key=key, | ||||
|                 description=description, | ||||
|                 submenu=submenu, | ||||
|                 style=raw_submenu.get("style", OneColors.CYAN), | ||||
|             ).model_dump() | ||||
|         ) | ||||
|     return submenus | ||||
|  | ||||
|  | ||||
| class Submenu(BaseModel): | ||||
|     """Submenu model for Falyx CLI configuration.""" | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     submenu: Any | ||||
|     style: str = OneColors.CYAN | ||||
|  | ||||
|  | ||||
| class FalyxConfig(BaseModel): | ||||
|     """Falyx CLI configuration model.""" | ||||
|  | ||||
|     title: str = "Falyx CLI" | ||||
|     prompt: str | list[tuple[str, str]] | list[list[str]] = [ | ||||
|         (OneColors.BLUE_b, "FALYX > ") | ||||
|     ] | ||||
|     columns: int = 4 | ||||
|     welcome_message: str = "" | ||||
|     exit_message: str = "" | ||||
|     commands: list[Command] | list[dict] = [] | ||||
|     submenus: list[dict[str, Any]] = [] | ||||
|  | ||||
|     @model_validator(mode="after") | ||||
|     def validate_prompt_format(self) -> FalyxConfig: | ||||
|         if isinstance(self.prompt, list): | ||||
|             for pair in self.prompt: | ||||
|                 if not isinstance(pair, (list, tuple)) or len(pair) != 2: | ||||
|                     raise ValueError( | ||||
|                         "Prompt list must contain 2-element (style, text) pairs" | ||||
|                     ) | ||||
|         return self | ||||
|  | ||||
|     def to_falyx(self) -> Falyx: | ||||
|         flx = Falyx( | ||||
|             title=self.title, | ||||
|             prompt=self.prompt,  # type: ignore[arg-type] | ||||
|             columns=self.columns, | ||||
|             welcome_message=self.welcome_message, | ||||
|             exit_message=self.exit_message, | ||||
|         ) | ||||
|         flx.add_commands(self.commands) | ||||
|         for submenu in self.submenus: | ||||
|             flx.add_submenu(**submenu) | ||||
|         return flx | ||||
|  | ||||
|  | ||||
| def loader(file_path: Path | str, _depth: int = 0) -> Falyx: | ||||
|     """ | ||||
|     Load command definitions from a YAML or TOML file. | ||||
|     Load Falyx CLI configuration from a YAML or TOML file. | ||||
|  | ||||
|     The file should contain a dictionary with a list of commands. | ||||
|  | ||||
|     Each command should be defined as a dictionary with at least: | ||||
|     - key: a unique single-character key | ||||
| @@ -47,12 +235,19 @@ def loader(file_path: str) -> list[dict[str, Any]]: | ||||
|         file_path (str): Path to the config file (YAML or TOML). | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]]: A list of command configuration dictionaries. | ||||
|         Falyx: An instance of the Falyx CLI with loaded commands. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If the file format is unsupported or file cannot be parsed. | ||||
|     """ | ||||
|     path = Path(file_path) | ||||
|     if _depth > 5: | ||||
|         raise ValueError("Maximum submenu depth exceeded (5 levels deep)") | ||||
|  | ||||
|     if isinstance(file_path, (str, Path)): | ||||
|         path = Path(file_path) | ||||
|     else: | ||||
|         raise TypeError("file_path must be a string or Path object.") | ||||
|  | ||||
|     if not path.is_file(): | ||||
|         raise FileNotFoundError(f"No such config file: {file_path}") | ||||
|  | ||||
| @@ -65,39 +260,25 @@ def loader(file_path: str) -> list[dict[str, Any]]: | ||||
|         else: | ||||
|             raise ValueError(f"Unsupported config format: {suffix}") | ||||
|  | ||||
|     if not isinstance(raw_config, list): | ||||
|         raise ValueError("Configuration file must contain a list of command definitions.") | ||||
|  | ||||
|  | ||||
|     required = ["key", "description", "action"] | ||||
|     commands = [] | ||||
|     for entry in raw_config: | ||||
|         for field in required: | ||||
|             if field not in entry: | ||||
|                 raise ValueError(f"Missing '{field}' in command entry: {entry}") | ||||
|  | ||||
|         command_dict = { | ||||
|             "key": entry["key"], | ||||
|             "description": entry["description"], | ||||
|             "aliases": entry.get("aliases", []), | ||||
|             "action": wrap_if_needed(import_action(entry["action"]), | ||||
|                                      name=entry["description"]), | ||||
|             "args": tuple(entry.get("args", ())), | ||||
|             "kwargs": entry.get("kwargs", {}), | ||||
|             "help_text": entry.get("help_text", ""), | ||||
|             "color": entry.get("color", "white"), | ||||
|             "confirm": entry.get("confirm", False), | ||||
|             "confirm_message": entry.get("confirm_message", "Are you sure?"), | ||||
|             "preview_before_confirm": entry.get("preview_before_confirm", True), | ||||
|             "spinner": entry.get("spinner", False), | ||||
|             "spinner_message": entry.get("spinner_message", "Processing..."), | ||||
|             "spinner_type": entry.get("spinner_type", "dots"), | ||||
|             "spinner_style": entry.get("spinner_style", "cyan"), | ||||
|             "spinner_kwargs": entry.get("spinner_kwargs", {}), | ||||
|             "tags": entry.get("tags", []), | ||||
|             "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), | ||||
|         } | ||||
|         commands.append(command_dict) | ||||
|  | ||||
|     return commands | ||||
|     if not isinstance(raw_config, dict): | ||||
|         raise ValueError( | ||||
|             "Configuration file must contain a dictionary with a list of commands.\n" | ||||
|             "Example:\n" | ||||
|             "title: 'My CLI'\n" | ||||
|             "commands:\n" | ||||
|             "  - key: 'a'\n" | ||||
|             "    description: 'Example command'\n" | ||||
|             "    action: 'my_module.my_function'" | ||||
|         ) | ||||
|  | ||||
|     commands = convert_commands(raw_config["commands"]) | ||||
|     submenus = convert_submenus(raw_config.get("submenus", [])) | ||||
|     return FalyxConfig( | ||||
|         title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"), | ||||
|         prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]), | ||||
|         columns=raw_config.get("columns", 4), | ||||
|         welcome_message=raw_config.get("welcome_message", ""), | ||||
|         exit_message=raw_config.get("exit_message", ""), | ||||
|         commands=commands, | ||||
|         submenus=submenus, | ||||
|     ).to_falyx() | ||||
|   | ||||
							
								
								
									
										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()) | ||||
							
								
								
									
										164
									
								
								falyx/context.py
									
									
									
									
									
								
							
							
						
						
									
										164
									
								
								falyx/context.py
									
									
									
									
									
								
							| @@ -1,4 +1,22 @@ | ||||
| """context.py""" | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Execution context management for Falyx CLI actions. | ||||
|  | ||||
| This module defines `ExecutionContext` and `SharedContext`, which are responsible for | ||||
| capturing per-action and cross-action metadata during CLI workflow execution. These | ||||
| context objects provide structured introspection, result tracking, error recording, | ||||
| and time-based performance metrics. | ||||
|  | ||||
| - `ExecutionContext`: Captures runtime information for a single action execution, | ||||
|   including arguments, results, exceptions, timing, and logging. | ||||
| - `SharedContext`: Maintains shared state and result propagation across | ||||
|   `ChainedAction` or `ActionGroup` executions. | ||||
|  | ||||
| These contexts enable rich introspection, traceability, and workflow coordination, | ||||
| supporting hook lifecycles, retries, and structured output generation. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import time | ||||
| from datetime import datetime | ||||
| from typing import Any | ||||
| @@ -6,22 +24,70 @@ from typing import Any | ||||
| from pydantic import BaseModel, ConfigDict, Field | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.console import console | ||||
|  | ||||
|  | ||||
| class ExecutionContext(BaseModel): | ||||
|     """ | ||||
|     Represents the runtime metadata and state for a single action execution. | ||||
|  | ||||
|     The `ExecutionContext` tracks arguments, results, exceptions, timing, and | ||||
|     additional metadata for each invocation of a Falyx `BaseAction`. It provides | ||||
|     integration with the Falyx hook system and execution registry, enabling lifecycle | ||||
|     management, diagnostics, and structured logging. | ||||
|  | ||||
|     Attributes: | ||||
|         name (str): The name of the action being executed. | ||||
|         args (tuple): Positional arguments passed to the action. | ||||
|         kwargs (dict): Keyword arguments passed to the action. | ||||
|         action (BaseAction | Callable): The action instance being executed. | ||||
|         result (Any | None): The result of the action, if successful. | ||||
|         exception (BaseException | None): The exception raised, if execution failed. | ||||
|         start_time (float | None): High-resolution performance start time. | ||||
|         end_time (float | None): High-resolution performance end time. | ||||
|         start_wall (datetime | None): Wall-clock timestamp when execution began. | ||||
|         end_wall (datetime | None): Wall-clock timestamp when execution ended. | ||||
|         extra (dict): Metadata for custom introspection or special use by Actions. | ||||
|         console (Console): Rich console instance for logging or UI output. | ||||
|         shared_context (SharedContext | None): Optional shared context when running in | ||||
|                                                a chain or group. | ||||
|  | ||||
|     Properties: | ||||
|         duration (float | None): The execution duration in seconds. | ||||
|         success (bool): Whether the action completed without raising an exception. | ||||
|         status (str): Returns "OK" if successful, otherwise "ERROR". | ||||
|  | ||||
|     Methods: | ||||
|         start_timer(): Starts the timing and timestamp tracking. | ||||
|         stop_timer(): Stops timing and stores end timestamps. | ||||
|         log_summary(logger=None): Logs a rich or plain summary of execution. | ||||
|         to_log_line(): Returns a single-line log entry for metrics or tracing. | ||||
|         as_dict(): Serializes core result and diagnostic metadata. | ||||
|         get_shared_context(): Returns the shared context or creates a default one. | ||||
|  | ||||
|     This class is used internally by all Falyx actions and hook events. It ensures | ||||
|     consistent tracking and reporting across asynchronous workflows, including CLI-driven | ||||
|     and automated batch executions. | ||||
|     """ | ||||
|  | ||||
|     name: str | ||||
|     args: tuple = () | ||||
|     kwargs: dict = {} | ||||
|     kwargs: dict = Field(default_factory=dict) | ||||
|     action: Any | ||||
|     result: Any | None = None | ||||
|     exception: Exception | None = None | ||||
|     exception: BaseException | None = None | ||||
|  | ||||
|     start_time: float | None = None | ||||
|     end_time: float | None = None | ||||
|     start_wall: datetime | None = None | ||||
|     end_wall: datetime | None = None | ||||
|  | ||||
|     index: int | None = None | ||||
|  | ||||
|     extra: dict[str, Any] = Field(default_factory=dict) | ||||
|     console: Console = Field(default_factory=lambda: Console(color_system="auto")) | ||||
|     console: Console = console | ||||
|  | ||||
|     shared_context: SharedContext | None = None | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
| @@ -33,6 +99,13 @@ class ExecutionContext(BaseModel): | ||||
|         self.end_time = time.perf_counter() | ||||
|         self.end_wall = datetime.now() | ||||
|  | ||||
|     def get_shared_context(self) -> SharedContext: | ||||
|         if not self.shared_context: | ||||
|             raise ValueError( | ||||
|                 "SharedContext is not set. This context is not part of a chain or group." | ||||
|             ) | ||||
|         return self.shared_context | ||||
|  | ||||
|     @property | ||||
|     def duration(self) -> float | None: | ||||
|         if self.start_time is None: | ||||
| @@ -49,6 +122,17 @@ class ExecutionContext(BaseModel): | ||||
|     def status(self) -> str: | ||||
|         return "OK" if self.success else "ERROR" | ||||
|  | ||||
|     @property | ||||
|     def signature(self) -> str: | ||||
|         """ | ||||
|         Returns a string representation of the action signature, including | ||||
|         its name and arguments. | ||||
|         """ | ||||
|         args = ", ".join(map(repr, self.args)) | ||||
|         kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) | ||||
|         signature = ", ".join(filter(None, [args, kwargs])) | ||||
|         return f"{self.action} ({signature})" | ||||
|  | ||||
|     def as_dict(self) -> dict: | ||||
|         return { | ||||
|             "name": self.name, | ||||
| @@ -58,28 +142,32 @@ class ExecutionContext(BaseModel): | ||||
|             "extra": self.extra, | ||||
|         } | ||||
|  | ||||
|     def log_summary(self, logger=None): | ||||
|     def log_summary(self, logger=None) -> None: | ||||
|         summary = self.as_dict() | ||||
|         message = [f"[SUMMARY] {summary['name']} | "] | ||||
|  | ||||
|         if self.start_wall: | ||||
|             message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ") | ||||
|  | ||||
|         if self.end_time: | ||||
|         if self.end_wall: | ||||
|             message.append(f"End: {self.end_wall.strftime('%H:%M:%S')} | ") | ||||
|  | ||||
|         message.append(f"Duration: {summary['duration']:.3f}s | ") | ||||
|  | ||||
|         if summary["exception"]: | ||||
|             message.append(f"❌ Exception: {summary['exception']}") | ||||
|             message.append(f"Exception: {summary['exception']}") | ||||
|         else: | ||||
|             message.append(f"✅ Result: {summary['result']}") | ||||
|             message.append(f"Result: {summary['result']}") | ||||
|         (logger or self.console.print)("".join(message)) | ||||
|  | ||||
|     def to_log_line(self) -> str: | ||||
|         """Structured flat-line format for logging and metrics.""" | ||||
|         duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" | ||||
|         exception_str = f"{type(self.exception).__name__}: {self.exception}" if self.exception else "None" | ||||
|         exception_str = ( | ||||
|             f"{type(self.exception).__name__}: {self.exception}" | ||||
|             if self.exception | ||||
|             else "None" | ||||
|         ) | ||||
|         return ( | ||||
|             f"[{self.name}] status={self.status} duration={duration_str} " | ||||
|             f"result={repr(self.result)} exception={exception_str}" | ||||
| @@ -87,7 +175,11 @@ class ExecutionContext(BaseModel): | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" | ||||
|         result_str = f"Result: {repr(self.result)}" if self.success else f"Exception: {self.exception}" | ||||
|         result_str = ( | ||||
|             f"Result: {repr(self.result)}" | ||||
|             if self.success | ||||
|             else f"Exception: {self.exception}" | ||||
|         ) | ||||
|         return ( | ||||
|             f"<ExecutionContext '{self.name}' | {self.status} | " | ||||
|             f"Duration: {duration_str} | {result_str}>" | ||||
| @@ -103,19 +195,58 @@ class ExecutionContext(BaseModel): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ResultsContext(BaseModel): | ||||
| class SharedContext(BaseModel): | ||||
|     """ | ||||
|     SharedContext maintains transient shared state during the execution | ||||
|     of a ChainedAction or ActionGroup. | ||||
|  | ||||
|     This context object is passed to all actions within a chain or group, | ||||
|     enabling result propagation, shared data exchange, and coordinated | ||||
|     tracking of execution order and failures. | ||||
|  | ||||
|     Attributes: | ||||
|         name (str): Identifier for the context (usually the parent action name). | ||||
|         results (list[Any]): Captures results from each action, in order of execution. | ||||
|         errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions. | ||||
|         current_index (int): Index of the currently executing action (used in chains). | ||||
|         is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). | ||||
|         shared_result (Any | None): Optional shared value available to all actions in | ||||
|                                     parallel mode. | ||||
|         share (dict[str, Any]): Custom shared key-value store for user-defined | ||||
|                                 communication | ||||
|             between actions (e.g., flags, intermediate data, settings). | ||||
|  | ||||
|     Note: | ||||
|         SharedContext is only used within grouped or chained workflows. It should not be | ||||
|         used for standalone `Action` executions, where state should be scoped to the | ||||
|         individual ExecutionContext instead. | ||||
|  | ||||
|     Example usage: | ||||
|         - In a ChainedAction: last_result is pulled from `results[-1]`. | ||||
|         - In an ActionGroup: all actions can read/write `shared_result` or use `share`. | ||||
|  | ||||
|     This class supports fault-tolerant and modular composition of CLI workflows | ||||
|     by enabling flexible intra-action communication without global state. | ||||
|     """ | ||||
|  | ||||
|     name: str | ||||
|     action: Any | ||||
|     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 | ||||
|     is_parallel: bool = False | ||||
|     shared_result: Any | None = None | ||||
|  | ||||
|     share: dict[str, Any] = Field(default_factory=dict) | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
|     def add_result(self, result: Any) -> None: | ||||
|         self.results.append(result) | ||||
|  | ||||
|     def add_error(self, index: int, error: BaseException) -> None: | ||||
|         self.errors.append((index, error)) | ||||
|  | ||||
|     def set_shared_result(self, result: Any) -> None: | ||||
|         self.shared_result = result | ||||
|         if self.is_parallel: | ||||
| @@ -126,14 +257,21 @@ class ResultsContext(BaseModel): | ||||
|             return self.shared_result | ||||
|         return self.results[-1] if self.results else None | ||||
|  | ||||
|     def get(self, key: str, default: Any = None) -> Any: | ||||
|         return self.share.get(key, default) | ||||
|  | ||||
|     def set(self, key: str, value: Any) -> None: | ||||
|         self.share[key] = value | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         parallel_label = "Parallel" if self.is_parallel else "Sequential" | ||||
|         return ( | ||||
|             f"<{parallel_label}ResultsContext '{self.name}' | " | ||||
|             f"<{parallel_label}SharedContext '{self.name}' | " | ||||
|             f"Results: {self.results} | " | ||||
|             f"Errors: {self.errors}>" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     import asyncio | ||||
|  | ||||
|   | ||||
| @@ -1,14 +1,16 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """debug.py""" | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| def log_before(context: ExecutionContext): | ||||
|     """Log the start of an action.""" | ||||
|     args = ", ".join(map(repr, context.args)) | ||||
|     kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) | ||||
|     kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items()) | ||||
|     signature = ", ".join(filter(None, [args, kwargs])) | ||||
|     logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) | ||||
|     logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature) | ||||
|  | ||||
|  | ||||
| def log_success(context: ExecutionContext): | ||||
| @@ -16,18 +18,18 @@ def log_success(context: ExecutionContext): | ||||
|     result_str = repr(context.result) | ||||
|     if len(result_str) > 100: | ||||
|         result_str = f"{result_str[:100]} ..." | ||||
|     logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) | ||||
|     logger.debug("[%s] Success -> Result: %s", context.name, result_str) | ||||
|  | ||||
|  | ||||
| def log_after(context: ExecutionContext): | ||||
|     """Log the completion of an action, regardless of success or failure.""" | ||||
|     logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) | ||||
|     logger.debug("[%s] Finished in %.3fs", context.name, context.duration) | ||||
|  | ||||
|  | ||||
| def log_error(context: ExecutionContext): | ||||
|     """Log an error that occurred during the action.""" | ||||
|     logger.error( | ||||
|         "[%s] ❌ Error (%s): %s", | ||||
|         "[%s] Error (%s): %s", | ||||
|         context.name, | ||||
|         type(context.exception).__name__, | ||||
|         context.exception, | ||||
|   | ||||
| @@ -1,3 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """exceptions.py""" | ||||
|  | ||||
|  | ||||
| class FalyxError(Exception): | ||||
|     """Custom exception for the Menu class.""" | ||||
|  | ||||
| @@ -20,3 +24,19 @@ class NotAFalyxError(FalyxError): | ||||
|  | ||||
| class CircuitBreakerOpen(FalyxError): | ||||
|     """Exception raised when the circuit breaker is open.""" | ||||
|  | ||||
|  | ||||
| class EmptyChainError(FalyxError): | ||||
|     """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): | ||||
|     """Exception raised when there is an error in the command argument parser.""" | ||||
|   | ||||
| @@ -1,34 +1,101 @@ | ||||
| """execution_registry.py""" | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| execution_registry.py | ||||
|  | ||||
| This module provides the `ExecutionRegistry`, a global class for tracking and | ||||
| introspecting the execution history of Falyx actions. | ||||
|  | ||||
| The registry captures `ExecutionContext` instances from all executed actions, making it | ||||
| easy to debug, audit, and visualize workflow behavior over time. It supports retrieval, | ||||
| filtering, clearing, and formatted summary display. | ||||
|  | ||||
| Core Features: | ||||
| - Stores all action execution contexts globally (with access by name). | ||||
| - Provides live execution summaries in a rich table format. | ||||
| - Enables creation of a built-in Falyx Action to print history on demand. | ||||
| - Integrates with Falyx's introspectable and hook-driven execution model. | ||||
|  | ||||
| Intended for: | ||||
| - Debugging and diagnostics | ||||
| - Post-run inspection of CLI workflows | ||||
| - Interactive tools built with Falyx | ||||
|  | ||||
| Example: | ||||
|     from falyx.execution_registry import ExecutionRegistry as er | ||||
|     er.record(context) | ||||
|     er.summary() | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from typing import Dict, List | ||||
| from threading import Lock | ||||
| from typing import Literal | ||||
|  | ||||
| from rich import box | ||||
| from rich.console import Console | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ExecutionRegistry: | ||||
|     _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) | ||||
|     _store_all: List[ExecutionContext] = [] | ||||
|     """ | ||||
|     Global registry for recording and inspecting Falyx action executions. | ||||
|  | ||||
|     This class captures every `ExecutionContext` generated by a Falyx `Action`, | ||||
|     `ChainedAction`, or `ActionGroup`, maintaining both full history and | ||||
|     name-indexed access for filtered analysis. | ||||
|  | ||||
|     Methods: | ||||
|         - record(context): Stores an ExecutionContext, logging a summary line. | ||||
|         - get_all(): Returns the list of all recorded executions. | ||||
|         - get_by_name(name): Returns all executions with the given action name. | ||||
|         - get_latest(): Returns the most recent execution. | ||||
|         - clear(): Wipes the registry for a fresh run. | ||||
|         - summary(): Renders a formatted Rich table of all execution results. | ||||
|  | ||||
|     Use Cases: | ||||
|         - Debugging chained or factory-generated workflows | ||||
|         - Viewing results and exceptions from multiple runs | ||||
|         - Embedding a diagnostic command into your CLI for user support | ||||
|  | ||||
|     Note: | ||||
|         This registry is in-memory and not persistent. It's reset each time the process | ||||
|         restarts or `clear()` is called. | ||||
|  | ||||
|     Example: | ||||
|         ExecutionRegistry.record(context) | ||||
|         ExecutionRegistry.summary() | ||||
|     """ | ||||
|  | ||||
|     _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) | ||||
|     _store_by_index: dict[int, ExecutionContext] = {} | ||||
|     _store_all: list[ExecutionContext] = [] | ||||
|     _console = Console(color_system="truecolor") | ||||
|     _index = 0 | ||||
|     _lock = Lock() | ||||
|  | ||||
|     @classmethod | ||||
|     def record(cls, context: ExecutionContext): | ||||
|         """Record an execution context.""" | ||||
|         logger.debug(context.to_log_line()) | ||||
|         with cls._lock: | ||||
|             context.index = cls._index | ||||
|             cls._store_by_index[cls._index] = context | ||||
|             cls._index += 1 | ||||
|         cls._store_by_name[context.name].append(context) | ||||
|         cls._store_all.append(context) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_all(cls) -> List[ExecutionContext]: | ||||
|     def get_all(cls) -> list[ExecutionContext]: | ||||
|         return cls._store_all | ||||
|  | ||||
|     @classmethod | ||||
|     def get_by_name(cls, name: str) -> List[ExecutionContext]: | ||||
|     def get_by_name(cls, name: str) -> list[ExecutionContext]: | ||||
|         return cls._store_by_name.get(name, []) | ||||
|  | ||||
|     @classmethod | ||||
| @@ -39,11 +106,79 @@ class ExecutionRegistry: | ||||
|     def clear(cls): | ||||
|         cls._store_by_name.clear() | ||||
|         cls._store_all.clear() | ||||
|         cls._store_by_index.clear() | ||||
|  | ||||
|     @classmethod | ||||
|     def summary(cls): | ||||
|         table = Table(title="[📊] Execution History", expand=True, box=box.SIMPLE) | ||||
|     def summary( | ||||
|         cls, | ||||
|         name: str = "", | ||||
|         index: int | None = None, | ||||
|         result_index: int | None = None, | ||||
|         clear: bool = False, | ||||
|         last_result: bool = False, | ||||
|         status: Literal["all", "success", "error"] = "all", | ||||
|     ): | ||||
|         if clear: | ||||
|             cls.clear() | ||||
|             cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.") | ||||
|             return | ||||
|  | ||||
|         if last_result: | ||||
|             for ctx in reversed(cls._store_all): | ||||
|                 if ctx.name.upper() not in [ | ||||
|                     "HISTORY", | ||||
|                     "HELP", | ||||
|                     "EXIT", | ||||
|                     "VIEW EXECUTION HISTORY", | ||||
|                     "BACK", | ||||
|                 ]: | ||||
|                     cls._console.print(ctx.result) | ||||
|                     return | ||||
|             cls._console.print( | ||||
|                 f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result." | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         if result_index is not None and result_index >= 0: | ||||
|             try: | ||||
|                 result_context = cls._store_by_index[result_index] | ||||
|             except KeyError: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No execution found for index {result_index}." | ||||
|                 ) | ||||
|                 return | ||||
|             cls._console.print(f"{result_context.signature}:") | ||||
|             if result_context.exception: | ||||
|                 cls._console.print(result_context.exception) | ||||
|             else: | ||||
|                 cls._console.print(result_context.result) | ||||
|             return | ||||
|  | ||||
|         if name: | ||||
|             contexts = cls.get_by_name(name) | ||||
|             if not contexts: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'." | ||||
|                 ) | ||||
|                 return | ||||
|             title = f"📊 Execution History for '{contexts[0].name}'" | ||||
|         elif index is not None and index >= 0: | ||||
|             try: | ||||
|                 contexts = [cls._store_by_index[index]] | ||||
|                 print(contexts) | ||||
|             except KeyError: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No execution found for index {index}." | ||||
|                 ) | ||||
|                 return | ||||
|             title = f"📊 Execution History for Index {index}" | ||||
|         else: | ||||
|             contexts = cls.get_all() | ||||
|             title = "📊 Execution History" | ||||
|  | ||||
|         table = Table(title=title, expand=True, box=box.SIMPLE) | ||||
|  | ||||
|         table.add_column("Index", justify="right", style="dim") | ||||
|         table.add_column("Name", style="bold cyan") | ||||
|         table.add_column("Start", justify="right", style="dim") | ||||
|         table.add_column("End", justify="right", style="dim") | ||||
| @@ -51,26 +186,32 @@ class ExecutionRegistry: | ||||
|         table.add_column("Status", style="bold") | ||||
|         table.add_column("Result / Exception", overflow="fold") | ||||
|  | ||||
|         for ctx in cls.get_all(): | ||||
|             start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a" | ||||
|             end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a" | ||||
|         for ctx in contexts: | ||||
|             start = ( | ||||
|                 datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") | ||||
|                 if ctx.start_time | ||||
|                 else "n/a" | ||||
|             ) | ||||
|             end = ( | ||||
|                 datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") | ||||
|                 if ctx.end_time | ||||
|                 else "n/a" | ||||
|             ) | ||||
|             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||
|  | ||||
|             if ctx.exception: | ||||
|                 status = "[bold red]❌ Error" | ||||
|                 result = repr(ctx.exception) | ||||
|             if ctx.exception and status.lower() in ["all", "error"]: | ||||
|                 final_status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 final_result = repr(ctx.exception) | ||||
|             elif status.lower() in ["all", "success"]: | ||||
|                 final_status = f"[{OneColors.GREEN}]✅ Success" | ||||
|                 final_result = repr(ctx.result) | ||||
|                 if len(final_result) > 1000: | ||||
|                     final_result = f"{final_result[:1000]}..." | ||||
|             else: | ||||
|                 status = "[green]✅ Success" | ||||
|                 result = repr(ctx.result) | ||||
|                 continue | ||||
|  | ||||
|             table.add_row(ctx.name, start, end, duration, status, result) | ||||
|             table.add_row( | ||||
|                 str(ctx.index), ctx.name, start, end, duration, final_status, final_result | ||||
|             ) | ||||
|  | ||||
|         cls._console.print(table) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_history_action(cls) -> "Action": | ||||
|         """Return an Action that prints the execution summary.""" | ||||
|         from falyx.action import Action | ||||
|         async def show_history(): | ||||
|             cls.summary() | ||||
|         return Action(name="View Execution History", action=show_history) | ||||
|   | ||||
							
								
								
									
										1061
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										1061
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,21 +1,22 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hook_manager.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import inspect | ||||
| from enum import Enum | ||||
| from typing import Awaitable, Callable, Dict, List, Optional, Union | ||||
| from typing import Awaitable, Callable, Union | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
| Hook = Union[ | ||||
|     Callable[[ExecutionContext], None], | ||||
|     Callable[[ExecutionContext], Awaitable[None]] | ||||
|     Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]] | ||||
| ] | ||||
|  | ||||
|  | ||||
| class HookType(Enum): | ||||
|     """Enum for hook types to categorize the hooks.""" | ||||
|  | ||||
|     BEFORE = "before" | ||||
|     ON_SUCCESS = "on_success" | ||||
|     ON_ERROR = "on_error" | ||||
| @@ -23,7 +24,7 @@ class HookType(Enum): | ||||
|     ON_TEARDOWN = "on_teardown" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> List[HookType]: | ||||
|     def choices(cls) -> list[HookType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
| @@ -33,17 +34,20 @@ class HookType(Enum): | ||||
|  | ||||
|  | ||||
| class HookManager: | ||||
|     """HookManager""" | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self._hooks: Dict[HookType, List[Hook]] = { | ||||
|         self._hooks: dict[HookType, list[Hook]] = { | ||||
|             hook_type: [] for hook_type in HookType | ||||
|         } | ||||
|  | ||||
|     def register(self, hook_type: HookType, hook: Hook): | ||||
|         if hook_type not in HookType: | ||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") | ||||
|     def register(self, hook_type: HookType | str, hook: Hook): | ||||
|         """Raises ValueError if the hook type is not supported.""" | ||||
|         if not isinstance(hook_type, HookType): | ||||
|             hook_type = HookType(hook_type) | ||||
|         self._hooks[hook_type].append(hook) | ||||
|  | ||||
|     def clear(self, hook_type: Optional[HookType] = None): | ||||
|     def clear(self, hook_type: HookType | None = None): | ||||
|         if hook_type: | ||||
|             self._hooks[hook_type] = [] | ||||
|         else: | ||||
| @@ -60,9 +64,28 @@ class HookManager: | ||||
|                 else: | ||||
|                     hook(context) | ||||
|             except Exception as hook_error: | ||||
|                 logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" | ||||
|                                f" for '{context.name}': {hook_error}") | ||||
|                 logger.warning( | ||||
|                     "[Hook:%s] raised an exception during '%s' for '%s': %s", | ||||
|                     hook.__name__, | ||||
|                     hook_type, | ||||
|                     context.name, | ||||
|                     hook_error, | ||||
|                 ) | ||||
|  | ||||
|                 if hook_type == HookType.ON_ERROR: | ||||
|                     assert isinstance(context.exception, BaseException) | ||||
|                     assert isinstance( | ||||
|                         context.exception, Exception | ||||
|                     ), "Context exception should be set for ON_ERROR hook" | ||||
|                     raise context.exception from hook_error | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return a formatted string of registered hooks grouped by hook type.""" | ||||
|  | ||||
|         def format_hook_list(hooks: list[Hook]) -> str: | ||||
|             return ", ".join(h.__name__ for h in hooks) if hooks else "—" | ||||
|  | ||||
|         lines = ["<HookManager>"] | ||||
|         for hook_type in HookType: | ||||
|             hook_list = self._hooks.get(hook_type, []) | ||||
|             lines.append(f"  {hook_type.value}: {format_hook_list(hook_list)}") | ||||
|         return "\n".join(lines) | ||||
|   | ||||
| @@ -1,32 +1,50 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hooks.py""" | ||||
| import time | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.exceptions import CircuitBreakerOpen | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ResultReporter: | ||||
|     def __init__(self, formatter: callable = None): | ||||
|     """Reports the success of an action.""" | ||||
|  | ||||
|     def __init__(self, formatter: Callable[[Any], str] | None = None): | ||||
|         """ | ||||
|         Optional result formatter. If not provided, uses repr(result). | ||||
|         """ | ||||
|         self.formatter = formatter or (lambda r: repr(r)) | ||||
|         self.formatter = formatter or (self.default_formatter) | ||||
|  | ||||
|     def default_formatter(self, result: Any): | ||||
|         """ | ||||
|         Default formatter for results. Converts the result to a string. | ||||
|         """ | ||||
|         return repr(result) | ||||
|  | ||||
|     @property | ||||
|     def __name__(self): | ||||
|         return "ResultReporter" | ||||
|  | ||||
|     async def report(self, context: ExecutionContext): | ||||
|         if not callable(self.formatter): | ||||
|             raise TypeError("formatter must be callable") | ||||
|         if context.result is not None: | ||||
|             result_text = self.formatter(context.result) | ||||
|             duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a" | ||||
|             context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' " | ||||
|                   f"completed:[/] {result_text} in {duration}.") | ||||
|             duration = ( | ||||
|                 f"{context.duration:.3f}s" if context.duration is not None else "n/a" | ||||
|             ) | ||||
|             context.console.print( | ||||
|                 f"[{OneColors.GREEN}]✅ '{context.name}' " | ||||
|                 f"completed:[/] {result_text} in {duration}." | ||||
|             ) | ||||
|  | ||||
|  | ||||
| class CircuitBreaker: | ||||
|     """Circuit Breaker pattern to prevent repeated failures.""" | ||||
|  | ||||
|     def __init__(self, max_failures=3, reset_timeout=10): | ||||
|         self.max_failures = max_failures | ||||
|         self.reset_timeout = reset_timeout | ||||
| @@ -37,21 +55,30 @@ class CircuitBreaker: | ||||
|         name = context.name | ||||
|         if self.open_until: | ||||
|             if time.time() < self.open_until: | ||||
|                 raise CircuitBreakerOpen(f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}.") | ||||
|                 raise CircuitBreakerOpen( | ||||
|                     f"Circuit open for '{name}' until {time.ctime(self.open_until)}." | ||||
|                 ) | ||||
|             else: | ||||
|                 logger.info(f"🟢 Circuit closed again for '{name}'.") | ||||
|                 logger.info("Circuit closed again for '%s'.") | ||||
|                 self.failures = 0 | ||||
|                 self.open_until = None | ||||
|  | ||||
|     def error_hook(self, context: ExecutionContext): | ||||
|         name = context.name | ||||
|         self.failures += 1 | ||||
|         logger.warning(f"⚠️ CircuitBreaker: '{name}' failure {self.failures}/{self.max_failures}.") | ||||
|         logger.warning( | ||||
|             "CircuitBreaker: '%s' failure %s/%s.", | ||||
|             name, | ||||
|             self.failures, | ||||
|             self.max_failures, | ||||
|         ) | ||||
|         if self.failures >= self.max_failures: | ||||
|             self.open_until = time.time() + self.reset_timeout | ||||
|             logger.error(f"🔴 Circuit opened for '{name}' until {time.ctime(self.open_until)}.") | ||||
|             logger.error( | ||||
|                 "Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) | ||||
|             ) | ||||
|  | ||||
|     def after_hook(self, context: ExecutionContext): | ||||
|     def after_hook(self, _: ExecutionContext): | ||||
|         self.failures = 0 | ||||
|  | ||||
|     def is_open(self): | ||||
| @@ -60,4 +87,4 @@ class CircuitBreaker: | ||||
|     def reset(self): | ||||
|         self.failures = 0 | ||||
|         self.open_until = None | ||||
|         logger.info("🔄 Circuit reset.") | ||||
|         logger.info("Circuit reset.") | ||||
|   | ||||
| @@ -1,29 +0,0 @@ | ||||
| """importer.py""" | ||||
|  | ||||
| import importlib | ||||
| from types import ModuleType | ||||
| from typing import Any, Callable | ||||
|  | ||||
|  | ||||
| def resolve_action(path: str) -> Callable[..., Any]: | ||||
|     """ | ||||
|     Resolve a dotted path to a Python callable. | ||||
|     Example: 'mypackage.mymodule.myfunction' | ||||
|  | ||||
|     Raises: | ||||
|         ImportError if the module or function does not exist. | ||||
|         ValueError if the resolved attribute is not callable. | ||||
|     """ | ||||
|     if ":" in path: | ||||
|         module_path, function_name = path.split(":") | ||||
|     else: | ||||
|         *module_parts, function_name = path.split(".") | ||||
|         module_path = ".".join(module_parts) | ||||
|  | ||||
|     module: ModuleType = importlib.import_module(module_path) | ||||
|     function: Any = getattr(module, function_name) | ||||
|  | ||||
|     if not callable(function): | ||||
|         raise ValueError(f"Resolved attribute '{function_name}' is not callable.") | ||||
|  | ||||
|     return function | ||||
							
								
								
									
										133
									
								
								falyx/init.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								falyx/init.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """init.py""" | ||||
| from pathlib import Path | ||||
|  | ||||
| from falyx.console import console | ||||
|  | ||||
| TEMPLATE_TASKS = """\ | ||||
| # This file is used by falyx.yaml to define CLI actions. | ||||
| # You can run: falyx run [key] or falyx list to see available commands. | ||||
|  | ||||
| import asyncio | ||||
| import json | ||||
|  | ||||
| from falyx.action import Action, ChainedAction, ShellAction, SelectionAction | ||||
|  | ||||
|  | ||||
| post_ids = ["1", "2", "3", "4", "5"] | ||||
|  | ||||
| pick_post = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=post_ids, | ||||
|     title="Choose a Post ID", | ||||
|     prompt_message="Select a post > ", | ||||
| ) | ||||
|  | ||||
| fetch_post = ShellAction( | ||||
|     name="Fetch Post via curl", | ||||
|     command_template="curl https://jsonplaceholder.typicode.com/posts/{}", | ||||
| ) | ||||
|  | ||||
| async def get_post_title(last_result): | ||||
|     return json.loads(last_result).get("title", "No title found") | ||||
|  | ||||
| post_flow = ChainedAction( | ||||
|     name="Fetch and Parse Post", | ||||
|     actions=[pick_post, fetch_post, get_post_title], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| async def hello(): | ||||
|     print("👋 Hello from Falyx!") | ||||
|     return "Hello Complete!" | ||||
|  | ||||
| async def some_work(): | ||||
|     await asyncio.sleep(2) | ||||
|     print("Work Finished!") | ||||
|     return "Work Complete!" | ||||
|  | ||||
| work_action = Action( | ||||
|     name="Work Action", | ||||
|     action=some_work, | ||||
| ) | ||||
| """ | ||||
|  | ||||
| TEMPLATE_CONFIG = """\ | ||||
| # falyx.yaml — Config-driven CLI definition | ||||
| # Define your commands here and point to Python callables in tasks.py | ||||
| title: Sample CLI Project | ||||
| prompt: | ||||
|   - ["#61AFEF bold", "FALYX > "] | ||||
| columns: 3 | ||||
| welcome_message: "🚀 Welcome to your new CLI project!" | ||||
| exit_message: "👋 See you next time!" | ||||
| commands: | ||||
|   - key: S | ||||
|     description: Say Hello | ||||
|     action: tasks.hello | ||||
|     aliases: [hi, hello] | ||||
|     tags: [example] | ||||
|  | ||||
|   - key: P | ||||
|     description: Get Post Title | ||||
|     action: tasks.post_flow | ||||
|     aliases: [submit] | ||||
|     preview_before_confirm: true | ||||
|     confirm: true | ||||
|     tags: [demo, network] | ||||
|  | ||||
|   - key: G | ||||
|     description: Do Some Work | ||||
|     action: tasks.work_action | ||||
|     aliases: [work] | ||||
|     spinner: true | ||||
|     spinner_message: "Working..." | ||||
| """ | ||||
|  | ||||
| GLOBAL_TEMPLATE_TASKS = """\ | ||||
| async def cleanup(): | ||||
|     print("🧹 Cleaning temp files...") | ||||
| """ | ||||
|  | ||||
| GLOBAL_CONFIG = """\ | ||||
| title: Global Falyx Config | ||||
| commands: | ||||
|   - key: C | ||||
|     description: Cleanup temp files | ||||
|     action: tasks.cleanup | ||||
|     aliases: [clean, cleanup] | ||||
| """ | ||||
|  | ||||
|  | ||||
| def init_project(name: str) -> None: | ||||
|     target = Path(name).resolve() | ||||
|     target.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|     tasks_path = target / "tasks.py" | ||||
|     config_path = target / "falyx.yaml" | ||||
|  | ||||
|     if tasks_path.exists() or config_path.exists(): | ||||
|         console.print(f"⚠️  Project already initialized at {target}") | ||||
|         return None | ||||
|  | ||||
|     tasks_path.write_text(TEMPLATE_TASKS) | ||||
|     config_path.write_text(TEMPLATE_CONFIG) | ||||
|  | ||||
|     console.print(f"✅ Initialized Falyx project in {target}") | ||||
|  | ||||
|  | ||||
| def init_global() -> None: | ||||
|     config_dir = Path.home() / ".config" / "falyx" | ||||
|     config_dir.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|     tasks_path = config_dir / "tasks.py" | ||||
|     config_path = config_dir / "falyx.yaml" | ||||
|  | ||||
|     if tasks_path.exists() or config_path.exists(): | ||||
|         console.print("⚠️  Global Falyx config already exists at ~/.config/falyx") | ||||
|         return None | ||||
|  | ||||
|     tasks_path.write_text(GLOBAL_TEMPLATE_TASKS) | ||||
|     config_path.write_text(GLOBAL_CONFIG) | ||||
|  | ||||
|     console.print("✅ Initialized global Falyx config at ~/.config/falyx") | ||||
							
								
								
									
										5
									
								
								falyx/logger.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								falyx/logger.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """logger.py""" | ||||
| import logging | ||||
|  | ||||
| logger: logging.Logger = logging.getLogger("falyx") | ||||
| @@ -1,88 +0,0 @@ | ||||
| import asyncio | ||||
| import logging | ||||
| from rich.markdown import Markdown | ||||
|  | ||||
| from falyx import Action, Falyx | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.debug import log_before, log_success, log_error, log_after | ||||
| from falyx.themes.colors import OneColors | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| # Setup logging | ||||
| setup_logging(console_log_level=logging.WARNING, json_log_to_file=True) | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     # Create the menu | ||||
|     menu = Falyx( | ||||
|         title=Markdown("# 🚀 Falyx CLI Demo"), | ||||
|         welcome_message="Welcome to Falyx!", | ||||
|         exit_message="Thanks for using Falyx!", | ||||
|         include_history_command=True, | ||||
|         include_help_command=True, | ||||
|     ) | ||||
|  | ||||
|     # Define async actions | ||||
|     async def hello(): | ||||
|         print("👋 Hello from Falyx CLI!") | ||||
|  | ||||
|     def goodbye(): | ||||
|         print("👋 Goodbye from Falyx CLI!") | ||||
|  | ||||
|     async def do_task_and_increment(counter_name: str = "tasks"): | ||||
|         await asyncio.sleep(3) | ||||
|         print("✅ Task completed.") | ||||
|         menu.bottom_bar.increment_total_counter(counter_name) | ||||
|  | ||||
|     # Register global logging hooks | ||||
|     menu.hooks.register(HookType.BEFORE, log_before) | ||||
|     menu.hooks.register(HookType.ON_SUCCESS, log_success) | ||||
|     menu.hooks.register(HookType.ON_ERROR, log_error) | ||||
|     menu.hooks.register(HookType.AFTER, log_after) | ||||
|  | ||||
|     # Add a toggle to the bottom bar | ||||
|     menu.add_toggle("D", "Debug Mode", state=False) | ||||
|  | ||||
|     # Add a counter to the bottom bar | ||||
|     menu.add_total_counter("tasks", "Tasks", current=0, total=5) | ||||
|  | ||||
|     # Add static text to the bottom bar | ||||
|     menu.add_static("env", "🌐 Local Env") | ||||
|  | ||||
|     # Add commands with help_text | ||||
|     menu.add_command( | ||||
|         key="S", | ||||
|         description="Say Hello", | ||||
|         help_text="Greets the user with a friendly hello message.", | ||||
|         action=Action("Hello", hello), | ||||
|         color=OneColors.CYAN, | ||||
|     ) | ||||
|  | ||||
|     menu.add_command( | ||||
|         key="G", | ||||
|         description="Say Goodbye", | ||||
|         help_text="Bids farewell and thanks the user for using the app.", | ||||
|         action=Action("Goodbye", goodbye), | ||||
|         color=OneColors.MAGENTA, | ||||
|     ) | ||||
|  | ||||
|     menu.add_command( | ||||
|         key="T", | ||||
|         description="Run a Task", | ||||
|         aliases=["task", "run"], | ||||
|         help_text="Performs a task and increments the counter by 1.", | ||||
|         action=do_task_and_increment, | ||||
|         args=("tasks",), | ||||
|         color=OneColors.GREEN, | ||||
|         spinner=True, | ||||
|     ) | ||||
|  | ||||
|     asyncio.run(menu.run()) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     """ | ||||
|     Entry point for the Falyx CLI demo application. | ||||
|     This function initializes the menu and runs it. | ||||
|     """ | ||||
|     main() | ||||
							
								
								
									
										105
									
								
								falyx/menu.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								falyx/menu.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from prompt_toolkit.formatted_text import FormattedText | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class MenuOption: | ||||
|     """Represents a single menu option with a description and an action to execute.""" | ||||
|  | ||||
|     description: str | ||||
|     action: BaseAction | ||||
|     style: str = OneColors.WHITE | ||||
|  | ||||
|     def __post_init__(self): | ||||
|         if not isinstance(self.description, str): | ||||
|             raise TypeError("MenuOption description must be a string.") | ||||
|         if not isinstance(self.action, BaseAction): | ||||
|             raise TypeError("MenuOption action must be a BaseAction instance.") | ||||
|  | ||||
|     def render(self, key: str) -> str: | ||||
|         """Render the menu option for display.""" | ||||
|         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|     def render_prompt(self, key: str) -> FormattedText: | ||||
|         """Render the menu option for prompt display.""" | ||||
|         return FormattedText( | ||||
|             [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class MenuOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|     Manages menu options including validation, reserved key protection, | ||||
|     and special signal entries like Quit and Back. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS = {"B", "X"} | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         options: dict[str, MenuOption] | None = None, | ||||
|         allow_reserved: bool = False, | ||||
|     ): | ||||
|         super().__init__() | ||||
|         self.allow_reserved = allow_reserved | ||||
|         if options: | ||||
|             self.update(options) | ||||
|         self._inject_reserved_defaults() | ||||
|  | ||||
|     def _inject_reserved_defaults(self): | ||||
|         from falyx.action import SignalAction | ||||
|  | ||||
|         self._add_reserved( | ||||
|             "B", | ||||
|             MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), | ||||
|         ) | ||||
|         self._add_reserved( | ||||
|             "X", | ||||
|             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), | ||||
|         ) | ||||
|  | ||||
|     def _add_reserved(self, key: str, option: MenuOption) -> None: | ||||
|         """Add a reserved key, bypassing validation.""" | ||||
|         norm_key = key.upper() | ||||
|         super().__setitem__(norm_key, option) | ||||
|  | ||||
|     def __setitem__(self, key: str, option: MenuOption) -> None: | ||||
|         if not isinstance(option, MenuOption): | ||||
|             raise TypeError(f"Value for key '{key}' must be a MenuOption.") | ||||
|         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 MenuOptionMap." | ||||
|             ) | ||||
|         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, MenuOption): | ||||
|                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|                 self[key] = option | ||||
|         for key, option in kwargs.items(): | ||||
|             if not isinstance(option, MenuOption): | ||||
|                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|             self[key] = option | ||||
|  | ||||
|     def items(self, include_reserved: bool = True): | ||||
|         for key, option in super().items(): | ||||
|             if not include_reserved and key in self.RESERVED_KEYS: | ||||
|                 continue | ||||
|             yield key, option | ||||
| @@ -1,15 +1,18 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """options_manager.py""" | ||||
|  | ||||
| from argparse import Namespace | ||||
| from collections import defaultdict | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class OptionsManager: | ||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None: | ||||
|         self.options = defaultdict(lambda: Namespace()) | ||||
|     """OptionsManager""" | ||||
|  | ||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: | ||||
|         self.options: defaultdict = defaultdict(Namespace) | ||||
|         if namespaces: | ||||
|             for namespace_name, namespace in namespaces: | ||||
|                 self.from_namespace(namespace, namespace_name) | ||||
| @@ -25,9 +28,7 @@ class OptionsManager: | ||||
|         """Get the value of an option.""" | ||||
|         return getattr(self.options[namespace_name], option_name, default) | ||||
|  | ||||
|     def set( | ||||
|         self, option_name: str, value: Any, namespace_name: str = "cli_args" | ||||
|     ) -> None: | ||||
|     def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None: | ||||
|         """Set the value of an option.""" | ||||
|         setattr(self.options[namespace_name], option_name, value) | ||||
|  | ||||
| @@ -43,7 +44,9 @@ class OptionsManager: | ||||
|                 f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'" | ||||
|             ) | ||||
|         self.set(option_name, not current, namespace_name=namespace_name) | ||||
|         logger.debug(f"Toggled '{option_name}' in '{namespace_name}' to {not current}") | ||||
|         logger.debug( | ||||
|             "Toggled '%s' in '%s' to %s", option_name, namespace_name, not current | ||||
|         ) | ||||
|  | ||||
|     def get_value_getter( | ||||
|         self, option_name: str, namespace_name: str = "cli_args" | ||||
|   | ||||
							
								
								
									
										0
									
								
								falyx/parser/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								falyx/parser/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										21
									
								
								falyx/parser/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								falyx/parser/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .argument import Argument | ||||
| from .argument_action import ArgumentAction | ||||
| from .command_argument_parser import CommandArgumentParser | ||||
| from .parsers import FalyxParsers, get_arg_parsers, get_root_parser, get_subparsers | ||||
|  | ||||
| __all__ = [ | ||||
|     "Argument", | ||||
|     "ArgumentAction", | ||||
|     "CommandArgumentParser", | ||||
|     "get_arg_parsers", | ||||
|     "get_root_parser", | ||||
|     "get_subparsers", | ||||
|     "FalyxParsers", | ||||
| ] | ||||
							
								
								
									
										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, | ||||
|     ) | ||||
							
								
								
									
										84
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| import inspect | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| def infer_args_from_func( | ||||
|     func: Callable[[Any], Any] | None, | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
| ) -> list[dict[str, Any]]: | ||||
|     """ | ||||
|     Infer argument definitions from a callable's signature. | ||||
|     Returns a list of kwargs suitable for CommandArgumentParser.add_argument. | ||||
|     """ | ||||
|     if not callable(func): | ||||
|         logger.debug("Provided argument is not callable: %s", func) | ||||
|         return [] | ||||
|     arg_metadata = arg_metadata or {} | ||||
|     signature = inspect.signature(func) | ||||
|     arg_defs = [] | ||||
|  | ||||
|     for name, param in signature.parameters.items(): | ||||
|         raw_metadata = arg_metadata.get(name, {}) | ||||
|         metadata = ( | ||||
|             {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata | ||||
|         ) | ||||
|         if param.kind not in ( | ||||
|             inspect.Parameter.POSITIONAL_ONLY, | ||||
|             inspect.Parameter.POSITIONAL_OR_KEYWORD, | ||||
|             inspect.Parameter.KEYWORD_ONLY, | ||||
|         ): | ||||
|             continue | ||||
|  | ||||
|         if metadata.get("type"): | ||||
|             arg_type = metadata["type"] | ||||
|         else: | ||||
|             arg_type = ( | ||||
|                 param.annotation | ||||
|                 if param.annotation is not inspect.Parameter.empty | ||||
|                 else str | ||||
|             ) | ||||
|             if isinstance(arg_type, str): | ||||
|                 arg_type = str | ||||
|         default = param.default if param.default is not inspect.Parameter.empty else None | ||||
|         is_required = param.default is inspect.Parameter.empty | ||||
|         if is_required: | ||||
|             flags = [f"{name.replace('_', '-')}"] | ||||
|         else: | ||||
|             flags = [f"--{name.replace('_', '-')}"] | ||||
|         action = "store" | ||||
|         nargs: int | str | None = None | ||||
|  | ||||
|         if arg_type is bool: | ||||
|             if param.default is False: | ||||
|                 action = "store_true" | ||||
|                 default = None | ||||
|             elif param.default is True: | ||||
|                 action = "store_false" | ||||
|                 default = None | ||||
|  | ||||
|         if arg_type is list: | ||||
|             action = "append" | ||||
|             if is_required: | ||||
|                 nargs = "+" | ||||
|             else: | ||||
|                 nargs = "*" | ||||
|  | ||||
|         arg_defs.append( | ||||
|             { | ||||
|                 "flags": flags, | ||||
|                 "dest": name, | ||||
|                 "type": arg_type, | ||||
|                 "default": default, | ||||
|                 "required": is_required, | ||||
|                 "nargs": nargs, | ||||
|                 "action": action, | ||||
|                 "help": metadata.get("help", ""), | ||||
|                 "choices": metadata.get("choices"), | ||||
|                 "suggestions": metadata.get("suggestions"), | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     return arg_defs | ||||
							
								
								
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| import types | ||||
| from datetime import datetime | ||||
| from enum import EnumMeta | ||||
| from typing import Any, Literal, Union, get_args, get_origin | ||||
|  | ||||
| from dateutil import parser as date_parser | ||||
|  | ||||
| from falyx.action.base_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 | ||||
							
								
								
									
										100
									
								
								falyx/parsers.py
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								falyx/parsers.py
									
									
									
									
									
								
							| @@ -1,100 +0,0 @@ | ||||
| """parsers.py | ||||
| This module contains the argument parsers used for the Falyx CLI. | ||||
| """ | ||||
| from argparse import ArgumentParser, HelpFormatter, Namespace | ||||
| from dataclasses import asdict, dataclass | ||||
| from typing import Any, Sequence | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class FalyxParsers: | ||||
|     """Defines the argument parsers for the Falyx CLI.""" | ||||
|     root: ArgumentParser | ||||
|     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 = None, | ||||
|         parents: Sequence[ArgumentParser] = [], | ||||
|         formatter_class: HelpFormatter = HelpFormatter, | ||||
|         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, | ||||
|         formatter_class=formatter_class, | ||||
|         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("-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("--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_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("--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, | ||||
|         run=run_parser, | ||||
|         run_all=run_all_parser, | ||||
|         preview=preview_parser, | ||||
|         list=list_parser, | ||||
|         version=version_parser, | ||||
|     ) | ||||
							
								
								
									
										48
									
								
								falyx/prompt_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								falyx/prompt_utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """prompt_utils.py""" | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import ( | ||||
|     AnyFormattedText, | ||||
|     FormattedText, | ||||
|     merge_formatted_text, | ||||
| ) | ||||
|  | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes import OneColors | ||||
| from falyx.validators import yes_no_validator | ||||
|  | ||||
|  | ||||
| def should_prompt_user( | ||||
|     *, | ||||
|     confirm: bool, | ||||
|     options: OptionsManager, | ||||
|     namespace: str = "cli_args", | ||||
| ): | ||||
|     """ | ||||
|     Determine whether to prompt the user for confirmation based on command | ||||
|     and global options. | ||||
|     """ | ||||
|     never_prompt = options.get("never_prompt", False, namespace) | ||||
|     force_confirm = options.get("force_confirm", False, namespace) | ||||
|     skip_confirm = options.get("skip_confirm", False, namespace) | ||||
|  | ||||
|     if never_prompt or skip_confirm: | ||||
|         return False | ||||
|  | ||||
|     return confirm or force_confirm | ||||
|  | ||||
|  | ||||
| async def confirm_async( | ||||
|     message: AnyFormattedText = "Are you sure?", | ||||
|     prefix: AnyFormattedText = FormattedText([(OneColors.CYAN, "❓ ")]), | ||||
|     suffix: AnyFormattedText = FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] > ")]), | ||||
|     session: PromptSession | None = None, | ||||
| ) -> bool: | ||||
|     """Prompt the user with a yes/no async confirmation and return True for 'Y'.""" | ||||
|     session = session or PromptSession() | ||||
|     merged_message: AnyFormattedText = merge_formatted_text([prefix, message, suffix]) | ||||
|     answer = await session.prompt_async( | ||||
|         merged_message, | ||||
|         validator=yes_no_validator(), | ||||
|     ) | ||||
|     return answer.upper() == "Y" | ||||
							
								
								
									
										19
									
								
								falyx/protocols.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								falyx/protocols.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """protocols.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Callable, Protocol, runtime_checkable | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
|  | ||||
|  | ||||
| @runtime_checkable | ||||
| class ActionFactoryProtocol(Protocol): | ||||
|     async def __call__( | ||||
|         self, *args: Any, **kwargs: Any | ||||
|     ) -> Callable[..., Awaitable[BaseAction]]: ... | ||||
|  | ||||
|  | ||||
| @runtime_checkable | ||||
| class ArgParserProtocol(Protocol): | ||||
|     def __call__(self, args: list[str]) -> tuple[tuple, dict]: ... | ||||
| @@ -1,18 +1,32 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from pydantic import BaseModel, Field | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.utils import logger | ||||
| from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class RetryPolicy(BaseModel): | ||||
|     """RetryPolicy""" | ||||
|  | ||||
|     max_retries: int = Field(default=3, ge=0) | ||||
|     delay: float = Field(default=1.0, ge=0.0) | ||||
|     backoff: float = Field(default=2.0, ge=1.0) | ||||
|     jitter: float = Field(default=0.0, ge=0.0) | ||||
|     enabled: bool = False | ||||
|  | ||||
|     def enable_policy(self) -> None: | ||||
|         """ | ||||
|         Enable the retry policy. | ||||
|         :return: None | ||||
|         """ | ||||
|         self.enabled = True | ||||
|  | ||||
|     def is_active(self) -> bool: | ||||
|         """ | ||||
|         Check if the retry policy is active. | ||||
| @@ -22,18 +36,28 @@ class RetryPolicy(BaseModel): | ||||
|  | ||||
|  | ||||
| class RetryHandler: | ||||
|     def __init__(self, policy: RetryPolicy=RetryPolicy()): | ||||
|     """RetryHandler class to manage retry policies for actions.""" | ||||
|  | ||||
|     def __init__(self, policy: RetryPolicy = RetryPolicy()): | ||||
|         self.policy = policy | ||||
|  | ||||
|     def enable_policy(self, backoff=2, max_retries=3, delay=1): | ||||
|     def enable_policy( | ||||
|         self, | ||||
|         max_retries: int = 3, | ||||
|         delay: float = 1.0, | ||||
|         backoff: float = 2.0, | ||||
|         jitter: float = 0.0, | ||||
|     ) -> None: | ||||
|         self.policy.enabled = True | ||||
|         self.policy.max_retries = max_retries | ||||
|         self.policy.delay = delay | ||||
|         self.policy.backoff = backoff | ||||
|         logger.info(f"🔄 Retry policy enabled: {self.policy}") | ||||
|         self.policy.jitter = jitter | ||||
|         logger.info("Retry policy enabled: %s", self.policy) | ||||
|  | ||||
|     async def retry_on_error(self, context: ExecutionContext): | ||||
|     async def retry_on_error(self, context: ExecutionContext) -> None: | ||||
|         from falyx.action import Action | ||||
|  | ||||
|         name = context.name | ||||
|         error = context.exception | ||||
|         target = context.action | ||||
| @@ -43,36 +67,55 @@ class RetryHandler: | ||||
|         last_error = error | ||||
|  | ||||
|         if not target: | ||||
|             logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") | ||||
|             return | ||||
|             logger.warning("[%s] No action target. Cannot retry.", name) | ||||
|             return None | ||||
|  | ||||
|         if not isinstance(target, Action): | ||||
|             logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.") | ||||
|             return | ||||
|             logger.warning( | ||||
|                 "[%s] RetryHandler only supports only supports Action objects.", name | ||||
|             ) | ||||
|             return None | ||||
|  | ||||
|         if not getattr(target, "is_retryable", False): | ||||
|             logger.warning(f"[{name}] ❌ Not retryable.") | ||||
|             return | ||||
|             logger.warning("[%s] Not retryable.", name) | ||||
|             return None | ||||
|  | ||||
|         if not self.policy.enabled: | ||||
|             logger.warning(f"[{name}] ❌ Retry policy is disabled.") | ||||
|             return | ||||
|             logger.warning("[%s] Retry policy is disabled.", name) | ||||
|             return None | ||||
|  | ||||
|         while retries_done < self.policy.max_retries: | ||||
|             retries_done += 1 | ||||
|             logger.info(f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...") | ||||
|  | ||||
|             sleep_delay = current_delay | ||||
|             if self.policy.jitter > 0: | ||||
|                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||
|  | ||||
|             logger.info( | ||||
|                 "[%s] Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 name, | ||||
|                 retries_done, | ||||
|                 self.policy.max_retries, | ||||
|                 current_delay, | ||||
|                 last_error, | ||||
|             ) | ||||
|             await asyncio.sleep(current_delay) | ||||
|             try: | ||||
|                 result = await target.action(*context.args, **context.kwargs) | ||||
|                 context.result = result | ||||
|                 context.exception = None | ||||
|                 logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") | ||||
|                 return | ||||
|                 logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done) | ||||
|                 return None | ||||
|             except Exception as retry_error: | ||||
|                 last_error = retry_error | ||||
|                 current_delay *= self.policy.backoff | ||||
|                 logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.") | ||||
|                 logger.warning( | ||||
|                     "[%s] Retry attempt %s/%s failed due to '%s'.", | ||||
|                     name, | ||||
|                     retries_done, | ||||
|                     self.policy.max_retries, | ||||
|                     retry_error, | ||||
|                 ) | ||||
|  | ||||
|         context.exception = last_error | ||||
|         logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") | ||||
|         return | ||||
|         logger.error("[%s] All %s retries failed.", name, self.policy.max_retries) | ||||
|   | ||||
							
								
								
									
										19
									
								
								falyx/retry_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								falyx/retry_utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry_utils.py""" | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
|  | ||||
|  | ||||
| def enable_retries_recursively(action: BaseAction, policy: RetryPolicy | None): | ||||
|     if not policy: | ||||
|         policy = RetryPolicy(enabled=True) | ||||
|     if isinstance(action, Action): | ||||
|         action.retry_policy = policy | ||||
|         action.retry_policy.enabled = True | ||||
|         action.hooks.register(HookType.ON_ERROR, RetryHandler(policy).retry_on_error) | ||||
|  | ||||
|     if hasattr(action, "actions"): | ||||
|         for sub in action.actions: | ||||
|             enable_retries_recursively(sub, policy) | ||||
							
								
								
									
										489
									
								
								falyx/selection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										489
									
								
								falyx/selection.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,489 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection.py""" | ||||
| from dataclasses import dataclass | ||||
| from typing import Any, Callable, KeysView, Sequence | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from rich import box | ||||
| from rich.markup import escape | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
| from falyx.validators import MultiIndexValidator, MultiKeyValidator | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class SelectionOption: | ||||
|     """Represents a single selection option with a description and a value.""" | ||||
|  | ||||
|     description: str | ||||
|     value: Any | ||||
|     style: str = OneColors.WHITE | ||||
|  | ||||
|     def __post_init__(self): | ||||
|         if not isinstance(self.description, str): | ||||
|             raise TypeError("SelectionOption description must be a string.") | ||||
|  | ||||
|     def render(self, key: str) -> str: | ||||
|         """Render the selection option for display.""" | ||||
|         key = escape(f"[{key}]") | ||||
|         return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|  | ||||
| class SelectionOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|     Manages selection options including validation and reserved key protection. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS: set[str] = set() | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         options: dict[str, SelectionOption] | None = None, | ||||
|         allow_reserved: bool = False, | ||||
|     ): | ||||
|         super().__init__() | ||||
|         self.allow_reserved = allow_reserved | ||||
|         if options: | ||||
|             self.update(options) | ||||
|  | ||||
|     def _add_reserved(self, key: str, option: SelectionOption) -> None: | ||||
|         """Add a reserved key, bypassing validation.""" | ||||
|         norm_key = key.upper() | ||||
|         super().__setitem__(norm_key, option) | ||||
|  | ||||
|     def __setitem__(self, key: str, option: SelectionOption) -> None: | ||||
|         if not isinstance(option, SelectionOption): | ||||
|             raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|         norm_key = key.upper() | ||||
|         if norm_key in self.RESERVED_KEYS and not self.allow_reserved: | ||||
|             raise ValueError( | ||||
|                 f"Key '{key}' is reserved and cannot be used in SelectionOptionMap." | ||||
|             ) | ||||
|         super().__setitem__(norm_key, option) | ||||
|  | ||||
|     def __delitem__(self, key: str) -> None: | ||||
|         if key.upper() in self.RESERVED_KEYS and not self.allow_reserved: | ||||
|             raise ValueError(f"Cannot delete reserved option '{key}'.") | ||||
|         super().__delitem__(key) | ||||
|  | ||||
|     def update(self, other=None, **kwargs): | ||||
|         """Update the selection options with another dictionary.""" | ||||
|         if other: | ||||
|             for key, option in other.items(): | ||||
|                 if not isinstance(option, SelectionOption): | ||||
|                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|                 self[key] = option | ||||
|         for key, option in kwargs.items(): | ||||
|             if not isinstance(option, SelectionOption): | ||||
|                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|             self[key] = option | ||||
|  | ||||
|     def items(self, include_reserved: bool = True): | ||||
|         for k, v in super().items(): | ||||
|             if not include_reserved and k in self.RESERVED_KEYS: | ||||
|                 continue | ||||
|             yield k, v | ||||
|  | ||||
|  | ||||
| def render_table_base( | ||||
|     title: str, | ||||
|     *, | ||||
|     caption: str = "", | ||||
|     columns: int = 4, | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
|     show_lines: bool = False, | ||||
|     show_header: bool = False, | ||||
|     show_footer: bool = False, | ||||
|     style: str = "", | ||||
|     header_style: str = "", | ||||
|     footer_style: str = "", | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = True, | ||||
|     column_names: Sequence[str] | None = None, | ||||
| ) -> Table: | ||||
|     table = Table( | ||||
|         title=title, | ||||
|         caption=caption, | ||||
|         box=box_style, | ||||
|         show_lines=show_lines, | ||||
|         show_header=show_header, | ||||
|         show_footer=show_footer, | ||||
|         style=style, | ||||
|         header_style=header_style, | ||||
|         footer_style=footer_style, | ||||
|         title_style=title_style, | ||||
|         caption_style=caption_style, | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|     if column_names: | ||||
|         for column_name in column_names: | ||||
|             table.add_column(column_name) | ||||
|     else: | ||||
|         for _ in range(columns): | ||||
|             table.add_column() | ||||
|     return table | ||||
|  | ||||
|  | ||||
| def render_selection_grid( | ||||
|     title: str, | ||||
|     selections: Sequence[str], | ||||
|     *, | ||||
|     columns: int = 4, | ||||
|     caption: str = "", | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
|     show_lines: bool = False, | ||||
|     show_header: bool = False, | ||||
|     show_footer: bool = False, | ||||
|     style: str = "", | ||||
|     header_style: str = "", | ||||
|     footer_style: str = "", | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
| ) -> Table: | ||||
|     """Create a selection table with the given parameters.""" | ||||
|     table = render_table_base( | ||||
|         title=title, | ||||
|         caption=caption, | ||||
|         columns=columns, | ||||
|         box_style=box_style, | ||||
|         show_lines=show_lines, | ||||
|         show_header=show_header, | ||||
|         show_footer=show_footer, | ||||
|         style=style, | ||||
|         header_style=header_style, | ||||
|         footer_style=footer_style, | ||||
|         title_style=title_style, | ||||
|         caption_style=caption_style, | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|  | ||||
|     for chunk in chunks(selections, columns): | ||||
|         table.add_row(*chunk) | ||||
|  | ||||
|     return table | ||||
|  | ||||
|  | ||||
| def render_selection_indexed_table( | ||||
|     title: str, | ||||
|     selections: Sequence[str], | ||||
|     *, | ||||
|     columns: int = 4, | ||||
|     caption: str = "", | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
|     show_lines: bool = False, | ||||
|     show_header: bool = False, | ||||
|     show_footer: bool = False, | ||||
|     style: str = "", | ||||
|     header_style: str = "", | ||||
|     footer_style: str = "", | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
|     formatter: Callable[[int, str], str] | None = None, | ||||
| ) -> Table: | ||||
|     """Create a selection table with the given parameters.""" | ||||
|     table = render_table_base( | ||||
|         title=title, | ||||
|         caption=caption, | ||||
|         columns=columns, | ||||
|         box_style=box_style, | ||||
|         show_lines=show_lines, | ||||
|         show_header=show_header, | ||||
|         show_footer=show_footer, | ||||
|         style=style, | ||||
|         header_style=header_style, | ||||
|         footer_style=footer_style, | ||||
|         title_style=title_style, | ||||
|         caption_style=caption_style, | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|  | ||||
|     for indexes, chunk in zip( | ||||
|         chunks(range(len(selections)), columns), chunks(selections, columns) | ||||
|     ): | ||||
|         row = [ | ||||
|             formatter(index, selection) if formatter else f"[{index}] {selection}" | ||||
|             for index, selection in zip(indexes, chunk) | ||||
|         ] | ||||
|         table.add_row(*row) | ||||
|  | ||||
|     return table | ||||
|  | ||||
|  | ||||
| def render_selection_dict_table( | ||||
|     title: str, | ||||
|     selections: dict[str, SelectionOption], | ||||
|     *, | ||||
|     columns: int = 2, | ||||
|     caption: str = "", | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
|     show_lines: bool = False, | ||||
|     show_header: bool = False, | ||||
|     show_footer: bool = False, | ||||
|     style: str = "", | ||||
|     header_style: str = "", | ||||
|     footer_style: str = "", | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
| ) -> Table: | ||||
|     """Create a selection table with the given parameters.""" | ||||
|     table = render_table_base( | ||||
|         title=title, | ||||
|         caption=caption, | ||||
|         columns=columns, | ||||
|         box_style=box_style, | ||||
|         show_lines=show_lines, | ||||
|         show_header=show_header, | ||||
|         show_footer=show_footer, | ||||
|         style=style, | ||||
|         header_style=header_style, | ||||
|         footer_style=footer_style, | ||||
|         title_style=title_style, | ||||
|         caption_style=caption_style, | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|  | ||||
|     for chunk in chunks(selections.items(), columns): | ||||
|         row = [] | ||||
|         for key, option in chunk: | ||||
|             row.append( | ||||
|                 f"[{OneColors.WHITE}][{key.upper()}] " | ||||
|                 f"[{option.style}]{option.description}[/]" | ||||
|             ) | ||||
|         table.add_row(*row) | ||||
|  | ||||
|     return table | ||||
|  | ||||
|  | ||||
| async def prompt_for_index( | ||||
|     max_index: int, | ||||
|     table: Table, | ||||
|     *, | ||||
|     min_index: int = 0, | ||||
|     default_selection: str = "", | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> int | list[int]: | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selection = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=MultiIndexValidator( | ||||
|             min_index, | ||||
|             max_index, | ||||
|             number_selections, | ||||
|             separator, | ||||
|             allow_duplicates, | ||||
|             cancel_key, | ||||
|         ), | ||||
|         default=default_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( | ||||
|     keys: Sequence[str] | KeysView[str], | ||||
|     table: Table, | ||||
|     *, | ||||
|     default_selection: str = "", | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt the user to select a key from a set of options. Return the selected key.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selected = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=MultiKeyValidator( | ||||
|             keys, number_selections, separator, allow_duplicates, cancel_key | ||||
|         ), | ||||
|         default=default_selection, | ||||
|     ) | ||||
|  | ||||
|     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( | ||||
|     title: str, | ||||
|     selections: Sequence[str], | ||||
|     *, | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
|     columns: int = 4, | ||||
|     caption: str = "", | ||||
|     box_style: box.Box = box.SIMPLE, | ||||
|     show_lines: bool = False, | ||||
|     show_header: bool = False, | ||||
|     show_footer: bool = False, | ||||
|     style: str = "", | ||||
|     header_style: str = "", | ||||
|     footer_style: str = "", | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a selection. Return the selected item.""" | ||||
|     table = render_selection_indexed_table( | ||||
|         title=title, | ||||
|         selections=selections, | ||||
|         columns=columns, | ||||
|         caption=caption, | ||||
|         box_style=box_style, | ||||
|         show_lines=show_lines, | ||||
|         show_header=show_header, | ||||
|         show_footer=show_footer, | ||||
|         style=style, | ||||
|         header_style=header_style, | ||||
|         footer_style=footer_style, | ||||
|         title_style=title_style, | ||||
|         caption_style=caption_style, | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|  | ||||
|     selection_index = await prompt_for_index( | ||||
|         len(selections) - 1, | ||||
|         table, | ||||
|         default_selection=default_selection, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|     if isinstance(selection_index, list): | ||||
|         return [selections[i] for i in selection_index] | ||||
|     return selections[selection_index] | ||||
|  | ||||
|  | ||||
| async def select_key_from_dict( | ||||
|     selections: dict[str, SelectionOption], | ||||
|     table: Table, | ||||
|     *, | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a key from a dict, returns the key.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|  | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
|     return await prompt_for_selection( | ||||
|         selections.keys(), | ||||
|         table, | ||||
|         default_selection=default_selection, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def select_value_from_dict( | ||||
|     selections: dict[str, SelectionOption], | ||||
|     table: Table, | ||||
|     *, | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> Any | list[Any]: | ||||
|     """Prompt for a key from a dict, but return the value.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|  | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
|     selection_key = await prompt_for_selection( | ||||
|         selections.keys(), | ||||
|         table, | ||||
|         default_selection=default_selection, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
|  | ||||
|     if isinstance(selection_key, list): | ||||
|         return [selections[key].value for key in selection_key] | ||||
|     return selections[selection_key].value | ||||
|  | ||||
|  | ||||
| async def get_selection_from_dict_menu( | ||||
|     title: str, | ||||
|     selections: dict[str, SelectionOption], | ||||
|     *, | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     default_selection: str = "", | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> Any | list[Any]: | ||||
|     """Prompt for a key from a dict, but return the value.""" | ||||
|     table = render_selection_dict_table( | ||||
|         title, | ||||
|         selections, | ||||
|     ) | ||||
|  | ||||
|     return await select_value_from_dict( | ||||
|         selections=selections, | ||||
|         table=table, | ||||
|         prompt_session=prompt_session, | ||||
|         prompt_message=prompt_message, | ||||
|         default_selection=default_selection, | ||||
|         number_selections=number_selections, | ||||
|         separator=separator, | ||||
|         allow_duplicates=allow_duplicates, | ||||
|         cancel_key=cancel_key, | ||||
|     ) | ||||
							
								
								
									
										45
									
								
								falyx/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								falyx/signals.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signals.py""" | ||||
|  | ||||
|  | ||||
| class FlowSignal(BaseException): | ||||
|     """Base class for all flow control signals in Falyx. | ||||
|  | ||||
|     These are not errors. They're used to control flow like quitting, | ||||
|     going back, or restarting from user input or nested menus. | ||||
|     """ | ||||
|  | ||||
|  | ||||
| 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): | ||||
|     """Raised to signal an immediate exit from the CLI framework.""" | ||||
|  | ||||
|     def __init__(self, message: str = "Quit signal received."): | ||||
|         super().__init__(message) | ||||
|  | ||||
|  | ||||
| class BackSignal(FlowSignal): | ||||
|     """Raised to return control to the previous menu or caller.""" | ||||
|  | ||||
|     def __init__(self, message: str = "Back signal received."): | ||||
|         super().__init__(message) | ||||
|  | ||||
|  | ||||
| class CancelSignal(FlowSignal): | ||||
|     """Raised to cancel the current command or action.""" | ||||
|  | ||||
|     def __init__(self, message: str = "Cancel signal received."): | ||||
|         super().__init__(message) | ||||
|  | ||||
|  | ||||
| class HelpSignal(FlowSignal): | ||||
|     """Raised to display help information.""" | ||||
|  | ||||
|     def __init__(self, message: str = "Help signal received."): | ||||
|         super().__init__(message) | ||||
							
								
								
									
										33
									
								
								falyx/tagged_table.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								falyx/tagged_table.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """tagged_table.py""" | ||||
| from collections import defaultdict | ||||
|  | ||||
| from rich import box | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.command import Command | ||||
| from falyx.falyx import Falyx | ||||
|  | ||||
|  | ||||
| def build_tagged_table(flx: Falyx) -> Table: | ||||
|     """Custom table builder that groups commands by tags.""" | ||||
|     table = Table(title=flx.title, show_header=False, box=box.SIMPLE)  # type: ignore[arg-type] | ||||
|  | ||||
|     # Group commands by first tag | ||||
|     grouped: dict[str, list[Command]] = defaultdict(list) | ||||
|     for cmd in flx.commands.values(): | ||||
|         first_tag = cmd.tags[0] if cmd.tags else "Other" | ||||
|         grouped[first_tag.capitalize()].append(cmd) | ||||
|  | ||||
|     # Add grouped commands to table | ||||
|     for group_name, commands in grouped.items(): | ||||
|         table.add_row(f"[bold underline]{group_name} Commands[/]") | ||||
|         for cmd in commands: | ||||
|             table.add_row(f"[{cmd.key}] [{cmd.style}]{cmd.description}") | ||||
|         table.add_row("") | ||||
|  | ||||
|     # Add bottom row | ||||
|     for row in flx.get_bottom_row(): | ||||
|         table.add_row(row) | ||||
|  | ||||
|     return table | ||||
							
								
								
									
										15
									
								
								falyx/themes/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								falyx/themes/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| """ | ||||
| Falyx CLI Framework | ||||
|  | ||||
| Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .colors import ColorsMeta, NordColors, OneColors, get_nord_theme | ||||
|  | ||||
| __all__ = [ | ||||
|     "OneColors", | ||||
|     "NordColors", | ||||
|     "get_nord_theme", | ||||
|     "ColorsMeta", | ||||
| ] | ||||
| @@ -17,6 +17,7 @@ Example dynamic usage: | ||||
|     console.print("Hello!", style=NordColors.NORD12bu) | ||||
|     # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles | ||||
| """ | ||||
|  | ||||
| import re | ||||
| from difflib import get_close_matches | ||||
|  | ||||
| @@ -82,14 +83,17 @@ class ColorsMeta(type): | ||||
|         except AttributeError: | ||||
|             error_msg = [f"'{cls.__name__}' has no color named '{base}'."] | ||||
|             valid_bases = [ | ||||
|                 key for key, val in cls.__dict__.items() if isinstance(val, str) and | ||||
|                 not key.startswith("__") | ||||
|                 key | ||||
|                 for key, val in cls.__dict__.items() | ||||
|                 if isinstance(val, str) and not key.startswith("__") | ||||
|             ] | ||||
|             suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5) | ||||
|             if suggestions: | ||||
|                 error_msg.append(f"Did you mean '{suggestions[0]}'?") | ||||
|             if valid_bases: | ||||
|                 error_msg.append(f"Valid base color names include: {', '.join(valid_bases)}") | ||||
|                 error_msg.append( | ||||
|                     f"Valid base color names include: {', '.join(valid_bases)}" | ||||
|                 ) | ||||
|             raise AttributeError(" ".join(error_msg)) from None | ||||
|  | ||||
|         if not isinstance(color_value, str): | ||||
| @@ -105,7 +109,9 @@ class ColorsMeta(type): | ||||
|             if mapped_style: | ||||
|                 styles.append(mapped_style) | ||||
|             else: | ||||
|                 raise AttributeError(f"Unknown style flag '{letter}' in attribute '{name}'") | ||||
|                 raise AttributeError( | ||||
|                     f"Unknown style flag '{letter}' in attribute '{name}'" | ||||
|                 ) | ||||
|  | ||||
|         order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6} | ||||
|         styles_sorted = sorted(styles, key=lambda s: order[s[0]]) | ||||
| @@ -133,7 +139,6 @@ class OneColors(metaclass=ColorsMeta): | ||||
|     BLUE = "#61AFEF" | ||||
|     MAGENTA = "#C678DD" | ||||
|  | ||||
|  | ||||
|     @classmethod | ||||
|     def as_dict(cls): | ||||
|         """ | ||||
| @@ -143,10 +148,10 @@ class OneColors(metaclass=ColorsMeta): | ||||
|         return { | ||||
|             attr: getattr(cls, attr) | ||||
|             for attr in dir(cls) | ||||
|             if not callable(getattr(cls, attr)) and | ||||
|             not attr.startswith("__") | ||||
|             if not callable(getattr(cls, attr)) and not attr.startswith("__") | ||||
|         } | ||||
|  | ||||
|  | ||||
| class NordColors(metaclass=ColorsMeta): | ||||
|     """ | ||||
|     Defines the Nord color palette as class attributes. | ||||
| @@ -215,8 +220,7 @@ class NordColors(metaclass=ColorsMeta): | ||||
|         return { | ||||
|             attr: getattr(cls, attr) | ||||
|             for attr in dir(cls) | ||||
|             if attr.startswith("NORD") and | ||||
|             not callable(getattr(cls, attr)) | ||||
|             if attr.startswith("NORD") and not callable(getattr(cls, attr)) | ||||
|         } | ||||
|  | ||||
|     @classmethod | ||||
| @@ -227,7 +231,8 @@ class NordColors(metaclass=ColorsMeta): | ||||
|         """ | ||||
|         skip_prefixes = ("NORD", "__") | ||||
|         alias_names = [ | ||||
|             attr for attr in dir(cls) | ||||
|             attr | ||||
|             for attr in dir(cls) | ||||
|             if not any(attr.startswith(sp) for sp in skip_prefixes) | ||||
|             and not callable(getattr(cls, attr)) | ||||
|         ] | ||||
| @@ -264,7 +269,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "blink2": Style(blink2=True), | ||||
|     "reverse": Style(reverse=True), | ||||
|     "strike": Style(strike=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Basic color names mapped to Nord | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -277,7 +281,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "cyan": Style(color=NordColors.CYAN), | ||||
|     "blue": Style(color=NordColors.BLUE), | ||||
|     "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Inspect | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -292,14 +295,12 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "inspect.help": Style(color=NordColors.FROST_ICE), | ||||
|     "inspect.doc": Style(dim=True), | ||||
|     "inspect.value.border": Style(color=NordColors.GREEN), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Live / Layout | ||||
|     # --------------------------------------------------------------- | ||||
|     "live.ellipsis": Style(bold=True, color=NordColors.RED), | ||||
|     "layout.tree.row": Style(dim=False, color=NordColors.RED), | ||||
|     "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Logging | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -314,7 +315,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "log.time": Style(color=NordColors.FROST_ICE, dim=True), | ||||
|     "log.message": Style.null(), | ||||
|     "log.path": Style(dim=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Python repr | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -340,18 +340,18 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "repr.bool_true": Style(color=NordColors.GREEN, italic=True), | ||||
|     "repr.bool_false": Style(color=NordColors.RED, italic=True), | ||||
|     "repr.none": Style(color=NordColors.PURPLE, italic=True), | ||||
|     "repr.url": Style(underline=True, color=NordColors.FROST_ICE, italic=False, bold=False), | ||||
|     "repr.url": Style( | ||||
|         underline=True, color=NordColors.FROST_ICE, italic=False, bold=False | ||||
|     ), | ||||
|     "repr.uuid": Style(color=NordColors.YELLOW, bold=False), | ||||
|     "repr.call": Style(color=NordColors.PURPLE, bold=True), | ||||
|     "repr.path": Style(color=NordColors.PURPLE), | ||||
|     "repr.filename": Style(color=NordColors.PURPLE), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Rule | ||||
|     # --------------------------------------------------------------- | ||||
|     "rule.line": Style(color=NordColors.GREEN), | ||||
|     "rule.text": Style.null(), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # JSON | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -362,7 +362,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), | ||||
|     "json.str": Style(color=NordColors.GREEN, italic=False, bold=False), | ||||
|     "json.key": Style(color=NordColors.FROST_ICE, bold=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Prompt | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -371,12 +370,10 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "prompt.default": Style(color=NordColors.FROST_ICE, bold=True), | ||||
|     "prompt.invalid": Style(color=NordColors.RED), | ||||
|     "prompt.invalid.choice": Style(color=NordColors.RED), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Pretty | ||||
|     # --------------------------------------------------------------- | ||||
|     "pretty": Style.null(), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Scope | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -384,7 +381,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "scope.key": Style(color=NordColors.YELLOW, italic=True), | ||||
|     "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), | ||||
|     "scope.equals": Style(color=NordColors.RED), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Table | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -393,7 +389,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "table.cell": Style.null(), | ||||
|     "table.title": Style(italic=True), | ||||
|     "table.caption": Style(italic=True, dim=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Traceback | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -405,7 +400,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "traceback.exc_type": Style(color=NordColors.RED, bold=True), | ||||
|     "traceback.exc_value": Style.null(), | ||||
|     "traceback.offset": Style(color=NordColors.RED, bold=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Progress bars | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -423,13 +417,11 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "progress.data.speed": Style(color=NordColors.RED), | ||||
|     "progress.spinner": Style(color=NordColors.GREEN), | ||||
|     "status.spinner": Style(color=NordColors.GREEN), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Tree | ||||
|     # --------------------------------------------------------------- | ||||
|     "tree": Style(), | ||||
|     "tree.line": Style(), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # Markdown | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -438,8 +430,12 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "markdown.em": Style(italic=True), | ||||
|     "markdown.emph": Style(italic=True),  # For commonmark compatibility | ||||
|     "markdown.strong": Style(bold=True), | ||||
|     "markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), | ||||
|     "markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), | ||||
|     "markdown.code": Style( | ||||
|         bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN | ||||
|     ), | ||||
|     "markdown.code_block": Style( | ||||
|         color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN | ||||
|     ), | ||||
|     "markdown.block_quote": Style(color=NordColors.PURPLE), | ||||
|     "markdown.list": Style(color=NordColors.FROST_ICE), | ||||
|     "markdown.item": Style(), | ||||
| @@ -457,7 +453,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | ||||
|     "markdown.link": Style(color=NordColors.FROST_ICE), | ||||
|     "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), | ||||
|     "markdown.s": Style(strike=True), | ||||
|  | ||||
|     # --------------------------------------------------------------- | ||||
|     # ISO8601 | ||||
|     # --------------------------------------------------------------- | ||||
| @@ -504,7 +499,9 @@ if __name__ == "__main__": | ||||
|         console.print(f"Caught error: {error}", style="red") | ||||
|  | ||||
|     # Demonstrate a traceback style: | ||||
|     console.print("\n8) Raising and displaying a traceback with Nord styling:\n", style="bold") | ||||
|     console.print( | ||||
|         "\n8) Raising and displaying a traceback with Nord styling:\n", style="bold" | ||||
|     ) | ||||
|     try: | ||||
|         raise ValueError("Nord test exception!") | ||||
|     except ValueError: | ||||
|   | ||||
							
								
								
									
										100
									
								
								falyx/utils.py
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								falyx/utils.py
									
									
									
									
									
								
							| @@ -1,37 +1,54 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """utils.py""" | ||||
| from __future__ import annotations | ||||
|  | ||||
| import functools | ||||
| import inspect | ||||
| import logging | ||||
| import os | ||||
| import shutil | ||||
| import sys | ||||
| from itertools import islice | ||||
| from typing import Any, Awaitable, Callable, TypeVar | ||||
|  | ||||
| import pythonjsonlogger.json | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import (AnyFormattedText, FormattedText, | ||||
|                                            merge_formatted_text) | ||||
| from rich.logging import RichHandler | ||||
|  | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
|  | ||||
| T = TypeVar("T") | ||||
|  | ||||
| async def _noop(*args, **kwargs): | ||||
|  | ||||
| async def _noop(*_, **__): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| def get_program_invocation() -> str: | ||||
|     """Returns the recommended program invocation prefix.""" | ||||
|     script = sys.argv[0] | ||||
|     program = shutil.which(script) | ||||
|     if program: | ||||
|         return os.path.basename(program) | ||||
|  | ||||
|     executable = sys.executable | ||||
|     if "python" in executable: | ||||
|         return f"python {script}" | ||||
|     return script | ||||
|  | ||||
|  | ||||
| def is_coroutine(function: Callable[..., Any]) -> bool: | ||||
|     return inspect.iscoroutinefunction(function) | ||||
|  | ||||
|  | ||||
| def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: | ||||
|     if is_coroutine(function): | ||||
|         return function # type: ignore | ||||
|         return function  # type: ignore | ||||
|  | ||||
|     @functools.wraps(function) | ||||
|     async def async_wrapper(*args, **kwargs) -> T: | ||||
|         return function(*args, **kwargs) | ||||
|  | ||||
|     if not callable(function): | ||||
|         raise TypeError(f"{function} is not callable") | ||||
|  | ||||
|     return async_wrapper | ||||
|  | ||||
|  | ||||
| @@ -45,41 +62,33 @@ def chunks(iterator, size): | ||||
|         yield chunk | ||||
|  | ||||
|  | ||||
| async def async_confirm(message: AnyFormattedText = "Are you sure?") -> bool: | ||||
|     session: PromptSession = PromptSession() | ||||
|     while True: | ||||
|         merged_message: AnyFormattedText = merge_formatted_text([message, FormattedText([(OneColors.LIGHT_YELLOW_b, " [Y/n] ")])]) | ||||
|         answer: str = (await session.prompt_async(merged_message)).strip().lower() | ||||
|         if answer in ("y", "yes"): | ||||
|             return True | ||||
|         if answer in ("n", "no", ""): | ||||
|             return False | ||||
|         print("Please enter y or n.") | ||||
|  | ||||
|  | ||||
| class CaseInsensitiveDict(dict): | ||||
|     """A case-insensitive dictionary that treats all keys as uppercase.""" | ||||
|  | ||||
|     def _normalize_key(self, key): | ||||
|         return key.upper() if isinstance(key, str) else key | ||||
|  | ||||
|     def __setitem__(self, key, value): | ||||
|         super().__setitem__(key.upper(), value) | ||||
|         super().__setitem__(self._normalize_key(key), value) | ||||
|  | ||||
|     def __getitem__(self, key): | ||||
|         return super().__getitem__(key.upper()) | ||||
|         return super().__getitem__(self._normalize_key(key)) | ||||
|  | ||||
|     def __contains__(self, key): | ||||
|         return super().__contains__(key.upper()) | ||||
|         return super().__contains__(self._normalize_key(key)) | ||||
|  | ||||
|     def get(self, key, default=None): | ||||
|         return super().get(key.upper(), default) | ||||
|         return super().get(self._normalize_key(key), default) | ||||
|  | ||||
|     def pop(self, key, default=None): | ||||
|         return super().pop(key.upper(), default) | ||||
|         return super().pop(self._normalize_key(key), default) | ||||
|  | ||||
|     def update(self, other=None, **kwargs): | ||||
|         items = {} | ||||
|         if other: | ||||
|             other = {k.upper(): v for k, v in other.items()} | ||||
|         kwargs = {k.upper(): v for k, v in kwargs.items()} | ||||
|         super().update(other, **kwargs) | ||||
|             items.update({self._normalize_key(k): v for k, v in other.items()}) | ||||
|         items.update({self._normalize_key(k): v for k, v in kwargs.items()}) | ||||
|         super().update(items) | ||||
|  | ||||
|  | ||||
| def running_in_container() -> bool: | ||||
| @@ -104,11 +113,13 @@ def setup_logging( | ||||
|     console_log_level: int = logging.WARNING, | ||||
| ): | ||||
|     """ | ||||
|     Configure logging for Falyx with support for both CLI-friendly and structured JSON output. | ||||
|     Configure logging for Falyx with support for both CLI-friendly and structured | ||||
|     JSON output. | ||||
|  | ||||
|     This function sets up separate logging handlers for console and file output, with optional | ||||
|     support for JSON formatting. It also auto-detects whether the application is running inside | ||||
|     a container to default to machine-readable logs when appropriate. | ||||
|     This function sets up separate logging handlers for console and file output, | ||||
|     with optional support for JSON formatting. It also auto-detects whether the | ||||
|     application is running inside a container to default to machine-readable logs | ||||
|     when appropriate. | ||||
|  | ||||
|     Args: | ||||
|         mode (str | None): | ||||
| @@ -131,7 +142,8 @@ def setup_logging( | ||||
|         - Clears existing root handlers before setup. | ||||
|         - Configures console logging using either Rich (for CLI) or JSON formatting. | ||||
|         - Configures file logging in plain text or JSON based on `json_log_to_file`. | ||||
|         - Automatically sets logging levels for noisy third-party modules (`urllib3`, `asyncio`). | ||||
|         - Automatically sets logging levels for noisy third-party modules | ||||
|           (`urllib3`, `asyncio`, `markdown_it`). | ||||
|         - Propagates logs from the "falyx" logger to ensure centralized output. | ||||
|  | ||||
|     Raises: | ||||
| @@ -162,7 +174,9 @@ def setup_logging( | ||||
|     elif mode == "json": | ||||
|         console_handler = logging.StreamHandler() | ||||
|         console_handler.setFormatter( | ||||
|             pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") | ||||
|             pythonjsonlogger.json.JsonFormatter( | ||||
|                 "%(asctime)s %(name)s %(levelname)s %(message)s" | ||||
|             ) | ||||
|         ) | ||||
|     else: | ||||
|         raise ValueError(f"Invalid log mode: {mode}") | ||||
| @@ -170,17 +184,21 @@ def setup_logging( | ||||
|     console_handler.setLevel(console_log_level) | ||||
|     root.addHandler(console_handler) | ||||
|  | ||||
|     file_handler = logging.FileHandler(log_filename) | ||||
|     file_handler = logging.FileHandler(log_filename, "a", "UTF-8") | ||||
|     file_handler.setLevel(file_log_level) | ||||
|     if json_log_to_file: | ||||
|         file_handler.setFormatter( | ||||
|             pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") | ||||
|             pythonjsonlogger.json.JsonFormatter( | ||||
|                 "%(asctime)s %(name)s %(levelname)s %(message)s" | ||||
|             ) | ||||
|         ) | ||||
|     else: | ||||
|         file_handler.setFormatter(logging.Formatter( | ||||
|             "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", | ||||
|             datefmt="%Y-%m-%d %H:%M:%S" | ||||
|         )) | ||||
|         file_handler.setFormatter( | ||||
|             logging.Formatter( | ||||
|                 "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", | ||||
|                 datefmt="%Y-%m-%d %H:%M:%S", | ||||
|             ) | ||||
|         ) | ||||
|     root.addHandler(file_handler) | ||||
|  | ||||
|     logging.getLogger("urllib3").setLevel(logging.WARNING) | ||||
|   | ||||
							
								
								
									
										162
									
								
								falyx/validators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										162
									
								
								falyx/validators.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,162 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """validators.py""" | ||||
| from typing import KeysView, Sequence | ||||
|  | ||||
| from prompt_toolkit.validation import ValidationError, Validator | ||||
|  | ||||
|  | ||||
| def int_range_validator(minimum: int, maximum: int) -> Validator: | ||||
|     """Validator for integer ranges.""" | ||||
|  | ||||
|     def validate(text: str) -> bool: | ||||
|         try: | ||||
|             value = int(text) | ||||
|             if not minimum <= value <= maximum: | ||||
|                 return False | ||||
|             return True | ||||
|         except ValueError: | ||||
|             return False | ||||
|  | ||||
|     return Validator.from_callable( | ||||
|         validate, | ||||
|         error_message=f"Invalid input. Enter a number between {minimum} and {maximum}.", | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def key_validator(keys: Sequence[str] | KeysView[str]) -> Validator: | ||||
|     """Validator for key inputs.""" | ||||
|  | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper() not in [key.upper() for key in keys]: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|     return Validator.from_callable( | ||||
|         validate, error_message=f"Invalid input. Available keys: {', '.join(keys)}." | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def yes_no_validator() -> Validator: | ||||
|     """Validator for yes/no inputs.""" | ||||
|  | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper() not in ["Y", "N"]: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|     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.5" | ||||
| __version__ = "0.1.63" | ||||
|   | ||||
							
								
								
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							| @@ -146,7 +146,10 @@ disable=abstract-method, | ||||
|         wrong-import-order, | ||||
|         xrange-builtin, | ||||
|         zip-builtin-not-iterating, | ||||
|         broad-exception-caught | ||||
|         broad-exception-caught, | ||||
|         too-many-positional-arguments, | ||||
|         inconsistent-quotes, | ||||
|         import-outside-toplevel | ||||
|  | ||||
|  | ||||
| [REPORTS] | ||||
| @@ -260,7 +263,7 @@ generated-members= | ||||
| [FORMAT] | ||||
|  | ||||
| # Maximum number of characters on a single line. | ||||
| max-line-length=80 | ||||
| max-line-length=90 | ||||
|  | ||||
| # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt | ||||
| # lines made too long by directives to pytype. | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.5" | ||||
| version = "0.1.63" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
| @@ -13,15 +13,27 @@ prompt_toolkit = "^3.0" | ||||
| rich = "^13.0" | ||||
| pydantic = "^2.0" | ||||
| python-json-logger = "^3.3.0" | ||||
| toml = "^0.10" | ||||
| pyyaml = "^6.0" | ||||
| aiohttp = "^3.11" | ||||
| python-dateutil = "^2.8" | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| pytest = "^7.0" | ||||
| pytest = "^8.3.5" | ||||
| pytest-asyncio = "^0.20" | ||||
| ruff = "^0.3" | ||||
| toml = "^0.10" | ||||
| black = { version = "^25.0", allow-prereleases = true } | ||||
| mypy = { version = "^1.0", allow-prereleases = true } | ||||
| isort = { version = "^5.0", allow-prereleases = true } | ||||
| 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] | ||||
| falyx = "falyx.cli.main:main" | ||||
| sync-version = "scripts.sync_version:main" | ||||
| falyx = "falyx.__main__:main" | ||||
|  | ||||
| [build-system] | ||||
| requires = ["poetry-core>=1.0.0"] | ||||
| @@ -30,7 +42,7 @@ build-backend = "poetry.core.masonry.api" | ||||
| [tool.pytest.ini_options] | ||||
| testpaths = ["tests"] | ||||
| asyncio_mode = "auto" | ||||
| asyncio_default_fixture_loop_scope = "function" | ||||
| #asyncio_default_fixture_loop_scope = "function" | ||||
|  | ||||
| [tool.pylint."MESSAGES CONTROL"] | ||||
| disable = ["broad-exception-caught"] | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| """scripts/sync_version.py""" | ||||
|  | ||||
| import toml | ||||
| from pathlib import Path | ||||
|  | ||||
| import toml | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     pyproject_path = Path(__file__).parent.parent / "pyproject.toml" | ||||
|     version_path = Path(__file__).parent.parent / "falyx" / "version.py" | ||||
| @@ -13,5 +15,6 @@ def main(): | ||||
|     version_path.write_text(f'__version__ = "{version}"\n') | ||||
|     print(f"✅ Synced version: {version} → {version_path}") | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     main() | ||||
							
								
								
									
										224
									
								
								tests/test_action_basic.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								tests/test_action_basic.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
|  | ||||
| asyncio_default_fixture_loop_scope = "function" | ||||
|  | ||||
|  | ||||
| # --- Helpers --- | ||||
| async def capturing_hook(context: ExecutionContext): | ||||
|     context.extra["hook_triggered"] = True | ||||
|  | ||||
|  | ||||
| # --- Fixtures --- | ||||
| @pytest.fixture(autouse=True) | ||||
| def clean_registry(): | ||||
|     er.clear() | ||||
|     yield | ||||
|     er.clear() | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_callable(): | ||||
|     """Test if Action can be created with a callable.""" | ||||
|     action = Action("test_action", lambda: "Hello, World!") | ||||
|     result = await action() | ||||
|     assert result == "Hello, World!" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_async_callable(): | ||||
|     """Test if Action can be created with an async callable.""" | ||||
|  | ||||
|     async def async_callable(): | ||||
|         return "Hello, World!" | ||||
|  | ||||
|     action = Action("test_action", async_callable) | ||||
|     result = await action() | ||||
|     assert result == "Hello, World!" | ||||
|     print(action) | ||||
|     assert ( | ||||
|         str(action) | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|     ) | ||||
|     assert ( | ||||
|         repr(action) | ||||
|         == "Action(name='test_action', action=async_callable, retry=False, rollback=False)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_chained_action(): | ||||
|     """Test if ChainedAction can be created and used.""" | ||||
|     action1 = Action("one", lambda: 1) | ||||
|     action2 = Action("two", lambda: 2) | ||||
|     chain = ChainedAction( | ||||
|         name="Simple Chain", | ||||
|         actions=[action1, action2], | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     result = await chain() | ||||
|     assert result == [1, 2] | ||||
|     assert ( | ||||
|         str(chain) | ||||
|         == "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_group(): | ||||
|     """Test if ActionGroup can be created and used.""" | ||||
|     action1 = Action("one", lambda: 1) | ||||
|     action2 = Action("two", lambda: 2) | ||||
|     group = ChainedAction( | ||||
|         name="Simple Group", | ||||
|         actions=[action1, action2], | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     result = await group() | ||||
|     assert result == [1, 2] | ||||
|     assert ( | ||||
|         str(group) | ||||
|         == "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_non_callable(): | ||||
|     """Test if Action raises an error when created with a non-callable.""" | ||||
|     with pytest.raises(TypeError): | ||||
|         Action("test_action", 42) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.parametrize( | ||||
|     "return_list, expected", | ||||
|     [ | ||||
|         (True, [1, 2, 3]), | ||||
|         (False, 3), | ||||
|     ], | ||||
| ) | ||||
| async def test_chained_action_return_modes(return_list, expected): | ||||
|     chain = ChainedAction( | ||||
|         name="Simple Chain", | ||||
|         actions=[ | ||||
|             Action(name="one", action=lambda: 1), | ||||
|             Action(name="two", action=lambda: 2), | ||||
|             Action(name="three", action=lambda: 3), | ||||
|         ], | ||||
|         return_list=return_list, | ||||
|     ) | ||||
|  | ||||
|     result = await chain() | ||||
|     assert result == expected | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.parametrize( | ||||
|     "return_list, auto_inject, expected", | ||||
|     [ | ||||
|         (True, True, [1, 2, 3]), | ||||
|         (True, False, [1, 2, 3]), | ||||
|         (False, True, 3), | ||||
|         (False, False, 3), | ||||
|     ], | ||||
| ) | ||||
| async def test_chained_action_literals(return_list, auto_inject, expected): | ||||
|     chain = ChainedAction( | ||||
|         name="Literal Chain", | ||||
|         actions=[1, 2, 3], | ||||
|         return_list=return_list, | ||||
|         auto_inject=auto_inject, | ||||
|     ) | ||||
|  | ||||
|     result = await chain() | ||||
|     assert result == expected | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_literal_input_action(): | ||||
|     """Test if LiteralInputAction can be created and used.""" | ||||
|     action = LiteralInputAction("Hello, World!") | ||||
|     result = await action() | ||||
|     assert result == "Hello, World!" | ||||
|     assert action.value == "Hello, World!" | ||||
|     assert str(action) == "LiteralInputAction(value='Hello, World!')" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_fallback_action(): | ||||
|     """Test if FallbackAction can be created and used.""" | ||||
|     action = FallbackAction("Fallback value") | ||||
|     chain = ChainedAction( | ||||
|         name="Fallback Chain", | ||||
|         actions=[ | ||||
|             Action(name="one", action=lambda: None), | ||||
|             action, | ||||
|         ], | ||||
|     ) | ||||
|     result = await chain() | ||||
|     assert result == "Fallback value" | ||||
|     assert str(action) == "FallbackAction(fallback='Fallback value')" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_remove_action_from_chain(): | ||||
|     """Test if an action can be removed from a chain.""" | ||||
|     action1 = Action(name="one", action=lambda: 1) | ||||
|     action2 = Action(name="two", action=lambda: 2) | ||||
|     chain = ChainedAction( | ||||
|         name="Simple Chain", | ||||
|         actions=[action1, action2], | ||||
|     ) | ||||
|  | ||||
|     assert len(chain.actions) == 2 | ||||
|  | ||||
|     # Remove the first action | ||||
|     chain.remove_action(action1.name) | ||||
|  | ||||
|     assert len(chain.actions) == 1 | ||||
|     assert chain.actions[0] == action2 | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_has_action_in_chain(): | ||||
|     """Test if an action can be checked for presence in a chain.""" | ||||
|     action1 = Action(name="one", action=lambda: 1) | ||||
|     action2 = Action(name="two", action=lambda: 2) | ||||
|     chain = ChainedAction( | ||||
|         name="Simple Chain", | ||||
|         actions=[action1, action2], | ||||
|     ) | ||||
|  | ||||
|     assert chain.has_action(action1.name) is True | ||||
|     assert chain.has_action(action2.name) is True | ||||
|  | ||||
|     # Remove the first action | ||||
|     chain.remove_action(action1.name) | ||||
|  | ||||
|     assert chain.has_action(action1.name) is False | ||||
|     assert chain.has_action(action2.name) is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_get_action_from_chain(): | ||||
|     """Test if an action can be retrieved from a chain.""" | ||||
|     action1 = Action(name="one", action=lambda: 1) | ||||
|     action2 = Action(name="two", action=lambda: 2) | ||||
|     chain = ChainedAction( | ||||
|         name="Simple Chain", | ||||
|         actions=[action1, action2], | ||||
|     ) | ||||
|  | ||||
|     assert chain.get_action(action1.name) == action1 | ||||
|     assert chain.get_action(action2.name) == action2 | ||||
|  | ||||
|     # Remove the first action | ||||
|     chain.remove_action(action1.name) | ||||
|  | ||||
|     assert chain.get_action(action1.name) is None | ||||
|     assert chain.get_action(action2.name) == action2 | ||||
							
								
								
									
										0
									
								
								tests/test_action_fallback.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_action_fallback.py
									
									
									
									
									
										Normal file
									
								
							Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user