Compare commits
	
		
			62 Commits
		
	
	
		
			pipes
			...
			2d1177e820
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2d1177e820 | |||
| 3c7ef3eb1c | |||
| 53ba6a896a | |||
| b24079ea7e | |||
| ac82076511 | |||
| 09eeb90dc6 | |||
| e3ebc1b17b | |||
| 079bc0ee77 | |||
| 1c97857cb8 | |||
| 21af003bc7 | |||
| 1585098513 | |||
| 3d3a706784 | |||
| c2eb854e5a | |||
| 8a3c1d6cc8 | |||
| f196e38c57 | |||
| fb1ffbe9f6 | |||
| 429b434566 | |||
| 4f3632bc6b | |||
| ba562168aa | |||
| ddb78bd5a7 | |||
| b0c0e7dc16 | |||
| 0a1ba22a3d | |||
| b51ba87999 | |||
| 3c0a81359c | |||
| 4fa6e3bf1f | |||
| afa47b0bac | |||
| 70a527358d | |||
| 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/ | .vscode/ | ||||||
| coverage.xml | coverage.xml | ||||||
| .coverage | .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 asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Falyx, Action, ChainedAction | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
|  |  | ||||||
| # A flaky async step that fails randomly | # A flaky async step that fails randomly | ||||||
| async def flaky_step(): | async def flaky_step(): | ||||||
| @@ -62,8 +63,8 @@ async def flaky_step(): | |||||||
|     return "ok" |     return "ok" | ||||||
|  |  | ||||||
| # Create the actions | # Create the actions | ||||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | step1 = Action(name="step_1", action=flaky_step) | ||||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | step2 = Action(name="step_2", action=flaky_step) | ||||||
|  |  | ||||||
| # Chain the actions | # Chain the actions | ||||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||||
| @@ -74,9 +75,9 @@ falyx.add_command( | |||||||
|     key="R", |     key="R", | ||||||
|     description="Run My Pipeline", |     description="Run My Pipeline", | ||||||
|     action=chain, |     action=chain, | ||||||
|     logging_hooks=True, |  | ||||||
|     preview_before_confirm=True, |     preview_before_confirm=True, | ||||||
|     confirm=True, |     confirm=True, | ||||||
|  |     retry_all=True, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| # Entry point | # Entry point | ||||||
|   | |||||||
| @@ -1,29 +1,33 @@ | |||||||
| import asyncio | import asyncio | ||||||
|  |  | ||||||
| from falyx import Action, ActionGroup, ChainedAction | from falyx.action import Action, ActionGroup, ChainedAction | ||||||
|  |  | ||||||
|  |  | ||||||
| # Actions can be defined as synchronous functions | # Actions can be defined as synchronous functions | ||||||
| # Falyx will automatically convert them to async functions | # Falyx will automatically convert them to async functions | ||||||
| def hello() -> None: | def hello() -> None: | ||||||
|     print("Hello, world!") |     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 | # 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 | # Actions are designed to be asynchronous first | ||||||
| async def goodbye() -> None: | async def goodbye() -> None: | ||||||
|     print("Goodbye!") |     print("Goodbye!") | ||||||
|  |  | ||||||
| goodbye = Action(name="goodbye_action", action=goodbye) |  | ||||||
|  | goodbye_action = Action(name="goodbye_action", action=goodbye) | ||||||
|  |  | ||||||
| asyncio.run(goodbye()) | asyncio.run(goodbye()) | ||||||
|  |  | ||||||
| # Actions can be run in parallel | # 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()) | asyncio.run(group()) | ||||||
|  |  | ||||||
| # Actions can be run in a chain | # 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()) | 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 ActionFactoryAction, 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 = ActionFactoryAction( | ||||||
|  |     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()) | ||||||
							
								
								
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | import asyncio | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ActionGroup | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Define a shared async function | ||||||
|  | async def say_hello(name: str, excited: bool = False): | ||||||
|  |     if excited: | ||||||
|  |         print(f"Hello, {name}!!!") | ||||||
|  |     else: | ||||||
|  |         print(f"Hello, {name}.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Wrap the same callable in multiple Actions | ||||||
|  | action1 = Action("say_hello_1", action=say_hello) | ||||||
|  | action2 = Action("say_hello_2", action=say_hello) | ||||||
|  | action3 = Action("say_hello_3", action=say_hello) | ||||||
|  |  | ||||||
|  | # Combine into an ActionGroup | ||||||
|  | group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) | ||||||
|  |  | ||||||
|  | flx = Falyx("Test Group") | ||||||
|  | flx.add_command( | ||||||
|  |     key="G", | ||||||
|  |     description="Greet someone with multiple variations.", | ||||||
|  |     aliases=["greet", "hello"], | ||||||
|  |     action=group, | ||||||
|  |     arg_metadata={ | ||||||
|  |         "name": { | ||||||
|  |             "help": "The name of the person to greet.", | ||||||
|  |         }, | ||||||
|  |         "excited": { | ||||||
|  |             "help": "Whether to greet excitedly.", | ||||||
|  |         }, | ||||||
|  |     }, | ||||||
|  | ) | ||||||
|  | asyncio.run(flx.run()) | ||||||
							
								
								
									
										59
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | import asyncio | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
|  | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
|  | setup_logging() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str: | ||||||
|  |     if verbose: | ||||||
|  |         print(f"Deploying {service} to {region}...") | ||||||
|  |     await asyncio.sleep(2) | ||||||
|  |     if verbose: | ||||||
|  |         print(f"{service} deployed successfully!") | ||||||
|  |     return f"{service} deployed to {region}" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | flx = Falyx("Deployment CLI") | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="D", | ||||||
|  |     aliases=["deploy"], | ||||||
|  |     description="Deploy", | ||||||
|  |     help_text="Deploy a service to a specified region.", | ||||||
|  |     action=Action( | ||||||
|  |         name="deploy_service", | ||||||
|  |         action=deploy, | ||||||
|  |     ), | ||||||
|  |     arg_metadata={ | ||||||
|  |         "service": "Service name", | ||||||
|  |         "region": {"help": "Deployment region", "choices": ["us-east-1", "us-west-2"]}, | ||||||
|  |         "verbose": {"help": "Enable verbose mode"}, | ||||||
|  |     }, | ||||||
|  |     tags=["deployment", "service"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | deploy_chain = ChainedAction( | ||||||
|  |     name="DeployChain", | ||||||
|  |     actions=[ | ||||||
|  |         Action(name="deploy_service", action=deploy), | ||||||
|  |         Action( | ||||||
|  |             name="notify", | ||||||
|  |             action=lambda last_result: print(f"Notification: {last_result}"), | ||||||
|  |         ), | ||||||
|  |     ], | ||||||
|  |     auto_inject=True, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | flx.add_command( | ||||||
|  |     key="N", | ||||||
|  |     aliases=["notify"], | ||||||
|  |     description="Deploy and Notify", | ||||||
|  |     help_text="Deploy a service and notify.", | ||||||
|  |     action=deploy_chain, | ||||||
|  |     tags=["deployment", "service", "notification"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | asyncio.run(flx.run()) | ||||||
							
								
								
									
										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()) | ||||||
							
								
								
									
										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 | ||||||
							
								
								
									
										27
									
								
								examples/file_select.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								examples/file_select.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | import asyncio | ||||||
|  |  | ||||||
|  | from falyx import Falyx | ||||||
|  | from falyx.action import SelectFileAction | ||||||
|  | from falyx.action.types import FileReturnType | ||||||
|  |  | ||||||
|  | sf = SelectFileAction( | ||||||
|  |     name="select_file", | ||||||
|  |     suffix_filter=".yaml", | ||||||
|  |     title="Select a YAML file", | ||||||
|  |     prompt_message="Choose 2 > ", | ||||||
|  |     return_type=FileReturnType.TEXT, | ||||||
|  |     columns=3, | ||||||
|  |     number_selections=2, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | flx = Falyx() | ||||||
|  |  | ||||||
|  | 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 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() | console = Console() | ||||||
| falyx = Falyx(title="🚀 Process Pool Demo") | falyx = Falyx(title="🚀 Process Pool Demo") | ||||||
|  |  | ||||||
| def generate_primes(n): |  | ||||||
|     primes = [] | def generate_primes(start: int = 2, end: int = 100_000) -> list[int]: | ||||||
|     for num in range(2, n): |     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): |         if all(num % p != 0 for p in primes): | ||||||
|             primes.append(num) |             primes.append(num) | ||||||
|     console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) |     console.print( | ||||||
|  |         f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN | ||||||
|  |     ) | ||||||
|     return primes |     return primes | ||||||
|  |  | ||||||
| # Will not block the event loop |  | ||||||
| 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__": | 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 asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Falyx, Action, ChainedAction | from falyx import Falyx | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
| from falyx.utils import setup_logging | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
| setup_logging() | setup_logging() | ||||||
|  |  | ||||||
|  |  | ||||||
| # A flaky async step that fails randomly | # A flaky async step that fails randomly | ||||||
| async def flaky_step(): | async def flaky_step() -> str: | ||||||
|     await asyncio.sleep(0.2) |     await asyncio.sleep(0.2) | ||||||
|     if random.random() < 0.5: |     if random.random() < 0.3: | ||||||
|         raise RuntimeError("Random failure!") |         raise RuntimeError("Random failure!") | ||||||
|  |     print("Flaky step succeeded!") | ||||||
|     return "ok" |     return "ok" | ||||||
|  |  | ||||||
|  |  | ||||||
| # Create a retry handler | # Create a retry handler | ||||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||||
| step2 = Action(name="step_2", 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,18 @@ | |||||||
|  | """ | ||||||
|  | Falyx CLI Framework | ||||||
|  |  | ||||||
|  | Copyright (c) 2025 rtj.dev LLC. | ||||||
|  | Licensed under the MIT License. See LICENSE file for details. | ||||||
|  | """ | ||||||
|  |  | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from .action import Action, ActionGroup, ChainedAction, ProcessAction |  | ||||||
| from .command import Command |  | ||||||
| from .context import ExecutionContext, ResultsContext |  | ||||||
| from .execution_registry import ExecutionRegistry | from .execution_registry import ExecutionRegistry | ||||||
| from .falyx import Falyx | from .falyx import Falyx | ||||||
|  |  | ||||||
| logger = logging.getLogger("falyx") | logger = logging.getLogger("falyx") | ||||||
|  |  | ||||||
| __version__ = "0.1.0" |  | ||||||
|  |  | ||||||
| __all__ = [ | __all__ = [ | ||||||
|     "Action", |  | ||||||
|     "ChainedAction", |  | ||||||
|     "ActionGroup", |  | ||||||
|     "ProcessAction", |  | ||||||
|     "Falyx", |     "Falyx", | ||||||
|     "Command", |  | ||||||
|     "ExecutionContext", |  | ||||||
|     "ResultsContext", |  | ||||||
|     "ExecutionRegistry", |     "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 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.falyx import Falyx | ||||||
|  | from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers | ||||||
|  |  | ||||||
|  |  | ||||||
| def build_falyx() -> Falyx: | def find_falyx_config() -> Path | None: | ||||||
|     """Build and return a Falyx instance with all your commands.""" |     candidates = [ | ||||||
|     app = Falyx(title="🚀 Falyx CLI") |         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( | def bootstrap() -> Path | None: | ||||||
|         key="B", |     config_path = find_falyx_config() | ||||||
|         description="Build project", |     if config_path and str(config_path.parent) not in sys.path: | ||||||
|         action=Action("Build", lambda: print("📦 Building...")), |         sys.path.insert(0, str(config_path.parent)) | ||||||
|         tags=["build"] |     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", | def init_callback(args: Namespace) -> None: | ||||||
|         description="Run tests", |     """Callback for the init command.""" | ||||||
|         action=Action("Test", lambda: print("🧪 Running tests...")), |     if args.command == "init": | ||||||
|         tags=["test"] |         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__": | if __name__ == "__main__": | ||||||
|     logging.basicConfig(level=logging.WARNING) |     main() | ||||||
|     falyx = build_falyx() |  | ||||||
|     asyncio.run(falyx.run()) |  | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										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
									
								
							
							
								
								
									
										46
									
								
								falyx/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								falyx/action/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | """ | ||||||
|  | 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 ActionFactoryAction | ||||||
|  | from .action_group import ActionGroup | ||||||
|  | from .base import BaseAction | ||||||
|  | from .chained_action import ChainedAction | ||||||
|  | from .fallback_action import FallbackAction | ||||||
|  | from .http_action import HTTPAction | ||||||
|  | from .io_action import BaseIOAction | ||||||
|  | from .literal_input_action import LiteralInputAction | ||||||
|  | from .menu_action import MenuAction | ||||||
|  | from .process_action import ProcessAction | ||||||
|  | from .process_pool_action import ProcessPoolAction | ||||||
|  | from .prompt_menu_action import PromptMenuAction | ||||||
|  | from .select_file_action import SelectFileAction | ||||||
|  | from .selection_action import SelectionAction | ||||||
|  | from .shell_action import ShellAction | ||||||
|  | from .signal_action import SignalAction | ||||||
|  | from .user_input_action import UserInputAction | ||||||
|  |  | ||||||
|  | __all__ = [ | ||||||
|  |     "Action", | ||||||
|  |     "ActionGroup", | ||||||
|  |     "BaseAction", | ||||||
|  |     "ChainedAction", | ||||||
|  |     "ProcessAction", | ||||||
|  |     "ActionFactoryAction", | ||||||
|  |     "HTTPAction", | ||||||
|  |     "BaseIOAction", | ||||||
|  |     "ShellAction", | ||||||
|  |     "SelectionAction", | ||||||
|  |     "SelectFileAction", | ||||||
|  |     "MenuAction", | ||||||
|  |     "SignalAction", | ||||||
|  |     "FallbackAction", | ||||||
|  |     "LiteralInputAction", | ||||||
|  |     "UserInputAction", | ||||||
|  |     "PromptMenuAction", | ||||||
|  |     "ProcessPoolAction", | ||||||
|  | ] | ||||||
							
								
								
									
										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, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.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], | ||||||
|  |         *, | ||||||
|  |         rollback: Callable[..., 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[..., Any]: | ||||||
|  |         return self._action | ||||||
|  |  | ||||||
|  |     @action.setter | ||||||
|  |     def action(self, value: Callable[..., Any]): | ||||||
|  |         self._action = ensure_async(value) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def rollback(self) -> Callable[..., Any] | None: | ||||||
|  |         return self._rollback | ||||||
|  |  | ||||||
|  |     @rollback.setter | ||||||
|  |     def rollback(self, value: Callable[..., 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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										126
									
								
								falyx/action/action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								falyx/action/action_factory.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,126 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """action_factory.py""" | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.protocols import ActionFactoryProtocol | ||||||
|  | from falyx.themes import OneColors | ||||||
|  | from falyx.utils import ensure_async | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ActionFactoryAction(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 = 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) | ||||||
							
								
								
									
										172
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """action_group.py""" | ||||||
|  | import asyncio | ||||||
|  | import random | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action import Action | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.action.mixins import ActionListMixin | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import Hook, HookManager, HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.parser.utils import same_argument_definitions | ||||||
|  | from falyx.themes.colors import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ActionGroup(BaseAction, ActionListMixin): | ||||||
|  |     """ | ||||||
|  |     ActionGroup executes multiple actions concurrently in parallel. | ||||||
|  |  | ||||||
|  |     It is ideal for independent tasks that can be safely run simultaneously, | ||||||
|  |     improving overall throughput and responsiveness of workflows. | ||||||
|  |  | ||||||
|  |     Core features: | ||||||
|  |     - Parallel execution of all contained actions. | ||||||
|  |     - Shared last_result injection across all actions if configured. | ||||||
|  |     - Aggregated collection of individual results as (name, result) pairs. | ||||||
|  |     - Hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||||
|  |     - Error aggregation: captures all action errors and reports them together. | ||||||
|  |  | ||||||
|  |     Behavior: | ||||||
|  |     - If any action fails, the group collects the errors but continues executing | ||||||
|  |       other actions without interruption. | ||||||
|  |     - After all actions complete, ActionGroup raises a single exception summarizing | ||||||
|  |       all failures, or returns all results if successful. | ||||||
|  |  | ||||||
|  |     Best used for: | ||||||
|  |     - Batch processing multiple independent tasks. | ||||||
|  |     - Reducing latency for workflows with parallelizable steps. | ||||||
|  |     - Isolating errors while maximizing successful execution. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         name (str): Name of the chain. | ||||||
|  |         actions (list): List of actions or literals to execute. | ||||||
|  |         hooks (HookManager, optional): Hooks for lifecycle events. | ||||||
|  |         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||||
|  |                                              by default. | ||||||
|  |         inject_into (str, optional): Key name for injection. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         actions: list[BaseAction] | None = None, | ||||||
|  |         *, | ||||||
|  |         hooks: HookManager | None = None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             hooks=hooks, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |         ) | ||||||
|  |         ActionListMixin.__init__(self) | ||||||
|  |         if actions: | ||||||
|  |             self.set_actions(actions) | ||||||
|  |  | ||||||
|  |     def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction: | ||||||
|  |         if isinstance(action, BaseAction): | ||||||
|  |             return action | ||||||
|  |         elif callable(action): | ||||||
|  |             return Action(name=action.__name__, action=action) | ||||||
|  |         else: | ||||||
|  |             raise TypeError( | ||||||
|  |                 "ActionGroup only accepts BaseAction or callable, got " | ||||||
|  |                 f"{type(action).__name__}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     def add_action(self, action: BaseAction | Any) -> None: | ||||||
|  |         action = self._wrap_if_needed(action) | ||||||
|  |         super().add_action(action) | ||||||
|  |         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||||
|  |             action.register_teardown(self.hooks) | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||||
|  |         arg_defs = same_argument_definitions(self.actions) | ||||||
|  |         if arg_defs: | ||||||
|  |             return self.actions[0].get_infer_target() | ||||||
|  |         logger.debug( | ||||||
|  |             "[%s] auto_args disabled: mismatched ActionGroup arguments", | ||||||
|  |             self.name, | ||||||
|  |         ) | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: | ||||||
|  |         shared_context = SharedContext(name=self.name, action=self, is_parallel=True) | ||||||
|  |         if self.shared_context: | ||||||
|  |             shared_context.set_shared_result(self.shared_context.last_result()) | ||||||
|  |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=args, | ||||||
|  |             kwargs=updated_kwargs, | ||||||
|  |             action=self, | ||||||
|  |             extra={"results": [], "errors": []}, | ||||||
|  |             shared_context=shared_context, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         async def run_one(action: BaseAction): | ||||||
|  |             try: | ||||||
|  |                 prepared = action.prepare(shared_context, self.options_manager) | ||||||
|  |                 result = await prepared(*args, **updated_kwargs) | ||||||
|  |                 shared_context.add_result((action.name, result)) | ||||||
|  |                 context.extra["results"].append((action.name, result)) | ||||||
|  |             except Exception as error: | ||||||
|  |                 shared_context.add_error(shared_context.current_index, error) | ||||||
|  |                 context.extra["errors"].append((action.name, error)) | ||||||
|  |  | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |             await asyncio.gather(*[run_one(a) for a in self.actions]) | ||||||
|  |  | ||||||
|  |             if context.extra["errors"]: | ||||||
|  |                 context.exception = Exception( | ||||||
|  |                     f"{len(context.extra['errors'])} action(s) failed: " | ||||||
|  |                     f"{' ,'.join(name for name, _ in context.extra['errors'])}" | ||||||
|  |                 ) | ||||||
|  |                 await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|  |                 raise context.exception | ||||||
|  |  | ||||||
|  |             context.result = context.extra["results"] | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return context.result | ||||||
|  |  | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             context.stop_timer() | ||||||
|  |             await self.hooks.trigger(HookType.AFTER, context) | ||||||
|  |             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||||
|  |             er.record(context) | ||||||
|  |  | ||||||
|  |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
|  |         """Register a hook for all actions and sub-actions.""" | ||||||
|  |         super().register_hooks_recursively(hook_type, hook) | ||||||
|  |         for action in self.actions: | ||||||
|  |             action.register_hooks_recursively(hook_type, hook) | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"] | ||||||
|  |         if self.inject_last_result: | ||||||
|  |             label.append(f" [dim](receives '{self.inject_into}')[/dim]") | ||||||
|  |         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||||
|  |         actions = self.actions.copy() | ||||||
|  |         random.shuffle(actions) | ||||||
|  |         await asyncio.gather(*(action.preview(parent=tree) for action in actions)) | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return ( | ||||||
|  |             f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}," | ||||||
|  |             f" inject_last_result={self.inject_last_result}, " | ||||||
|  |             f"inject_into={self.inject_into!r})" | ||||||
|  |         ) | ||||||
							
								
								
									
										156
									
								
								falyx/action/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								falyx/action/base.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,156 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """base.py | ||||||
|  |  | ||||||
|  | Core action system for Falyx. | ||||||
|  |  | ||||||
|  | This module defines the building blocks for executable actions and workflows, | ||||||
|  | providing a structured way to compose, execute, recover, and manage sequences of | ||||||
|  | operations. | ||||||
|  |  | ||||||
|  | All actions are callable and follow a unified signature: | ||||||
|  |     result = action(*args, **kwargs) | ||||||
|  |  | ||||||
|  | Core guarantees: | ||||||
|  | - Full hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||||
|  | - Consistent timing and execution context tracking for each run. | ||||||
|  | - Unified, predictable result handling and error propagation. | ||||||
|  | - Optional last_result injection to enable flexible, data-driven workflows. | ||||||
|  | - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback | ||||||
|  |   recovery. | ||||||
|  |  | ||||||
|  | Key components: | ||||||
|  | - Action: wraps a function or coroutine into a standard executable unit. | ||||||
|  | - ChainedAction: runs actions sequentially, optionally injecting last results. | ||||||
|  | - ActionGroup: runs actions in parallel and gathers results. | ||||||
|  | - ProcessAction: executes CPU-bound functions in a separate process. | ||||||
|  | - LiteralInputAction: injects static values into workflows. | ||||||
|  | - FallbackAction: gracefully recovers from failures or missing data. | ||||||
|  |  | ||||||
|  | This design promotes clean, fault-tolerant, modular CLI and automation systems. | ||||||
|  | """ | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from abc import ABC, abstractmethod | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.context import SharedContext | ||||||
|  | from falyx.debug import register_debug_hooks | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import Hook, HookManager, HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.options_manager import OptionsManager | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class BaseAction(ABC): | ||||||
|  |     """ | ||||||
|  |     Base class for actions. Actions can be simple functions or more | ||||||
|  |     complex actions like `ChainedAction` or `ActionGroup`. They can also | ||||||
|  |     be run independently or as part of Falyx. | ||||||
|  |  | ||||||
|  |     inject_last_result (bool): Whether to inject the previous action's result | ||||||
|  |                                into kwargs. | ||||||
|  |     inject_into (str): The name of the kwarg key to inject the result as | ||||||
|  |                        (default: 'last_result'). | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         *, | ||||||
|  |         hooks: HookManager | None = None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |         never_prompt: bool = False, | ||||||
|  |         logging_hooks: bool = False, | ||||||
|  |     ) -> None: | ||||||
|  |         self.name = name | ||||||
|  |         self.hooks = hooks or HookManager() | ||||||
|  |         self.is_retryable: bool = False | ||||||
|  |         self.shared_context: SharedContext | None = None | ||||||
|  |         self.inject_last_result: bool = inject_last_result | ||||||
|  |         self.inject_into: str = inject_into | ||||||
|  |         self._never_prompt: bool = never_prompt | ||||||
|  |         self._skip_in_chain: bool = False | ||||||
|  |         self.console = Console(color_system="truecolor") | ||||||
|  |         self.options_manager: OptionsManager | None = None | ||||||
|  |  | ||||||
|  |         if logging_hooks: | ||||||
|  |             register_debug_hooks(self.hooks) | ||||||
|  |  | ||||||
|  |     async def __call__(self, *args, **kwargs) -> Any: | ||||||
|  |         return await self._run(*args, **kwargs) | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         raise NotImplementedError("_run must be implemented by subclasses") | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         raise NotImplementedError("preview must be implemented by subclasses") | ||||||
|  |  | ||||||
|  |     @abstractmethod | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||||
|  |         """ | ||||||
|  |         Returns the callable to be used for argument inference. | ||||||
|  |         By default, it returns None. | ||||||
|  |         """ | ||||||
|  |         raise NotImplementedError("get_infer_target must be implemented by subclasses") | ||||||
|  |  | ||||||
|  |     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||||
|  |         self.options_manager = options_manager | ||||||
|  |  | ||||||
|  |     def set_shared_context(self, shared_context: SharedContext) -> None: | ||||||
|  |         self.shared_context = shared_context | ||||||
|  |  | ||||||
|  |     def get_option(self, option_name: str, default: Any = None) -> Any: | ||||||
|  |         """ | ||||||
|  |         Resolve an option from the OptionsManager if present, otherwise use the fallback. | ||||||
|  |         """ | ||||||
|  |         if self.options_manager: | ||||||
|  |             return self.options_manager.get(option_name, default) | ||||||
|  |         return default | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def last_result(self) -> Any: | ||||||
|  |         """Return the last result from the shared context.""" | ||||||
|  |         if self.shared_context: | ||||||
|  |             return self.shared_context.last_result() | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def never_prompt(self) -> bool: | ||||||
|  |         return self.get_option("never_prompt", self._never_prompt) | ||||||
|  |  | ||||||
|  |     def prepare( | ||||||
|  |         self, shared_context: SharedContext, options_manager: OptionsManager | None = None | ||||||
|  |     ) -> BaseAction: | ||||||
|  |         """ | ||||||
|  |         Prepare the action specifically for sequential (ChainedAction) execution. | ||||||
|  |         Can be overridden for chain-specific logic. | ||||||
|  |         """ | ||||||
|  |         self.set_shared_context(shared_context) | ||||||
|  |         if options_manager: | ||||||
|  |             self.set_options_manager(options_manager) | ||||||
|  |         return self | ||||||
|  |  | ||||||
|  |     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: | ||||||
|  |         if self.inject_last_result and self.shared_context: | ||||||
|  |             key = self.inject_into | ||||||
|  |             if key in kwargs: | ||||||
|  |                 logger.warning("[%s] Overriding '%s' with last_result", self.name, key) | ||||||
|  |             kwargs = dict(kwargs) | ||||||
|  |             kwargs[key] = self.shared_context.last_result() | ||||||
|  |         return kwargs | ||||||
|  |  | ||||||
|  |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
|  |         """Register a hook for all actions and sub-actions.""" | ||||||
|  |         self.hooks.register(hook_type, hook) | ||||||
|  |  | ||||||
|  |     async def _write_stdout(self, data: str) -> None: | ||||||
|  |         """Override in subclasses that produce terminal output.""" | ||||||
|  |  | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         return str(self) | ||||||
							
								
								
									
										210
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """chained_action.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action import Action | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.action.fallback_action import FallbackAction | ||||||
|  | from falyx.action.literal_input_action import LiteralInputAction | ||||||
|  | from falyx.action.mixins import ActionListMixin | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.exceptions import EmptyChainError | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import Hook, HookManager, HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ChainedAction(BaseAction, ActionListMixin): | ||||||
|  |     """ | ||||||
|  |     ChainedAction executes a sequence of actions one after another. | ||||||
|  |  | ||||||
|  |     Features: | ||||||
|  |     - Supports optional automatic last_result injection (auto_inject). | ||||||
|  |     - Recovers from intermediate errors using FallbackAction if present. | ||||||
|  |     - Rolls back all previously executed actions if a failure occurs. | ||||||
|  |     - Handles literal values with LiteralInputAction. | ||||||
|  |  | ||||||
|  |     Best used for defining robust, ordered workflows where each step can depend on | ||||||
|  |     previous results. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         name (str): Name of the chain. | ||||||
|  |         actions (list): List of actions or literals to execute. | ||||||
|  |         hooks (HookManager, optional): Hooks for lifecycle events. | ||||||
|  |         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||||
|  |                                              by default. | ||||||
|  |         inject_into (str, optional): Key name for injection. | ||||||
|  |         auto_inject (bool, optional): Auto-enable injection for subsequent actions. | ||||||
|  |         return_list (bool, optional): Whether to return a list of all results. False | ||||||
|  |                                       returns the last result. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         actions: list[BaseAction | Any] | None = None, | ||||||
|  |         *, | ||||||
|  |         hooks: HookManager | None = None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |         auto_inject: bool = False, | ||||||
|  |         return_list: bool = False, | ||||||
|  |     ) -> None: | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             hooks=hooks, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |         ) | ||||||
|  |         ActionListMixin.__init__(self) | ||||||
|  |         self.auto_inject = auto_inject | ||||||
|  |         self.return_list = return_list | ||||||
|  |         if actions: | ||||||
|  |             self.set_actions(actions) | ||||||
|  |  | ||||||
|  |     def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction: | ||||||
|  |         if isinstance(action, BaseAction): | ||||||
|  |             return action | ||||||
|  |         elif callable(action): | ||||||
|  |             return Action(name=action.__name__, action=action) | ||||||
|  |         else: | ||||||
|  |             return LiteralInputAction(action) | ||||||
|  |  | ||||||
|  |     def add_action(self, action: BaseAction | Any) -> None: | ||||||
|  |         action = self._wrap_if_needed(action) | ||||||
|  |         if self.actions and self.auto_inject and not action.inject_last_result: | ||||||
|  |             action.inject_last_result = True | ||||||
|  |         super().add_action(action) | ||||||
|  |         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||||
|  |             action.register_teardown(self.hooks) | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||||
|  |         if self.actions: | ||||||
|  |             return self.actions[0].get_infer_target() | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     def _clear_args(self): | ||||||
|  |         return (), {} | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> list[Any]: | ||||||
|  |         if not self.actions: | ||||||
|  |             raise EmptyChainError(f"[{self.name}] No actions to execute.") | ||||||
|  |  | ||||||
|  |         shared_context = SharedContext(name=self.name, action=self) | ||||||
|  |         if self.shared_context: | ||||||
|  |             shared_context.add_result(self.shared_context.last_result()) | ||||||
|  |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=args, | ||||||
|  |             kwargs=updated_kwargs, | ||||||
|  |             action=self, | ||||||
|  |             extra={"results": [], "rollback_stack": []}, | ||||||
|  |             shared_context=shared_context, | ||||||
|  |         ) | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |  | ||||||
|  |             for index, action in enumerate(self.actions): | ||||||
|  |                 if action._skip_in_chain: | ||||||
|  |                     logger.debug( | ||||||
|  |                         "[%s] Skipping consumed action '%s'", self.name, action.name | ||||||
|  |                     ) | ||||||
|  |                     continue | ||||||
|  |                 shared_context.current_index = index | ||||||
|  |                 prepared = action.prepare(shared_context, self.options_manager) | ||||||
|  |                 try: | ||||||
|  |                     result = await prepared(*args, **updated_kwargs) | ||||||
|  |                 except Exception as error: | ||||||
|  |                     if index + 1 < len(self.actions) and isinstance( | ||||||
|  |                         self.actions[index + 1], FallbackAction | ||||||
|  |                     ): | ||||||
|  |                         logger.warning( | ||||||
|  |                             "[%s] Fallback triggered: %s, recovering with fallback " | ||||||
|  |                             "'%s'.", | ||||||
|  |                             self.name, | ||||||
|  |                             error, | ||||||
|  |                             self.actions[index + 1].name, | ||||||
|  |                         ) | ||||||
|  |                         shared_context.add_result(None) | ||||||
|  |                         context.extra["results"].append(None) | ||||||
|  |                         fallback = self.actions[index + 1].prepare(shared_context) | ||||||
|  |                         result = await fallback() | ||||||
|  |                         fallback._skip_in_chain = True | ||||||
|  |                     else: | ||||||
|  |                         raise | ||||||
|  |                 args, updated_kwargs = self._clear_args() | ||||||
|  |                 shared_context.add_result(result) | ||||||
|  |                 context.extra["results"].append(result) | ||||||
|  |                 context.extra["rollback_stack"].append(prepared) | ||||||
|  |  | ||||||
|  |             all_results = context.extra["results"] | ||||||
|  |             assert ( | ||||||
|  |                 all_results | ||||||
|  |             ), f"[{self.name}] No results captured. Something seriously went wrong." | ||||||
|  |             context.result = all_results if self.return_list else all_results[-1] | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return context.result | ||||||
|  |  | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             shared_context.add_error(shared_context.current_index, error) | ||||||
|  |             await self._rollback(context.extra["rollback_stack"], *args, **kwargs) | ||||||
|  |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             context.stop_timer() | ||||||
|  |             await self.hooks.trigger(HookType.AFTER, context) | ||||||
|  |             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||||
|  |             er.record(context) | ||||||
|  |  | ||||||
|  |     async def _rollback(self, rollback_stack, *args, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Roll back all executed actions in reverse order. | ||||||
|  |  | ||||||
|  |         Rollbacks run even if a fallback recovered from failure, | ||||||
|  |         ensuring consistent undo of all side effects. | ||||||
|  |  | ||||||
|  |         Actions without rollback handlers are skipped. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             rollback_stack (list): Actions to roll back. | ||||||
|  |             *args, **kwargs: Passed to rollback handlers. | ||||||
|  |         """ | ||||||
|  |         for action in reversed(rollback_stack): | ||||||
|  |             rollback = getattr(action, "rollback", None) | ||||||
|  |             if rollback: | ||||||
|  |                 try: | ||||||
|  |                     logger.warning("[%s] Rolling back...", action.name) | ||||||
|  |                     await action.rollback(*args, **kwargs) | ||||||
|  |                 except Exception as error: | ||||||
|  |                     logger.error("[%s] Rollback failed: %s", action.name, error) | ||||||
|  |  | ||||||
|  |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
|  |         """Register a hook for all actions and sub-actions.""" | ||||||
|  |         self.hooks.register(hook_type, hook) | ||||||
|  |         for action in self.actions: | ||||||
|  |             action.register_hooks_recursively(hook_type, hook) | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"] | ||||||
|  |         if self.inject_last_result: | ||||||
|  |             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||||
|  |         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||||
|  |         for action in self.actions: | ||||||
|  |             await action.preview(parent=tree) | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return ( | ||||||
|  |             f"ChainedAction(name={self.name!r}, " | ||||||
|  |             f"actions={[a.name for a in self.actions]!r}, " | ||||||
|  |             f"auto_inject={self.auto_inject}, return_list={self.return_list})" | ||||||
|  |         ) | ||||||
							
								
								
									
										51
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """fallback_action.py""" | ||||||
|  | from functools import cached_property | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.action import Action | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FallbackAction(Action): | ||||||
|  |     """ | ||||||
|  |     FallbackAction provides a default value if the previous action failed or | ||||||
|  |     returned None. | ||||||
|  |  | ||||||
|  |     It injects the last result and checks: | ||||||
|  |     - If last_result is not None, it passes it through unchanged. | ||||||
|  |     - If last_result is None (e.g., due to failure), it replaces it with a fallback value. | ||||||
|  |  | ||||||
|  |     Used in ChainedAction pipelines to gracefully recover from errors or missing data. | ||||||
|  |     When activated, it consumes the preceding error and allows the chain to continue | ||||||
|  |     normally. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         fallback (Any): The fallback value to use if last_result is None. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__(self, fallback: Any): | ||||||
|  |         self._fallback = fallback | ||||||
|  |  | ||||||
|  |         async def _fallback_logic(last_result): | ||||||
|  |             return last_result if last_result is not None else fallback | ||||||
|  |  | ||||||
|  |         super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True) | ||||||
|  |  | ||||||
|  |     @cached_property | ||||||
|  |     def fallback(self) -> Any: | ||||||
|  |         """Return the fallback value.""" | ||||||
|  |         return self._fallback | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"] | ||||||
|  |         label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]") | ||||||
|  |         if parent: | ||||||
|  |             parent.add("".join(label)) | ||||||
|  |         else: | ||||||
|  |             self.console.print(Tree("".join(label))) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"FallbackAction(fallback={self.fallback!r})" | ||||||
							
								
								
									
										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 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})" | ||||||
							
								
								
									
										166
									
								
								falyx/action/menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								falyx/action/menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """menu_action.py""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from prompt_toolkit import PromptSession | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.table import Table | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.menu import MenuOptionMap | ||||||
|  | from falyx.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", | ||||||
|  |         console: Console | None = None, | ||||||
|  |         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 | ||||||
|  |         if isinstance(console, Console): | ||||||
|  |             self.console = console | ||||||
|  |         elif console: | ||||||
|  |             raise ValueError("`console` must be an instance of `rich.console.Console`") | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.include_reserved = include_reserved | ||||||
|  |         self.show_table = show_table | ||||||
|  |         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, | ||||||
|  |                     console=self.console, | ||||||
|  |                     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'})" | ||||||
|  |         ) | ||||||
							
								
								
									
										35
									
								
								falyx/action/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								falyx/action/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """mixins.py""" | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ActionListMixin: | ||||||
|  |     """Mixin for managing a list of actions.""" | ||||||
|  |  | ||||||
|  |     def __init__(self) -> None: | ||||||
|  |         self.actions: list[BaseAction] = [] | ||||||
|  |  | ||||||
|  |     def set_actions(self, actions: list[BaseAction]) -> None: | ||||||
|  |         """Replaces the current action list with a new one.""" | ||||||
|  |         self.actions.clear() | ||||||
|  |         for action in actions: | ||||||
|  |             self.add_action(action) | ||||||
|  |  | ||||||
|  |     def add_action(self, action: BaseAction) -> None: | ||||||
|  |         """Adds an action to the list.""" | ||||||
|  |         self.actions.append(action) | ||||||
|  |  | ||||||
|  |     def remove_action(self, name: str) -> None: | ||||||
|  |         """Removes an action by name.""" | ||||||
|  |         self.actions = [action for action in self.actions if action.name != name] | ||||||
|  |  | ||||||
|  |     def has_action(self, name: str) -> bool: | ||||||
|  |         """Checks if an action with the given name exists.""" | ||||||
|  |         return any(action.name == name for action in self.actions) | ||||||
|  |  | ||||||
|  |     def get_action(self, name: str) -> BaseAction | None: | ||||||
|  |         """Retrieves an action by name.""" | ||||||
|  |         for action in self.actions: | ||||||
|  |             if action.name == name: | ||||||
|  |                 return action | ||||||
|  |         return None | ||||||
							
								
								
									
										130
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,130 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """process_action.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import asyncio | ||||||
|  | from concurrent.futures import ProcessPoolExecutor | ||||||
|  | from functools import partial | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ProcessAction(BaseAction): | ||||||
|  |     """ | ||||||
|  |     ProcessAction runs a function in a separate process using ProcessPoolExecutor. | ||||||
|  |  | ||||||
|  |     Features: | ||||||
|  |     - Executes CPU-bound or blocking tasks without blocking the main event loop. | ||||||
|  |     - Supports last_result injection into the subprocess. | ||||||
|  |     - Validates that last_result is pickleable when injection is enabled. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         name (str): Name of the action. | ||||||
|  |         func (Callable): Function to execute in a new process. | ||||||
|  |         args (tuple, optional): Positional arguments. | ||||||
|  |         kwargs (dict, optional): Keyword arguments. | ||||||
|  |         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||||
|  |         executor (ProcessPoolExecutor, optional): Custom executor if desired. | ||||||
|  |         inject_last_result (bool, optional): Inject last result into the function. | ||||||
|  |         inject_into (str, optional): Name of the injected key. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         action: Callable[..., Any], | ||||||
|  |         *, | ||||||
|  |         args: tuple = (), | ||||||
|  |         kwargs: dict[str, Any] | None = None, | ||||||
|  |         hooks: HookManager | None = None, | ||||||
|  |         executor: ProcessPoolExecutor | None = None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             hooks=hooks, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |         ) | ||||||
|  |         self.action = action | ||||||
|  |         self.args = args | ||||||
|  |         self.kwargs = kwargs or {} | ||||||
|  |         self.executor = executor or ProcessPoolExecutor() | ||||||
|  |         self.is_retryable = True | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]: | ||||||
|  |         return self.action, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         if self.inject_last_result and self.shared_context: | ||||||
|  |             last_result = self.shared_context.last_result() | ||||||
|  |             if not self._validate_pickleable(last_result): | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f"Cannot inject last result into {self.name}: " | ||||||
|  |                     f"last result is not pickleable." | ||||||
|  |                 ) | ||||||
|  |         combined_args = args + self.args | ||||||
|  |         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=combined_args, | ||||||
|  |             kwargs=combined_kwargs, | ||||||
|  |             action=self, | ||||||
|  |         ) | ||||||
|  |         loop = asyncio.get_running_loop() | ||||||
|  |  | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |             result = await loop.run_in_executor( | ||||||
|  |                 self.executor, partial(self.action, *combined_args, **combined_kwargs) | ||||||
|  |             ) | ||||||
|  |             context.result = result | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return result | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|  |             if context.result is not None: | ||||||
|  |                 return context.result | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             context.stop_timer() | ||||||
|  |             await self.hooks.trigger(HookType.AFTER, context) | ||||||
|  |             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||||
|  |             er.record(context) | ||||||
|  |  | ||||||
|  |     def _validate_pickleable(self, obj: Any) -> bool: | ||||||
|  |         try: | ||||||
|  |             import pickle | ||||||
|  |  | ||||||
|  |             pickle.dumps(obj) | ||||||
|  |             return True | ||||||
|  |         except (pickle.PicklingError, TypeError): | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [ | ||||||
|  |             f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'" | ||||||
|  |         ] | ||||||
|  |         if self.inject_last_result: | ||||||
|  |             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||||
|  |         if parent: | ||||||
|  |             parent.add("".join(label)) | ||||||
|  |         else: | ||||||
|  |             self.console.print(Tree("".join(label))) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f"ProcessAction(name={self.name!r}, " | ||||||
|  |             f"action={getattr(self.action, '__name__', repr(self.action))}, " | ||||||
|  |             f"args={self.args!r}, kwargs={self.kwargs!r})" | ||||||
|  |         ) | ||||||
							
								
								
									
										168
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,168 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """process_pool_action.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import asyncio | ||||||
|  | import random | ||||||
|  | from concurrent.futures import ProcessPoolExecutor | ||||||
|  | from dataclasses import dataclass, field | ||||||
|  | from functools import partial | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.parser.utils import same_argument_definitions | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class ProcessTask: | ||||||
|  |     task: Callable[..., Any] | ||||||
|  |     args: tuple = () | ||||||
|  |     kwargs: dict[str, Any] = field(default_factory=dict) | ||||||
|  |  | ||||||
|  |     def __post_init__(self): | ||||||
|  |         if not callable(self.task): | ||||||
|  |             raise TypeError(f"Expected a callable task, got {type(self.task).__name__}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ProcessPoolAction(BaseAction): | ||||||
|  |     """ """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         actions: list[ProcessTask] | None = None, | ||||||
|  |         *, | ||||||
|  |         hooks: HookManager | None = None, | ||||||
|  |         executor: ProcessPoolExecutor | None = None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             hooks=hooks, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |         ) | ||||||
|  |         self.executor = executor or ProcessPoolExecutor() | ||||||
|  |         self.is_retryable = True | ||||||
|  |         self.actions: list[ProcessTask] = [] | ||||||
|  |         if actions: | ||||||
|  |             self.set_actions(actions) | ||||||
|  |  | ||||||
|  |     def set_actions(self, actions: list[ProcessTask]) -> None: | ||||||
|  |         """Replaces the current action list with a new one.""" | ||||||
|  |         self.actions.clear() | ||||||
|  |         for action in actions: | ||||||
|  |             self.add_action(action) | ||||||
|  |  | ||||||
|  |     def add_action(self, action: ProcessTask) -> None: | ||||||
|  |         if not isinstance(action, ProcessTask): | ||||||
|  |             raise TypeError(f"Expected a ProcessTask, got {type(action).__name__}") | ||||||
|  |         self.actions.append(action) | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]: | ||||||
|  |         arg_defs = same_argument_definitions([action.task for action in self.actions]) | ||||||
|  |         if arg_defs: | ||||||
|  |             return self.actions[0].task, None | ||||||
|  |         logger.debug( | ||||||
|  |             "[%s] auto_args disabled: mismatched ProcessPoolAction arguments", | ||||||
|  |             self.name, | ||||||
|  |         ) | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         shared_context = SharedContext(name=self.name, action=self, is_parallel=True) | ||||||
|  |         if self.shared_context: | ||||||
|  |             shared_context.set_shared_result(self.shared_context.last_result()) | ||||||
|  |         if self.inject_last_result and self.shared_context: | ||||||
|  |             last_result = self.shared_context.last_result() | ||||||
|  |             if not self._validate_pickleable(last_result): | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f"Cannot inject last result into {self.name}: " | ||||||
|  |                     f"last result is not pickleable." | ||||||
|  |                 ) | ||||||
|  |         print(kwargs) | ||||||
|  |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         print(updated_kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=args, | ||||||
|  |             kwargs=updated_kwargs, | ||||||
|  |             action=self, | ||||||
|  |         ) | ||||||
|  |         loop = asyncio.get_running_loop() | ||||||
|  |  | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |             futures = [ | ||||||
|  |                 loop.run_in_executor( | ||||||
|  |                     self.executor, | ||||||
|  |                     partial( | ||||||
|  |                         task.task, | ||||||
|  |                         *(*args, *task.args), | ||||||
|  |                         **{**updated_kwargs, **task.kwargs}, | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |                 for task in self.actions | ||||||
|  |             ] | ||||||
|  |             results = await asyncio.gather(*futures, return_exceptions=True) | ||||||
|  |             context.result = results | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return results | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|  |             if context.result is not None: | ||||||
|  |                 return context.result | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             context.stop_timer() | ||||||
|  |             await self.hooks.trigger(HookType.AFTER, context) | ||||||
|  |             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||||
|  |             er.record(context) | ||||||
|  |  | ||||||
|  |     def _validate_pickleable(self, obj: Any) -> bool: | ||||||
|  |         try: | ||||||
|  |             import pickle | ||||||
|  |  | ||||||
|  |             pickle.dumps(obj) | ||||||
|  |             return True | ||||||
|  |         except (pickle.PicklingError, TypeError): | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessPoolAction[/] '{self.name}'"] | ||||||
|  |         if self.inject_last_result: | ||||||
|  |             label.append(f" [dim](receives '{self.inject_into}')[/dim]") | ||||||
|  |         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||||
|  |         actions = self.actions.copy() | ||||||
|  |         random.shuffle(actions) | ||||||
|  |         for action in actions: | ||||||
|  |             label = [ | ||||||
|  |                 f"[{OneColors.DARK_YELLOW_b}]  - {getattr(action.task, '__name__', repr(action.task))}[/] " | ||||||
|  |                 f"[dim]({', '.join(map(repr, action.args))})[/]" | ||||||
|  |             ] | ||||||
|  |             if action.kwargs: | ||||||
|  |                 label.append( | ||||||
|  |                     f" [dim]({', '.join(f'{k}={v!r}' for k, v in action.kwargs.items())})[/]" | ||||||
|  |                 ) | ||||||
|  |             tree.add("".join(label)) | ||||||
|  |  | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f"ProcessPoolAction(name={self.name!r}, " | ||||||
|  |             f"actions={[getattr(action.task, '__name__', repr(action.task)) for action in self.actions]}, " | ||||||
|  |             f"inject_last_result={self.inject_last_result}, " | ||||||
|  |             f"inject_into={self.inject_into!r})" | ||||||
|  |         ) | ||||||
							
								
								
									
										137
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """prompt_menu_action.py""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from prompt_toolkit import PromptSession | ||||||
|  | from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.menu import MenuOptionMap | ||||||
|  | from falyx.signals import BackSignal, QuitSignal | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PromptMenuAction(BaseAction): | ||||||
|  |     """PromptMenuAction class for creating prompt -> actions.""" | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         menu_options: MenuOptionMap, | ||||||
|  |         *, | ||||||
|  |         prompt_message: str = "Select > ", | ||||||
|  |         default_selection: str = "", | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_into: str = "last_result", | ||||||
|  |         console: Console | None = None, | ||||||
|  |         prompt_session: PromptSession | None = None, | ||||||
|  |         never_prompt: bool = False, | ||||||
|  |         include_reserved: bool = True, | ||||||
|  |     ): | ||||||
|  |         super().__init__( | ||||||
|  |             name, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_into=inject_into, | ||||||
|  |             never_prompt=never_prompt, | ||||||
|  |         ) | ||||||
|  |         self.menu_options = menu_options | ||||||
|  |         self.prompt_message = prompt_message | ||||||
|  |         self.default_selection = default_selection | ||||||
|  |         if isinstance(console, Console): | ||||||
|  |             self.console = console | ||||||
|  |         elif console: | ||||||
|  |             raise ValueError("`console` must be an instance of `rich.console.Console`") | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.include_reserved = include_reserved | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|  |         kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             name=self.name, | ||||||
|  |             args=args, | ||||||
|  |             kwargs=kwargs, | ||||||
|  |             action=self, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         effective_default = self.default_selection | ||||||
|  |         maybe_result = str(self.last_result) | ||||||
|  |         if maybe_result in self.menu_options: | ||||||
|  |             effective_default = maybe_result | ||||||
|  |         elif self.inject_last_result: | ||||||
|  |             logger.warning( | ||||||
|  |                 "[%s] Injected last result '%s' not found in menu options", | ||||||
|  |                 self.name, | ||||||
|  |                 maybe_result, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if self.never_prompt and not effective_default: | ||||||
|  |             raise ValueError( | ||||||
|  |                 f"[{self.name}] 'never_prompt' is True but no valid default_selection" | ||||||
|  |                 " was provided." | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         context.start_timer() | ||||||
|  |         try: | ||||||
|  |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |             key = effective_default | ||||||
|  |             if not self.never_prompt: | ||||||
|  |                 placeholder_formatted_text = [] | ||||||
|  |                 for index, (key, option) in enumerate(self.menu_options.items()): | ||||||
|  |                     placeholder_formatted_text.append(option.render_prompt(key)) | ||||||
|  |                     if index < len(self.menu_options) - 1: | ||||||
|  |                         placeholder_formatted_text.append( | ||||||
|  |                             FormattedText([(OneColors.WHITE, " | ")]) | ||||||
|  |                         ) | ||||||
|  |                 placeholder = merge_formatted_text(placeholder_formatted_text) | ||||||
|  |                 key = await self.prompt_session.prompt_async( | ||||||
|  |                     message=self.prompt_message, placeholder=placeholder | ||||||
|  |                 ) | ||||||
|  |             option = self.menu_options[key] | ||||||
|  |             result = await option.action(*args, **kwargs) | ||||||
|  |             context.result = result | ||||||
|  |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|  |             return result | ||||||
|  |  | ||||||
|  |         except BackSignal: | ||||||
|  |             logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) | ||||||
|  |             return None | ||||||
|  |         except QuitSignal: | ||||||
|  |             logger.debug("[%s][QuitSignal] ← Exiting application", self.name) | ||||||
|  |             raise | ||||||
|  |         except Exception as error: | ||||||
|  |             context.exception = error | ||||||
|  |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             context.stop_timer() | ||||||
|  |             await self.hooks.trigger(HookType.AFTER, context) | ||||||
|  |             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||||
|  |             er.record(context) | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = f"[{OneColors.LIGHT_YELLOW_b}]📋 PromptMenuAction[/] '{self.name}'" | ||||||
|  |         tree = parent.add(label) if parent else Tree(label) | ||||||
|  |         for key, option in self.menu_options.items(): | ||||||
|  |             tree.add( | ||||||
|  |                 f"[dim]{key}[/]: {option.description} → [italic]{option.action.name}[/]" | ||||||
|  |             ) | ||||||
|  |             await option.action.preview(parent=tree) | ||||||
|  |         if not parent: | ||||||
|  |             self.console.print(tree) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return ( | ||||||
|  |             f"PromptMenuAction(name={self.name!r}, options={list(self.menu_options.keys())!r}, " | ||||||
|  |             f"default_selection={self.default_selection!r}, " | ||||||
|  |             f"include_reserved={self.include_reserved}, " | ||||||
|  |             f"prompt={'off' if self.never_prompt else 'on'})" | ||||||
|  |         ) | ||||||
							
								
								
									
										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.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.action.types import FileReturnType | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | 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 (FileReturnType): What to return (path, content, parsed). | ||||||
|  |         console (Console | None): Console instance for output. | ||||||
|  |         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: FileReturnType | str = FileReturnType.PATH, | ||||||
|  |         number_selections: int | str = 1, | ||||||
|  |         separator: str = ",", | ||||||
|  |         allow_duplicates: bool = False, | ||||||
|  |         console: Console | None = None, | ||||||
|  |         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 | ||||||
|  |         if isinstance(console, Console): | ||||||
|  |             self.console = console | ||||||
|  |         elif console: | ||||||
|  |             raise ValueError("`console` must be an instance of `rich.console.Console`") | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.return_type = self._coerce_return_type(return_type) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def number_selections(self) -> int | str: | ||||||
|  |         return self._number_selections | ||||||
|  |  | ||||||
|  |     @number_selections.setter | ||||||
|  |     def number_selections(self, value: int | str): | ||||||
|  |         if isinstance(value, int) and value > 0: | ||||||
|  |             self._number_selections: int | str = value | ||||||
|  |         elif isinstance(value, str): | ||||||
|  |             if value not in ("*"): | ||||||
|  |                 raise ValueError("number_selections string must be one of '*'") | ||||||
|  |             self._number_selections = value | ||||||
|  |         else: | ||||||
|  |             raise ValueError("number_selections must be a positive integer or one of '*'") | ||||||
|  |  | ||||||
|  |     def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: | ||||||
|  |         if isinstance(return_type, FileReturnType): | ||||||
|  |             return return_type | ||||||
|  |         return FileReturnType(return_type) | ||||||
|  |  | ||||||
|  |     def get_options(self, files: list[Path]) -> dict[str, SelectionOption]: | ||||||
|  |         value: Any | ||||||
|  |         options = {} | ||||||
|  |         for index, file in enumerate(files): | ||||||
|  |             try: | ||||||
|  |                 if self.return_type == FileReturnType.TEXT: | ||||||
|  |                     value = file.read_text(encoding="UTF-8") | ||||||
|  |                 elif self.return_type == FileReturnType.PATH: | ||||||
|  |                     value = file | ||||||
|  |                 elif self.return_type == FileReturnType.JSON: | ||||||
|  |                     value = json.loads(file.read_text(encoding="UTF-8")) | ||||||
|  |                 elif self.return_type == FileReturnType.TOML: | ||||||
|  |                     value = toml.loads(file.read_text(encoding="UTF-8")) | ||||||
|  |                 elif self.return_type == FileReturnType.YAML: | ||||||
|  |                     value = yaml.safe_load(file.read_text(encoding="UTF-8")) | ||||||
|  |                 elif self.return_type == FileReturnType.CSV: | ||||||
|  |                     with open(file, newline="", encoding="UTF-8") as csvfile: | ||||||
|  |                         reader = csv.reader(csvfile) | ||||||
|  |                         value = list(reader) | ||||||
|  |                 elif self.return_type == FileReturnType.TSV: | ||||||
|  |                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||||
|  |                         reader = csv.reader(tsvfile, delimiter="\t") | ||||||
|  |                         value = list(reader) | ||||||
|  |                 elif self.return_type == FileReturnType.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) | ||||||
|  |  | ||||||
|  |             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, | ||||||
|  |                 console=self.console, | ||||||
|  |                 prompt_session=self.prompt_session, | ||||||
|  |                 prompt_message=self.prompt_message, | ||||||
|  |                 number_selections=self.number_selections, | ||||||
|  |                 separator=self.separator, | ||||||
|  |                 allow_duplicates=self.allow_duplicates, | ||||||
|  |                 cancel_key=cancel_key, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             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})" | ||||||
|  |         ) | ||||||
							
								
								
									
										382
									
								
								falyx/action/selection_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										382
									
								
								falyx/action/selection_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,382 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """selection_action.py""" | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from prompt_toolkit import PromptSession | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.action.types import SelectionReturnType | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.selection import ( | ||||||
|  |     SelectionOption, | ||||||
|  |     SelectionOptionMap, | ||||||
|  |     prompt_for_index, | ||||||
|  |     prompt_for_selection, | ||||||
|  |     render_selection_dict_table, | ||||||
|  |     render_selection_indexed_table, | ||||||
|  | ) | ||||||
|  | from falyx.signals import CancelSignal | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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", | ||||||
|  |         console: Console | None = None, | ||||||
|  |         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 | ||||||
|  |         if isinstance(console, Console): | ||||||
|  |             self.console = console | ||||||
|  |         elif console: | ||||||
|  |             raise ValueError("`console` must be an instance of `rich.console.Console`") | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.default_selection = default_selection | ||||||
|  |         self.number_selections = number_selections | ||||||
|  |         self.separator = separator | ||||||
|  |         self.allow_duplicates = allow_duplicates | ||||||
|  |         self.prompt_message = prompt_message | ||||||
|  |         self.show_table = show_table | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def 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, | ||||||
|  |                         console=self.console, | ||||||
|  |                         prompt_session=self.prompt_session, | ||||||
|  |                         prompt_message=self.prompt_message, | ||||||
|  |                         show_table=self.show_table, | ||||||
|  |                         number_selections=self.number_selections, | ||||||
|  |                         separator=self.separator, | ||||||
|  |                         allow_duplicates=self.allow_duplicates, | ||||||
|  |                         cancel_key=self.cancel_key, | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     if effective_default: | ||||||
|  |                         indices = int(effective_default) | ||||||
|  |                     else: | ||||||
|  |                         raise ValueError( | ||||||
|  |                             f"[{self.name}] 'never_prompt' is True but no valid " | ||||||
|  |                             "default_selection was provided." | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                 if indices == int(self.cancel_key): | ||||||
|  |                     raise CancelSignal("User cancelled the selection.") | ||||||
|  |                 if isinstance(indices, list): | ||||||
|  |                     result: str | list[str] = [ | ||||||
|  |                         self.selections[index] for index in indices | ||||||
|  |                     ] | ||||||
|  |                 elif isinstance(indices, int): | ||||||
|  |                     result = self.selections[indices] | ||||||
|  |                 else: | ||||||
|  |                     assert False, "unreachable" | ||||||
|  |             elif isinstance(self.selections, dict): | ||||||
|  |                 cancel_option = { | ||||||
|  |                     self.cancel_key: SelectionOption( | ||||||
|  |                         description="Cancel", value=CancelSignal, style=OneColors.DARK_RED | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |                 table = render_selection_dict_table( | ||||||
|  |                     title=self.title, | ||||||
|  |                     selections=self.selections | cancel_option, | ||||||
|  |                     columns=self.columns, | ||||||
|  |                 ) | ||||||
|  |                 if not self.never_prompt: | ||||||
|  |                     keys = await prompt_for_selection( | ||||||
|  |                         (self.selections | cancel_option).keys(), | ||||||
|  |                         table, | ||||||
|  |                         default_selection=effective_default, | ||||||
|  |                         console=self.console, | ||||||
|  |                         prompt_session=self.prompt_session, | ||||||
|  |                         prompt_message=self.prompt_message, | ||||||
|  |                         show_table=self.show_table, | ||||||
|  |                         number_selections=self.number_selections, | ||||||
|  |                         separator=self.separator, | ||||||
|  |                         allow_duplicates=self.allow_duplicates, | ||||||
|  |                         cancel_key=self.cancel_key, | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     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: Exception): | ||||||
|  |         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) | ||||||
							
								
								
									
										54
									
								
								falyx/action/types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								falyx/action/types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """types.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FileReturnType(Enum): | ||||||
|  |     """Enum for file return types.""" | ||||||
|  |  | ||||||
|  |     TEXT = "text" | ||||||
|  |     PATH = "path" | ||||||
|  |     JSON = "json" | ||||||
|  |     TOML = "toml" | ||||||
|  |     YAML = "yaml" | ||||||
|  |     CSV = "csv" | ||||||
|  |     TSV = "tsv" | ||||||
|  |     XML = "xml" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _get_alias(cls, value: str) -> str: | ||||||
|  |         aliases = { | ||||||
|  |             "yml": "yaml", | ||||||
|  |             "txt": "text", | ||||||
|  |             "file": "path", | ||||||
|  |             "filepath": "path", | ||||||
|  |         } | ||||||
|  |         return aliases.get(value, value) | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def _missing_(cls, value: object) -> FileReturnType: | ||||||
|  |         if isinstance(value, str): | ||||||
|  |             normalized = value.lower() | ||||||
|  |             alias = cls._get_alias(normalized) | ||||||
|  |             for member in cls: | ||||||
|  |                 if member.value == alias: | ||||||
|  |                     return member | ||||||
|  |         valid = ", ".join(member.value for member in cls) | ||||||
|  |         raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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}") | ||||||
							
								
								
									
										105
									
								
								falyx/action/user_input_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								falyx/action/user_input_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """user_input_action.py""" | ||||||
|  | from prompt_toolkit import PromptSession | ||||||
|  | from prompt_toolkit.validation import Validator | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action.base 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. | ||||||
|  |         console (Console, optional): Rich console for rendering. | ||||||
|  |         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, | ||||||
|  |         console: Console | 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 | ||||||
|  |         if isinstance(console, Console): | ||||||
|  |             self.console = console | ||||||
|  |         elif console: | ||||||
|  |             raise ValueError("`console` must be an instance of `rich.console.Console`") | ||||||
|  |         self.prompt_session = prompt_session or PromptSession() | ||||||
|  |         self.default_text = default_text | ||||||
|  |  | ||||||
|  |     def get_infer_target(self) -> tuple[None, None]: | ||||||
|  |         return None, None | ||||||
|  |  | ||||||
|  |     async def _run(self, *args, **kwargs) -> str: | ||||||
|  |         context = ExecutionContext( | ||||||
|  |             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""" | """bottom_bar.py""" | ||||||
|  |  | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
| @@ -7,8 +8,8 @@ from prompt_toolkit.key_binding import KeyBindings | |||||||
| from rich.console import Console | from rich.console import Console | ||||||
|  |  | ||||||
| from falyx.options_manager import OptionsManager | from falyx.options_manager import OptionsManager | ||||||
| from falyx.themes.colors import OneColors | from falyx.themes import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict | from falyx.utils import CaseInsensitiveDict, chunks | ||||||
|  |  | ||||||
|  |  | ||||||
| class BottomBar: | class BottomBar: | ||||||
| @@ -29,8 +30,7 @@ class BottomBar: | |||||||
|         key_validator: Callable[[str], bool] | None = None, |         key_validator: Callable[[str], bool] | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         self.columns = columns |         self.columns = columns | ||||||
|         self.console = Console() |         self.console = Console(color_system="truecolor") | ||||||
|         self._items: list[Callable[[], HTML]] = [] |  | ||||||
|         self._named_items: dict[str, Callable[[], HTML]] = {} |         self._named_items: dict[str, Callable[[], HTML]] = {} | ||||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() |         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||||
|         self.toggle_keys: list[str] = [] |         self.toggle_keys: list[str] = [] | ||||||
| @@ -45,11 +45,7 @@ class BottomBar: | |||||||
|     def space(self) -> int: |     def space(self) -> int: | ||||||
|         return self.console.width // self.columns |         return self.console.width // self.columns | ||||||
|  |  | ||||||
|     def add_custom( |     def add_custom(self, name: str, render_fn: Callable[[], HTML]) -> None: | ||||||
|         self, |  | ||||||
|         name: str, |  | ||||||
|         render_fn: Callable[[], HTML] |  | ||||||
|     ) -> None: |  | ||||||
|         """Add a custom render function to the bottom bar.""" |         """Add a custom render function to the bottom bar.""" | ||||||
|         if not callable(render_fn): |         if not callable(render_fn): | ||||||
|             raise ValueError("`render_fn` must be callable") |             raise ValueError("`render_fn` must be callable") | ||||||
| @@ -63,9 +59,7 @@ class BottomBar: | |||||||
|         bg: str = OneColors.WHITE, |         bg: str = OneColors.WHITE, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         def render(): |         def render(): | ||||||
|             return HTML( |             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         self._add_named(name, render) |         self._add_named(name, render) | ||||||
|  |  | ||||||
| @@ -85,9 +79,7 @@ class BottomBar: | |||||||
|             get_value_ = self._value_getters[name] |             get_value_ = self._value_getters[name] | ||||||
|             current_ = get_value_() |             current_ = get_value_() | ||||||
|             text = f"{label}: {current_}" |             text = f"{label}: {current_}" | ||||||
|             return HTML( |             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         self._add_named(name, render) |         self._add_named(name, render) | ||||||
|  |  | ||||||
| @@ -99,6 +91,7 @@ class BottomBar: | |||||||
|         total: int, |         total: int, | ||||||
|         fg: str = OneColors.BLACK, |         fg: str = OneColors.BLACK, | ||||||
|         bg: str = OneColors.WHITE, |         bg: str = OneColors.WHITE, | ||||||
|  |         enforce_total: bool = True, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         if not callable(get_current): |         if not callable(get_current): | ||||||
|             raise ValueError("`get_current` must be a callable returning int") |             raise ValueError("`get_current` must be a callable returning int") | ||||||
| @@ -108,14 +101,12 @@ class BottomBar: | |||||||
|         def render(): |         def render(): | ||||||
|             get_current_ = self._value_getters[name] |             get_current_ = self._value_getters[name] | ||||||
|             current_value = get_current_() |             current_value = get_current_() | ||||||
|             if current_value > total: |             if current_value > total and enforce_total: | ||||||
|                 raise ValueError( |                 raise ValueError( | ||||||
|                     f"Current value {current_value} is greater than total value {total}" |                     f"Current value {current_value} is greater than total value {total}" | ||||||
|                 ) |                 ) | ||||||
|             text = f"{label}: {current_value}/{total}" |             text = f"{label}: {current_value}/{total}" | ||||||
|             return HTML( |             return HTML(f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>") | ||||||
|                 f"<style fg='{fg}' bg='{bg}'>{text:^{self.space}}</style>" |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         self._add_named(name, render) |         self._add_named(name, render) | ||||||
|  |  | ||||||
| @@ -137,7 +128,9 @@ class BottomBar: | |||||||
|         if key in self.toggle_keys: |         if key in self.toggle_keys: | ||||||
|             raise ValueError(f"Key {key} is already used as a toggle") |             raise ValueError(f"Key {key} is already used as a toggle") | ||||||
|         if self.key_validator and not self.key_validator(key): |         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._value_getters[key] = get_state | ||||||
|         self.toggle_keys.append(key) |         self.toggle_keys.append(key) | ||||||
|  |  | ||||||
| @@ -146,16 +139,14 @@ class BottomBar: | |||||||
|             color = bg_on if get_state_() else bg_off |             color = bg_on if get_state_() else bg_off | ||||||
|             status = "ON" if get_state_() else "OFF" |             status = "ON" if get_state_() else "OFF" | ||||||
|             text = f"({key.upper()}) {label}: {status}" |             text = f"({key.upper()}) {label}: {status}" | ||||||
|             return HTML( |             return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>") | ||||||
|                 f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>" |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         self._add_named(key, render) |         self._add_named(key, render) | ||||||
|  |  | ||||||
|         for k in (key.upper(), key.lower()): |         for k in (key.upper(), key.lower()): | ||||||
|  |  | ||||||
|             @self.key_bindings.add(k) |             @self.key_bindings.add(k) | ||||||
|             def _(event): |             def _(_): | ||||||
|                 toggle_state() |                 toggle_state() | ||||||
|  |  | ||||||
|     def add_toggle_from_option( |     def add_toggle_from_option( | ||||||
| @@ -169,6 +160,7 @@ class BottomBar: | |||||||
|         bg_on: str = OneColors.GREEN, |         bg_on: str = OneColors.GREEN, | ||||||
|         bg_off: str = OneColors.DARK_RED, |         bg_off: str = OneColors.DARK_RED, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|  |         """Add a toggle to the bottom bar based on an option from OptionsManager.""" | ||||||
|         self.add_toggle( |         self.add_toggle( | ||||||
|             key=key, |             key=key, | ||||||
|             label=label, |             label=label, | ||||||
| @@ -185,15 +177,33 @@ class BottomBar: | |||||||
|         return {label: getter() for label, getter in self._value_getters.items()} |         return {label: getter() for label, getter in self._value_getters.items()} | ||||||
|  |  | ||||||
|     def get_value(self, name: str) -> Any: |     def get_value(self, name: str) -> Any: | ||||||
|  |         """Get the current value of a registered item.""" | ||||||
|         if name not in self._value_getters: |         if name not in self._value_getters: | ||||||
|             raise ValueError(f"No value getter registered under name: '{name}'") |             raise ValueError(f"No value getter registered under name: '{name}'") | ||||||
|         return self._value_getters[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: |     def _add_named(self, name: str, render_fn: Callable[[], HTML]) -> None: | ||||||
|         if name in self._named_items: |         if name in self._named_items: | ||||||
|             raise ValueError(f"Bottom bar item '{name}' already exists") |             raise ValueError(f"Bottom bar item '{name}' already exists") | ||||||
|         self._named_items[name] = render_fn |         self._named_items[name] = render_fn | ||||||
|         self._items = list(self._named_items.values()) |  | ||||||
|  |  | ||||||
|     def render(self): |     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]]) | ||||||
|   | |||||||
							
								
								
									
										318
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										318
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -1,14 +1,24 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """command.py | """command.py | ||||||
| Any Action or Command is callable and supports the signature: |  | ||||||
|     result = thing(*args, **kwargs) |  | ||||||
|  |  | ||||||
| This guarantees: | Defines the Command class for Falyx CLI. | ||||||
| - Hook lifecycle (before/after/error/teardown) |  | ||||||
| - Timing | Commands are callable units representing a menu option or CLI task, | ||||||
| - Consistent return values | 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 __future__ import annotations | ||||||
|  |  | ||||||
|  | import shlex | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
|  |  | ||||||
| from prompt_toolkit.formatted_text import FormattedText | from prompt_toolkit.formatted_text import FormattedText | ||||||
| @@ -16,28 +26,93 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | |||||||
| from rich.console import Console | from rich.console import Console | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action import Action, BaseAction | from falyx.action.action import Action | ||||||
|  | from falyx.action.base import BaseAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.debug import register_debug_hooks | from falyx.debug import register_debug_hooks | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.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.retry import RetryPolicy | ||||||
| from falyx.themes.colors import OneColors | from falyx.retry_utils import enable_retries_recursively | ||||||
| from falyx.utils import _noop, ensure_async, logger | from falyx.signals import CancelSignal | ||||||
|  | from falyx.themes import OneColors | ||||||
|  | from falyx.utils import ensure_async | ||||||
|  |  | ||||||
| console = Console() | console = Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseModel): | 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. | ||||||
|  |         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 |     key: str | ||||||
|     description: str |     description: str | ||||||
|     aliases: list[str] = Field(default_factory=list) |     action: BaseAction | Callable[..., Any] | ||||||
|     action: BaseAction | Callable[[], Any] = _noop |  | ||||||
|     args: tuple = () |     args: tuple = () | ||||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) |     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||||
|  |     hidden: bool = False | ||||||
|  |     aliases: list[str] = Field(default_factory=list) | ||||||
|     help_text: str = "" |     help_text: str = "" | ||||||
|     color: str = OneColors.WHITE |     help_epilog: str = "" | ||||||
|  |     style: str = OneColors.WHITE | ||||||
|     confirm: bool = False |     confirm: bool = False | ||||||
|     confirm_message: str = "Are you sure?" |     confirm_message: str = "Are you sure?" | ||||||
|     preview_before_confirm: bool = True |     preview_before_confirm: bool = True | ||||||
| @@ -52,24 +127,55 @@ class Command(BaseModel): | |||||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) |     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||||
|     tags: list[str] = Field(default_factory=list) |     tags: list[str] = Field(default_factory=list) | ||||||
|     logging_hooks: bool = False |     logging_hooks: bool = False | ||||||
|  |     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) |     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
|     def model_post_init(self, __context: Any) -> None: |     async def parse_args( | ||||||
|         """Post-initialization to set up the action and hooks.""" |         self, raw_args: list[str] | str, from_validate: bool = False | ||||||
|         if self.retry and isinstance(self.action, Action): |     ) -> tuple[tuple, dict]: | ||||||
|             self.action.enable_retry() |         if callable(self.custom_parser): | ||||||
|         elif self.retry_policy and isinstance(self.action, Action): |             if isinstance(raw_args, str): | ||||||
|             self.action.set_retry_policy(self.retry_policy) |                 try: | ||||||
|         elif self.retry: |                     raw_args = shlex.split(raw_args) | ||||||
|             logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") |                 except ValueError: | ||||||
|         if self.retry_all: |                     logger.warning( | ||||||
|             self.action.enable_retries_recursively(self.action, self.retry_policy) |                         "[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): |         if isinstance(raw_args, str): | ||||||
|             register_debug_hooks(self.action.hooks) |             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") |     @field_validator("action", mode="before") | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -80,11 +186,70 @@ class Command(BaseModel): | |||||||
|             return ensure_async(action) |             return ensure_async(action) | ||||||
|         raise TypeError("Action must be a callable or an instance of BaseAction") |         raise TypeError("Action must be a callable or an instance of BaseAction") | ||||||
|  |  | ||||||
|     def __str__(self): |     def get_argument_definitions(self) -> list[dict[str, Any]]: | ||||||
|         return f"Command(key='{self.key}', description='{self.description}')" |         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): |     def model_post_init(self, _: Any) -> None: | ||||||
|         """Run the action with full hook lifecycle, timing, and error handling.""" |         """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: | ||||||
|  |             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_args = args + self.args | ||||||
|         combined_kwargs = {**self.kwargs, **kwargs} |         combined_kwargs = {**self.kwargs, **kwargs} | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
| @@ -94,20 +259,35 @@ class Command(BaseModel): | |||||||
|             action=self, |             action=self, | ||||||
|         ) |         ) | ||||||
|         self._context = context |         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() |         context.start_timer() | ||||||
|  |  | ||||||
|         try: |         try: | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             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 |             context.result = result | ||||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|             return context.result |             return context.result | ||||||
|         except Exception as error: |         except Exception as error: | ||||||
|             context.exception = error |             context.exception = error | ||||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|             if context.result is not None: |  | ||||||
|                 logger.info(f"✅ Recovered: {self.key}") |  | ||||||
|                 return context.result |  | ||||||
|             raise error |             raise error | ||||||
|         finally: |         finally: | ||||||
|             context.stop_timer() |             context.stop_timer() | ||||||
| @@ -124,9 +304,7 @@ class Command(BaseModel): | |||||||
|     def confirmation_prompt(self) -> FormattedText: |     def confirmation_prompt(self) -> FormattedText: | ||||||
|         """Generate a styled prompt_toolkit FormattedText confirmation message.""" |         """Generate a styled prompt_toolkit FormattedText confirmation message.""" | ||||||
|         if self.confirm_message and self.confirm_message != "Are you sure?": |         if self.confirm_message and self.confirm_message != "Are you sure?": | ||||||
|             return FormattedText([ |             return FormattedText([("class:confirm", self.confirm_message)]) | ||||||
|                 ("class:confirm", self.confirm_message) |  | ||||||
|             ]) |  | ||||||
|  |  | ||||||
|         action_name = getattr(self.action, "__name__", None) |         action_name = getattr(self.action, "__name__", None) | ||||||
|         if isinstance(self.action, BaseAction): |         if isinstance(self.action, BaseAction): | ||||||
| @@ -141,27 +319,81 @@ class Command(BaseModel): | |||||||
|             prompt.append(("class:confirm", f"(calls `{action_name}`) ")) |             prompt.append(("class:confirm", f"(calls `{action_name}`) ")) | ||||||
|  |  | ||||||
|         if self.args or self.kwargs: |         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) |         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: |         if self._context: | ||||||
|             self._context.log_summary() |             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}" |         label = f"[{OneColors.GREEN_b}]Command:[/] '{self.key}' — {self.description}" | ||||||
|  |  | ||||||
|         if hasattr(self.action, "preview") and callable(self.action.preview): |         if hasattr(self.action, "preview") and callable(self.action.preview): | ||||||
|             tree = Tree(label) |             tree = Tree(label) | ||||||
|             await self.action.preview(parent=tree) |             await self.action.preview(parent=tree) | ||||||
|  |             if self.help_text: | ||||||
|  |                 tree.add(f"[dim]💡 {self.help_text}[/dim]") | ||||||
|             console.print(tree) |             console.print(tree) | ||||||
|         elif callable(self.action): |         elif callable(self.action) and not isinstance(self.action, BaseAction): | ||||||
|             console.print(f"{label}") |             console.print(f"{label}") | ||||||
|  |             if self.help_text: | ||||||
|  |                 console.print(f"[dim]💡 {self.help_text}[/dim]") | ||||||
|             console.print( |             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]" |                 f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]" | ||||||
|             ) |             ) | ||||||
|         else: |         else: | ||||||
|             console.print(f"{label}") |             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}')" | ||||||
|  |         ) | ||||||
|   | |||||||
							
								
								
									
										275
									
								
								falyx/config.py
									
									
									
									
									
								
							
							
						
						
									
										275
									
								
								falyx/config.py
									
									
									
									
									
								
							| @@ -1,16 +1,27 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """config.py | """config.py | ||||||
| Configuration loader for Falyx CLI commands.""" | Configuration loader for Falyx CLI commands.""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import importlib | import importlib | ||||||
|  | import sys | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from typing import Any | from typing import Any, Callable | ||||||
|  |  | ||||||
| import toml | import toml | ||||||
| import yaml | import yaml | ||||||
|  | from pydantic import BaseModel, Field, field_validator, model_validator | ||||||
|  | from rich.console import Console | ||||||
|  |  | ||||||
| from falyx.action import Action, BaseAction | from falyx.action.action import Action | ||||||
|  | from falyx.action.base import BaseAction | ||||||
| from falyx.command import Command | from falyx.command import Command | ||||||
|  | from falyx.falyx import Falyx | ||||||
|  | from falyx.logger import logger | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  | console = Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |  | ||||||
| def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||||
| @@ -20,8 +31,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | |||||||
|         return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) |         return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj) | ||||||
|     else: |     else: | ||||||
|         raise TypeError( |         raise TypeError( | ||||||
|             f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. " |             f"Cannot wrap object of type '{type(obj).__name__}'. " | ||||||
|             "It must be a callable or an instance of BaseAction." |             "Expected a function or BaseAction." | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -29,14 +40,193 @@ def import_action(dotted_path: str) -> Any: | |||||||
|     """Dynamically imports a callable from a dotted path like 'my.module.func'.""" |     """Dynamically imports a callable from a dotted path like 'my.module.func'.""" | ||||||
|     module_path, _, attr = dotted_path.rpartition(".") |     module_path, _, attr = dotted_path.rpartition(".") | ||||||
|     if not module_path: |     if not module_path: | ||||||
|         raise ValueError(f"Invalid action path: {dotted_path}") |         console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}") | ||||||
|     module = importlib.import_module(module_path) |         sys.exit(1) | ||||||
|     return getattr(module, attr) |     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: |     Each command should be defined as a dictionary with at least: | ||||||
|     - key: a unique single-character key |     - key: a unique single-character key | ||||||
| @@ -47,12 +237,19 @@ def loader(file_path: str) -> list[dict[str, Any]]: | |||||||
|         file_path (str): Path to the config file (YAML or TOML). |         file_path (str): Path to the config file (YAML or TOML). | ||||||
|  |  | ||||||
|     Returns: |     Returns: | ||||||
|         list[dict[str, Any]]: A list of command configuration dictionaries. |         Falyx: An instance of the Falyx CLI with loaded commands. | ||||||
|  |  | ||||||
|     Raises: |     Raises: | ||||||
|         ValueError: If the file format is unsupported or file cannot be parsed. |         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(): |     if not path.is_file(): | ||||||
|         raise FileNotFoundError(f"No such config file: {file_path}") |         raise FileNotFoundError(f"No such config file: {file_path}") | ||||||
|  |  | ||||||
| @@ -65,39 +262,25 @@ def loader(file_path: str) -> list[dict[str, Any]]: | |||||||
|         else: |         else: | ||||||
|             raise ValueError(f"Unsupported config format: {suffix}") |             raise ValueError(f"Unsupported config format: {suffix}") | ||||||
|  |  | ||||||
|     if not isinstance(raw_config, list): |     if not isinstance(raw_config, dict): | ||||||
|         raise ValueError("Configuration file must contain a list of command definitions.") |         raise ValueError( | ||||||
|  |             "Configuration file must contain a dictionary with a list of commands.\n" | ||||||
|  |             "Example:\n" | ||||||
|     required = ["key", "description", "action"] |             "title: 'My CLI'\n" | ||||||
|     commands = [] |             "commands:\n" | ||||||
|     for entry in raw_config: |             "  - key: 'a'\n" | ||||||
|         for field in required: |             "    description: 'Example command'\n" | ||||||
|             if field not in entry: |             "    action: 'my_module.my_function'" | ||||||
|                 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 |  | ||||||
|  |  | ||||||
|  |     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() | ||||||
|   | |||||||
							
								
								
									
										158
									
								
								falyx/context.py
									
									
									
									
									
								
							
							
						
						
									
										158
									
								
								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 | import time | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import Any | from typing import Any | ||||||
| @@ -8,9 +26,51 @@ from rich.console import Console | |||||||
|  |  | ||||||
|  |  | ||||||
| class ExecutionContext(BaseModel): | 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 (Exception | 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 |     name: str | ||||||
|     args: tuple = () |     args: tuple = () | ||||||
|     kwargs: dict = {} |     kwargs: dict = Field(default_factory=dict) | ||||||
|     action: Any |     action: Any | ||||||
|     result: Any | None = None |     result: Any | None = None | ||||||
|     exception: Exception | None = None |     exception: Exception | None = None | ||||||
| @@ -20,8 +80,12 @@ class ExecutionContext(BaseModel): | |||||||
|     start_wall: datetime | None = None |     start_wall: datetime | None = None | ||||||
|     end_wall: datetime | None = None |     end_wall: datetime | None = None | ||||||
|  |  | ||||||
|  |     index: int | None = None | ||||||
|  |  | ||||||
|     extra: dict[str, Any] = Field(default_factory=dict) |     extra: dict[str, Any] = Field(default_factory=dict) | ||||||
|     console: Console = Field(default_factory=lambda: Console(color_system="auto")) |     console: Console = Field(default_factory=lambda: Console(color_system="truecolor")) | ||||||
|  |  | ||||||
|  |     shared_context: SharedContext | None = None | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
| @@ -33,6 +97,13 @@ class ExecutionContext(BaseModel): | |||||||
|         self.end_time = time.perf_counter() |         self.end_time = time.perf_counter() | ||||||
|         self.end_wall = datetime.now() |         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 |     @property | ||||||
|     def duration(self) -> float | None: |     def duration(self) -> float | None: | ||||||
|         if self.start_time is None: |         if self.start_time is None: | ||||||
| @@ -49,6 +120,17 @@ class ExecutionContext(BaseModel): | |||||||
|     def status(self) -> str: |     def status(self) -> str: | ||||||
|         return "OK" if self.success else "ERROR" |         return "OK" if self.success else "ERROR" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def signature(self) -> str: | ||||||
|  |         """ | ||||||
|  |         Returns a string representation of the action signature, including | ||||||
|  |         its name and arguments. | ||||||
|  |         """ | ||||||
|  |         args = ", ".join(map(repr, self.args)) | ||||||
|  |         kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) | ||||||
|  |         signature = ", ".join(filter(None, [args, kwargs])) | ||||||
|  |         return f"{self.action} ({signature})" | ||||||
|  |  | ||||||
|     def as_dict(self) -> dict: |     def as_dict(self) -> dict: | ||||||
|         return { |         return { | ||||||
|             "name": self.name, |             "name": self.name, | ||||||
| @@ -58,28 +140,32 @@ class ExecutionContext(BaseModel): | |||||||
|             "extra": self.extra, |             "extra": self.extra, | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def log_summary(self, logger=None): |     def log_summary(self, logger=None) -> None: | ||||||
|         summary = self.as_dict() |         summary = self.as_dict() | ||||||
|         message = [f"[SUMMARY] {summary['name']} | "] |         message = [f"[SUMMARY] {summary['name']} | "] | ||||||
|  |  | ||||||
|         if self.start_wall: |         if self.start_wall: | ||||||
|             message.append(f"Start: {self.start_wall.strftime('%H:%M:%S')} | ") |             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"End: {self.end_wall.strftime('%H:%M:%S')} | ") | ||||||
|  |  | ||||||
|         message.append(f"Duration: {summary['duration']:.3f}s | ") |         message.append(f"Duration: {summary['duration']:.3f}s | ") | ||||||
|  |  | ||||||
|         if summary["exception"]: |         if summary["exception"]: | ||||||
|             message.append(f"❌ Exception: {summary['exception']}") |             message.append(f"Exception: {summary['exception']}") | ||||||
|         else: |         else: | ||||||
|             message.append(f"✅ Result: {summary['result']}") |             message.append(f"Result: {summary['result']}") | ||||||
|         (logger or self.console.print)("".join(message)) |         (logger or self.console.print)("".join(message)) | ||||||
|  |  | ||||||
|     def to_log_line(self) -> str: |     def to_log_line(self) -> str: | ||||||
|         """Structured flat-line format for logging and metrics.""" |         """Structured flat-line format for logging and metrics.""" | ||||||
|         duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" |         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 ( |         return ( | ||||||
|             f"[{self.name}] status={self.status} duration={duration_str} " |             f"[{self.name}] status={self.status} duration={duration_str} " | ||||||
|             f"result={repr(self.result)} exception={exception_str}" |             f"result={repr(self.result)} exception={exception_str}" | ||||||
| @@ -87,7 +173,11 @@ class ExecutionContext(BaseModel): | |||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         duration_str = f"{self.duration:.3f}s" if self.duration is not None else "n/a" |         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 ( |         return ( | ||||||
|             f"<ExecutionContext '{self.name}' | {self.status} | " |             f"<ExecutionContext '{self.name}' | {self.status} | " | ||||||
|             f"Duration: {duration_str} | {result_str}>" |             f"Duration: {duration_str} | {result_str}>" | ||||||
| @@ -103,19 +193,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, Exception]]): 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 |     name: str | ||||||
|  |     action: Any | ||||||
|     results: list[Any] = Field(default_factory=list) |     results: list[Any] = Field(default_factory=list) | ||||||
|     errors: list[tuple[int, Exception]] = Field(default_factory=list) |     errors: list[tuple[int, Exception]] = Field(default_factory=list) | ||||||
|     current_index: int = -1 |     current_index: int = -1 | ||||||
|     is_parallel: bool = False |     is_parallel: bool = False | ||||||
|     shared_result: Any | None = None |     shared_result: Any | None = None | ||||||
|  |  | ||||||
|  |     share: dict[str, Any] = Field(default_factory=dict) | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
|     def add_result(self, result: Any) -> None: |     def add_result(self, result: Any) -> None: | ||||||
|         self.results.append(result) |         self.results.append(result) | ||||||
|  |  | ||||||
|  |     def add_error(self, index: int, error: Exception) -> None: | ||||||
|  |         self.errors.append((index, error)) | ||||||
|  |  | ||||||
|     def set_shared_result(self, result: Any) -> None: |     def set_shared_result(self, result: Any) -> None: | ||||||
|         self.shared_result = result |         self.shared_result = result | ||||||
|         if self.is_parallel: |         if self.is_parallel: | ||||||
| @@ -126,14 +255,21 @@ class ResultsContext(BaseModel): | |||||||
|             return self.shared_result |             return self.shared_result | ||||||
|         return self.results[-1] if self.results else None |         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: |     def __str__(self) -> str: | ||||||
|         parallel_label = "Parallel" if self.is_parallel else "Sequential" |         parallel_label = "Parallel" if self.is_parallel else "Sequential" | ||||||
|         return ( |         return ( | ||||||
|             f"<{parallel_label}ResultsContext '{self.name}' | " |             f"<{parallel_label}SharedContext '{self.name}' | " | ||||||
|             f"Results: {self.results} | " |             f"Results: {self.results} | " | ||||||
|             f"Errors: {self.errors}>" |             f"Errors: {self.errors}>" | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     import asyncio |     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.context import ExecutionContext | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.utils import logger | from falyx.logger import logger | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_before(context: ExecutionContext): | def log_before(context: ExecutionContext): | ||||||
|     """Log the start of an action.""" |     """Log the start of an action.""" | ||||||
|     args = ", ".join(map(repr, context.args)) |     args = ", ".join(map(repr, context.args)) | ||||||
|     kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) |     kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items()) | ||||||
|     signature = ", ".join(filter(None, [args, kwargs])) |     signature = ", ".join(filter(None, [args, kwargs])) | ||||||
|     logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) |     logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_success(context: ExecutionContext): | def log_success(context: ExecutionContext): | ||||||
| @@ -16,18 +18,18 @@ def log_success(context: ExecutionContext): | |||||||
|     result_str = repr(context.result) |     result_str = repr(context.result) | ||||||
|     if len(result_str) > 100: |     if len(result_str) > 100: | ||||||
|         result_str = f"{result_str[:100]} ..." |         result_str = f"{result_str[:100]} ..." | ||||||
|     logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) |     logger.debug("[%s] Success -> Result: %s", context.name, result_str) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_after(context: ExecutionContext): | def log_after(context: ExecutionContext): | ||||||
|     """Log the completion of an action, regardless of success or failure.""" |     """Log the completion of an action, regardless of success or failure.""" | ||||||
|     logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) |     logger.debug("[%s] Finished in %.3fs", context.name, context.duration) | ||||||
|  |  | ||||||
|  |  | ||||||
| def log_error(context: ExecutionContext): | def log_error(context: ExecutionContext): | ||||||
|     """Log an error that occurred during the action.""" |     """Log an error that occurred during the action.""" | ||||||
|     logger.error( |     logger.error( | ||||||
|         "[%s] ❌ Error (%s): %s", |         "[%s] Error (%s): %s", | ||||||
|         context.name, |         context.name, | ||||||
|         type(context.exception).__name__, |         type(context.exception).__name__, | ||||||
|         context.exception, |         context.exception, | ||||||
|   | |||||||
| @@ -1,3 +1,7 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """exceptions.py""" | ||||||
|  |  | ||||||
|  |  | ||||||
| class FalyxError(Exception): | class FalyxError(Exception): | ||||||
|     """Custom exception for the Menu class.""" |     """Custom exception for the Menu class.""" | ||||||
|  |  | ||||||
| @@ -20,3 +24,11 @@ class NotAFalyxError(FalyxError): | |||||||
|  |  | ||||||
| class CircuitBreakerOpen(FalyxError): | class CircuitBreakerOpen(FalyxError): | ||||||
|     """Exception raised when the circuit breaker is open.""" |     """Exception raised when the circuit breaker is open.""" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class EmptyChainError(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,100 @@ | |||||||
| """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 collections import defaultdict | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import Dict, List | from threading import Lock | ||||||
|  | from typing import Literal | ||||||
|  |  | ||||||
| from rich import box | from rich import box | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.utils import logger | from falyx.logger import logger | ||||||
|  | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
| class ExecutionRegistry: | 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") |     _console = Console(color_system="truecolor") | ||||||
|  |     _index = 0 | ||||||
|  |     _lock = Lock() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def record(cls, context: ExecutionContext): |     def record(cls, context: ExecutionContext): | ||||||
|         """Record an execution context.""" |         """Record an execution context.""" | ||||||
|         logger.debug(context.to_log_line()) |         logger.debug(context.to_log_line()) | ||||||
|  |         with cls._lock: | ||||||
|  |             context.index = cls._index | ||||||
|  |             cls._store_by_index[cls._index] = context | ||||||
|  |             cls._index += 1 | ||||||
|         cls._store_by_name[context.name].append(context) |         cls._store_by_name[context.name].append(context) | ||||||
|         cls._store_all.append(context) |         cls._store_all.append(context) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_all(cls) -> List[ExecutionContext]: |     def get_all(cls) -> list[ExecutionContext]: | ||||||
|         return cls._store_all |         return cls._store_all | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def get_by_name(cls, name: str) -> List[ExecutionContext]: |     def get_by_name(cls, name: str) -> list[ExecutionContext]: | ||||||
|         return cls._store_by_name.get(name, []) |         return cls._store_by_name.get(name, []) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -39,11 +105,79 @@ class ExecutionRegistry: | |||||||
|     def clear(cls): |     def clear(cls): | ||||||
|         cls._store_by_name.clear() |         cls._store_by_name.clear() | ||||||
|         cls._store_all.clear() |         cls._store_all.clear() | ||||||
|  |         cls._store_by_index.clear() | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def summary(cls): |     def summary( | ||||||
|         table = Table(title="[📊] Execution History", expand=True, box=box.SIMPLE) |         cls, | ||||||
|  |         name: str = "", | ||||||
|  |         index: int | None = None, | ||||||
|  |         result_index: int | None = None, | ||||||
|  |         clear: bool = False, | ||||||
|  |         last_result: bool = False, | ||||||
|  |         status: Literal["all", "success", "error"] = "all", | ||||||
|  |     ): | ||||||
|  |         if clear: | ||||||
|  |             cls.clear() | ||||||
|  |             cls._console.print(f"[{OneColors.GREEN}]✅ Execution history cleared.") | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if last_result: | ||||||
|  |             for ctx in reversed(cls._store_all): | ||||||
|  |                 if ctx.name.upper() not in [ | ||||||
|  |                     "HISTORY", | ||||||
|  |                     "HELP", | ||||||
|  |                     "EXIT", | ||||||
|  |                     "VIEW EXECUTION HISTORY", | ||||||
|  |                     "BACK", | ||||||
|  |                 ]: | ||||||
|  |                     cls._console.print(ctx.result) | ||||||
|  |                     return | ||||||
|  |             cls._console.print( | ||||||
|  |                 f"[{OneColors.DARK_RED}]❌ No valid executions found to display last result." | ||||||
|  |             ) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if result_index is not None and result_index >= 0: | ||||||
|  |             try: | ||||||
|  |                 result_context = cls._store_by_index[result_index] | ||||||
|  |             except KeyError: | ||||||
|  |                 cls._console.print( | ||||||
|  |                     f"[{OneColors.DARK_RED}]❌ No execution found for index {result_index}." | ||||||
|  |                 ) | ||||||
|  |                 return | ||||||
|  |             cls._console.print(f"{result_context.signature}:") | ||||||
|  |             if result_context.exception: | ||||||
|  |                 cls._console.print(result_context.exception) | ||||||
|  |             else: | ||||||
|  |                 cls._console.print(result_context.result) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         if name: | ||||||
|  |             contexts = cls.get_by_name(name) | ||||||
|  |             if not contexts: | ||||||
|  |                 cls._console.print( | ||||||
|  |                     f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'." | ||||||
|  |                 ) | ||||||
|  |                 return | ||||||
|  |             title = f"📊 Execution History for '{contexts[0].name}'" | ||||||
|  |         elif index is not None and index >= 0: | ||||||
|  |             try: | ||||||
|  |                 contexts = [cls._store_by_index[index]] | ||||||
|  |                 print(contexts) | ||||||
|  |             except KeyError: | ||||||
|  |                 cls._console.print( | ||||||
|  |                     f"[{OneColors.DARK_RED}]❌ No execution found for index {index}." | ||||||
|  |                 ) | ||||||
|  |                 return | ||||||
|  |             title = f"📊 Execution History for Index {index}" | ||||||
|  |         else: | ||||||
|  |             contexts = cls.get_all() | ||||||
|  |             title = "📊 Execution History" | ||||||
|  |  | ||||||
|  |         table = Table(title=title, expand=True, box=box.SIMPLE) | ||||||
|  |  | ||||||
|  |         table.add_column("Index", justify="right", style="dim") | ||||||
|         table.add_column("Name", style="bold cyan") |         table.add_column("Name", style="bold cyan") | ||||||
|         table.add_column("Start", justify="right", style="dim") |         table.add_column("Start", justify="right", style="dim") | ||||||
|         table.add_column("End", justify="right", style="dim") |         table.add_column("End", justify="right", style="dim") | ||||||
| @@ -51,26 +185,32 @@ class ExecutionRegistry: | |||||||
|         table.add_column("Status", style="bold") |         table.add_column("Status", style="bold") | ||||||
|         table.add_column("Result / Exception", overflow="fold") |         table.add_column("Result / Exception", overflow="fold") | ||||||
|  |  | ||||||
|         for ctx in cls.get_all(): |         for ctx in contexts: | ||||||
|             start = datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") if ctx.start_time else "n/a" |             start = ( | ||||||
|             end = datetime.fromtimestamp(ctx.end_time).strftime("%H:%M:%S") if ctx.end_time else "n/a" |                 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" |             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||||
|  |  | ||||||
|             if ctx.exception: |             if ctx.exception and status.lower() in ["all", "error"]: | ||||||
|                 status = "[bold red]❌ Error" |                 final_status = f"[{OneColors.DARK_RED}]❌ Error" | ||||||
|                 result = repr(ctx.exception) |                 final_result = repr(ctx.exception) | ||||||
|  |             elif status.lower() in ["all", "success"]: | ||||||
|  |                 final_status = f"[{OneColors.GREEN}]✅ Success" | ||||||
|  |                 final_result = repr(ctx.result) | ||||||
|  |                 if len(final_result) > 1000: | ||||||
|  |                     final_result = f"{final_result[:1000]}..." | ||||||
|             else: |             else: | ||||||
|                 status = "[green]✅ Success" |                 continue | ||||||
|                 result = repr(ctx.result) |  | ||||||
|  |  | ||||||
|             table.add_row(ctx.name, start, end, duration, status, result) |             table.add_row( | ||||||
|  |                 str(ctx.index), ctx.name, start, end, duration, final_status, final_result | ||||||
|  |             ) | ||||||
|  |  | ||||||
|         cls._console.print(table) |         cls._console.print(table) | ||||||
|  |  | ||||||
|     @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) |  | ||||||
|   | |||||||
							
								
								
									
										1037
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										1037
									
								
								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""" | """hook_manager.py""" | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| import inspect | import inspect | ||||||
| from enum import Enum | from enum import Enum | ||||||
| from typing import Awaitable, Callable, Dict, List, Optional, Union | from typing import Awaitable, Callable, Union | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.utils import logger | from falyx.logger import logger | ||||||
|  |  | ||||||
| Hook = Union[ | Hook = Union[ | ||||||
|     Callable[[ExecutionContext], None], |     Callable[[ExecutionContext], None], Callable[[ExecutionContext], Awaitable[None]] | ||||||
|     Callable[[ExecutionContext], Awaitable[None]] |  | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  |  | ||||||
| class HookType(Enum): | class HookType(Enum): | ||||||
|     """Enum for hook types to categorize the hooks.""" |     """Enum for hook types to categorize the hooks.""" | ||||||
|  |  | ||||||
|     BEFORE = "before" |     BEFORE = "before" | ||||||
|     ON_SUCCESS = "on_success" |     ON_SUCCESS = "on_success" | ||||||
|     ON_ERROR = "on_error" |     ON_ERROR = "on_error" | ||||||
| @@ -23,7 +24,7 @@ class HookType(Enum): | |||||||
|     ON_TEARDOWN = "on_teardown" |     ON_TEARDOWN = "on_teardown" | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def choices(cls) -> List[HookType]: |     def choices(cls) -> list[HookType]: | ||||||
|         """Return a list of all hook type choices.""" |         """Return a list of all hook type choices.""" | ||||||
|         return list(cls) |         return list(cls) | ||||||
|  |  | ||||||
| @@ -33,17 +34,20 @@ class HookType(Enum): | |||||||
|  |  | ||||||
|  |  | ||||||
| class HookManager: | class HookManager: | ||||||
|  |     """HookManager""" | ||||||
|  |  | ||||||
|     def __init__(self) -> None: |     def __init__(self) -> None: | ||||||
|         self._hooks: Dict[HookType, List[Hook]] = { |         self._hooks: dict[HookType, list[Hook]] = { | ||||||
|             hook_type: [] for hook_type in HookType |             hook_type: [] for hook_type in HookType | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     def register(self, hook_type: HookType, hook: Hook): |     def register(self, hook_type: HookType | str, hook: Hook): | ||||||
|         if hook_type not in HookType: |         """Raises ValueError if the hook type is not supported.""" | ||||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") |         if not isinstance(hook_type, HookType): | ||||||
|  |             hook_type = HookType(hook_type) | ||||||
|         self._hooks[hook_type].append(hook) |         self._hooks[hook_type].append(hook) | ||||||
|  |  | ||||||
|     def clear(self, hook_type: Optional[HookType] = None): |     def clear(self, hook_type: HookType | None = None): | ||||||
|         if hook_type: |         if hook_type: | ||||||
|             self._hooks[hook_type] = [] |             self._hooks[hook_type] = [] | ||||||
|         else: |         else: | ||||||
| @@ -60,9 +64,28 @@ class HookManager: | |||||||
|                 else: |                 else: | ||||||
|                     hook(context) |                     hook(context) | ||||||
|             except Exception as hook_error: |             except Exception as hook_error: | ||||||
|                 logger.warning(f"⚠️ Hook '{hook.__name__}' raised an exception during '{hook_type}'" |                 logger.warning( | ||||||
|                                f" for '{context.name}': {hook_error}") |                     "[Hook:%s] raised an exception during '%s' for '%s': %s", | ||||||
|  |                     hook.__name__, | ||||||
|  |                     hook_type, | ||||||
|  |                     context.name, | ||||||
|  |                     hook_error, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|                 if hook_type == HookType.ON_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 |                     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""" | """hooks.py""" | ||||||
| import time | import time | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.exceptions import CircuitBreakerOpen | from falyx.exceptions import CircuitBreakerOpen | ||||||
| from falyx.themes.colors import OneColors | from falyx.logger import logger | ||||||
| from falyx.utils import logger | from falyx.themes import OneColors | ||||||
|  |  | ||||||
|  |  | ||||||
| class ResultReporter: | 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). |         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 |     @property | ||||||
|     def __name__(self): |     def __name__(self): | ||||||
|         return "ResultReporter" |         return "ResultReporter" | ||||||
|  |  | ||||||
|     async def report(self, context: ExecutionContext): |     async def report(self, context: ExecutionContext): | ||||||
|  |         if not callable(self.formatter): | ||||||
|  |             raise TypeError("formatter must be callable") | ||||||
|         if context.result is not None: |         if context.result is not None: | ||||||
|             result_text = self.formatter(context.result) |             result_text = self.formatter(context.result) | ||||||
|             duration = f"{context.duration:.3f}s" if context.duration is not None else "n/a" |             duration = ( | ||||||
|             context.console.print(f"[{OneColors.GREEN}]✅ '{context.name}' " |                 f"{context.duration:.3f}s" if context.duration is not None else "n/a" | ||||||
|                   f"completed:[/] {result_text} in {duration}.") |             ) | ||||||
|  |             context.console.print( | ||||||
|  |                 f"[{OneColors.GREEN}]✅ '{context.name}' " | ||||||
|  |                 f"completed:[/] {result_text} in {duration}." | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class CircuitBreaker: | class CircuitBreaker: | ||||||
|  |     """Circuit Breaker pattern to prevent repeated failures.""" | ||||||
|  |  | ||||||
|     def __init__(self, max_failures=3, reset_timeout=10): |     def __init__(self, max_failures=3, reset_timeout=10): | ||||||
|         self.max_failures = max_failures |         self.max_failures = max_failures | ||||||
|         self.reset_timeout = reset_timeout |         self.reset_timeout = reset_timeout | ||||||
| @@ -37,21 +55,30 @@ class CircuitBreaker: | |||||||
|         name = context.name |         name = context.name | ||||||
|         if self.open_until: |         if self.open_until: | ||||||
|             if time.time() < 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: |             else: | ||||||
|                 logger.info(f"🟢 Circuit closed again for '{name}'.") |                 logger.info("Circuit closed again for '%s'.") | ||||||
|                 self.failures = 0 |                 self.failures = 0 | ||||||
|                 self.open_until = None |                 self.open_until = None | ||||||
|  |  | ||||||
|     def error_hook(self, context: ExecutionContext): |     def error_hook(self, context: ExecutionContext): | ||||||
|         name = context.name |         name = context.name | ||||||
|         self.failures += 1 |         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: |         if self.failures >= self.max_failures: | ||||||
|             self.open_until = time.time() + self.reset_timeout |             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 |         self.failures = 0 | ||||||
|  |  | ||||||
|     def is_open(self): |     def is_open(self): | ||||||
| @@ -60,4 +87,4 @@ class CircuitBreaker: | |||||||
|     def reset(self): |     def reset(self): | ||||||
|         self.failures = 0 |         self.failures = 0 | ||||||
|         self.open_until = None |         self.open_until = None | ||||||
|         logger.info("🔄 Circuit reset.") |         logger.info("Circuit reset.") | ||||||
|   | |||||||
| @@ -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 |  | ||||||
							
								
								
									
										135
									
								
								falyx/init.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								falyx/init.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """init.py""" | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from rich.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] | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | console = Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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.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 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""" | """options_manager.py""" | ||||||
|  |  | ||||||
| from argparse import Namespace | from argparse import Namespace | ||||||
| from collections import defaultdict | from collections import defaultdict | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
|  |  | ||||||
| from falyx.utils import logger | from falyx.logger import logger | ||||||
|  |  | ||||||
|  |  | ||||||
| class OptionsManager: | class OptionsManager: | ||||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] = None) -> None: |     """OptionsManager""" | ||||||
|         self.options = defaultdict(lambda: Namespace()) |  | ||||||
|  |     def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: | ||||||
|  |         self.options: defaultdict = defaultdict(Namespace) | ||||||
|         if namespaces: |         if namespaces: | ||||||
|             for namespace_name, namespace in namespaces: |             for namespace_name, namespace in namespaces: | ||||||
|                 self.from_namespace(namespace, namespace_name) |                 self.from_namespace(namespace, namespace_name) | ||||||
| @@ -25,9 +28,7 @@ class OptionsManager: | |||||||
|         """Get the value of an option.""" |         """Get the value of an option.""" | ||||||
|         return getattr(self.options[namespace_name], option_name, default) |         return getattr(self.options[namespace_name], option_name, default) | ||||||
|  |  | ||||||
|     def set( |     def set(self, option_name: str, value: Any, namespace_name: str = "cli_args") -> None: | ||||||
|         self, option_name: str, value: Any, namespace_name: str = "cli_args" |  | ||||||
|     ) -> None: |  | ||||||
|         """Set the value of an option.""" |         """Set the value of an option.""" | ||||||
|         setattr(self.options[namespace_name], option_name, value) |         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}'" |                 f"Cannot toggle non-boolean option: '{option_name}' in '{namespace_name}'" | ||||||
|             ) |             ) | ||||||
|         self.set(option_name, not current, namespace_name=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( |     def get_value_getter( | ||||||
|         self, option_name: str, namespace_name: str = "cli_args" |         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", | ||||||
|  | ] | ||||||
							
								
								
									
										98
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """argument.py""" | ||||||
|  | from dataclasses import dataclass | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.parser.argument_action import ArgumentAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @dataclass | ||||||
|  | class Argument: | ||||||
|  |     """Represents a command-line argument.""" | ||||||
|  |  | ||||||
|  |     flags: tuple[str, ...] | ||||||
|  |     dest: str  # Destination name for the argument | ||||||
|  |     action: ArgumentAction = ( | ||||||
|  |         ArgumentAction.STORE | ||||||
|  |     )  # Action to be taken when the argument is encountered | ||||||
|  |     type: Any = str  # Type of the argument (e.g., str, int, float) or callable | ||||||
|  |     default: Any = None  # Default value if the argument is not provided | ||||||
|  |     choices: list[str] | None = None  # List of valid choices for the argument | ||||||
|  |     required: bool = False  # True if the argument is required | ||||||
|  |     help: str = ""  # Help text for the argument | ||||||
|  |     nargs: int | str | None = None  # int, '?', '*', '+', None | ||||||
|  |     positional: bool = False  # True if no leading - or -- in flags | ||||||
|  |     resolver: BaseAction | None = None  # Action object for the argument | ||||||
|  |  | ||||||
|  |     def get_positional_text(self) -> str: | ||||||
|  |         """Get the positional text for the argument.""" | ||||||
|  |         text = "" | ||||||
|  |         if self.positional: | ||||||
|  |             if self.choices: | ||||||
|  |                 text = f"{{{','.join([str(choice) for choice in self.choices])}}}" | ||||||
|  |             else: | ||||||
|  |                 text = self.dest | ||||||
|  |         return text | ||||||
|  |  | ||||||
|  |     def get_choice_text(self) -> str: | ||||||
|  |         """Get the choice text for the argument.""" | ||||||
|  |         choice_text = "" | ||||||
|  |         if self.choices: | ||||||
|  |             choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}" | ||||||
|  |         elif ( | ||||||
|  |             self.action | ||||||
|  |             in ( | ||||||
|  |                 ArgumentAction.STORE, | ||||||
|  |                 ArgumentAction.APPEND, | ||||||
|  |                 ArgumentAction.EXTEND, | ||||||
|  |             ) | ||||||
|  |             and not self.positional | ||||||
|  |         ): | ||||||
|  |             choice_text = self.dest.upper() | ||||||
|  |         elif self.action in ( | ||||||
|  |             ArgumentAction.STORE, | ||||||
|  |             ArgumentAction.APPEND, | ||||||
|  |             ArgumentAction.EXTEND, | ||||||
|  |         ) or isinstance(self.nargs, str): | ||||||
|  |             choice_text = self.dest | ||||||
|  |  | ||||||
|  |         if self.nargs == "?": | ||||||
|  |             choice_text = f"[{choice_text}]" | ||||||
|  |         elif self.nargs == "*": | ||||||
|  |             choice_text = f"[{choice_text} ...]" | ||||||
|  |         elif self.nargs == "+": | ||||||
|  |             choice_text = f"{choice_text} [{choice_text} ...]" | ||||||
|  |         return choice_text | ||||||
|  |  | ||||||
|  |     def __eq__(self, other: object) -> bool: | ||||||
|  |         if not isinstance(other, Argument): | ||||||
|  |             return False | ||||||
|  |         return ( | ||||||
|  |             self.flags == other.flags | ||||||
|  |             and self.dest == other.dest | ||||||
|  |             and self.action == other.action | ||||||
|  |             and self.type == other.type | ||||||
|  |             and self.choices == other.choices | ||||||
|  |             and self.required == other.required | ||||||
|  |             and self.nargs == other.nargs | ||||||
|  |             and self.positional == other.positional | ||||||
|  |             and self.default == other.default | ||||||
|  |             and self.help == other.help | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def __hash__(self) -> int: | ||||||
|  |         return hash( | ||||||
|  |             ( | ||||||
|  |                 tuple(self.flags), | ||||||
|  |                 self.dest, | ||||||
|  |                 self.action, | ||||||
|  |                 self.type, | ||||||
|  |                 tuple(self.choices or []), | ||||||
|  |                 self.required, | ||||||
|  |                 self.nargs, | ||||||
|  |                 self.positional, | ||||||
|  |                 self.default, | ||||||
|  |                 self.help, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
							
								
								
									
										27
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """argument_action.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArgumentAction(Enum): | ||||||
|  |     """Defines the action to be taken when the argument is encountered.""" | ||||||
|  |  | ||||||
|  |     ACTION = "action" | ||||||
|  |     STORE = "store" | ||||||
|  |     STORE_TRUE = "store_true" | ||||||
|  |     STORE_FALSE = "store_false" | ||||||
|  |     APPEND = "append" | ||||||
|  |     EXTEND = "extend" | ||||||
|  |     COUNT = "count" | ||||||
|  |     HELP = "help" | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def choices(cls) -> list[ArgumentAction]: | ||||||
|  |         """Return a list of all argument actions.""" | ||||||
|  |         return list(cls) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         """Return the string representation of the argument action.""" | ||||||
|  |         return self.value | ||||||
							
								
								
									
										837
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										837
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,837 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """command_argument_parser.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from copy import deepcopy | ||||||
|  | from typing import Any, Iterable | ||||||
|  |  | ||||||
|  | from rich.console import Console | ||||||
|  | from rich.markup import escape | ||||||
|  | from rich.text import Text | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.exceptions import CommandArgumentError | ||||||
|  | from falyx.parser.argument import Argument | ||||||
|  | from falyx.parser.argument_action import ArgumentAction | ||||||
|  | from falyx.parser.utils import coerce_value | ||||||
|  | from falyx.signals import HelpSignal | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CommandArgumentParser: | ||||||
|  |     """ | ||||||
|  |     Custom argument parser for Falyx Commands. | ||||||
|  |     It is used to create a command-line interface for Falyx | ||||||
|  |     commands, allowing users to specify options and arguments | ||||||
|  |     when executing commands. | ||||||
|  |     It is not intended to be a full-featured replacement for | ||||||
|  |     argparse, but rather a lightweight alternative for specific use | ||||||
|  |     cases within the Falyx framework. | ||||||
|  |  | ||||||
|  |     Features: | ||||||
|  |     - Customizable argument parsing. | ||||||
|  |     - Type coercion for arguments. | ||||||
|  |     - Support for positional and keyword arguments. | ||||||
|  |     - Support for default values. | ||||||
|  |     - Support for boolean flags. | ||||||
|  |     - Exception handling for invalid arguments. | ||||||
|  |     - Render Help using Rich library. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         command_key: str = "", | ||||||
|  |         command_description: str = "", | ||||||
|  |         command_style: str = "bold", | ||||||
|  |         help_text: str = "", | ||||||
|  |         help_epilog: str = "", | ||||||
|  |         aliases: list[str] | None = None, | ||||||
|  |     ) -> None: | ||||||
|  |         """Initialize the CommandArgumentParser.""" | ||||||
|  |         self.console = Console(color_system="truecolor") | ||||||
|  |         self.command_key: str = command_key | ||||||
|  |         self.command_description: str = command_description | ||||||
|  |         self.command_style: str = command_style | ||||||
|  |         self.help_text: str = help_text | ||||||
|  |         self.help_epilog: str = help_epilog | ||||||
|  |         self.aliases: list[str] = aliases or [] | ||||||
|  |         self._arguments: list[Argument] = [] | ||||||
|  |         self._positional: dict[str, Argument] = {} | ||||||
|  |         self._keyword: dict[str, Argument] = {} | ||||||
|  |         self._keyword_list: list[Argument] = [] | ||||||
|  |         self._flag_map: dict[str, Argument] = {} | ||||||
|  |         self._dest_set: set[str] = set() | ||||||
|  |         self._add_help() | ||||||
|  |  | ||||||
|  |     def _add_help(self): | ||||||
|  |         """Add help argument to the parser.""" | ||||||
|  |         self.add_argument( | ||||||
|  |             "-h", | ||||||
|  |             "--help", | ||||||
|  |             action=ArgumentAction.HELP, | ||||||
|  |             help="Show this help message.", | ||||||
|  |             dest="help", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def _is_positional(self, flags: tuple[str, ...]) -> bool: | ||||||
|  |         """Check if the flags are positional.""" | ||||||
|  |         positional = False | ||||||
|  |         if any(not flag.startswith("-") for flag in flags): | ||||||
|  |             positional = True | ||||||
|  |  | ||||||
|  |         if positional and len(flags) > 1: | ||||||
|  |             raise CommandArgumentError("Positional arguments cannot have multiple flags") | ||||||
|  |         return positional | ||||||
|  |  | ||||||
|  |     def _get_dest_from_flags(self, flags: tuple[str, ...], dest: str | None) -> str: | ||||||
|  |         """Convert flags to a destination name.""" | ||||||
|  |         if dest: | ||||||
|  |             if not dest.replace("_", "").isalnum(): | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     "dest must be a valid identifier (letters, digits, and underscores only)" | ||||||
|  |                 ) | ||||||
|  |             if dest[0].isdigit(): | ||||||
|  |                 raise CommandArgumentError("dest must not start with a digit") | ||||||
|  |             return dest | ||||||
|  |         dest = None | ||||||
|  |         for flag in flags: | ||||||
|  |             if flag.startswith("--"): | ||||||
|  |                 dest = flag.lstrip("-").replace("-", "_").lower() | ||||||
|  |                 break | ||||||
|  |             elif flag.startswith("-"): | ||||||
|  |                 dest = flag.lstrip("-").replace("-", "_").lower() | ||||||
|  |             else: | ||||||
|  |                 dest = flag.replace("-", "_").lower() | ||||||
|  |         assert dest is not None, "dest should not be None" | ||||||
|  |         if not dest.replace("_", "").isalnum(): | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 "dest must be a valid identifier (letters, digits, and underscores only)" | ||||||
|  |             ) | ||||||
|  |         if dest[0].isdigit(): | ||||||
|  |             raise CommandArgumentError("dest must not start with a digit") | ||||||
|  |         return dest | ||||||
|  |  | ||||||
|  |     def _determine_required( | ||||||
|  |         self, required: bool, positional: bool, nargs: int | str | None | ||||||
|  |     ) -> bool: | ||||||
|  |         """Determine if the argument is required.""" | ||||||
|  |         if required: | ||||||
|  |             return True | ||||||
|  |         if positional: | ||||||
|  |             assert ( | ||||||
|  |                 nargs is None | ||||||
|  |                 or isinstance(nargs, int) | ||||||
|  |                 or isinstance(nargs, str) | ||||||
|  |                 and nargs in ("+", "*", "?") | ||||||
|  |             ), f"Invalid nargs value: {nargs}" | ||||||
|  |             if isinstance(nargs, int): | ||||||
|  |                 return nargs > 0 | ||||||
|  |             elif isinstance(nargs, str): | ||||||
|  |                 if nargs in ("+"): | ||||||
|  |                     return True | ||||||
|  |                 elif nargs in ("*", "?"): | ||||||
|  |                     return False | ||||||
|  |             else: | ||||||
|  |                 return True | ||||||
|  |  | ||||||
|  |         return required | ||||||
|  |  | ||||||
|  |     def _validate_nargs( | ||||||
|  |         self, nargs: int | str | None, action: ArgumentAction | ||||||
|  |     ) -> int | str | None: | ||||||
|  |         if action in ( | ||||||
|  |             ArgumentAction.STORE_FALSE, | ||||||
|  |             ArgumentAction.STORE_TRUE, | ||||||
|  |             ArgumentAction.COUNT, | ||||||
|  |             ArgumentAction.HELP, | ||||||
|  |         ): | ||||||
|  |             if nargs is not None: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"nargs cannot be specified for {action} actions" | ||||||
|  |                 ) | ||||||
|  |             return None | ||||||
|  |         if nargs is None: | ||||||
|  |             return None | ||||||
|  |         allowed_nargs = ("?", "*", "+") | ||||||
|  |         if isinstance(nargs, int): | ||||||
|  |             if nargs <= 0: | ||||||
|  |                 raise CommandArgumentError("nargs must be a positive integer") | ||||||
|  |         elif isinstance(nargs, str): | ||||||
|  |             if nargs not in allowed_nargs: | ||||||
|  |                 raise CommandArgumentError(f"Invalid nargs value: {nargs}") | ||||||
|  |         else: | ||||||
|  |             raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") | ||||||
|  |         return nargs | ||||||
|  |  | ||||||
|  |     def _normalize_choices( | ||||||
|  |         self, choices: Iterable | None, expected_type: Any | ||||||
|  |     ) -> list[Any]: | ||||||
|  |         if choices is not None: | ||||||
|  |             if isinstance(choices, dict): | ||||||
|  |                 raise CommandArgumentError("choices cannot be a dict") | ||||||
|  |             try: | ||||||
|  |                 choices = list(choices) | ||||||
|  |             except TypeError: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     "choices must be iterable (like list, tuple, or set)" | ||||||
|  |                 ) | ||||||
|  |         else: | ||||||
|  |             choices = [] | ||||||
|  |         for choice in choices: | ||||||
|  |             if not isinstance(choice, expected_type): | ||||||
|  |                 try: | ||||||
|  |                     coerce_value(choice, expected_type) | ||||||
|  |                 except Exception as error: | ||||||
|  |                     raise CommandArgumentError( | ||||||
|  |                         f"Invalid choice {choice!r}: not coercible to {expected_type.__name__} error: {error}" | ||||||
|  |                     ) from error | ||||||
|  |         return choices | ||||||
|  |  | ||||||
|  |     def _validate_default_type( | ||||||
|  |         self, default: Any, expected_type: type, dest: str | ||||||
|  |     ) -> None: | ||||||
|  |         """Validate the default value type.""" | ||||||
|  |         if default is not None and not isinstance(default, expected_type): | ||||||
|  |             try: | ||||||
|  |                 coerce_value(default, expected_type) | ||||||
|  |             except Exception as error: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" | ||||||
|  |                 ) from error | ||||||
|  |  | ||||||
|  |     def _validate_default_list_type( | ||||||
|  |         self, default: list[Any], expected_type: type, dest: str | ||||||
|  |     ) -> None: | ||||||
|  |         if isinstance(default, list): | ||||||
|  |             for item in default: | ||||||
|  |                 if not isinstance(item, expected_type): | ||||||
|  |                     try: | ||||||
|  |                         coerce_value(item, expected_type) | ||||||
|  |                     except Exception as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__} error: {error}" | ||||||
|  |                         ) from error | ||||||
|  |  | ||||||
|  |     def _validate_resolver( | ||||||
|  |         self, action: ArgumentAction, resolver: BaseAction | None | ||||||
|  |     ) -> BaseAction | None: | ||||||
|  |         """Validate the action object.""" | ||||||
|  |         if action != ArgumentAction.ACTION and resolver is None: | ||||||
|  |             return None | ||||||
|  |         elif action == ArgumentAction.ACTION and resolver is None: | ||||||
|  |             raise CommandArgumentError("resolver must be provided for ACTION action") | ||||||
|  |         elif action != ArgumentAction.ACTION and resolver is not None: | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"resolver should not be provided for action {action}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if not isinstance(resolver, BaseAction): | ||||||
|  |             raise CommandArgumentError("resolver must be an instance of BaseAction") | ||||||
|  |         return resolver | ||||||
|  |  | ||||||
|  |     def _validate_action( | ||||||
|  |         self, action: ArgumentAction | str, positional: bool | ||||||
|  |     ) -> ArgumentAction: | ||||||
|  |         if not isinstance(action, ArgumentAction): | ||||||
|  |             try: | ||||||
|  |                 action = ArgumentAction(action) | ||||||
|  |             except ValueError: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||||
|  |                 ) | ||||||
|  |         if action in ( | ||||||
|  |             ArgumentAction.STORE_TRUE, | ||||||
|  |             ArgumentAction.STORE_FALSE, | ||||||
|  |             ArgumentAction.COUNT, | ||||||
|  |             ArgumentAction.HELP, | ||||||
|  |         ): | ||||||
|  |             if positional: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Action '{action}' cannot be used with positional arguments" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |         return action | ||||||
|  |  | ||||||
|  |     def _resolve_default( | ||||||
|  |         self, | ||||||
|  |         default: Any, | ||||||
|  |         action: ArgumentAction, | ||||||
|  |         nargs: str | int | None, | ||||||
|  |     ) -> Any: | ||||||
|  |         """Get the default value for the argument.""" | ||||||
|  |         if default is None: | ||||||
|  |             if action == ArgumentAction.STORE_TRUE: | ||||||
|  |                 return False | ||||||
|  |             elif action == ArgumentAction.STORE_FALSE: | ||||||
|  |                 return True | ||||||
|  |             elif action == ArgumentAction.COUNT: | ||||||
|  |                 return 0 | ||||||
|  |             elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): | ||||||
|  |                 return [] | ||||||
|  |             elif isinstance(nargs, int): | ||||||
|  |                 return [] | ||||||
|  |             elif nargs in ("+", "*"): | ||||||
|  |                 return [] | ||||||
|  |             else: | ||||||
|  |                 return None | ||||||
|  |         return default | ||||||
|  |  | ||||||
|  |     def _validate_flags(self, flags: tuple[str, ...]) -> None: | ||||||
|  |         """Validate the flags provided for the argument.""" | ||||||
|  |         if not flags: | ||||||
|  |             raise CommandArgumentError("No flags provided") | ||||||
|  |         for flag in flags: | ||||||
|  |             if not isinstance(flag, str): | ||||||
|  |                 raise CommandArgumentError(f"Flag '{flag}' must be a string") | ||||||
|  |             if flag.startswith("--") and len(flag) < 3: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Flag '{flag}' must be at least 3 characters long" | ||||||
|  |                 ) | ||||||
|  |             if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Flag '{flag}' must be a single character or start with '--'" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     def add_argument( | ||||||
|  |         self, | ||||||
|  |         *flags, | ||||||
|  |         action: str | ArgumentAction = "store", | ||||||
|  |         nargs: int | str | None = None, | ||||||
|  |         default: Any = None, | ||||||
|  |         type: Any = str, | ||||||
|  |         choices: Iterable | None = None, | ||||||
|  |         required: bool = False, | ||||||
|  |         help: str = "", | ||||||
|  |         dest: str | None = None, | ||||||
|  |         resolver: BaseAction | None = None, | ||||||
|  |     ) -> None: | ||||||
|  |         """Add an argument to the parser. | ||||||
|  |         For `ArgumentAction.ACTION`, `nargs` and `type` determine how many and what kind | ||||||
|  |         of inputs are passed to the `resolver`. | ||||||
|  |  | ||||||
|  |         The return value of the `resolver` is used directly (no type coercion is applied). | ||||||
|  |         Validation, structure, and post-processing should be handled within the `resolver`. | ||||||
|  |  | ||||||
|  |         Args: | ||||||
|  |             name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). | ||||||
|  |             action: The action to be taken when the argument is encountered. | ||||||
|  |             nargs: The number of arguments expected. | ||||||
|  |             default: The default value if the argument is not provided. | ||||||
|  |             type: The type to which the command-line argument should be converted. | ||||||
|  |             choices: A container of the allowable values for the argument. | ||||||
|  |             required: Whether or not the argument is required. | ||||||
|  |             help: A brief description of the argument. | ||||||
|  |             dest: The name of the attribute to be added to the object returned by parse_args(). | ||||||
|  |             resolver: A BaseAction called with optional nargs specified parsed arguments. | ||||||
|  |         """ | ||||||
|  |         expected_type = type | ||||||
|  |         self._validate_flags(flags) | ||||||
|  |         positional = self._is_positional(flags) | ||||||
|  |         dest = self._get_dest_from_flags(flags, dest) | ||||||
|  |         if dest in self._dest_set: | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"Destination '{dest}' is already defined.\n" | ||||||
|  |                 "Merging multiple arguments into the same dest (e.g. positional + flagged) " | ||||||
|  |                 "is not supported. Define a unique 'dest' for each argument." | ||||||
|  |             ) | ||||||
|  |         action = self._validate_action(action, positional) | ||||||
|  |         resolver = self._validate_resolver(action, resolver) | ||||||
|  |         nargs = self._validate_nargs(nargs, action) | ||||||
|  |         default = self._resolve_default(default, action, nargs) | ||||||
|  |         if ( | ||||||
|  |             action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) | ||||||
|  |             and default is not None | ||||||
|  |         ): | ||||||
|  |             if isinstance(default, list): | ||||||
|  |                 self._validate_default_list_type(default, expected_type, dest) | ||||||
|  |             else: | ||||||
|  |                 self._validate_default_type(default, expected_type, dest) | ||||||
|  |         choices = self._normalize_choices(choices, expected_type) | ||||||
|  |         if default is not None and choices and default not in choices: | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"Default value '{default}' not in allowed choices: {choices}" | ||||||
|  |             ) | ||||||
|  |         required = self._determine_required(required, positional, nargs) | ||||||
|  |         argument = Argument( | ||||||
|  |             flags=flags, | ||||||
|  |             dest=dest, | ||||||
|  |             action=action, | ||||||
|  |             type=expected_type, | ||||||
|  |             default=default, | ||||||
|  |             choices=choices, | ||||||
|  |             required=required, | ||||||
|  |             help=help, | ||||||
|  |             nargs=nargs, | ||||||
|  |             positional=positional, | ||||||
|  |             resolver=resolver, | ||||||
|  |         ) | ||||||
|  |         for flag in flags: | ||||||
|  |             if flag in self._flag_map: | ||||||
|  |                 existing = self._flag_map[flag] | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Flag '{flag}' is already used by argument '{existing.dest}'" | ||||||
|  |                 ) | ||||||
|  |         for flag in flags: | ||||||
|  |             self._flag_map[flag] = argument | ||||||
|  |             if not positional: | ||||||
|  |                 self._keyword[flag] = argument | ||||||
|  |         self._dest_set.add(dest) | ||||||
|  |         self._arguments.append(argument) | ||||||
|  |         if positional: | ||||||
|  |             self._positional[dest] = argument | ||||||
|  |         else: | ||||||
|  |             self._keyword_list.append(argument) | ||||||
|  |  | ||||||
|  |     def get_argument(self, dest: str) -> Argument | None: | ||||||
|  |         return next((a for a in self._arguments if a.dest == dest), None) | ||||||
|  |  | ||||||
|  |     def to_definition_list(self) -> list[dict[str, Any]]: | ||||||
|  |         defs = [] | ||||||
|  |         for arg in self._arguments: | ||||||
|  |             defs.append( | ||||||
|  |                 { | ||||||
|  |                     "flags": arg.flags, | ||||||
|  |                     "dest": arg.dest, | ||||||
|  |                     "action": arg.action, | ||||||
|  |                     "type": arg.type, | ||||||
|  |                     "choices": arg.choices, | ||||||
|  |                     "required": arg.required, | ||||||
|  |                     "nargs": arg.nargs, | ||||||
|  |                     "positional": arg.positional, | ||||||
|  |                     "default": arg.default, | ||||||
|  |                     "help": arg.help, | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         return defs | ||||||
|  |  | ||||||
|  |     def _consume_nargs( | ||||||
|  |         self, args: list[str], start: int, spec: Argument | ||||||
|  |     ) -> tuple[list[str], int]: | ||||||
|  |         assert ( | ||||||
|  |             spec.nargs is None | ||||||
|  |             or isinstance(spec.nargs, int) | ||||||
|  |             or isinstance(spec.nargs, str) | ||||||
|  |             and spec.nargs in ("+", "*", "?") | ||||||
|  |         ), f"Invalid nargs value: {spec.nargs}" | ||||||
|  |         values = [] | ||||||
|  |         i = start | ||||||
|  |         if isinstance(spec.nargs, int): | ||||||
|  |             values = args[i : i + spec.nargs] | ||||||
|  |             return values, i + spec.nargs | ||||||
|  |         elif spec.nargs == "+": | ||||||
|  |             if i >= len(args): | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Expected at least one value for '{spec.dest}'" | ||||||
|  |                 ) | ||||||
|  |             while i < len(args) and not args[i].startswith("-"): | ||||||
|  |                 values.append(args[i]) | ||||||
|  |                 i += 1 | ||||||
|  |             assert values, "Expected at least one value for '+' nargs: shouldn't happen" | ||||||
|  |             return values, i | ||||||
|  |         elif spec.nargs == "*": | ||||||
|  |             while i < len(args) and not args[i].startswith("-"): | ||||||
|  |                 values.append(args[i]) | ||||||
|  |                 i += 1 | ||||||
|  |             return values, i | ||||||
|  |         elif spec.nargs == "?": | ||||||
|  |             if i < len(args) and not args[i].startswith("-"): | ||||||
|  |                 return [args[i]], i + 1 | ||||||
|  |             return [], i | ||||||
|  |         elif spec.nargs is None: | ||||||
|  |             if i < len(args) and not args[i].startswith("-"): | ||||||
|  |                 return [args[i]], i + 1 | ||||||
|  |             return [], i | ||||||
|  |         assert False, "Invalid nargs value: shouldn't happen" | ||||||
|  |  | ||||||
|  |     async def _consume_all_positional_args( | ||||||
|  |         self, | ||||||
|  |         args: list[str], | ||||||
|  |         result: dict[str, Any], | ||||||
|  |         positional_args: list[Argument], | ||||||
|  |         consumed_positional_indicies: set[int], | ||||||
|  |     ) -> int: | ||||||
|  |         remaining_positional_args = [ | ||||||
|  |             (j, spec) | ||||||
|  |             for j, spec in enumerate(positional_args) | ||||||
|  |             if j not in consumed_positional_indicies | ||||||
|  |         ] | ||||||
|  |         i = 0 | ||||||
|  |  | ||||||
|  |         for j, spec in remaining_positional_args: | ||||||
|  |             # estimate how many args the remaining specs might need | ||||||
|  |             is_last = j == len(positional_args) - 1 | ||||||
|  |             remaining = len(args) - i | ||||||
|  |             min_required = 0 | ||||||
|  |             for next_spec in positional_args[j + 1 :]: | ||||||
|  |                 assert ( | ||||||
|  |                     next_spec.nargs is None | ||||||
|  |                     or isinstance(next_spec.nargs, int) | ||||||
|  |                     or isinstance(next_spec.nargs, str) | ||||||
|  |                     and next_spec.nargs in ("+", "*", "?") | ||||||
|  |                 ), f"Invalid nargs value: {spec.nargs}" | ||||||
|  |                 if next_spec.nargs is None: | ||||||
|  |                     min_required += 1 | ||||||
|  |                 elif isinstance(next_spec.nargs, int): | ||||||
|  |                     min_required += next_spec.nargs | ||||||
|  |                 elif next_spec.nargs == "+": | ||||||
|  |                     min_required += 1 | ||||||
|  |                 elif next_spec.nargs == "?": | ||||||
|  |                     min_required += 0 | ||||||
|  |                 elif next_spec.nargs == "*": | ||||||
|  |                     min_required += 0 | ||||||
|  |  | ||||||
|  |             slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] | ||||||
|  |             values, new_i = self._consume_nargs(slice_args, 0, spec) | ||||||
|  |             i += new_i | ||||||
|  |  | ||||||
|  |             try: | ||||||
|  |                 typed = [coerce_value(value, spec.type) for value in values] | ||||||
|  |             except Exception as error: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Invalid value for '{spec.dest}': {error}" | ||||||
|  |                 ) from error | ||||||
|  |             if spec.action == ArgumentAction.ACTION: | ||||||
|  |                 assert isinstance( | ||||||
|  |                     spec.resolver, BaseAction | ||||||
|  |                 ), "resolver should be an instance of BaseAction" | ||||||
|  |                 try: | ||||||
|  |                     result[spec.dest] = await spec.resolver(*typed) | ||||||
|  |                 except Exception as error: | ||||||
|  |                     raise CommandArgumentError( | ||||||
|  |                         f"[{spec.dest}] Action failed: {error}" | ||||||
|  |                     ) from error | ||||||
|  |             elif spec.action == ArgumentAction.APPEND: | ||||||
|  |                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||||
|  |                 if spec.nargs is None: | ||||||
|  |                     result[spec.dest].append(typed[0]) | ||||||
|  |                 else: | ||||||
|  |                     result[spec.dest].append(typed) | ||||||
|  |             elif spec.action == ArgumentAction.EXTEND: | ||||||
|  |                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||||
|  |                 result[spec.dest].extend(typed) | ||||||
|  |             elif spec.nargs in (None, 1, "?"): | ||||||
|  |                 result[spec.dest] = typed[0] if len(typed) == 1 else typed | ||||||
|  |             else: | ||||||
|  |                 result[spec.dest] = typed | ||||||
|  |  | ||||||
|  |             if spec.nargs not in ("*", "+"): | ||||||
|  |                 consumed_positional_indicies.add(j) | ||||||
|  |  | ||||||
|  |         if i < len(args): | ||||||
|  |             plural = "s" if len(args[i:]) > 1 else "" | ||||||
|  |             raise CommandArgumentError( | ||||||
|  |                 f"Unexpected positional argument{plural}: {', '.join(args[i:])}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         return i | ||||||
|  |  | ||||||
|  |     def _expand_posix_bundling(self, args: list[str]) -> list[str]: | ||||||
|  |         """Expand POSIX-style bundled arguments into separate arguments.""" | ||||||
|  |         expanded = [] | ||||||
|  |         for token in args: | ||||||
|  |             if token.startswith("-") and not token.startswith("--") and len(token) > 2: | ||||||
|  |                 # POSIX bundle | ||||||
|  |                 # e.g. -abc -> -a -b -c | ||||||
|  |                 for char in token[1:]: | ||||||
|  |                     flag = f"-{char}" | ||||||
|  |                     arg = self._flag_map.get(flag) | ||||||
|  |                     if not arg: | ||||||
|  |                         raise CommandArgumentError(f"Unrecognized option: {flag}") | ||||||
|  |                     expanded.append(flag) | ||||||
|  |             else: | ||||||
|  |                 expanded.append(token) | ||||||
|  |         return expanded | ||||||
|  |  | ||||||
|  |     async def parse_args( | ||||||
|  |         self, args: list[str] | None = None, from_validate: bool = False | ||||||
|  |     ) -> dict[str, Any]: | ||||||
|  |         """Parse Falyx Command arguments.""" | ||||||
|  |         if args is None: | ||||||
|  |             args = [] | ||||||
|  |  | ||||||
|  |         args = self._expand_posix_bundling(args) | ||||||
|  |  | ||||||
|  |         result = {arg.dest: deepcopy(arg.default) for arg in self._arguments} | ||||||
|  |         positional_args = [arg for arg in self._arguments if arg.positional] | ||||||
|  |         consumed_positional_indices: set[int] = set() | ||||||
|  |         consumed_indices: set[int] = set() | ||||||
|  |  | ||||||
|  |         i = 0 | ||||||
|  |         while i < len(args): | ||||||
|  |             token = args[i] | ||||||
|  |             if token in self._keyword: | ||||||
|  |                 spec = self._keyword[token] | ||||||
|  |                 action = spec.action | ||||||
|  |  | ||||||
|  |                 if action == ArgumentAction.HELP: | ||||||
|  |                     if not from_validate: | ||||||
|  |                         self.render_help() | ||||||
|  |                     raise HelpSignal() | ||||||
|  |                 elif action == ArgumentAction.ACTION: | ||||||
|  |                     assert isinstance( | ||||||
|  |                         spec.resolver, BaseAction | ||||||
|  |                     ), "resolver should be an instance of BaseAction" | ||||||
|  |                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||||
|  |                     try: | ||||||
|  |                         typed_values = [ | ||||||
|  |                             coerce_value(value, spec.type) for value in values | ||||||
|  |                         ] | ||||||
|  |                     except ValueError as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Invalid value for '{spec.dest}': {error}" | ||||||
|  |                         ) from error | ||||||
|  |                     try: | ||||||
|  |                         result[spec.dest] = await spec.resolver(*typed_values) | ||||||
|  |                     except Exception as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"[{spec.dest}] Action failed: {error}" | ||||||
|  |                         ) from error | ||||||
|  |                     consumed_indices.update(range(i, new_i)) | ||||||
|  |                     i = new_i | ||||||
|  |                 elif action == ArgumentAction.STORE_TRUE: | ||||||
|  |                     result[spec.dest] = True | ||||||
|  |                     consumed_indices.add(i) | ||||||
|  |                     i += 1 | ||||||
|  |                 elif action == ArgumentAction.STORE_FALSE: | ||||||
|  |                     result[spec.dest] = False | ||||||
|  |                     consumed_indices.add(i) | ||||||
|  |                     i += 1 | ||||||
|  |                 elif action == ArgumentAction.COUNT: | ||||||
|  |                     result[spec.dest] = result.get(spec.dest, 0) + 1 | ||||||
|  |                     consumed_indices.add(i) | ||||||
|  |                     i += 1 | ||||||
|  |                 elif action == ArgumentAction.APPEND: | ||||||
|  |                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||||
|  |                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||||
|  |                     try: | ||||||
|  |                         typed_values = [ | ||||||
|  |                             coerce_value(value, spec.type) for value in values | ||||||
|  |                         ] | ||||||
|  |                     except ValueError as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Invalid value for '{spec.dest}': {error}" | ||||||
|  |                         ) from error | ||||||
|  |                     if spec.nargs is None: | ||||||
|  |                         result[spec.dest].append(spec.type(values[0])) | ||||||
|  |                     else: | ||||||
|  |                         result[spec.dest].append(typed_values) | ||||||
|  |                     consumed_indices.update(range(i, new_i)) | ||||||
|  |                     i = new_i | ||||||
|  |                 elif action == ArgumentAction.EXTEND: | ||||||
|  |                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||||
|  |                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||||
|  |                     try: | ||||||
|  |                         typed_values = [ | ||||||
|  |                             coerce_value(value, spec.type) for value in values | ||||||
|  |                         ] | ||||||
|  |                     except ValueError as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Invalid value for '{spec.dest}': {error}" | ||||||
|  |                         ) from error | ||||||
|  |                     result[spec.dest].extend(typed_values) | ||||||
|  |                     consumed_indices.update(range(i, new_i)) | ||||||
|  |                     i = new_i | ||||||
|  |                 else: | ||||||
|  |                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||||
|  |                     try: | ||||||
|  |                         typed_values = [ | ||||||
|  |                             coerce_value(value, spec.type) for value in values | ||||||
|  |                         ] | ||||||
|  |                     except ValueError as error: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Invalid value for '{spec.dest}': {error}" | ||||||
|  |                         ) from error | ||||||
|  |                     if not typed_values and spec.nargs not in ("*", "?"): | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Expected at least one value for '{spec.dest}'" | ||||||
|  |                         ) | ||||||
|  |                     if ( | ||||||
|  |                         spec.nargs in (None, 1, "?") | ||||||
|  |                         and spec.action != ArgumentAction.APPEND | ||||||
|  |                     ): | ||||||
|  |                         result[spec.dest] = ( | ||||||
|  |                             typed_values[0] if len(typed_values) == 1 else typed_values | ||||||
|  |                         ) | ||||||
|  |                     else: | ||||||
|  |                         result[spec.dest] = typed_values | ||||||
|  |                     consumed_indices.update(range(i, new_i)) | ||||||
|  |                     i = new_i | ||||||
|  |             elif token.startswith("-"): | ||||||
|  |                 # Handle unrecognized option | ||||||
|  |                 raise CommandArgumentError(f"Unrecognized flag: {token}") | ||||||
|  |             else: | ||||||
|  |                 # Get the next flagged argument index if it exists | ||||||
|  |                 next_flagged_index = -1 | ||||||
|  |                 for index, arg in enumerate(args[i:], start=i): | ||||||
|  |                     if arg.startswith("-"): | ||||||
|  |                         next_flagged_index = index | ||||||
|  |                         break | ||||||
|  |                 if next_flagged_index == -1: | ||||||
|  |                     next_flagged_index = len(args) | ||||||
|  |                 args_consumed = await self._consume_all_positional_args( | ||||||
|  |                     args[i:next_flagged_index], | ||||||
|  |                     result, | ||||||
|  |                     positional_args, | ||||||
|  |                     consumed_positional_indices, | ||||||
|  |                 ) | ||||||
|  |                 i += args_consumed | ||||||
|  |  | ||||||
|  |         # Required validation | ||||||
|  |         for spec in self._arguments: | ||||||
|  |             if spec.dest == "help": | ||||||
|  |                 continue | ||||||
|  |             if spec.required and not result.get(spec.dest): | ||||||
|  |                 raise CommandArgumentError(f"Missing required argument: {spec.dest}") | ||||||
|  |  | ||||||
|  |             if spec.choices and result.get(spec.dest) not in spec.choices: | ||||||
|  |                 raise CommandArgumentError( | ||||||
|  |                     f"Invalid value for {spec.dest}: must be one of {spec.choices}" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |             if spec.action == ArgumentAction.ACTION: | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             if isinstance(spec.nargs, int) and spec.nargs > 1: | ||||||
|  |                 assert isinstance( | ||||||
|  |                     result.get(spec.dest), list | ||||||
|  |                 ), f"Invalid value for {spec.dest}: expected a list" | ||||||
|  |                 if not result[spec.dest] and not spec.required: | ||||||
|  |                     continue | ||||||
|  |                 if spec.action == ArgumentAction.APPEND: | ||||||
|  |                     for group in result[spec.dest]: | ||||||
|  |                         if len(group) % spec.nargs != 0: | ||||||
|  |                             raise CommandArgumentError( | ||||||
|  |                                 f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||||
|  |                             ) | ||||||
|  |                 elif spec.action == ArgumentAction.EXTEND: | ||||||
|  |                     if len(result[spec.dest]) % spec.nargs != 0: | ||||||
|  |                         raise CommandArgumentError( | ||||||
|  |                             f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||||
|  |                         ) | ||||||
|  |                 elif len(result[spec.dest]) != spec.nargs: | ||||||
|  |                     raise CommandArgumentError( | ||||||
|  |                         f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}" | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |         result.pop("help", None) | ||||||
|  |         return result | ||||||
|  |  | ||||||
|  |     async def parse_args_split( | ||||||
|  |         self, args: list[str], from_validate: bool = False | ||||||
|  |     ) -> tuple[tuple[Any, ...], dict[str, Any]]: | ||||||
|  |         """ | ||||||
|  |         Returns: | ||||||
|  |             tuple[args, kwargs] - Positional arguments in defined order, | ||||||
|  |             followed by keyword argument mapping. | ||||||
|  |         """ | ||||||
|  |         parsed = await self.parse_args(args, from_validate) | ||||||
|  |         args_list = [] | ||||||
|  |         kwargs_dict = {} | ||||||
|  |         for arg in self._arguments: | ||||||
|  |             if arg.dest == "help": | ||||||
|  |                 continue | ||||||
|  |             if arg.positional: | ||||||
|  |                 args_list.append(parsed[arg.dest]) | ||||||
|  |             else: | ||||||
|  |                 kwargs_dict[arg.dest] = parsed[arg.dest] | ||||||
|  |         return tuple(args_list), kwargs_dict | ||||||
|  |  | ||||||
|  |     def get_options_text(self, plain_text=False) -> str: | ||||||
|  |         # Options | ||||||
|  |         # Add all keyword arguments to the options list | ||||||
|  |         options_list = [] | ||||||
|  |         for arg in self._keyword_list: | ||||||
|  |             choice_text = arg.get_choice_text() | ||||||
|  |             if choice_text: | ||||||
|  |                 options_list.extend([f"[{arg.flags[0]} {choice_text}]"]) | ||||||
|  |             else: | ||||||
|  |                 options_list.extend([f"[{arg.flags[0]}]"]) | ||||||
|  |  | ||||||
|  |         # Add positional arguments to the options list | ||||||
|  |         for arg in self._positional.values(): | ||||||
|  |             choice_text = arg.get_choice_text() | ||||||
|  |             if isinstance(arg.nargs, int): | ||||||
|  |                 choice_text = " ".join([choice_text] * arg.nargs) | ||||||
|  |             if plain_text: | ||||||
|  |                 options_list.append(choice_text) | ||||||
|  |             else: | ||||||
|  |                 options_list.append(escape(choice_text)) | ||||||
|  |  | ||||||
|  |         return " ".join(options_list) | ||||||
|  |  | ||||||
|  |     def get_command_keys_text(self, plain_text=False) -> str: | ||||||
|  |         if plain_text: | ||||||
|  |             command_keys = " | ".join( | ||||||
|  |                 [f"{self.command_key}"] + [f"{alias}" for alias in self.aliases] | ||||||
|  |             ) | ||||||
|  |         else: | ||||||
|  |             command_keys = " | ".join( | ||||||
|  |                 [f"[{self.command_style}]{self.command_key}[/{self.command_style}]"] | ||||||
|  |                 + [ | ||||||
|  |                     f"[{self.command_style}]{alias}[/{self.command_style}]" | ||||||
|  |                     for alias in self.aliases | ||||||
|  |                 ] | ||||||
|  |             ) | ||||||
|  |         return command_keys | ||||||
|  |  | ||||||
|  |     def get_usage(self, plain_text=False) -> str: | ||||||
|  |         """Get the usage text for the command.""" | ||||||
|  |         command_keys = self.get_command_keys_text(plain_text) | ||||||
|  |         options_text = self.get_options_text(plain_text) | ||||||
|  |         if options_text: | ||||||
|  |             return f"{command_keys} {options_text}" | ||||||
|  |         return command_keys | ||||||
|  |  | ||||||
|  |     def render_help(self) -> None: | ||||||
|  |         usage = self.get_usage() | ||||||
|  |         self.console.print(f"[bold]usage: {usage}[/bold]\n") | ||||||
|  |  | ||||||
|  |         # Description | ||||||
|  |         if self.help_text: | ||||||
|  |             self.console.print(self.help_text + "\n") | ||||||
|  |  | ||||||
|  |         # Arguments | ||||||
|  |         if self._arguments: | ||||||
|  |             if self._positional: | ||||||
|  |                 self.console.print("[bold]positional:[/bold]") | ||||||
|  |                 for arg in self._positional.values(): | ||||||
|  |                     flags = arg.get_positional_text() | ||||||
|  |                     arg_line = Text(f"  {flags:<30} ") | ||||||
|  |                     help_text = arg.help or "" | ||||||
|  |                     arg_line.append(help_text) | ||||||
|  |                     self.console.print(arg_line) | ||||||
|  |             self.console.print("[bold]options:[/bold]") | ||||||
|  |             for arg in self._keyword_list: | ||||||
|  |                 flags = ", ".join(arg.flags) | ||||||
|  |                 flags_choice = f"{flags} {arg.get_choice_text()}" | ||||||
|  |                 arg_line = Text(f"  {flags_choice:<30} ") | ||||||
|  |                 help_text = arg.help or "" | ||||||
|  |                 arg_line.append(help_text) | ||||||
|  |                 self.console.print(arg_line) | ||||||
|  |  | ||||||
|  |         # Epilog | ||||||
|  |         if self.help_epilog: | ||||||
|  |             self.console.print("\n" + self.help_epilog, style="dim") | ||||||
|  |  | ||||||
|  |     def __eq__(self, other: object) -> bool: | ||||||
|  |         if not isinstance(other, CommandArgumentParser): | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         def sorted_args(parser): | ||||||
|  |             return sorted(parser._arguments, key=lambda a: a.dest) | ||||||
|  |  | ||||||
|  |         return sorted_args(self) == sorted_args(other) | ||||||
|  |  | ||||||
|  |     def __hash__(self) -> int: | ||||||
|  |         return hash(tuple(sorted(self._arguments, key=lambda a: a.dest))) | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         positional = sum(arg.positional for arg in self._arguments) | ||||||
|  |         required = sum(arg.required for arg in self._arguments) | ||||||
|  |         return ( | ||||||
|  |             f"CommandArgumentParser(args={len(self._arguments)}, " | ||||||
|  |             f"flags={len(self._flag_map)}, keywords={len(self._keyword)}, " | ||||||
|  |             f"positional={positional}, required={required})" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     def __repr__(self) -> str: | ||||||
|  |         return str(self) | ||||||
							
								
								
									
										272
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,272 @@ | |||||||
|  | # 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: | ||||||
|  |     parser = ArgumentParser( | ||||||
|  |         prog=prog, | ||||||
|  |         usage=usage, | ||||||
|  |         description=description, | ||||||
|  |         epilog=epilog, | ||||||
|  |         parents=parents if parents else [], | ||||||
|  |         prefix_chars=prefix_chars, | ||||||
|  |         fromfile_prefix_chars=fromfile_prefix_chars, | ||||||
|  |         argument_default=argument_default, | ||||||
|  |         conflict_handler=conflict_handler, | ||||||
|  |         add_help=add_help, | ||||||
|  |         allow_abbrev=allow_abbrev, | ||||||
|  |         exit_on_error=exit_on_error, | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--never-prompt", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Run in non-interactive mode with all prompts bypassed.", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "--debug-hooks", | ||||||
|  |         action="store_true", | ||||||
|  |         help="Enable default lifecycle debug logging", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument("--version", action="store_true", help="Show Falyx version") | ||||||
|  |     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 action for the given parser.""" | ||||||
|  |     if not isinstance(parser, ArgumentParser): | ||||||
|  |         raise TypeError("parser must be an instance of ArgumentParser") | ||||||
|  |     subparsers = parser.add_subparsers( | ||||||
|  |         title=title, | ||||||
|  |         description=description, | ||||||
|  |         metavar="COMMAND", | ||||||
|  |         dest="command", | ||||||
|  |     ) | ||||||
|  |     return subparsers | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_arg_parsers( | ||||||
|  |     prog: str | None = "falyx", | ||||||
|  |     usage: str | None = None, | ||||||
|  |     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||||
|  |     epilog: ( | ||||||
|  |         str | None | ||||||
|  |     ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", | ||||||
|  |     parents: Sequence[ArgumentParser] | None = None, | ||||||
|  |     prefix_chars: str = "-", | ||||||
|  |     fromfile_prefix_chars: str | None = None, | ||||||
|  |     argument_default: Any = None, | ||||||
|  |     conflict_handler: str = "error", | ||||||
|  |     add_help: bool = True, | ||||||
|  |     allow_abbrev: bool = True, | ||||||
|  |     exit_on_error: bool = True, | ||||||
|  |     commands: dict[str, Command] | None = None, | ||||||
|  |     root_parser: ArgumentParser | None = None, | ||||||
|  |     subparsers: _SubParsersAction | None = None, | ||||||
|  | ) -> FalyxParsers: | ||||||
|  |     """Returns the argument parser for the CLI.""" | ||||||
|  |     if root_parser is None: | ||||||
|  |         parser = get_root_parser( | ||||||
|  |             prog=prog, | ||||||
|  |             usage=usage, | ||||||
|  |             description=description, | ||||||
|  |             epilog=epilog, | ||||||
|  |             parents=parents, | ||||||
|  |             prefix_chars=prefix_chars, | ||||||
|  |             fromfile_prefix_chars=fromfile_prefix_chars, | ||||||
|  |             argument_default=argument_default, | ||||||
|  |             conflict_handler=conflict_handler, | ||||||
|  |             add_help=add_help, | ||||||
|  |             allow_abbrev=allow_abbrev, | ||||||
|  |             exit_on_error=exit_on_error, | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         if not isinstance(root_parser, ArgumentParser): | ||||||
|  |             raise TypeError("root_parser must be an instance of ArgumentParser") | ||||||
|  |         parser = root_parser | ||||||
|  |  | ||||||
|  |     if subparsers is None: | ||||||
|  |         subparsers = get_subparsers(parser) | ||||||
|  |     if not isinstance(subparsers, _SubParsersAction): | ||||||
|  |         raise TypeError("subparsers must be an instance of _SubParsersAction") | ||||||
|  |  | ||||||
|  |     run_description = ["Run a command by its key or alias.\n"] | ||||||
|  |     run_description.append("commands:") | ||||||
|  |     if isinstance(commands, dict): | ||||||
|  |         for command in commands.values(): | ||||||
|  |             run_description.append(command.usage) | ||||||
|  |             command_description = command.description or command.help_text | ||||||
|  |             run_description.append(f"{' '*24}{command_description}") | ||||||
|  |     run_epilog = ( | ||||||
|  |         "Tip: Use 'falyx run ?[COMMAND]' to preview commands by their key or alias." | ||||||
|  |     ) | ||||||
|  |     run_parser = subparsers.add_parser( | ||||||
|  |         "run", | ||||||
|  |         help="Run a specific command", | ||||||
|  |         description="\n".join(run_description), | ||||||
|  |         epilog=run_epilog, | ||||||
|  |         formatter_class=RawDescriptionHelpFormatter, | ||||||
|  |     ) | ||||||
|  |     run_parser.add_argument( | ||||||
|  |         "name", help="Run a command by its key or alias", metavar="COMMAND" | ||||||
|  |     ) | ||||||
|  |     run_parser.add_argument( | ||||||
|  |         "--summary", | ||||||
|  |         action="store_true", | ||||||
|  |         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="Show the Falyx version") | ||||||
|  |  | ||||||
|  |     return FalyxParsers( | ||||||
|  |         root=parser, | ||||||
|  |         subparsers=subparsers, | ||||||
|  |         run=run_parser, | ||||||
|  |         run_all=run_all_parser, | ||||||
|  |         preview=preview_parser, | ||||||
|  |         list=list_parser, | ||||||
|  |         version=version_parser, | ||||||
|  |     ) | ||||||
							
								
								
									
										81
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | import inspect | ||||||
|  | from typing import Any, Callable | ||||||
|  |  | ||||||
|  | from falyx.logger import logger | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def infer_args_from_func( | ||||||
|  |     func: Callable[[Any], Any] | None, | ||||||
|  |     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||||
|  | ) -> list[dict[str, Any]]: | ||||||
|  |     """ | ||||||
|  |     Infer argument definitions from a callable's signature. | ||||||
|  |     Returns a list of kwargs suitable for CommandArgumentParser.add_argument. | ||||||
|  |     """ | ||||||
|  |     if not callable(func): | ||||||
|  |         logger.debug("Provided argument is not callable: %s", func) | ||||||
|  |         return [] | ||||||
|  |     arg_metadata = arg_metadata or {} | ||||||
|  |     signature = inspect.signature(func) | ||||||
|  |     arg_defs = [] | ||||||
|  |  | ||||||
|  |     for name, param in signature.parameters.items(): | ||||||
|  |         raw_metadata = arg_metadata.get(name, {}) | ||||||
|  |         metadata = ( | ||||||
|  |             {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata | ||||||
|  |         ) | ||||||
|  |         if param.kind not in ( | ||||||
|  |             inspect.Parameter.POSITIONAL_ONLY, | ||||||
|  |             inspect.Parameter.POSITIONAL_OR_KEYWORD, | ||||||
|  |             inspect.Parameter.KEYWORD_ONLY, | ||||||
|  |         ): | ||||||
|  |             continue | ||||||
|  |  | ||||||
|  |         if metadata.get("type"): | ||||||
|  |             arg_type = metadata["type"] | ||||||
|  |         else: | ||||||
|  |             arg_type = ( | ||||||
|  |                 param.annotation | ||||||
|  |                 if param.annotation is not inspect.Parameter.empty | ||||||
|  |                 else str | ||||||
|  |             ) | ||||||
|  |             if isinstance(arg_type, str): | ||||||
|  |                 arg_type = str | ||||||
|  |         default = param.default if param.default is not inspect.Parameter.empty else None | ||||||
|  |         is_required = param.default is inspect.Parameter.empty | ||||||
|  |         if is_required: | ||||||
|  |             flags = [f"{name.replace('_', '-')}"] | ||||||
|  |         else: | ||||||
|  |             flags = [f"--{name.replace('_', '-')}"] | ||||||
|  |         action = "store" | ||||||
|  |         nargs: int | str | None = None | ||||||
|  |  | ||||||
|  |         if arg_type is bool: | ||||||
|  |             if param.default is False: | ||||||
|  |                 action = "store_true" | ||||||
|  |             else: | ||||||
|  |                 action = "store_false" | ||||||
|  |  | ||||||
|  |         if arg_type is list: | ||||||
|  |             action = "append" | ||||||
|  |             if is_required: | ||||||
|  |                 nargs = "+" | ||||||
|  |             else: | ||||||
|  |                 nargs = "*" | ||||||
|  |  | ||||||
|  |         arg_defs.append( | ||||||
|  |             { | ||||||
|  |                 "flags": flags, | ||||||
|  |                 "dest": name, | ||||||
|  |                 "type": arg_type, | ||||||
|  |                 "default": default, | ||||||
|  |                 "required": is_required, | ||||||
|  |                 "nargs": nargs, | ||||||
|  |                 "action": action, | ||||||
|  |                 "help": metadata.get("help", ""), | ||||||
|  |                 "choices": metadata.get("choices"), | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     return arg_defs | ||||||
							
								
								
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | import types | ||||||
|  | from datetime import datetime | ||||||
|  | from enum import EnumMeta | ||||||
|  | from typing import Any, Literal, Union, get_args, get_origin | ||||||
|  |  | ||||||
|  | from dateutil import parser as date_parser | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  | from falyx.logger import logger | ||||||
|  | from falyx.parser.signature import infer_args_from_func | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def coerce_bool(value: str) -> bool: | ||||||
|  |     if isinstance(value, bool): | ||||||
|  |         return value | ||||||
|  |     value = value.strip().lower() | ||||||
|  |     if value in {"true", "1", "yes", "on"}: | ||||||
|  |         return True | ||||||
|  |     elif value in {"false", "0", "no", "off"}: | ||||||
|  |         return False | ||||||
|  |     return bool(value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: | ||||||
|  |     if isinstance(value, enum_type): | ||||||
|  |         return value | ||||||
|  |  | ||||||
|  |     if isinstance(value, str): | ||||||
|  |         try: | ||||||
|  |             return enum_type[value] | ||||||
|  |         except KeyError: | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     base_type = type(next(iter(enum_type)).value) | ||||||
|  |     print(base_type) | ||||||
|  |     try: | ||||||
|  |         coerced_value = base_type(value) | ||||||
|  |         return enum_type(coerced_value) | ||||||
|  |     except (ValueError, TypeError): | ||||||
|  |         raise ValueError(f"Value '{value}' could not be coerced to enum type {enum_type}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def coerce_value(value: str, target_type: type) -> Any: | ||||||
|  |     origin = get_origin(target_type) | ||||||
|  |     args = get_args(target_type) | ||||||
|  |  | ||||||
|  |     if origin is Literal: | ||||||
|  |         if value not in args: | ||||||
|  |             raise ValueError( | ||||||
|  |                 f"Value '{value}' is not a valid literal for type {target_type}" | ||||||
|  |             ) | ||||||
|  |         return value | ||||||
|  |  | ||||||
|  |     if isinstance(target_type, types.UnionType) or get_origin(target_type) is Union: | ||||||
|  |         for arg in args: | ||||||
|  |             try: | ||||||
|  |                 return coerce_value(value, arg) | ||||||
|  |             except Exception: | ||||||
|  |                 continue | ||||||
|  |         raise ValueError(f"Value '{value}' could not be coerced to any of {args!r}") | ||||||
|  |  | ||||||
|  |     if isinstance(target_type, EnumMeta): | ||||||
|  |         return coerce_enum(value, target_type) | ||||||
|  |  | ||||||
|  |     if target_type is bool: | ||||||
|  |         return coerce_bool(value) | ||||||
|  |  | ||||||
|  |     if target_type is datetime: | ||||||
|  |         try: | ||||||
|  |             return date_parser.parse(value) | ||||||
|  |         except ValueError as e: | ||||||
|  |             raise ValueError(f"Value '{value}' could not be parsed as a datetime") from e | ||||||
|  |  | ||||||
|  |     return target_type(value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def same_argument_definitions( | ||||||
|  |     actions: list[Any], | ||||||
|  |     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||||
|  | ) -> list[dict[str, Any]] | None: | ||||||
|  |  | ||||||
|  |     arg_sets = [] | ||||||
|  |     for action in actions: | ||||||
|  |         if isinstance(action, BaseAction): | ||||||
|  |             infer_target, _ = action.get_infer_target() | ||||||
|  |             arg_defs = infer_args_from_func(infer_target, arg_metadata) | ||||||
|  |         elif callable(action): | ||||||
|  |             arg_defs = infer_args_from_func(action, arg_metadata) | ||||||
|  |         else: | ||||||
|  |             logger.debug("Auto args unsupported for action: %s", action) | ||||||
|  |             return None | ||||||
|  |         arg_sets.append(arg_defs) | ||||||
|  |  | ||||||
|  |     first = arg_sets[0] | ||||||
|  |     if all(arg_set == first for arg_set in arg_sets[1:]): | ||||||
|  |         return first | ||||||
|  |     return None | ||||||
							
								
								
									
										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" | ||||||
							
								
								
									
										17
									
								
								falyx/protocols.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								falyx/protocols.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
|  | """protocols.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Any, Awaitable, Protocol, runtime_checkable | ||||||
|  |  | ||||||
|  | from falyx.action.base import BaseAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @runtime_checkable | ||||||
|  | class ActionFactoryProtocol(Protocol): | ||||||
|  |     async def __call__(self, *args: Any, **kwargs: Any) -> 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""" | """retry.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import asyncio | import asyncio | ||||||
|  | import random | ||||||
|  |  | ||||||
| from pydantic import BaseModel, Field | from pydantic import BaseModel, Field | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.utils import logger | from falyx.logger import logger | ||||||
|  |  | ||||||
|  |  | ||||||
| class RetryPolicy(BaseModel): | class RetryPolicy(BaseModel): | ||||||
|  |     """RetryPolicy""" | ||||||
|  |  | ||||||
|     max_retries: int = Field(default=3, ge=0) |     max_retries: int = Field(default=3, ge=0) | ||||||
|     delay: float = Field(default=1.0, ge=0.0) |     delay: float = Field(default=1.0, ge=0.0) | ||||||
|     backoff: float = Field(default=2.0, ge=1.0) |     backoff: float = Field(default=2.0, ge=1.0) | ||||||
|  |     jitter: float = Field(default=0.0, ge=0.0) | ||||||
|     enabled: bool = False |     enabled: bool = False | ||||||
|  |  | ||||||
|  |     def enable_policy(self) -> None: | ||||||
|  |         """ | ||||||
|  |         Enable the retry policy. | ||||||
|  |         :return: None | ||||||
|  |         """ | ||||||
|  |         self.enabled = True | ||||||
|  |  | ||||||
|     def is_active(self) -> bool: |     def is_active(self) -> bool: | ||||||
|         """ |         """ | ||||||
|         Check if the retry policy is active. |         Check if the retry policy is active. | ||||||
| @@ -22,18 +36,28 @@ class RetryPolicy(BaseModel): | |||||||
|  |  | ||||||
|  |  | ||||||
| class RetryHandler: | 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 |         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.enabled = True | ||||||
|         self.policy.max_retries = max_retries |         self.policy.max_retries = max_retries | ||||||
|         self.policy.delay = delay |         self.policy.delay = delay | ||||||
|         self.policy.backoff = backoff |         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 |         from falyx.action import Action | ||||||
|  |  | ||||||
|         name = context.name |         name = context.name | ||||||
|         error = context.exception |         error = context.exception | ||||||
|         target = context.action |         target = context.action | ||||||
| @@ -43,36 +67,55 @@ class RetryHandler: | |||||||
|         last_error = error |         last_error = error | ||||||
|  |  | ||||||
|         if not target: |         if not target: | ||||||
|             logger.warning(f"[{name}] ⚠️ No action target. Cannot retry.") |             logger.warning("[%s] No action target. Cannot retry.", name) | ||||||
|             return |             return None | ||||||
|  |  | ||||||
|         if not isinstance(target, Action): |         if not isinstance(target, Action): | ||||||
|             logger.warning(f"[{name}] ❌ RetryHandler only supports only supports Action objects.") |             logger.warning( | ||||||
|             return |                 "[%s] RetryHandler only supports only supports Action objects.", name | ||||||
|  |             ) | ||||||
|  |             return None | ||||||
|  |  | ||||||
|         if not getattr(target, "is_retryable", False): |         if not getattr(target, "is_retryable", False): | ||||||
|             logger.warning(f"[{name}] ❌ Not retryable.") |             logger.warning("[%s] Not retryable.", name) | ||||||
|             return |             return None | ||||||
|  |  | ||||||
|         if not self.policy.enabled: |         if not self.policy.enabled: | ||||||
|             logger.warning(f"[{name}] ❌ Retry policy is disabled.") |             logger.warning("[%s] Retry policy is disabled.", name) | ||||||
|             return |             return None | ||||||
|  |  | ||||||
|         while retries_done < self.policy.max_retries: |         while retries_done < self.policy.max_retries: | ||||||
|             retries_done += 1 |             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) |             await asyncio.sleep(current_delay) | ||||||
|             try: |             try: | ||||||
|                 result = await target.action(*context.args, **context.kwargs) |                 result = await target.action(*context.args, **context.kwargs) | ||||||
|                 context.result = result |                 context.result = result | ||||||
|                 context.exception = None |                 context.exception = None | ||||||
|                 logger.info(f"[{name}] ✅ Retry succeeded on attempt {retries_done}.") |                 logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done) | ||||||
|                 return |                 return None | ||||||
|             except Exception as retry_error: |             except Exception as retry_error: | ||||||
|                 last_error = retry_error |                 last_error = retry_error | ||||||
|                 current_delay *= self.policy.backoff |                 current_delay *= self.policy.backoff | ||||||
|                 logger.warning(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 |         context.exception = last_error | ||||||
|         logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") |         logger.error("[%s] All %s retries failed.", name, self.policy.max_retries) | ||||||
|         return |  | ||||||
|   | |||||||
							
								
								
									
										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 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) | ||||||
							
								
								
									
										504
									
								
								falyx/selection.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										504
									
								
								falyx/selection.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,504 @@ | |||||||
|  | # 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.console import Console | ||||||
|  | from rich.markup import escape | ||||||
|  | from rich.table import Table | ||||||
|  |  | ||||||
|  | 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 = "", | ||||||
|  |     console: Console | None = None, | ||||||
|  |     prompt_session: PromptSession | None = None, | ||||||
|  |     prompt_message: str = "Select an option > ", | ||||||
|  |     show_table: bool = True, | ||||||
|  |     number_selections: int | str = 1, | ||||||
|  |     separator: str = ",", | ||||||
|  |     allow_duplicates: bool = False, | ||||||
|  |     cancel_key: str = "", | ||||||
|  | ) -> int | list[int]: | ||||||
|  |     prompt_session = prompt_session or PromptSession() | ||||||
|  |     console = console or Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |     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 = "", | ||||||
|  |     console: Console | None = None, | ||||||
|  |     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() | ||||||
|  |     console = console or Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |     if show_table: | ||||||
|  |         console.print(table, justify="center") | ||||||
|  |  | ||||||
|  |     selected = await prompt_session.prompt_async( | ||||||
|  |         message=prompt_message, | ||||||
|  |         validator=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], | ||||||
|  |     *, | ||||||
|  |     console: Console | None = None, | ||||||
|  |     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() | ||||||
|  |     console = console or Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |     selection_index = await prompt_for_index( | ||||||
|  |         len(selections) - 1, | ||||||
|  |         table, | ||||||
|  |         default_selection=default_selection, | ||||||
|  |         console=console, | ||||||
|  |         prompt_session=prompt_session, | ||||||
|  |         prompt_message=prompt_message, | ||||||
|  |         number_selections=number_selections, | ||||||
|  |         separator=separator, | ||||||
|  |         allow_duplicates=allow_duplicates, | ||||||
|  |         cancel_key=cancel_key, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     if isinstance(selection_index, list): | ||||||
|  |         return [selections[i] for i in selection_index] | ||||||
|  |     return selections[selection_index] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def select_key_from_dict( | ||||||
|  |     selections: dict[str, SelectionOption], | ||||||
|  |     table: Table, | ||||||
|  |     *, | ||||||
|  |     console: Console | None = None, | ||||||
|  |     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 = console or Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |     console.print(table, justify="center") | ||||||
|  |  | ||||||
|  |     return await prompt_for_selection( | ||||||
|  |         selections.keys(), | ||||||
|  |         table, | ||||||
|  |         default_selection=default_selection, | ||||||
|  |         console=console, | ||||||
|  |         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, | ||||||
|  |     *, | ||||||
|  |     console: Console | None = None, | ||||||
|  |     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 = console or Console(color_system="truecolor") | ||||||
|  |  | ||||||
|  |     console.print(table, justify="center") | ||||||
|  |  | ||||||
|  |     selection_key = await prompt_for_selection( | ||||||
|  |         selections.keys(), | ||||||
|  |         table, | ||||||
|  |         default_selection=default_selection, | ||||||
|  |         console=console, | ||||||
|  |         prompt_session=prompt_session, | ||||||
|  |         prompt_message=prompt_message, | ||||||
|  |         number_selections=number_selections, | ||||||
|  |         separator=separator, | ||||||
|  |         allow_duplicates=allow_duplicates, | ||||||
|  |         cancel_key=cancel_key, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     if isinstance(selection_key, list): | ||||||
|  |         return [selections[key].value for key in selection_key] | ||||||
|  |     return selections[selection_key].value | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def get_selection_from_dict_menu( | ||||||
|  |     title: str, | ||||||
|  |     selections: dict[str, SelectionOption], | ||||||
|  |     *, | ||||||
|  |     console: Console | None = None, | ||||||
|  |     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, | ||||||
|  |         console=console, | ||||||
|  |         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, | ||||||
|  |     ) | ||||||
							
								
								
									
										38
									
								
								falyx/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								falyx/signals.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | # 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 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) |     console.print("Hello!", style=NordColors.NORD12bu) | ||||||
|     # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles |     # => Renders "Hello!" in #D08770 (Nord12) plus bold and underline styles | ||||||
| """ | """ | ||||||
|  |  | ||||||
| import re | import re | ||||||
| from difflib import get_close_matches | from difflib import get_close_matches | ||||||
|  |  | ||||||
| @@ -82,14 +83,17 @@ class ColorsMeta(type): | |||||||
|         except AttributeError: |         except AttributeError: | ||||||
|             error_msg = [f"'{cls.__name__}' has no color named '{base}'."] |             error_msg = [f"'{cls.__name__}' has no color named '{base}'."] | ||||||
|             valid_bases = [ |             valid_bases = [ | ||||||
|                 key for key, val in cls.__dict__.items() if isinstance(val, str) and |                 key | ||||||
|                 not key.startswith("__") |                 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) |             suggestions = get_close_matches(base, valid_bases, n=1, cutoff=0.5) | ||||||
|             if suggestions: |             if suggestions: | ||||||
|                 error_msg.append(f"Did you mean '{suggestions[0]}'?") |                 error_msg.append(f"Did you mean '{suggestions[0]}'?") | ||||||
|             if valid_bases: |             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 |             raise AttributeError(" ".join(error_msg)) from None | ||||||
|  |  | ||||||
|         if not isinstance(color_value, str): |         if not isinstance(color_value, str): | ||||||
| @@ -105,7 +109,9 @@ class ColorsMeta(type): | |||||||
|             if mapped_style: |             if mapped_style: | ||||||
|                 styles.append(mapped_style) |                 styles.append(mapped_style) | ||||||
|             else: |             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} |         order = {"b": 1, "i": 2, "u": 3, "d": 4, "r": 5, "s": 6} | ||||||
|         styles_sorted = sorted(styles, key=lambda s: order[s[0]]) |         styles_sorted = sorted(styles, key=lambda s: order[s[0]]) | ||||||
| @@ -133,7 +139,6 @@ class OneColors(metaclass=ColorsMeta): | |||||||
|     BLUE = "#61AFEF" |     BLUE = "#61AFEF" | ||||||
|     MAGENTA = "#C678DD" |     MAGENTA = "#C678DD" | ||||||
|  |  | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def as_dict(cls): |     def as_dict(cls): | ||||||
|         """ |         """ | ||||||
| @@ -143,10 +148,10 @@ class OneColors(metaclass=ColorsMeta): | |||||||
|         return { |         return { | ||||||
|             attr: getattr(cls, attr) |             attr: getattr(cls, attr) | ||||||
|             for attr in dir(cls) |             for attr in dir(cls) | ||||||
|             if not callable(getattr(cls, attr)) and |             if not callable(getattr(cls, attr)) and not attr.startswith("__") | ||||||
|             not attr.startswith("__") |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class NordColors(metaclass=ColorsMeta): | class NordColors(metaclass=ColorsMeta): | ||||||
|     """ |     """ | ||||||
|     Defines the Nord color palette as class attributes. |     Defines the Nord color palette as class attributes. | ||||||
| @@ -215,19 +220,19 @@ class NordColors(metaclass=ColorsMeta): | |||||||
|         return { |         return { | ||||||
|             attr: getattr(cls, attr) |             attr: getattr(cls, attr) | ||||||
|             for attr in dir(cls) |             for attr in dir(cls) | ||||||
|             if attr.startswith("NORD") and |             if attr.startswith("NORD") and not callable(getattr(cls, attr)) | ||||||
|             not callable(getattr(cls, attr)) |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def aliases(cls): |     def aliases(cls): | ||||||
|         """ |         """ | ||||||
|         Returns a dictionary of *all* other aliases  |         Returns a dictionary of *all* other aliases | ||||||
|         (Polar Night, Snow Storm, Frost, Aurora). |         (Polar Night, Snow Storm, Frost, Aurora). | ||||||
|         """ |         """ | ||||||
|         skip_prefixes = ("NORD", "__") |         skip_prefixes = ("NORD", "__") | ||||||
|         alias_names = [ |         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) |             if not any(attr.startswith(sp) for sp in skip_prefixes) | ||||||
|             and not callable(getattr(cls, attr)) |             and not callable(getattr(cls, attr)) | ||||||
|         ] |         ] | ||||||
| @@ -264,7 +269,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "blink2": Style(blink2=True), |     "blink2": Style(blink2=True), | ||||||
|     "reverse": Style(reverse=True), |     "reverse": Style(reverse=True), | ||||||
|     "strike": Style(strike=True), |     "strike": Style(strike=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Basic color names mapped to Nord |     # Basic color names mapped to Nord | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -277,7 +281,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "cyan": Style(color=NordColors.CYAN), |     "cyan": Style(color=NordColors.CYAN), | ||||||
|     "blue": Style(color=NordColors.BLUE), |     "blue": Style(color=NordColors.BLUE), | ||||||
|     "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), |     "white": Style(color=NordColors.SNOW_STORM_BRIGHTEST), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Inspect |     # Inspect | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -292,14 +295,12 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "inspect.help": Style(color=NordColors.FROST_ICE), |     "inspect.help": Style(color=NordColors.FROST_ICE), | ||||||
|     "inspect.doc": Style(dim=True), |     "inspect.doc": Style(dim=True), | ||||||
|     "inspect.value.border": Style(color=NordColors.GREEN), |     "inspect.value.border": Style(color=NordColors.GREEN), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Live / Layout |     # Live / Layout | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     "live.ellipsis": Style(bold=True, color=NordColors.RED), |     "live.ellipsis": Style(bold=True, color=NordColors.RED), | ||||||
|     "layout.tree.row": Style(dim=False, color=NordColors.RED), |     "layout.tree.row": Style(dim=False, color=NordColors.RED), | ||||||
|     "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), |     "layout.tree.column": Style(dim=False, color=NordColors.FROST_DEEP), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Logging |     # Logging | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -314,7 +315,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "log.time": Style(color=NordColors.FROST_ICE, dim=True), |     "log.time": Style(color=NordColors.FROST_ICE, dim=True), | ||||||
|     "log.message": Style.null(), |     "log.message": Style.null(), | ||||||
|     "log.path": Style(dim=True), |     "log.path": Style(dim=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Python repr |     # Python repr | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -340,18 +340,18 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "repr.bool_true": Style(color=NordColors.GREEN, italic=True), |     "repr.bool_true": Style(color=NordColors.GREEN, italic=True), | ||||||
|     "repr.bool_false": Style(color=NordColors.RED, italic=True), |     "repr.bool_false": Style(color=NordColors.RED, italic=True), | ||||||
|     "repr.none": Style(color=NordColors.PURPLE, 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.uuid": Style(color=NordColors.YELLOW, bold=False), | ||||||
|     "repr.call": Style(color=NordColors.PURPLE, bold=True), |     "repr.call": Style(color=NordColors.PURPLE, bold=True), | ||||||
|     "repr.path": Style(color=NordColors.PURPLE), |     "repr.path": Style(color=NordColors.PURPLE), | ||||||
|     "repr.filename": Style(color=NordColors.PURPLE), |     "repr.filename": Style(color=NordColors.PURPLE), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Rule |     # Rule | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     "rule.line": Style(color=NordColors.GREEN), |     "rule.line": Style(color=NordColors.GREEN), | ||||||
|     "rule.text": Style.null(), |     "rule.text": Style.null(), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # JSON |     # JSON | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -362,7 +362,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), |     "json.number": Style(color=NordColors.FROST_ICE, bold=True, italic=False), | ||||||
|     "json.str": Style(color=NordColors.GREEN, italic=False, bold=False), |     "json.str": Style(color=NordColors.GREEN, italic=False, bold=False), | ||||||
|     "json.key": Style(color=NordColors.FROST_ICE, bold=True), |     "json.key": Style(color=NordColors.FROST_ICE, bold=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Prompt |     # Prompt | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -371,12 +370,10 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "prompt.default": Style(color=NordColors.FROST_ICE, bold=True), |     "prompt.default": Style(color=NordColors.FROST_ICE, bold=True), | ||||||
|     "prompt.invalid": Style(color=NordColors.RED), |     "prompt.invalid": Style(color=NordColors.RED), | ||||||
|     "prompt.invalid.choice": Style(color=NordColors.RED), |     "prompt.invalid.choice": Style(color=NordColors.RED), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Pretty |     # Pretty | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     "pretty": Style.null(), |     "pretty": Style.null(), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Scope |     # Scope | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -384,7 +381,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "scope.key": Style(color=NordColors.YELLOW, italic=True), |     "scope.key": Style(color=NordColors.YELLOW, italic=True), | ||||||
|     "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), |     "scope.key.special": Style(color=NordColors.YELLOW, italic=True, dim=True), | ||||||
|     "scope.equals": Style(color=NordColors.RED), |     "scope.equals": Style(color=NordColors.RED), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Table |     # Table | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -393,7 +389,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "table.cell": Style.null(), |     "table.cell": Style.null(), | ||||||
|     "table.title": Style(italic=True), |     "table.title": Style(italic=True), | ||||||
|     "table.caption": Style(italic=True, dim=True), |     "table.caption": Style(italic=True, dim=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Traceback |     # Traceback | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -405,7 +400,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "traceback.exc_type": Style(color=NordColors.RED, bold=True), |     "traceback.exc_type": Style(color=NordColors.RED, bold=True), | ||||||
|     "traceback.exc_value": Style.null(), |     "traceback.exc_value": Style.null(), | ||||||
|     "traceback.offset": Style(color=NordColors.RED, bold=True), |     "traceback.offset": Style(color=NordColors.RED, bold=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Progress bars |     # Progress bars | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -423,13 +417,11 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "progress.data.speed": Style(color=NordColors.RED), |     "progress.data.speed": Style(color=NordColors.RED), | ||||||
|     "progress.spinner": Style(color=NordColors.GREEN), |     "progress.spinner": Style(color=NordColors.GREEN), | ||||||
|     "status.spinner": Style(color=NordColors.GREEN), |     "status.spinner": Style(color=NordColors.GREEN), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Tree |     # Tree | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     "tree": Style(), |     "tree": Style(), | ||||||
|     "tree.line": Style(), |     "tree.line": Style(), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # Markdown |     # Markdown | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -438,8 +430,12 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "markdown.em": Style(italic=True), |     "markdown.em": Style(italic=True), | ||||||
|     "markdown.emph": Style(italic=True),  # For commonmark compatibility |     "markdown.emph": Style(italic=True),  # For commonmark compatibility | ||||||
|     "markdown.strong": Style(bold=True), |     "markdown.strong": Style(bold=True), | ||||||
|     "markdown.code": Style(bold=True, color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), |     "markdown.code": Style( | ||||||
|     "markdown.code_block": Style(color=NordColors.FROST_ICE, bgcolor=NordColors.POLAR_NIGHT_ORIGIN), |         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.block_quote": Style(color=NordColors.PURPLE), | ||||||
|     "markdown.list": Style(color=NordColors.FROST_ICE), |     "markdown.list": Style(color=NordColors.FROST_ICE), | ||||||
|     "markdown.item": Style(), |     "markdown.item": Style(), | ||||||
| @@ -457,7 +453,6 @@ NORD_THEME_STYLES: dict[str, Style] = { | |||||||
|     "markdown.link": Style(color=NordColors.FROST_ICE), |     "markdown.link": Style(color=NordColors.FROST_ICE), | ||||||
|     "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), |     "markdown.link_url": Style(color=NordColors.FROST_SKY, underline=True), | ||||||
|     "markdown.s": Style(strike=True), |     "markdown.s": Style(strike=True), | ||||||
|  |  | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
|     # ISO8601 |     # ISO8601 | ||||||
|     # --------------------------------------------------------------- |     # --------------------------------------------------------------- | ||||||
| @@ -504,7 +499,9 @@ if __name__ == "__main__": | |||||||
|         console.print(f"Caught error: {error}", style="red") |         console.print(f"Caught error: {error}", style="red") | ||||||
|  |  | ||||||
|     # Demonstrate a traceback style: |     # 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: |     try: | ||||||
|         raise ValueError("Nord test exception!") |         raise ValueError("Nord test exception!") | ||||||
|     except ValueError: |     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""" | """utils.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import functools | import functools | ||||||
| import inspect | import inspect | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
|  | import shutil | ||||||
|  | import sys | ||||||
| from itertools import islice | from itertools import islice | ||||||
| from typing import Any, Awaitable, Callable, TypeVar | from typing import Any, Awaitable, Callable, TypeVar | ||||||
|  |  | ||||||
| import pythonjsonlogger.json | 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 rich.logging import RichHandler | ||||||
|  |  | ||||||
| from falyx.themes.colors import OneColors |  | ||||||
|  |  | ||||||
| logger = logging.getLogger("falyx") |  | ||||||
|  |  | ||||||
| T = TypeVar("T") | T = TypeVar("T") | ||||||
|  |  | ||||||
| async def _noop(*args, **kwargs): |  | ||||||
|  | async def _noop(*_, **__): | ||||||
|     pass |     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: | def is_coroutine(function: Callable[..., Any]) -> bool: | ||||||
|     return inspect.iscoroutinefunction(function) |     return inspect.iscoroutinefunction(function) | ||||||
|  |  | ||||||
|  |  | ||||||
| def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: | def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: | ||||||
|     if is_coroutine(function): |     if is_coroutine(function): | ||||||
|         return function # type: ignore |         return function  # type: ignore | ||||||
|  |  | ||||||
|     @functools.wraps(function) |     @functools.wraps(function) | ||||||
|     async def async_wrapper(*args, **kwargs) -> T: |     async def async_wrapper(*args, **kwargs) -> T: | ||||||
|         return function(*args, **kwargs) |         return function(*args, **kwargs) | ||||||
|  |  | ||||||
|  |     if not callable(function): | ||||||
|  |         raise TypeError(f"{function} is not callable") | ||||||
|  |  | ||||||
|     return async_wrapper |     return async_wrapper | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -45,41 +62,33 @@ def chunks(iterator, size): | |||||||
|         yield chunk |         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): | class CaseInsensitiveDict(dict): | ||||||
|     """A case-insensitive dictionary that treats all keys as uppercase.""" |     """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): |     def __setitem__(self, key, value): | ||||||
|         super().__setitem__(key.upper(), value) |         super().__setitem__(self._normalize_key(key), value) | ||||||
|  |  | ||||||
|     def __getitem__(self, key): |     def __getitem__(self, key): | ||||||
|         return super().__getitem__(key.upper()) |         return super().__getitem__(self._normalize_key(key)) | ||||||
|  |  | ||||||
|     def __contains__(self, key): |     def __contains__(self, key): | ||||||
|         return super().__contains__(key.upper()) |         return super().__contains__(self._normalize_key(key)) | ||||||
|  |  | ||||||
|     def get(self, key, default=None): |     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): |     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): |     def update(self, other=None, **kwargs): | ||||||
|  |         items = {} | ||||||
|         if other: |         if other: | ||||||
|             other = {k.upper(): v for k, v in other.items()} |             items.update({self._normalize_key(k): v for k, v in other.items()}) | ||||||
|         kwargs = {k.upper(): v for k, v in kwargs.items()} |         items.update({self._normalize_key(k): v for k, v in kwargs.items()}) | ||||||
|         super().update(other, **kwargs) |         super().update(items) | ||||||
|  |  | ||||||
|  |  | ||||||
| def running_in_container() -> bool: | def running_in_container() -> bool: | ||||||
| @@ -104,11 +113,13 @@ def setup_logging( | |||||||
|     console_log_level: int = logging.WARNING, |     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 |     This function sets up separate logging handlers for console and file output, | ||||||
|     support for JSON formatting. It also auto-detects whether the application is running inside |     with optional support for JSON formatting. It also auto-detects whether the | ||||||
|     a container to default to machine-readable logs when appropriate. |     application is running inside a container to default to machine-readable logs | ||||||
|  |     when appropriate. | ||||||
|  |  | ||||||
|     Args: |     Args: | ||||||
|         mode (str | None): |         mode (str | None): | ||||||
| @@ -131,7 +142,8 @@ def setup_logging( | |||||||
|         - Clears existing root handlers before setup. |         - Clears existing root handlers before setup. | ||||||
|         - Configures console logging using either Rich (for CLI) or JSON formatting. |         - 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`. |         - 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. |         - Propagates logs from the "falyx" logger to ensure centralized output. | ||||||
|  |  | ||||||
|     Raises: |     Raises: | ||||||
| @@ -162,7 +174,9 @@ def setup_logging( | |||||||
|     elif mode == "json": |     elif mode == "json": | ||||||
|         console_handler = logging.StreamHandler() |         console_handler = logging.StreamHandler() | ||||||
|         console_handler.setFormatter( |         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: |     else: | ||||||
|         raise ValueError(f"Invalid log mode: {mode}") |         raise ValueError(f"Invalid log mode: {mode}") | ||||||
| @@ -170,17 +184,21 @@ def setup_logging( | |||||||
|     console_handler.setLevel(console_log_level) |     console_handler.setLevel(console_log_level) | ||||||
|     root.addHandler(console_handler) |     root.addHandler(console_handler) | ||||||
|  |  | ||||||
|     file_handler = logging.FileHandler(log_filename) |     file_handler = logging.FileHandler(log_filename, "a", "UTF-8") | ||||||
|     file_handler.setLevel(file_log_level) |     file_handler.setLevel(file_log_level) | ||||||
|     if json_log_to_file: |     if json_log_to_file: | ||||||
|         file_handler.setFormatter( |         file_handler.setFormatter( | ||||||
|             pythonjsonlogger.json.JsonFormatter("%(asctime)s %(name)s %(levelname)s %(message)s") |             pythonjsonlogger.json.JsonFormatter( | ||||||
|  |                 "%(asctime)s %(name)s %(levelname)s %(message)s" | ||||||
|  |             ) | ||||||
|         ) |         ) | ||||||
|     else: |     else: | ||||||
|         file_handler.setFormatter(logging.Formatter( |         file_handler.setFormatter( | ||||||
|             "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", |             logging.Formatter( | ||||||
|             datefmt="%Y-%m-%d %H:%M:%S" |                 "%(asctime)s [%(name)s] [%(levelname)s] %(message)s", | ||||||
|         )) |                 datefmt="%Y-%m-%d %H:%M:%S", | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|     root.addHandler(file_handler) |     root.addHandler(file_handler) | ||||||
|  |  | ||||||
|     logging.getLogger("urllib3").setLevel(logging.WARNING) |     logging.getLogger("urllib3").setLevel(logging.WARNING) | ||||||
|   | |||||||
							
								
								
									
										135
									
								
								falyx/validators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								falyx/validators.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | |||||||
|  | # 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' or 'n'.") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MultiIndexValidator(Validator): | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         minimum: int, | ||||||
|  |         maximum: int, | ||||||
|  |         number_selections: int | str, | ||||||
|  |         separator: str, | ||||||
|  |         allow_duplicates: bool, | ||||||
|  |         cancel_key: str, | ||||||
|  |     ) -> None: | ||||||
|  |         self.minimum = minimum | ||||||
|  |         self.maximum = maximum | ||||||
|  |         self.number_selections = number_selections | ||||||
|  |         self.separator = separator | ||||||
|  |         self.allow_duplicates = allow_duplicates | ||||||
|  |         self.cancel_key = cancel_key | ||||||
|  |         super().__init__() | ||||||
|  |  | ||||||
|  |     def validate(self, document): | ||||||
|  |         selections = [ | ||||||
|  |             index.strip() for index in document.text.strip().split(self.separator) | ||||||
|  |         ] | ||||||
|  |         if not selections or selections == [""]: | ||||||
|  |             raise ValidationError(message="Select at least 1 item.") | ||||||
|  |         if self.cancel_key in selections and len(selections) == 1: | ||||||
|  |             return | ||||||
|  |         elif self.cancel_key in selections: | ||||||
|  |             raise ValidationError(message="Cancel key must be selected alone.") | ||||||
|  |         for selection in selections: | ||||||
|  |             try: | ||||||
|  |                 index = int(selection) | ||||||
|  |                 if not self.minimum <= index <= self.maximum: | ||||||
|  |                     raise ValidationError( | ||||||
|  |                         message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||||
|  |                     ) | ||||||
|  |             except ValueError: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||||
|  |                 ) | ||||||
|  |             if not self.allow_duplicates and selections.count(selection) > 1: | ||||||
|  |                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||||
|  |         if isinstance(self.number_selections, int): | ||||||
|  |             if self.number_selections == 1 and len(selections) > 1: | ||||||
|  |                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||||
|  |             if len(selections) != self.number_selections: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MultiKeyValidator(Validator): | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         keys: Sequence[str] | KeysView[str], | ||||||
|  |         number_selections: int | str, | ||||||
|  |         separator: str, | ||||||
|  |         allow_duplicates: bool, | ||||||
|  |         cancel_key: str, | ||||||
|  |     ) -> None: | ||||||
|  |         self.keys = keys | ||||||
|  |         self.separator = separator | ||||||
|  |         self.number_selections = number_selections | ||||||
|  |         self.allow_duplicates = allow_duplicates | ||||||
|  |         self.cancel_key = cancel_key | ||||||
|  |         super().__init__() | ||||||
|  |  | ||||||
|  |     def validate(self, document): | ||||||
|  |         selections = [key.strip() for key in document.text.strip().split(self.separator)] | ||||||
|  |         if not selections or selections == [""]: | ||||||
|  |             raise ValidationError(message="Select at least 1 item.") | ||||||
|  |         if self.cancel_key in selections and len(selections) == 1: | ||||||
|  |             return | ||||||
|  |         elif self.cancel_key in selections: | ||||||
|  |             raise ValidationError(message="Cancel key must be selected alone.") | ||||||
|  |         for selection in selections: | ||||||
|  |             if selection.upper() not in [key.upper() for key in self.keys]: | ||||||
|  |                 raise ValidationError(message=f"Invalid selection: {selection}") | ||||||
|  |             if not self.allow_duplicates and selections.count(selection) > 1: | ||||||
|  |                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||||
|  |         if isinstance(self.number_selections, int): | ||||||
|  |             if self.number_selections == 1 and len(selections) > 1: | ||||||
|  |                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||||
|  |             if len(selections) != self.number_selections: | ||||||
|  |                 raise ValidationError( | ||||||
|  |                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||||
|  |                 ) | ||||||
| @@ -1 +1 @@ | |||||||
| __version__ = "0.1.5" | __version__ = "0.1.52" | ||||||
|   | |||||||
							
								
								
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							
							
						
						
									
										7
									
								
								pylintrc
									
									
									
									
									
								
							| @@ -146,7 +146,10 @@ disable=abstract-method, | |||||||
|         wrong-import-order, |         wrong-import-order, | ||||||
|         xrange-builtin, |         xrange-builtin, | ||||||
|         zip-builtin-not-iterating, |         zip-builtin-not-iterating, | ||||||
|         broad-exception-caught |         broad-exception-caught, | ||||||
|  |         too-many-positional-arguments, | ||||||
|  |         inconsistent-quotes, | ||||||
|  |         import-outside-toplevel | ||||||
|  |  | ||||||
|  |  | ||||||
| [REPORTS] | [REPORTS] | ||||||
| @@ -260,7 +263,7 @@ generated-members= | |||||||
| [FORMAT] | [FORMAT] | ||||||
|  |  | ||||||
| # Maximum number of characters on a single line. | # 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 | # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt | ||||||
| # lines made too long by directives to pytype. | # lines made too long by directives to pytype. | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.5" | version = "0.1.52" | ||||||
| description = "Reliable and introspectable async CLI action framework." | description = "Reliable and introspectable async CLI action framework." | ||||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||||
| license = "MIT" | license = "MIT" | ||||||
| @@ -13,15 +13,23 @@ prompt_toolkit = "^3.0" | |||||||
| rich = "^13.0" | rich = "^13.0" | ||||||
| pydantic = "^2.0" | pydantic = "^2.0" | ||||||
| python-json-logger = "^3.3.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] | [tool.poetry.group.dev.dependencies] | ||||||
| pytest = "^7.0" | pytest = "^8.3.5" | ||||||
| pytest-asyncio = "^0.20" | pytest-asyncio = "^0.20" | ||||||
| ruff = "^0.3" | 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" | ||||||
|  |  | ||||||
| [tool.poetry.scripts] | [tool.poetry.scripts] | ||||||
| falyx = "falyx.cli.main:main" | falyx = "falyx.__main__:main" | ||||||
| sync-version = "scripts.sync_version:main" |  | ||||||
|  |  | ||||||
| [build-system] | [build-system] | ||||||
| requires = ["poetry-core>=1.0.0"] | requires = ["poetry-core>=1.0.0"] | ||||||
| @@ -30,7 +38,7 @@ build-backend = "poetry.core.masonry.api" | |||||||
| [tool.pytest.ini_options] | [tool.pytest.ini_options] | ||||||
| testpaths = ["tests"] | testpaths = ["tests"] | ||||||
| asyncio_mode = "auto" | asyncio_mode = "auto" | ||||||
| asyncio_default_fixture_loop_scope = "function" | #asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
| [tool.pylint."MESSAGES CONTROL"] | [tool.pylint."MESSAGES CONTROL"] | ||||||
| disable = ["broad-exception-caught"] | disable = ["broad-exception-caught"] | ||||||
|   | |||||||
| @@ -1,8 +1,10 @@ | |||||||
| """scripts/sync_version.py""" | """scripts/sync_version.py""" | ||||||
|  |  | ||||||
| import toml |  | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
|  | import toml | ||||||
|  |  | ||||||
|  |  | ||||||
| def main(): | def main(): | ||||||
|     pyproject_path = Path(__file__).parent.parent / "pyproject.toml" |     pyproject_path = Path(__file__).parent.parent / "pyproject.toml" | ||||||
|     version_path = Path(__file__).parent.parent / "falyx" / "version.py" |     version_path = Path(__file__).parent.parent / "falyx" / "version.py" | ||||||
| @@ -13,5 +15,6 @@ def main(): | |||||||
|     version_path.write_text(f'__version__ = "{version}"\n') |     version_path.write_text(f'__version__ = "{version}"\n') | ||||||
|     print(f"✅ Synced version: {version} → {version_path}") |     print(f"✅ Synced version: {version} → {version_path}") | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     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
									
								
							
							
								
								
									
										0
									
								
								tests/test_action_hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_action_hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										46
									
								
								tests/test_action_process.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								tests/test_action_process.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | import pickle | ||||||
|  | import warnings | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import ProcessAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def slow_add(x, y): | ||||||
|  |     return x + y | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Tests --- | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_process_action_executes_correctly(): | ||||||
|  |     with warnings.catch_warnings(): | ||||||
|  |         warnings.simplefilter("ignore", DeprecationWarning) | ||||||
|  |  | ||||||
|  |         action = ProcessAction(name="proc", action=slow_add, args=(2, 3)) | ||||||
|  |         result = await action() | ||||||
|  |         assert result == 5 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | unpickleable = lambda x: x + 1  # noqa: E731 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_process_action_rejects_unpickleable(): | ||||||
|  |     with warnings.catch_warnings(): | ||||||
|  |         warnings.simplefilter("ignore", DeprecationWarning) | ||||||
|  |  | ||||||
|  |         action = ProcessAction(name="proc_fail", action=unpickleable, args=(2,)) | ||||||
|  |         with pytest.raises(pickle.PicklingError, match="Can't pickle"): | ||||||
|  |             await action() | ||||||
							
								
								
									
										36
									
								
								tests/test_action_retries.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								tests/test_action_retries.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ChainedAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.retry_utils import enable_retries_recursively | ||||||
|  |  | ||||||
|  | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_action_enable_retry(): | ||||||
|  |     """Test if Action can be created with retry=True.""" | ||||||
|  |     action = Action("test_action", lambda: "Hello, World!", retry=True) | ||||||
|  |     assert action.retry_policy.enabled is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_enable_retries_recursively(): | ||||||
|  |     """Test if Action can be created with retry=True.""" | ||||||
|  |     action = Action("test_action", lambda: "Hello, World!") | ||||||
|  |     assert action.retry_policy.enabled is False | ||||||
|  |  | ||||||
|  |     chained_action = ChainedAction( | ||||||
|  |         name="Chained Action", | ||||||
|  |         actions=[action], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     enable_retries_recursively(chained_action, policy=None) | ||||||
|  |     assert action.retry_policy.enabled is True | ||||||
| @@ -1,54 +1,52 @@ | |||||||
| import pytest | import pytest | ||||||
| import asyncio |  | ||||||
| import pickle | from falyx.action import Action, ActionGroup, ChainedAction, FallbackAction | ||||||
| import warnings | from falyx.context import ExecutionContext | ||||||
| from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction |  | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.context import ExecutionContext, ResultsContext |  | ||||||
|  |  | ||||||
| asyncio_default_fixture_loop_scope = "function" | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  |  | ||||||
| # --- Helpers --- | # --- Helpers --- | ||||||
|  |  | ||||||
| async def dummy_action(x: int = 0) -> int: |  | ||||||
|     return x + 1 |  | ||||||
|  |  | ||||||
| async def capturing_hook(context: ExecutionContext): | async def capturing_hook(context: ExecutionContext): | ||||||
|     context.extra["hook_triggered"] = True |     context.extra["hook_triggered"] = True | ||||||
|  |  | ||||||
|  |  | ||||||
| # --- Fixtures --- | # --- Fixtures --- | ||||||
|  |  | ||||||
| @pytest.fixture |  | ||||||
| def sample_action(): |  | ||||||
|     return Action(name="increment", action=dummy_action, kwargs={"x": 5}) |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def hook_manager(): | def hook_manager(): | ||||||
|     hm = HookManager() |     hm = HookManager() | ||||||
|     hm.register(HookType.BEFORE, capturing_hook) |     hm.register(HookType.BEFORE, capturing_hook) | ||||||
|     return hm |     return hm | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture(autouse=True) | @pytest.fixture(autouse=True) | ||||||
| def clean_registry(): | def clean_registry(): | ||||||
|     er.clear() |     er.clear() | ||||||
|     yield |     yield | ||||||
|     er.clear() |     er.clear() | ||||||
|  |  | ||||||
|  |  | ||||||
| # --- Tests --- | # --- Tests --- | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_runs_correctly(sample_action): | async def test_action_runs_correctly(): | ||||||
|  |     async def dummy_action(x: int = 0) -> int: | ||||||
|  |         return x + 1 | ||||||
|  |  | ||||||
|  |     sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5}) | ||||||
|     result = await sample_action() |     result = await sample_action() | ||||||
|     assert result == 6 |     assert result == 6 | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_hook_lifecycle(hook_manager): | async def test_action_hook_lifecycle(hook_manager): | ||||||
|     action = Action( |     async def a1(): | ||||||
|         name="hooked", |         return 42 | ||||||
|         action=lambda: 42, |  | ||||||
|         hooks=hook_manager |     action = Action(name="hooked", action=a1, hooks=hook_manager) | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     await action() |     await action() | ||||||
|  |  | ||||||
| @@ -56,67 +54,124 @@ async def test_action_hook_lifecycle(hook_manager): | |||||||
|     assert context.name == "hooked" |     assert context.name == "hooked" | ||||||
|     assert context.extra.get("hook_triggered") is True |     assert context.extra.get("hook_triggered") is True | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_with_result_injection(): | async def test_chained_action_with_result_injection(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 5 | ||||||
|  |  | ||||||
|  |     async def a3(last_result): | ||||||
|  |         return last_result * 2 | ||||||
|  |  | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="start", action=lambda: 1), |         Action(name="start", action=a1), | ||||||
|         Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True), |         Action(name="add_last", action=a2, inject_last_result=True), | ||||||
|         Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True) |         Action(name="multiply", action=a3, inject_last_result=True), | ||||||
|     ] |     ] | ||||||
|     chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) |     chain = ChainedAction( | ||||||
|  |         name="test_chain", actions=actions, inject_last_result=True, return_list=True | ||||||
|  |     ) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     assert result == [1, 6, 12] |     assert result == [1, 6, 12] | ||||||
|  |     chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 12 | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_group_runs_in_parallel(): | async def test_action_group_runs_in_parallel(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(): | ||||||
|  |         return 2 | ||||||
|  |  | ||||||
|  |     async def a3(): | ||||||
|  |         return 3 | ||||||
|  |  | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="a", action=lambda: 1), |         Action(name="a", action=a1), | ||||||
|         Action(name="b", action=lambda: 2), |         Action(name="b", action=a2), | ||||||
|         Action(name="c", action=lambda: 3), |         Action(name="c", action=a3), | ||||||
|     ] |     ] | ||||||
|     group = ActionGroup(name="parallel", actions=actions) |     group = ActionGroup(name="parallel", actions=actions) | ||||||
|     result = await group() |     result = await group() | ||||||
|     result_dict = dict(result) |     result_dict = dict(result) | ||||||
|     assert result_dict == {"a": 1, "b": 2, "c": 3} |     assert result_dict == {"a": 1, "b": 2, "c": 3} | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_inject_from_action(): | async def test_chained_action_inject_from_action(): | ||||||
|  |     async def a1(last_result): | ||||||
|  |         return last_result + 10 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 5 | ||||||
|  |  | ||||||
|     inner_chain = ChainedAction( |     inner_chain = ChainedAction( | ||||||
|         name="inner_chain", |         name="inner_chain", | ||||||
|         actions=[ |         actions=[ | ||||||
|             Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True), |             Action(name="inner_first", action=a1, inject_last_result=True), | ||||||
|             Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True), |             Action(name="inner_second", action=a2, inject_last_result=True), | ||||||
|         ] |         ], | ||||||
|  |         return_list=True, | ||||||
|     ) |     ) | ||||||
|     actions = [ |  | ||||||
|         Action(name="first", action=lambda: 1), |  | ||||||
|         Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), |  | ||||||
|         inner_chain, |  | ||||||
|  |  | ||||||
|  |     async def a3(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a4(last_result): | ||||||
|  |         return last_result + 2 | ||||||
|  |  | ||||||
|  |     actions = [ | ||||||
|  |         Action(name="first", action=a3), | ||||||
|  |         Action(name="second", action=a4, inject_last_result=True), | ||||||
|  |         inner_chain, | ||||||
|     ] |     ] | ||||||
|     outer_chain = ChainedAction(name="test_chain", actions=actions) |     outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True) | ||||||
|     result = await outer_chain() |     result = await outer_chain() | ||||||
|     assert result == [1, 3, [13, 18]] |     assert result == [1, 3, [13, 18]] | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_with_group(): | async def test_chained_action_with_group(): | ||||||
|  |     async def a1(last_result): | ||||||
|  |         return last_result + 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 2 | ||||||
|  |  | ||||||
|  |     async def a3(): | ||||||
|  |         return 3 | ||||||
|  |  | ||||||
|     group = ActionGroup( |     group = ActionGroup( | ||||||
|         name="group", |         name="group", | ||||||
|         actions=[ |         actions=[ | ||||||
|             Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True), |             Action(name="a", action=a1, inject_last_result=True), | ||||||
|             Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True), |             Action(name="b", action=a2, inject_last_result=True), | ||||||
|             Action(name="c", action=lambda: 3), |             Action(name="c", action=a3), | ||||||
|         ] |         ], | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |     async def a4(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a5(last_result): | ||||||
|  |         return last_result + 2 | ||||||
|  |  | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="first", action=lambda: 1), |         Action(name="first", action=a4), | ||||||
|         Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), |         Action(name="second", action=a5, inject_last_result=True), | ||||||
|         group, |         group, | ||||||
|     ] |     ] | ||||||
|     chain = ChainedAction(name="test_chain", actions=actions) |     chain = ChainedAction(name="test_chain", actions=actions, return_list=True) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] |     assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_error_triggers_error_hook(): | async def test_action_error_triggers_error_hook(): | ||||||
|     def fail(): |     def fail(): | ||||||
| @@ -136,6 +191,7 @@ async def test_action_error_triggers_error_hook(): | |||||||
|  |  | ||||||
|     assert flag.get("called") is True |     assert flag.get("called") is True | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_rollback_on_failure(): | async def test_chained_action_rollback_on_failure(): | ||||||
|     rollback_called = [] |     rollback_called = [] | ||||||
| @@ -151,7 +207,7 @@ async def test_chained_action_rollback_on_failure(): | |||||||
|  |  | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="ok", action=success, rollback=rollback_fn), |         Action(name="ok", action=success, rollback=rollback_fn), | ||||||
|         Action(name="fail", action=fail, rollback=rollback_fn) |         Action(name="fail", action=fail, rollback=rollback_fn), | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|     chain = ChainedAction(name="chain", actions=actions) |     chain = ChainedAction(name="chain", actions=actions) | ||||||
| @@ -161,37 +217,25 @@ async def test_chained_action_rollback_on_failure(): | |||||||
|  |  | ||||||
|     assert rollback_called == ["rolled back"] |     assert rollback_called == ["rolled back"] | ||||||
|  |  | ||||||
| def slow_add(x, y): |  | ||||||
|     return x + y |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio |  | ||||||
| async def test_process_action_executes_correctly(): |  | ||||||
|     with warnings.catch_warnings(): |  | ||||||
|         warnings.simplefilter("ignore", DeprecationWarning) |  | ||||||
|  |  | ||||||
|         action = ProcessAction(name="proc", func=slow_add, args=(2, 3)) |  | ||||||
|         result = await action() |  | ||||||
|         assert result == 5 |  | ||||||
|  |  | ||||||
| unpickleable = lambda x: x + 1 |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio |  | ||||||
| async def test_process_action_rejects_unpickleable(): |  | ||||||
|     with warnings.catch_warnings(): |  | ||||||
|         warnings.simplefilter("ignore", DeprecationWarning) |  | ||||||
|  |  | ||||||
|         action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,)) |  | ||||||
|         with pytest.raises(pickle.PicklingError, match="Can't pickle"): |  | ||||||
|             await action() |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_register_hooks_recursively_propagates(): | async def test_register_hooks_recursively_propagates(): | ||||||
|     hook = lambda ctx: ctx.extra.update({"test_marker": True}) |     def hook(context): | ||||||
|  |         context.extra.update({"test_marker": True}) | ||||||
|  |  | ||||||
|     chain = ChainedAction(name="chain", actions=[ |     async def a1(): | ||||||
|         Action(name="a", action=lambda: 1), |         return 1 | ||||||
|         Action(name="b", action=lambda: 2), |  | ||||||
|     ]) |     async def a2(): | ||||||
|  |         return 2 | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="a", action=a1), | ||||||
|  |             Action(name="b", action=a2), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|     chain.register_hooks_recursively(HookType.BEFORE, hook) |     chain.register_hooks_recursively(HookType.BEFORE, hook) | ||||||
|  |  | ||||||
|     await chain() |     await chain() | ||||||
| @@ -199,6 +243,7 @@ async def test_register_hooks_recursively_propagates(): | |||||||
|     for ctx in er.get_by_name("a") + er.get_by_name("b"): |     for ctx in er.get_by_name("a") + er.get_by_name("b"): | ||||||
|         assert ctx.extra.get("test_marker") is True |         assert ctx.extra.get("test_marker") is True | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_hook_recovers_error(): | async def test_action_hook_recovers_error(): | ||||||
|     async def flaky(): |     async def flaky(): | ||||||
| @@ -215,16 +260,329 @@ async def test_action_hook_recovers_error(): | |||||||
|     result = await action() |     result = await action() | ||||||
|     assert result == 99 |     assert result == 99 | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_group_injects_last_result(): | async def test_action_group_injects_last_result(): | ||||||
|     group = ActionGroup(name="group", actions=[ |     async def a1(last_result): | ||||||
|         Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True), |         return last_result + 10 | ||||||
|         Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True), |  | ||||||
|     ]) |     async def a2(last_result): | ||||||
|     chain = ChainedAction(name="with_group", actions=[ |         return last_result + 20 | ||||||
|         Action(name="first", action=lambda: 5), |  | ||||||
|         group, |     group = ActionGroup( | ||||||
|     ]) |         name="group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="g1", action=a1, inject_last_result=True), | ||||||
|  |             Action(name="g2", action=a2, inject_last_result=True), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     async def a3(): | ||||||
|  |         return 5 | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="with_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="first", action=a3), | ||||||
|  |             group, | ||||||
|  |         ], | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     result_dict = dict(result[1]) |     result_dict = dict(result[1]) | ||||||
|     assert result_dict == {"g1": 15, "g2": 25} |     assert result_dict == {"g1": 15, "g2": 25} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_inject_last_result(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 1 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2, inject_last_result=True) | ||||||
|  |     chain = ChainedAction(name="chain", actions=[a1, a2]) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_inject_last_result_fail(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 1 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="chain", actions=[a1, a2]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(TypeError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "last_result" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_auto_inject(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 2 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="chain", actions=[a1, a2], auto_inject=True, return_list=True | ||||||
|  |     ) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [1, 3]  # a2 receives last_result=1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_no_auto_inject(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(): | ||||||
|  |         return 2 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True | ||||||
|  |     ) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [1, 2]  # a2 does not receive 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_auto_inject_after_first(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result + 1 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 2  # a2 receives last_result=1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_literal_input(): | ||||||
|  |     async def a1(last_result): | ||||||
|  |         return last_result + " world" | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == "hello world"  # "hello" is injected as last_result | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_manual_inject_override(): | ||||||
|  |     async def a1(): | ||||||
|  |         return 10 | ||||||
|  |  | ||||||
|  |     async def a2(last_result): | ||||||
|  |         return last_result * 2 | ||||||
|  |  | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2, inject_last_result=True) | ||||||
|  |     chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 20  # Even without auto_inject, a2 still gets last_result | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_mid_literal(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return None  # Simulate failure or missing data | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             "default_value",  # <-- literal fallback injected mid-chain | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default_value", "default_value", "Enriched: default_value"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_mid_fallback(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return None  # Simulate failure or missing data | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default_value"), | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default_value", "default_value", "Enriched: default_value"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_success_mid_fallback(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return "Result"  # Simulate success | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default_value"), | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == ["Result", "Result", "Result", "Enriched: Result"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_group_partial_failure(): | ||||||
|  |     async def succeed(): | ||||||
|  |         return "ok" | ||||||
|  |  | ||||||
|  |     async def fail(): | ||||||
|  |         raise ValueError("oops") | ||||||
|  |  | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="partial_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="succeed_action", action=succeed), | ||||||
|  |             Action(name="fail_action", action=fail), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         await group() | ||||||
|  |  | ||||||
|  |     assert er.get_by_name("succeed_action")[0].result == "ok" | ||||||
|  |     assert er.get_by_name("fail_action")[0].exception is not None | ||||||
|  |     assert "fail_action" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_nested_group(): | ||||||
|  |     async def g1(last_result): | ||||||
|  |         return last_result + "10" | ||||||
|  |  | ||||||
|  |     async def g2(last_result): | ||||||
|  |         return last_result + "20" | ||||||
|  |  | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="nested_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="g1", action=g1, inject_last_result=True), | ||||||
|  |             Action(name="g2", action=g2, inject_last_result=True), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="chain_with_group", | ||||||
|  |         actions=[ | ||||||
|  |             "start", | ||||||
|  |             group, | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     # "start" -> group both receive "start" as last_result | ||||||
|  |     assert result[0] == "start" | ||||||
|  |     assert dict(result[1]) == { | ||||||
|  |         "g1": "start10", | ||||||
|  |         "g2": "start20", | ||||||
|  |     }  # Assuming string concatenation for example | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_double_fallback(): | ||||||
|  |     async def fetch_data(last_result=None): | ||||||
|  |         raise ValueError("No data!")  # Simulate failure | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("No data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="Fetch", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default1"), | ||||||
|  |             Action(name="Validate", action=validate_data), | ||||||
|  |             Action(name="Fetch", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default2"), | ||||||
|  |             Action(name="Enrich", action=enrich), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [ | ||||||
|  |         None, | ||||||
|  |         "default1", | ||||||
|  |         "default1", | ||||||
|  |         None, | ||||||
|  |         "default2", | ||||||
|  |         "Enriched: default2", | ||||||
|  |     ] | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ActionFactoryAction, ChainedAction | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def make_chain(value) -> ChainedAction: | ||||||
|  |     return ChainedAction( | ||||||
|  |         "test_chain", | ||||||
|  |         [ | ||||||
|  |             Action("action1", lambda: value + "_1"), | ||||||
|  |             Action("action2", lambda: value + "_2"), | ||||||
|  |         ], | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_factory_action(): | ||||||
|  |     action = ActionFactoryAction( | ||||||
|  |         name="test_action", factory=make_chain, args=("test_value",) | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await action() | ||||||
|  |  | ||||||
|  |     assert result == ["test_value_1", "test_value_2"] | ||||||
							
								
								
									
										28
									
								
								tests/test_chained_action_empty.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								tests/test_chained_action_empty.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import ChainedAction | ||||||
|  | from falyx.exceptions import EmptyChainError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_raises_empty_chain_error_when_no_actions(): | ||||||
|  |     """A ChainedAction with no actions should raise an EmptyChainError immediately.""" | ||||||
|  |     chain = ChainedAction(name="empty_chain", actions=[]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(EmptyChainError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "No actions to execute." in str(exc_info.value) | ||||||
|  |     assert "empty_chain" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_raises_empty_chain_error_when_actions_are_none(): | ||||||
|  |     """A ChainedAction with None as actions should raise an EmptyChainError immediately.""" | ||||||
|  |     chain = ChainedAction(name="none_chain", actions=None) | ||||||
|  |  | ||||||
|  |     with pytest.raises(EmptyChainError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "No actions to execute." in str(exc_info.value) | ||||||
|  |     assert "none_chain" in str(exc_info.value) | ||||||
							
								
								
									
										166
									
								
								tests/test_command.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								tests/test_command.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,166 @@ | |||||||
|  | # test_command.py | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, BaseIOAction, ChainedAction | ||||||
|  | from falyx.command import Command | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.retry import RetryPolicy | ||||||
|  |  | ||||||
|  | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Dummy Action --- | ||||||
|  | async def dummy_action(): | ||||||
|  |     return "ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Dummy IO Action --- | ||||||
|  | class DummyInputAction(BaseIOAction): | ||||||
|  |     async def _run(self, *args, **kwargs): | ||||||
|  |         return "needs input" | ||||||
|  |  | ||||||
|  |     async def preview(self, parent=None): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Tests --- | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_command_creation(): | ||||||
|  |     """Test if Command can be created with a callable.""" | ||||||
|  |     action = Action("test_action", dummy_action) | ||||||
|  |     cmd = Command(key="TEST", description="Test Command", action=action) | ||||||
|  |     assert cmd.key == "TEST" | ||||||
|  |     assert cmd.description == "Test Command" | ||||||
|  |     assert cmd.action == action | ||||||
|  |  | ||||||
|  |     result = await cmd() | ||||||
|  |     assert result == "ok" | ||||||
|  |     assert cmd.result == "ok" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_str(): | ||||||
|  |     """Test if Command string representation is correct.""" | ||||||
|  |     action = Action("test_action", dummy_action) | ||||||
|  |     cmd = Command(key="TEST", description="Test Command", action=action) | ||||||
|  |     print(cmd) | ||||||
|  |     assert ( | ||||||
|  |         str(cmd) | ||||||
|  |         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, retry=False, rollback=False)')" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_enable_retry(): | ||||||
|  |     """Command should enable retry if action is an Action and  retry is set to True.""" | ||||||
|  |     cmd = Command( | ||||||
|  |         key="A", | ||||||
|  |         description="Retry action", | ||||||
|  |         action=Action( | ||||||
|  |             name="retry_action", | ||||||
|  |             action=lambda: 42, | ||||||
|  |         ), | ||||||
|  |         retry=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry is True | ||||||
|  |     assert cmd.action.retry_policy.enabled is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_enable_retry_with_retry_policy(): | ||||||
|  |     """Command should enable retry if action is an Action and retry_policy is set.""" | ||||||
|  |     retry_policy = RetryPolicy( | ||||||
|  |         max_retries=3, | ||||||
|  |         delay=1, | ||||||
|  |         backoff=2, | ||||||
|  |         enabled=True, | ||||||
|  |     ) | ||||||
|  |     cmd = Command( | ||||||
|  |         key="B", | ||||||
|  |         description="Retry action with policy", | ||||||
|  |         action=Action( | ||||||
|  |             name="retry_action_with_policy", | ||||||
|  |             action=lambda: 42, | ||||||
|  |         ), | ||||||
|  |         retry_policy=retry_policy, | ||||||
|  |     ) | ||||||
|  |     assert cmd.action.retry_policy.enabled is True | ||||||
|  |     assert cmd.action.retry_policy == retry_policy | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_enable_retry_not_action(): | ||||||
|  |     """Command should not enable retry if action is not an Action.""" | ||||||
|  |     cmd = Command( | ||||||
|  |         key="C", | ||||||
|  |         description="Retry action", | ||||||
|  |         action=DummyInputAction, | ||||||
|  |         retry=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry is True | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         assert cmd.action.retry_policy.enabled is False | ||||||
|  |     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_chain_retry_all(): | ||||||
|  |     """retry_all should retry all Actions inside a ChainedAction recursively.""" | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="ChainWithRetry", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="action1", action=lambda: 1), | ||||||
|  |             Action(name="action2", action=lambda: 2), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     cmd = Command( | ||||||
|  |         key="D", | ||||||
|  |         description="Chain with retry", | ||||||
|  |         action=chain, | ||||||
|  |         retry_all=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert cmd.retry_all is True | ||||||
|  |     assert cmd.retry_policy.enabled is True | ||||||
|  |     assert chain.actions[0].retry_policy.enabled is True | ||||||
|  |     assert chain.actions[1].retry_policy.enabled is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_chain_retry_all_not_base_action(): | ||||||
|  |     """retry_all should not be set if action is not a ChainedAction.""" | ||||||
|  |     cmd = Command( | ||||||
|  |         key="E", | ||||||
|  |         description="Chain with retry", | ||||||
|  |         action=DummyInputAction, | ||||||
|  |         retry_all=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry_all is True | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         assert cmd.action.retry_policy.enabled is False | ||||||
|  |     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_command_exception_handling(): | ||||||
|  |     """Test if Command handles exceptions correctly.""" | ||||||
|  |  | ||||||
|  |     async def bad_action(): | ||||||
|  |         raise ZeroDivisionError("This is a test exception") | ||||||
|  |  | ||||||
|  |     cmd = Command(key="TEST", description="Test Command", action=bad_action) | ||||||
|  |  | ||||||
|  |     with pytest.raises(ZeroDivisionError): | ||||||
|  |         await cmd() | ||||||
|  |  | ||||||
|  |     assert cmd.result is None | ||||||
|  |     assert isinstance(cmd._context.exception, ZeroDivisionError) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_bad_action(): | ||||||
|  |     """Test if Command raises an exception when action is not callable.""" | ||||||
|  |     with pytest.raises(TypeError) as exc_info: | ||||||
|  |         Command(key="TEST", description="Test Command", action="not_callable") | ||||||
|  |     assert str(exc_info.value) == "Action must be a callable or an instance of BaseAction" | ||||||
							
								
								
									
										828
									
								
								tests/test_command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										828
									
								
								tests/test_command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,828 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.exceptions import CommandArgumentError | ||||||
|  | from falyx.parser import ArgumentAction, CommandArgumentParser | ||||||
|  | from falyx.signals import HelpSignal | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def build_parser_and_parse(args, config): | ||||||
|  |     cap = CommandArgumentParser() | ||||||
|  |     config(cap) | ||||||
|  |     return await cap.parse_args(args) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_none(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--foo", type=str) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(None, config) | ||||||
|  |     assert parsed["foo"] is None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_append_multiple_flags(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse( | ||||||
|  |         ["--tag", "a", "--tag", "b", "--tag", "c"], config | ||||||
|  |     ) | ||||||
|  |     assert parsed["tag"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_positional_nargs_plus_and_single(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("files", nargs="+", type=str) | ||||||
|  |         parser.add_argument("mode", nargs=1) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||||
|  |     assert parsed["files"] == ["a", "b", "c"] | ||||||
|  |     assert parsed["mode"] == "prod" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_type_validation_failure(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--count", type=int) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await build_parser_and_parse(["--count", "abc"], config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_required_field_missing(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--env", type=str, required=True) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await build_parser_and_parse([], config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_choices_enforced(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--mode", choices=["dev", "prod"]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await build_parser_and_parse(["--mode", "staging"], config) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_boolean_flags(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) | ||||||
|  |         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) | ||||||
|  |     assert parsed["debug"] is True | ||||||
|  |     assert parsed["no_debug"] is False | ||||||
|  |     parsed = await build_parser_and_parse([], config) | ||||||
|  |     assert parsed["debug"] is False | ||||||
|  |     assert parsed["no_debug"] is True | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_count_action(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("-v", action=ArgumentAction.COUNT) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) | ||||||
|  |     assert parsed["v"] == 3 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_nargs_star(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("args", nargs="*", type=str) | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(["one", "two", "three"], config) | ||||||
|  |     assert parsed["args"] == ["one", "two", "three"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_flag_and_positional_mix(): | ||||||
|  |     def config(parser): | ||||||
|  |         parser.add_argument("--env", type=str) | ||||||
|  |         parser.add_argument("tasks", nargs="+") | ||||||
|  |  | ||||||
|  |     parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||||
|  |     assert parsed["env"] == "prod" | ||||||
|  |     assert parsed["tasks"] == ["build", "test"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_duplicate_dest_fails(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--foo", dest="shared") | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("bar", dest="shared") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_positional_flag_conflict(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Single positional argument should work | ||||||
|  |     parser.add_argument("faylx") | ||||||
|  |  | ||||||
|  |     # ❌ Multiple positional flags is invalid | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("falyx", "test") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_positional_and_flag_conflict(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Cannot mix positional and optional in one declaration | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("faylx", "--falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_multiple_optional_flags_same_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Valid: multiple flags for same dest | ||||||
|  |     parser.add_argument("-f", "--falyx") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("-f", "--falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_flag_dest_conflict(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # First one is fine | ||||||
|  |     parser.add_argument("falyx") | ||||||
|  |  | ||||||
|  |     # ❌ Cannot reuse dest name with another flag or positional | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--test", dest="falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_flag_and_positional_conflict_dest_inference(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ "--falyx" and "falyx" result in dest conflict | ||||||
|  |     parser.add_argument("--falyx") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_multiple_flags_custom_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Multiple flags with explicit dest | ||||||
|  |     parser.add_argument("-f", "--falyx", "--test", dest="falyx") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("-f", "--falyx", "--test") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_multiple_flags_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Multiple flags with implicit dest first non -flag | ||||||
|  |     parser.add_argument("-f", "--falyx", "--test") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("-f", "--falyx", "--test") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_single_flag_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Single flag with explicit dest | ||||||
|  |     parser.add_argument("-f") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "f" | ||||||
|  |     assert arg.flags == ("-f",) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_dest(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid dest name | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", dest="1falyx") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", dest="falyx%") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_flag(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid flag name | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--1falyx") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--!falyx") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("_") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument(None) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument(0) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("-") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("-asdf") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_duplicate_flags(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     parser.add_argument("--falyx") | ||||||
|  |  | ||||||
|  |     # ❌ Duplicate flag | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--test", "--falyx") | ||||||
|  |  | ||||||
|  |     # ❌ Duplicate flag | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_no_flags(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ No flags provided | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_default_value(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Default value provided | ||||||
|  |     parser.add_argument("--falyx", default="default_value") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("--falyx",) | ||||||
|  |     assert arg.default == "default_value" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_default(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid default value | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", type=int, default="1falyx") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_default_list(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid default value | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", type=int, default=["a", 2, 3]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_action(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid action | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", action="invalid_action") | ||||||
|  |  | ||||||
|  |     # ❌ Invalid action type | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", action=123) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_default_not_in_choices(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Default value not in choices | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_add_argument_choices(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ✅ Choices provided | ||||||
|  |     parser.add_argument("--falyx", choices=["a", "b", "c"]) | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("--falyx",) | ||||||
|  |     assert arg.choices == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--falyx", "a"]) | ||||||
|  |     assert args["falyx"] == "a" | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["--falyx", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_choices_invalid(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     # ❌ Invalid choices | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--bad", choices=123) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--bad3", choices={1: "a", 2: "b"}) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--bad4", choices=["a", "b"], type=int) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_bad_nargs(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--falyx", nargs="invalid") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--foo", nargs="123") | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--foo", nargs=[1, 2]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("--too", action="count", nargs=5) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("falyx", action="store_true", nargs=5) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_nargs(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--falyx", nargs=2) | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("--falyx",) | ||||||
|  |     assert arg.nargs == 2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_add_argument_valid_nargs(): | ||||||
|  |     # Valid nargs int, +, * and ? | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--falyx", nargs="+") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.nargs == "+" | ||||||
|  |  | ||||||
|  |     parser.add_argument("--test", nargs="*") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.nargs == "*" | ||||||
|  |  | ||||||
|  |     parser.add_argument("--test2", nargs="?") | ||||||
|  |     arg = parser._arguments[-1] | ||||||
|  |     assert arg.nargs == "?" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_argument(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--falyx", type=str, default="default_value") | ||||||
|  |     arg = parser.get_argument("falyx") | ||||||
|  |     assert arg.dest == "falyx" | ||||||
|  |     assert arg.flags == ("--falyx",) | ||||||
|  |     assert arg.default == "default_value" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |     parser.add_argument("mode", nargs=1) | ||||||
|  |     parser.add_argument("--action", action="store_true") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c", "--action"]) | ||||||
|  |     args = await parser.parse_args(["--action", "a", "b", "c"]) | ||||||
|  |  | ||||||
|  |     assert args["files"] == ["a", "b"] | ||||||
|  |     assert args["mode"] == "c" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_plus(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a"]) | ||||||
|  |     assert args["files"] == ["a"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_flagged_nargs_plus(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--files", nargs="+", type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--files", "a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--files", "a"]) | ||||||
|  |     print(args) | ||||||
|  |     assert args["files"] == ["a"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_numbered_nargs(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs=2, type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b"]) | ||||||
|  |     assert args["files"] == ["a", "b"] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         args = await parser.parse_args(["a"]) | ||||||
|  |         print(args) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_parse_args_nargs_zero(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         parser.add_argument("files", nargs=0, type=str) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_more_than_expected(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs=2, type=str) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["a", "b", "c", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_one_or_none(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="?", type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a"]) | ||||||
|  |     assert args["files"] == "a" | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["files"] is None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_positional(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="*", type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_positional_plus(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         args = await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_multiple_positional(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", nargs="+", type=str) | ||||||
|  |     parser.add_argument("mode", nargs=1) | ||||||
|  |     parser.add_argument("action", nargs="?") | ||||||
|  |     parser.add_argument("target", nargs="*") | ||||||
|  |     parser.add_argument("extra", nargs="+") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c", "d", "e"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |     assert args["mode"] == "d" | ||||||
|  |     assert args["action"] == [] | ||||||
|  |     assert args["target"] == [] | ||||||
|  |     assert args["extra"] == ["e"] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_none(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", type=int) | ||||||
|  |     parser.add_argument("mode") | ||||||
|  |  | ||||||
|  |     await parser.parse_args(["1", "2"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_invalid_positional_arguments(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", nargs="*", type=int) | ||||||
|  |     parser.add_argument("mode", nargs=1) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["1", "2", "c", "d"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||||
|  |     assert args["numbers"] == [1, 2, 3] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1"]) | ||||||
|  |     assert args["numbers"] == [1] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_int_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||||
|  |     assert args["numbers"] == [[1], [2], [3]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--numbers", "1"]) | ||||||
|  |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") | ||||||
|  |     parser.add_argument("--mode") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1"]) | ||||||
|  |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||||
|  |     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||||
|  |     assert args["mode"] == "numbers" | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1", "2", "3"]) | ||||||
|  |     assert args["numbers"] == [[1, 2, 3]] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["numbers"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_int_optional_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1"]) | ||||||
|  |     assert args["numbers"] == [1] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_int_optional_append_multiple_values(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["1", "2"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_nargs_int_positional_append(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["1"]) | ||||||
|  |     assert args["numbers"] == [[1]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["1", "2", "3"]) | ||||||
|  |  | ||||||
|  |     parser2 = CommandArgumentParser() | ||||||
|  |     parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2) | ||||||
|  |  | ||||||
|  |     args = await parser2.parse_args(["1", "2"]) | ||||||
|  |     assert args["numbers"] == [[1, 2]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser2.parse_args(["1", "2", "3"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_append_flagged_invalid_type(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["--numbers", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_append_groups_nargs(): | ||||||
|  |     cap = CommandArgumentParser() | ||||||
|  |     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) | ||||||
|  |  | ||||||
|  |     parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||||
|  |     assert parsed["item"] == [["a", "b"], ["c", "d"]] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await cap.parse_args(["--item", "a", "b", "--item", "c"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_flattened(): | ||||||
|  |     cap = CommandArgumentParser() | ||||||
|  |     cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) | ||||||
|  |  | ||||||
|  |     parsed = await cap.parse_args(["--value", "x", "--value", "y"]) | ||||||
|  |     assert parsed["value"] == ["x", "y"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_parse_args_split_order(): | ||||||
|  |     cap = CommandArgumentParser() | ||||||
|  |     cap.add_argument("a") | ||||||
|  |     cap.add_argument("--x") | ||||||
|  |     cap.add_argument("b", nargs="*") | ||||||
|  |     args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) | ||||||
|  |     assert args == ("1", ["2"]) | ||||||
|  |     assert kwargs == {"x": "100"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_help_signal_triggers(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--foo") | ||||||
|  |     with pytest.raises(HelpSignal): | ||||||
|  |         await parser.parse_args(["--help"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_empty_parser_defaults(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     with pytest.raises(HelpSignal): | ||||||
|  |         await parser.parse_args(["--help"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_basic(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||||
|  |     assert args["tag"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_2(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||||
|  |     assert args["pair"] == ["a", "b", "c", "d"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_star(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--files", "x", "y", "z"]) | ||||||
|  |     assert args["files"] == ["x", "y", "z"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--files"]) | ||||||
|  |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_nargs_plus(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||||
|  |     assert args["inputs"] == [1, 2, 3, 4] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_invalid_type(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["--nums", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_greedy_invalid_type(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--nums", nargs="*", type=int) | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args(["--nums", "a"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_append_vs_extend_behavior(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||||
|  |     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args( | ||||||
|  |         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] | ||||||
|  |     ) | ||||||
|  |     assert args["x"] == [["a", "b"], ["c", "d"]] | ||||||
|  |     assert args["y"] == ["1", "2", "3", "4"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_append_vs_extend_behavior_error(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||||
|  |     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||||
|  |  | ||||||
|  |     # This should raise an error because the last argument is not a valid pair | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args( | ||||||
|  |             ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args( | ||||||
|  |             ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_positional(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args([]) | ||||||
|  |     assert args["files"] == [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_extend_positional_nargs(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") | ||||||
|  |  | ||||||
|  |     args = await parser.parse_args(["a", "b", "c"]) | ||||||
|  |     assert args["files"] == ["a", "b", "c"] | ||||||
|  |  | ||||||
|  |     with pytest.raises(CommandArgumentError): | ||||||
|  |         await parser.parse_args([]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_command_argument_parser_equality(): | ||||||
|  |     parser1 = CommandArgumentParser() | ||||||
|  |     parser2 = CommandArgumentParser() | ||||||
|  |  | ||||||
|  |     parser1.add_argument("--foo", type=str) | ||||||
|  |     parser2.add_argument("--foo", type=str) | ||||||
|  |  | ||||||
|  |     assert parser1 == parser2 | ||||||
|  |  | ||||||
|  |     parser1.add_argument("--bar", type=int) | ||||||
|  |     assert parser1 != parser2 | ||||||
|  |  | ||||||
|  |     parser2.add_argument("--bar", type=int) | ||||||
|  |     assert parser1 == parser2 | ||||||
|  |  | ||||||
|  |     assert parser1 != "not a parser" | ||||||
|  |     assert parser1 is not None | ||||||
|  |     assert parser1 != object() | ||||||
|  |  | ||||||
|  |     assert parser1.to_definition_list() == parser2.to_definition_list() | ||||||
|  |     assert hash(parser1) == hash(parser2) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_render_help(): | ||||||
|  |     parser = CommandArgumentParser() | ||||||
|  |     parser.add_argument("--foo", type=str, help="Foo help") | ||||||
|  |     parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help") | ||||||
|  |  | ||||||
|  |     assert parser.render_help() is None | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user