Compare commits
	
		
			55 Commits
		
	
	
		
			command-ar
			...
			3b2c33d28f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3b2c33d28f | |||
| f37aee568d | |||
| 8a0a45e17f | |||
| da38f6d6ee | |||
| 7836ff4dfd | |||
| 7dca416346 | |||
| 734f7b5962 | |||
| 489d730755 | |||
| 825ff60f08 | |||
| fa5e2a4c2c | |||
| de53c889a6 | |||
| 0319058531 | |||
| 5769882afd | |||
| 7f63e16097 | |||
| 21402bff9a | |||
| fddc3ea8d9 | |||
| 9b9f6434a4 | |||
| c15e3afa5e | |||
| dc1764e752 | |||
| 2288015cf3 | |||
| 68d7d89d64 | |||
| 9654b9926c | |||
| 294bbc9062 | |||
| 4c1498121f | |||
| ed42f6488e | |||
| e2f0bf5903 | |||
| bb325684ac | |||
| 38f5f1e934 | |||
| 2d1177e820 | |||
| 3c7ef3eb1c | |||
| 53ba6a896a | |||
| b24079ea7e | |||
| ac82076511 | |||
| 09eeb90dc6 | |||
| e3ebc1b17b | |||
| 079bc0ee77 | |||
| 1c97857cb8 | |||
| 21af003bc7 | |||
| 1585098513 | |||
| 3d3a706784 | |||
| c2eb854e5a | |||
| 8a3c1d6cc8 | |||
| f196e38c57 | |||
| fb1ffbe9f6 | |||
| 429b434566 | |||
| 4f3632bc6b | |||
| ba562168aa | |||
| ddb78bd5a7 | |||
| b0c0e7dc16 | |||
| 0a1ba22a3d | |||
| b51ba87999 | |||
| 3c0a81359c | |||
| 4fa6e3bf1f | |||
| afa47b0bac | |||
| 70a527358d | 
							
								
								
									
										154
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										154
									
								
								README.md
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | ||||
| - ⚙️ Full lifecycle hooks (before, after, success, error, teardown) | ||||
| - 📊 Execution tracing, logging, and introspection | ||||
| - 🧙♂️ Async-first design with Process support | ||||
| - 🧩 Extensible CLI menus and customizable output | ||||
| - 🧩 Extensible CLI menus, customizable bottom bars, and keyboard shortcuts | ||||
|  | ||||
| > Built for developers who value *clarity*, *resilience*, and *visibility* in their terminal workflows. | ||||
|  | ||||
| @@ -21,12 +21,13 @@ | ||||
| Modern CLI tools deserve the same resilience as production systems. Falyx makes it easy to: | ||||
|  | ||||
| - Compose workflows using `Action`, `ChainedAction`, or `ActionGroup` | ||||
| - Inject the result of one step into the next (`last_result`) | ||||
| - Handle flaky operations with retries and exponential backoff | ||||
| - Inject the result of one step into the next (`last_result` / `auto_inject`) | ||||
| - Handle flaky operations with retries, backoff, and jitter | ||||
| - Roll back safely on failure with structured undo logic | ||||
| - Add observability with execution timing, result tracking, and hooks | ||||
| - Add observability with timing, tracebacks, and lifecycle hooks | ||||
| - Run in both interactive *and* headless (scriptable) modes | ||||
| - Customize output with Rich `Table`s (grouping, theming, etc.) | ||||
| - Support config-driven workflows with YAML or TOML | ||||
| - Visualize tagged command groups and menu state via Rich tables | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -52,18 +53,20 @@ poetry install | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Falyx, Action, ChainedAction | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
|  | ||||
| # A flaky async step that fails randomly | ||||
| async def flaky_step(): | ||||
|     await asyncio.sleep(0.2) | ||||
|     if random.random() < 0.5: | ||||
|         raise RuntimeError("Random failure!") | ||||
|     print("ok") | ||||
|     return "ok" | ||||
|  | ||||
| # Create the actions | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
| step1 = Action(name="step_1", action=flaky_step) | ||||
| step2 = Action(name="step_2", action=flaky_step) | ||||
|  | ||||
| # Chain the actions | ||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||
| @@ -74,9 +77,11 @@ falyx.add_command( | ||||
|     key="R", | ||||
|     description="Run My Pipeline", | ||||
|     action=chain, | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
|     retry_all=True, | ||||
|     spinner=True, | ||||
|     style="cyan", | ||||
| ) | ||||
|  | ||||
| # Entry point | ||||
| @@ -85,76 +90,131 @@ if __name__ == "__main__": | ||||
| ``` | ||||
|  | ||||
| ```bash | ||||
| ❯ python simple.py | ||||
| $ python simple.py | ||||
|                           🚀 Falyx Demo | ||||
|  | ||||
|            [R] Run My Pipeline | ||||
|   [Y] History                                         [Q] Exit | ||||
|            [H] Help              [Y] History   [X] Exit | ||||
|  | ||||
| > | ||||
| ``` | ||||
|  | ||||
| ```bash | ||||
| ❯ python simple.py run R | ||||
| $ python simple.py run r | ||||
| Command: 'R' — Run My Pipeline | ||||
| └── ⛓ ChainedAction 'my_pipeline' | ||||
|     ├── ⚙ Action 'step_1' | ||||
|     │   ↻ Retries: 3x, delay 1.0s, backoff 2.0x | ||||
|     └── ⚙ Action 'step_2' | ||||
|         ↻ Retries: 3x, delay 1.0s, backoff 2.0x | ||||
| Confirm execution of R — Run My Pipeline (calls `my_pipeline`)  [Y/n] y | ||||
| [2025-04-15 22:03:57] WARNING   ⚠️ Retry attempt 1/3 failed due to 'Random failure!'. | ||||
| ✅ Result: ['ok', 'ok'] | ||||
| ❓ Confirm execution of R — Run My Pipeline (calls `my_pipeline`)  [Y/n] > y | ||||
| [2025-07-20 09:29:35] WARNING   Retry attempt 1/3 failed due to 'Random failure!'. | ||||
| ok | ||||
| [2025-07-20 09:29:38] WARNING   Retry attempt 1/3 failed due to 'Random failure!'. | ||||
| ok | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 📦 Core Features | ||||
|  | ||||
| - ✅ Async-native `Action`, `ChainedAction`, `ActionGroup` | ||||
| - 🔁 Retry policies + exponential backoff | ||||
| - ⛓ Rollbacks on chained failures | ||||
| - 🎛️ Headless or interactive CLI with argparse and prompt_toolkit | ||||
| - 📊 Built-in execution registry, result tracking, and timing | ||||
| - 🧠 Supports `ProcessAction` for CPU-bound workloads | ||||
| - 🧩 Custom `Table` rendering for CLI menu views | ||||
| - 🔍 Hook lifecycle: `before`, `on_success`, `on_error`, `after`, `on_teardown` | ||||
| - ✅ Async-native `Action`, `ChainedAction`, `ActionGroup`, `ProcessAction` | ||||
| - 🔁 Retry policies with delay, backoff, jitter — opt-in per action or globally | ||||
| - ⛓ Rollbacks and lifecycle hooks for chained execution | ||||
| - 🎛️ Headless or interactive CLI powered by `argparse` + `prompt_toolkit` | ||||
| - 📊 In-memory `ExecutionRegistry` with result tracking, timing, and tracebacks | ||||
| - 🌐 CLI menu construction via config files or Python | ||||
| - ⚡ Bottom bar toggle switches and counters with `Ctrl+<key>` shortcuts | ||||
| - 🔍 Structured confirmation prompts and help rendering | ||||
| - 🪵 Flexible logging: Rich console for devs, JSON logs for ops | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 🔍 Execution Trace | ||||
| ### 🧰 Building Blocks | ||||
|  | ||||
| ```bash | ||||
| [2025-04-14 10:33:22] DEBUG    [Step 1] ⚙ flaky_step() | ||||
| [2025-04-14 10:33:22] INFO     [Step 1] 🔁 Retrying (1/3) in 1.0s... | ||||
| [2025-04-14 10:33:23] DEBUG    [Step 1] ✅ Success | Result: ok | ||||
| [2025-04-14 10:33:23] DEBUG    [My Pipeline] ✅ Result: ['ok', 'ok'] | ||||
| - **`Action`**: A single unit of async (or sync) logic | ||||
| - **`ChainedAction`**: Execute a sequence of actions, with rollback and injection | ||||
| - **`ActionGroup`**: Run actions concurrently and collect results | ||||
| - **`ProcessAction`**: Use `multiprocessing` for CPU-bound workflows | ||||
| - **`Falyx`**: Interactive or headless CLI controller with history, menus, and theming | ||||
| - **`ExecutionContext`**: Metadata store per invocation (name, args, result, timing) | ||||
| - **`HookManager`**: Attach `before`, `after`, `on_success`, `on_error`, `on_teardown` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ### 🔍 Logging | ||||
| ``` | ||||
| 2025-07-20 09:29:32 [falyx] [INFO] Command 'R' selected. | ||||
| 2025-07-20 09:29:32 [falyx] [INFO] [run_key] Executing: R — Run My Pipeline | ||||
| 2025-07-20 09:29:33 [falyx] [INFO] [my_pipeline] Starting -> ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], args=(), kwargs={}, auto_inject=False, return_list=False)() | ||||
| 2025-07-20 09:29:33 [falyx] [INFO] [step_1] Retrying (1/3) in 1.0s due to 'Random failure!'... | ||||
| 2025-07-20 09:29:35 [falyx] [WARNING] [step_1] Retry attempt 1/3 failed due to 'Random failure!'. | ||||
| 2025-07-20 09:29:35 [falyx] [INFO] [step_1] Retrying (2/3) in 2.0s due to 'Random failure!'... | ||||
| 2025-07-20 09:29:37 [falyx] [INFO] [step_1] Retry succeeded on attempt 2. | ||||
| 2025-07-20 09:29:37 [falyx] [INFO] [step_1] Recovered: step_1 | ||||
| 2025-07-20 09:29:37 [falyx] [DEBUG] [step_1] status=OK duration=3.627s result='ok' exception=None | ||||
| 2025-07-20 09:29:37 [falyx] [INFO] [step_2] Retrying (1/3) in 1.0s due to 'Random failure!'... | ||||
| 2025-07-20 09:29:38 [falyx] [WARNING] [step_2] Retry attempt 1/3 failed due to 'Random failure!'. | ||||
| 2025-07-20 09:29:38 [falyx] [INFO] [step_2] Retrying (2/3) in 2.0s due to 'Random failure!'... | ||||
| 2025-07-20 09:29:40 [falyx] [INFO] [step_2] Retry succeeded on attempt 2. | ||||
| 2025-07-20 09:29:40 [falyx] [INFO] [step_2] Recovered: step_2 | ||||
| 2025-07-20 09:29:40 [falyx] [DEBUG] [step_2] status=OK duration=3.609s result='ok' exception=None | ||||
| 2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Success -> Result: 'ok' | ||||
| 2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] Finished in 7.237s | ||||
| 2025-07-20 09:29:40 [falyx] [DEBUG] [my_pipeline] status=OK duration=7.237s result='ok' exception=None | ||||
| 2025-07-20 09:29:40 [falyx] [DEBUG] [Run My Pipeline] status=OK duration=7.238s result='ok' exception=None | ||||
| ``` | ||||
|  | ||||
| --- | ||||
| ### 📊 History Tracking | ||||
|  | ||||
| ### 🧱 Core Building Blocks | ||||
| View full execution history: | ||||
|  | ||||
| #### `Action` | ||||
| A single async unit of work. Painless retry support. | ||||
| ```bash | ||||
| > history | ||||
|                                                    📊 Execution History | ||||
|  | ||||
| #### `ChainedAction` | ||||
| Run tasks in sequence. Supports rollback on failure and context propagation. | ||||
|    Index   Name                           Start         End    Duration   Status        Result / Exception | ||||
|  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── | ||||
|        0   step_1                      09:23:55    09:23:55      0.201s   ✅ Success    'ok' | ||||
|        1   step_2                      09:23:55    09:24:03      7.829s   ❌ Error      RuntimeError('Random failure!') | ||||
|        2   my_pipeline                 09:23:55    09:24:03      8.080s   ❌ Error      RuntimeError('Random failure!') | ||||
|        3   Run My Pipeline             09:23:55    09:24:03      8.082s   ❌ Error      RuntimeError('Random failure!') | ||||
| ``` | ||||
|  | ||||
| #### `ActionGroup` | ||||
| Run tasks in parallel. Useful for fan-out operations like batch API calls. | ||||
| Inspect result by index: | ||||
|  | ||||
| #### `ProcessAction` | ||||
| Offload CPU-bound work to another process — no extra code needed. | ||||
| ```bash | ||||
| > history --result-index 0 | ||||
| Action(name='step_1', action=flaky_step, args=(), kwargs={}, retry=True, rollback=False) (): | ||||
| ok | ||||
| ``` | ||||
|  | ||||
| #### `Falyx` | ||||
| Your CLI controller — powers menus, subcommands, history, bottom bars, and more. | ||||
| Print last result includes tracebacks: | ||||
|  | ||||
| #### `ExecutionContext` | ||||
| Tracks metadata, arguments, timing, and results for each action execution. | ||||
|  | ||||
| #### `HookManager` | ||||
| Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for actions and commands. | ||||
| ```bash | ||||
| > history --last-result | ||||
| Command(key='R', description='Run My Pipeline' action='ChainedAction(name=my_pipeline, actions=['step_1', 'step_2'], | ||||
| args=(), kwargs={}, auto_inject=False, return_list=False)') (): | ||||
| Traceback (most recent call last): | ||||
|   File ".../falyx/command.py", line 291, in __call__ | ||||
|     result = await self.action(*combined_args, **combined_kwargs) | ||||
|              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|   File ".../falyx/action/base_action.py", line 91, in __call__ | ||||
|     return await self._run(*args, **kwargs) | ||||
|            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|   File ".../falyx/action/chained_action.py", line 212, in _run | ||||
|     result = await prepared(*combined_args, **updated_kwargs) | ||||
|              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|   File ".../falyx/action/base_action.py", line 91, in __call__ | ||||
|     return await self._run(*args, **kwargs) | ||||
|            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|   File ".../falyx/action/action.py", line 157, in _run | ||||
|     result = await self.action(*combined_args, **combined_kwargs) | ||||
|              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | ||||
|   File ".../falyx/examples/simple.py", line 15, in flaky_step | ||||
|     raise RuntimeError("Random failure!") | ||||
| RuntimeError: Random failure! | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -162,6 +222,6 @@ Registers and triggers lifecycle hooks (`before`, `after`, `on_error`, etc.) for | ||||
|  | ||||
| > “Like a phalanx: organized, resilient, and reliable.” | ||||
|  | ||||
| Falyx is designed for developers who don’t just want CLI tools to run — they want them to **fail meaningfully**, **recover gracefully**, and **log clearly**. | ||||
| Falyx is designed for developers who don’t just want CLI tools to run — they want them to **fail meaningfully**, **recover intentionally**, and **log clearly**. | ||||
|  | ||||
| --- | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ActionGroup, ChainedAction | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
|  | ||||
|  | ||||
| # Actions can be defined as synchronous functions | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ActionFactoryAction, ChainedAction, HTTPAction, SelectionAction | ||||
| from falyx.action import ActionFactory, ChainedAction, HTTPAction, SelectionAction | ||||
|  | ||||
| # Selection of a post ID to fetch (just an example set) | ||||
| post_selector = SelectionAction( | ||||
|     name="Pick Post ID", | ||||
|     selections=["1", "2", "3", "4", "5"], | ||||
|     selections=["15", "25", "35", "45", "55"], | ||||
|     title="Choose a Post ID to submit", | ||||
|     prompt_message="Post ID > ", | ||||
|     show_table=True, | ||||
| @@ -14,7 +14,7 @@ post_selector = SelectionAction( | ||||
|  | ||||
|  | ||||
| # Factory that builds and executes the actual HTTP POST request | ||||
| def build_post_action(post_id) -> HTTPAction: | ||||
| async def build_post_action(post_id) -> HTTPAction: | ||||
|     print(f"Building HTTPAction for Post ID: {post_id}") | ||||
|     return HTTPAction( | ||||
|         name=f"POST to /posts (id={post_id})", | ||||
| @@ -24,7 +24,7 @@ def build_post_action(post_id) -> HTTPAction: | ||||
|     ) | ||||
|  | ||||
|  | ||||
| post_factory = ActionFactoryAction( | ||||
| post_factory = ActionFactory( | ||||
|     name="Build HTTPAction from Post ID", | ||||
|     factory=build_post_action, | ||||
|     inject_last_result=True, | ||||
|   | ||||
							
								
								
									
										101
									
								
								examples/argument_examples.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								examples/argument_examples.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| import asyncio | ||||
| from enum import Enum | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action | ||||
| from falyx.parser.command_argument_parser import CommandArgumentParser | ||||
|  | ||||
|  | ||||
| class Place(Enum): | ||||
|     """Enum for different places.""" | ||||
|  | ||||
|     NEW_YORK = "New York" | ||||
|     SAN_FRANCISCO = "San Francisco" | ||||
|     LONDON = "London" | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| async def test_args( | ||||
|     service: str, | ||||
|     place: Place = Place.NEW_YORK, | ||||
|     region: str = "us-east-1", | ||||
|     tag: str | None = None, | ||||
|     verbose: bool | None = None, | ||||
|     number: int | None = None, | ||||
| ) -> str: | ||||
|     if verbose: | ||||
|         print(f"Deploying {service}:{tag}:{number} to {region} at {place}...") | ||||
|     return f"{service}:{tag}:{number} deployed to {region} at {place}" | ||||
|  | ||||
|  | ||||
| def default_config(parser: CommandArgumentParser) -> None: | ||||
|     """Default argument configuration for the command.""" | ||||
|     parser.add_argument( | ||||
|         "service", | ||||
|         type=str, | ||||
|         choices=["web", "database", "cache"], | ||||
|         help="Service name to deploy.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "place", | ||||
|         type=Place, | ||||
|         choices=list(Place), | ||||
|         default=Place.NEW_YORK, | ||||
|         help="Place where the service will be deployed.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--region", | ||||
|         type=str, | ||||
|         default="us-east-1", | ||||
|         help="Deployment region.", | ||||
|         choices=["us-east-1", "us-west-2", "eu-west-1"], | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--verbose", | ||||
|         action="store_bool_optional", | ||||
|         help="Enable verbose output.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--tag", | ||||
|         type=str, | ||||
|         help="Optional tag for the deployment.", | ||||
|         suggestions=["latest", "stable", "beta"], | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--number", | ||||
|         type=int, | ||||
|         help="Optional number argument.", | ||||
|     ) | ||||
|     parser.add_tldr_examples( | ||||
|         [ | ||||
|             ("web", "Deploy 'web' to the default location (New York)"), | ||||
|             ("cache London --tag beta", "Deploy 'cache' to London with tag"), | ||||
|             ("database --region us-west-2 --verbose", "Verbose deploy to west region"), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| flx = Falyx( | ||||
|     "Argument Examples", | ||||
|     program="argument_examples.py", | ||||
|     hide_menu_table=True, | ||||
|     show_placeholder_menu=True, | ||||
|     enable_prompt_history=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="T", | ||||
|     aliases=["test"], | ||||
|     description="Test Command", | ||||
|     help_text="A command to test argument parsing.", | ||||
|     action=Action( | ||||
|         name="test_args", | ||||
|         action=test_args, | ||||
|     ), | ||||
|     style="bold #B3EBF2", | ||||
|     argument_config=default_config, | ||||
| ) | ||||
|  | ||||
| asyncio.run(flx.run()) | ||||
							
								
								
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								examples/auto_args_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup | ||||
|  | ||||
|  | ||||
| # Define a shared async function | ||||
| async def say_hello(name: str, excited: bool = False): | ||||
|     if excited: | ||||
|         print(f"Hello, {name}!!!") | ||||
|     else: | ||||
|         print(f"Hello, {name}.") | ||||
|  | ||||
|  | ||||
| # Wrap the same callable in multiple Actions | ||||
| action1 = Action("say_hello_1", action=say_hello) | ||||
| action2 = Action("say_hello_2", action=say_hello) | ||||
| action3 = Action("say_hello_3", action=say_hello) | ||||
|  | ||||
| # Combine into an ActionGroup | ||||
| group = ActionGroup(name="greet_group", actions=[action1, action2, action3]) | ||||
|  | ||||
| flx = Falyx("Test Group") | ||||
| flx.add_command( | ||||
|     key="G", | ||||
|     description="Greet someone with multiple variations.", | ||||
|     aliases=["greet", "hello"], | ||||
|     action=group, | ||||
|     arg_metadata={ | ||||
|         "name": { | ||||
|             "help": "The name of the person to greet.", | ||||
|         }, | ||||
|         "excited": { | ||||
|             "help": "Whether to greet excitedly.", | ||||
|         }, | ||||
|     }, | ||||
| ) | ||||
| asyncio.run(flx.run()) | ||||
							
								
								
									
										62
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								examples/auto_parse_demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|  | ||||
|  | ||||
| async def deploy(service: str, region: str = "us-east-1", verbose: bool = False) -> str: | ||||
|     if verbose: | ||||
|         print(f"Deploying {service} to {region}...") | ||||
|     await asyncio.sleep(2) | ||||
|     if verbose: | ||||
|         print(f"{service} deployed successfully!") | ||||
|     return f"{service} deployed to {region}" | ||||
|  | ||||
|  | ||||
| flx = Falyx("Deployment CLI") | ||||
|  | ||||
| flx.add_command( | ||||
|     key="D", | ||||
|     aliases=["deploy"], | ||||
|     description="Deploy", | ||||
|     help_text="Deploy a service to a specified region.", | ||||
|     action=Action( | ||||
|         name="deploy_service", | ||||
|         action=deploy, | ||||
|     ), | ||||
|     arg_metadata={ | ||||
|         "service": "Service name", | ||||
|         "region": { | ||||
|             "help": "Deployment region", | ||||
|             "choices": ["us-east-1", "us-west-2", "eu-west-1"], | ||||
|         }, | ||||
|         "verbose": {"help": "Enable verbose mode"}, | ||||
|     }, | ||||
|     tags=["deployment", "service"], | ||||
| ) | ||||
|  | ||||
| deploy_chain = ChainedAction( | ||||
|     name="DeployChain", | ||||
|     actions=[ | ||||
|         Action(name="deploy_service", action=deploy), | ||||
|         Action( | ||||
|             name="notify", | ||||
|             action=lambda last_result: print(f"Notification: {last_result}"), | ||||
|         ), | ||||
|     ], | ||||
|     auto_inject=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="N", | ||||
|     aliases=["notify"], | ||||
|     description="Deploy and Notify", | ||||
|     help_text="Deploy a service and notify.", | ||||
|     action=deploy_chain, | ||||
|     tags=["deployment", "service", "notification"], | ||||
| ) | ||||
|  | ||||
| asyncio.run(flx.run()) | ||||
							
								
								
									
										127
									
								
								examples/confirm_example.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								examples/confirm_example.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| import asyncio | ||||
| from typing import Any | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionFactory, | ||||
|     ChainedAction, | ||||
|     ConfirmAction, | ||||
|     SaveFileAction, | ||||
| ) | ||||
| from falyx.parser import CommandArgumentParser | ||||
|  | ||||
|  | ||||
| class Dog(BaseModel): | ||||
|     name: str | ||||
|     age: int | ||||
|     breed: str | ||||
|  | ||||
|  | ||||
| async def get_dogs(*dog_names: str) -> list[Dog]: | ||||
|     """Simulate fetching dog data.""" | ||||
|     await asyncio.sleep(0.1)  # Simulate network delay | ||||
|     dogs = [ | ||||
|         Dog(name="Buddy", age=3, breed="Golden Retriever"), | ||||
|         Dog(name="Max", age=5, breed="Beagle"), | ||||
|         Dog(name="Bella", age=2, breed="Bulldog"), | ||||
|         Dog(name="Charlie", age=4, breed="Poodle"), | ||||
|         Dog(name="Lucy", age=1, breed="Labrador"), | ||||
|         Dog(name="Spot", age=6, breed="German Shepherd"), | ||||
|     ] | ||||
|     dogs = [ | ||||
|         dog for dog in dogs if dog.name.upper() in (name.upper() for name in dog_names) | ||||
|     ] | ||||
|     if not dogs: | ||||
|         raise ValueError(f"No dogs found with the names: {', '.join(dog_names)}") | ||||
|     return dogs | ||||
|  | ||||
|  | ||||
| async def build_json_updates(dogs: list[Dog]) -> list[dict[str, Any]]: | ||||
|     """Build JSON updates for the dogs.""" | ||||
|     print(f"Building JSON updates for {','.join(dog.name for dog in dogs)}") | ||||
|     return [dog.model_dump(mode="json") for dog in dogs] | ||||
|  | ||||
|  | ||||
| async def save_dogs(dogs) -> None: | ||||
|     if not dogs: | ||||
|         print("No dogs processed.") | ||||
|         return | ||||
|     for result in dogs: | ||||
|         print(f"Saving {Dog(**result)} to file.") | ||||
|         await SaveFileAction( | ||||
|             name="Save Dog Data", | ||||
|             file_path=f"dogs/{result['name']}.json", | ||||
|             data=result, | ||||
|             file_type="json", | ||||
|         )() | ||||
|  | ||||
|  | ||||
| async def build_chain(dogs: list[Dog]) -> ChainedAction: | ||||
|     return ChainedAction( | ||||
|         name="test_chain", | ||||
|         actions=[ | ||||
|             Action( | ||||
|                 name="build_json_updates", | ||||
|                 action=build_json_updates, | ||||
|                 kwargs={"dogs": dogs}, | ||||
|             ), | ||||
|             ConfirmAction( | ||||
|                 name="test_confirm", | ||||
|                 prompt_message="Do you want to process the dogs?", | ||||
|                 confirm_type="yes_no_cancel", | ||||
|                 return_last_result=True, | ||||
|                 inject_into="dogs", | ||||
|             ), | ||||
|             Action( | ||||
|                 name="save_dogs", | ||||
|                 action=save_dogs, | ||||
|                 inject_into="dogs", | ||||
|             ), | ||||
|         ], | ||||
|         auto_inject=True, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| factory = ActionFactory( | ||||
|     name="Dog Post Factory", | ||||
|     factory=build_chain, | ||||
|     preview_kwargs={"dogs": ["Buddy", "Max"]}, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def dog_config(parser: CommandArgumentParser) -> None: | ||||
|     parser.add_argument( | ||||
|         "dogs", | ||||
|         nargs="+", | ||||
|         action="action", | ||||
|         resolver=Action("Get Dogs", get_dogs), | ||||
|         lazy_resolver=False, | ||||
|         help="List of dogs to process.", | ||||
|     ) | ||||
|     parser.add_tldr_examples( | ||||
|         [ | ||||
|             ("max", "Process the dog named Max"), | ||||
|             ("bella buddy max", "Process the dogs named Bella, Buddy, and Max"), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def main(): | ||||
|     flx = Falyx("Save Dogs Example", program="confirm_example.py") | ||||
|  | ||||
|     flx.add_command( | ||||
|         key="D", | ||||
|         description="Save Dog Data", | ||||
|         action=factory, | ||||
|         aliases=["save_dogs"], | ||||
|         argument_config=dog_config, | ||||
|     ) | ||||
|  | ||||
|     await flx.run() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(main()) | ||||
| @@ -3,7 +3,7 @@ commands: | ||||
|     description: Pipeline Demo | ||||
|     action: pipeline_demo.pipeline | ||||
|     tags: [pipeline, demo] | ||||
|     help_text: Run Demployment Pipeline with retries. | ||||
|     help_text: Run Deployment Pipeline with retries. | ||||
|  | ||||
|   - key: G | ||||
|     description: Run HTTP Action Group | ||||
|   | ||||
| @@ -7,11 +7,9 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
| from argparse import Namespace | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, ChainedAction | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | ||||
| from falyx.version import __version__ | ||||
|  | ||||
|  | ||||
| @@ -74,17 +72,10 @@ class Foo: | ||||
|         await self.flx.run() | ||||
|  | ||||
|  | ||||
| def parse_args() -> Namespace: | ||||
|     parsers: FalyxParsers = get_arg_parsers() | ||||
|     return parsers.parse_args() | ||||
|  | ||||
|  | ||||
| async def main() -> None: | ||||
|     """Build and return a Falyx instance with all your commands.""" | ||||
|     args = parse_args() | ||||
|     flx = Falyx( | ||||
|         title="🚀 Falyx CLI", | ||||
|         cli_args=args, | ||||
|         columns=5, | ||||
|         welcome_message="Welcome to Falyx CLI!", | ||||
|         exit_message="Goodbye!", | ||||
| @@ -93,7 +84,7 @@ async def main() -> None: | ||||
|  | ||||
|     # --- 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("B", "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__}") | ||||
|  | ||||
|   | ||||
| @@ -2,18 +2,26 @@ import asyncio | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import SelectFileAction | ||||
| from falyx.action.types import FileReturnType | ||||
| from falyx.action.action_types import FileType | ||||
|  | ||||
| sf = SelectFileAction( | ||||
|     name="select_file", | ||||
|     suffix_filter=".py", | ||||
|     suffix_filter=".yaml", | ||||
|     title="Select a YAML file", | ||||
|     prompt_message="Choose > ", | ||||
|     return_type=FileReturnType.TEXT, | ||||
|     prompt_message="Choose 2 > ", | ||||
|     return_type=FileType.TEXT, | ||||
|     columns=3, | ||||
|     number_selections=2, | ||||
| ) | ||||
|  | ||||
| flx = Falyx() | ||||
| flx = Falyx( | ||||
|     title="File Selection Example", | ||||
|     description="This example demonstrates how to select files using Falyx.", | ||||
|     version="1.0.0", | ||||
|     program="file_select.py", | ||||
|     hide_menu_table=True, | ||||
|     show_placeholder_menu=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="S", | ||||
|   | ||||
| @@ -2,9 +2,8 @@ import asyncio | ||||
|  | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx import ActionGroup, Falyx | ||||
| from falyx.action import HTTPAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx import Falyx | ||||
| from falyx.action import ActionGroup, HTTPAction | ||||
| from falyx.hooks import ResultReporter | ||||
|  | ||||
| console = Console() | ||||
| @@ -49,7 +48,7 @@ action_group = ActionGroup( | ||||
| reporter = ResultReporter() | ||||
|  | ||||
| action_group.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -2,8 +2,16 @@ import asyncio | ||||
| import time | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, MenuAction, ProcessAction | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     MenuAction, | ||||
|     ProcessAction, | ||||
|     PromptMenuAction, | ||||
| ) | ||||
| from falyx.menu import MenuOption, MenuOptionMap | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| # Basic coroutine for Action | ||||
| @@ -77,20 +85,28 @@ parallel = ActionGroup( | ||||
|  | ||||
| process = ProcessAction(name="compute", action=heavy_computation) | ||||
|  | ||||
| menu_options = MenuOptionMap( | ||||
|     { | ||||
|         "A": MenuOption("Run basic Action", basic_action, style=OneColors.LIGHT_YELLOW), | ||||
|         "C": MenuOption("Run ChainedAction", chained, style=OneColors.MAGENTA), | ||||
|         "P": MenuOption("Run ActionGroup (parallel)", parallel, style=OneColors.CYAN), | ||||
|         "H": MenuOption("Run ProcessAction (heavy task)", process, style=OneColors.GREEN), | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| # Menu setup | ||||
|  | ||||
| menu = MenuAction( | ||||
|     name="main-menu", | ||||
|     title="Choose a task to run", | ||||
|     menu_options=MenuOptionMap( | ||||
|         { | ||||
|             "1": MenuOption("Run basic Action", basic_action), | ||||
|             "2": MenuOption("Run ChainedAction", chained), | ||||
|             "3": MenuOption("Run ActionGroup (parallel)", parallel), | ||||
|             "4": MenuOption("Run ProcessAction (heavy task)", process), | ||||
|         } | ||||
|     ), | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
|  | ||||
| prompt_menu = PromptMenuAction( | ||||
|     name="select-user", | ||||
|     menu_options=menu_options, | ||||
| ) | ||||
|  | ||||
| flx = Falyx( | ||||
| @@ -108,6 +124,13 @@ flx.add_command( | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     key="P", | ||||
|     description="Show Prompt Menu", | ||||
|     action=prompt_menu, | ||||
|     logging_hooks=True, | ||||
| ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(flx.run()) | ||||
|   | ||||
| @@ -1,61 +1,92 @@ | ||||
| import asyncio | ||||
| import random | ||||
| import time | ||||
|  | ||||
| from falyx import Action, ActionGroup, ChainedAction | ||||
| from falyx import ExecutionRegistry as er | ||||
| from falyx import ProcessAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ActionGroup, ChainedAction, ProcessAction | ||||
| from falyx.console import console | ||||
|  | ||||
|  | ||||
| # Step 1: Fast I/O-bound setup (standard Action) | ||||
| async def checkout_code(): | ||||
|     print("📥 Checking out code...") | ||||
|     console.print("🔄 Checking out code...") | ||||
|     await asyncio.sleep(0.5) | ||||
|     console.print("📦 Code checked out successfully.") | ||||
|  | ||||
|  | ||||
| # 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 | ||||
|     time.sleep(2) | ||||
|     return total | ||||
|  | ||||
|  | ||||
| # Step 3: Simulated flaky test with retry | ||||
| async def flaky_tests(): | ||||
|     import random | ||||
|  | ||||
|     console.print("🧪 Running tests...") | ||||
|     await asyncio.sleep(0.3) | ||||
|     if random.random() < 0.3: | ||||
|         raise RuntimeError("❌ Random test failure!") | ||||
|     print("🧪 Tests passed.") | ||||
|     console.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) | ||||
|     console.print(f"🚀 Deploying to {target}...") | ||||
|     await asyncio.sleep(random.randint(2, 6)) | ||||
|     console.print(f"✅ Deployment to {target} complete.") | ||||
|     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(HookType.ON_ERROR, retry_handler.retry_on_error) | ||||
|     analysis = ProcessAction( | ||||
|         "Static Analysis", | ||||
|         run_static_analysis, | ||||
|         spinner=True, | ||||
|         spinner_message="Analyzing code...", | ||||
|     ) | ||||
|     analysis.hooks.register( | ||||
|         "before", lambda ctx: console.print("🧠 Running static analysis (CPU-bound)...") | ||||
|     ) | ||||
|     analysis.hooks.register("after", lambda ctx: console.print("🧠 Analysis complete!")) | ||||
|     tests = Action( | ||||
|         "Run Tests", | ||||
|         flaky_tests, | ||||
|         retry=True, | ||||
|         spinner=True, | ||||
|         spinner_message="Running tests...", | ||||
|     ) | ||||
|  | ||||
|     # 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",)), | ||||
|             Action( | ||||
|                 "Deploy US", | ||||
|                 deploy_to, | ||||
|                 args=("us-west",), | ||||
|                 spinner=True, | ||||
|                 spinner_message="Deploying US...", | ||||
|             ), | ||||
|             Action( | ||||
|                 "Deploy EU", | ||||
|                 deploy_to, | ||||
|                 args=("eu-central",), | ||||
|                 spinner=True, | ||||
|                 spinner_message="Deploying EU...", | ||||
|             ), | ||||
|             Action( | ||||
|                 "Deploy Asia", | ||||
|                 deploy_to, | ||||
|                 args=("asia-east",), | ||||
|                 spinner=True, | ||||
|                 spinner_message="Deploying Asia...", | ||||
|             ), | ||||
|         ], | ||||
|     ) | ||||
|  | ||||
| @@ -68,10 +99,22 @@ pipeline = build_pipeline() | ||||
|  | ||||
| # Run the pipeline | ||||
| async def main(): | ||||
|     pipeline = build_pipeline() | ||||
|     await pipeline() | ||||
|     er.summary() | ||||
|     await pipeline.preview() | ||||
|  | ||||
|     flx = Falyx( | ||||
|         hide_menu_table=True, program="pipeline_demo.py", show_placeholder_menu=True | ||||
|     ) | ||||
|     flx.add_command( | ||||
|         "P", | ||||
|         "Run Pipeline", | ||||
|         pipeline, | ||||
|         spinner=True, | ||||
|         spinner_type="line", | ||||
|         spinner_message="Running pipeline...", | ||||
|         tags=["pipeline", "demo"], | ||||
|         help_text="Run the full CI/CD pipeline demo.", | ||||
|     ) | ||||
|  | ||||
|     await flx.run() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
| @@ -1,25 +1,36 @@ | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx import Falyx, ProcessAction | ||||
| from falyx import Falyx | ||||
| from falyx.action import ProcessPoolAction | ||||
| from falyx.action.process_pool_action import ProcessTask | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.themes import NordColors as nc | ||||
|  | ||||
| console = Console() | ||||
| falyx = Falyx(title="🚀 Process Pool Demo") | ||||
|  | ||||
|  | ||||
| def generate_primes(n): | ||||
|     primes = [] | ||||
|     for num in range(2, n): | ||||
| def generate_primes(start: int = 2, end: int = 100_000) -> list[int]: | ||||
|     primes: list[int] = [] | ||||
|     console.print(f"Generating primes from {start} to {end}...", style=nc.YELLOW) | ||||
|     for num in range(start, end): | ||||
|         if all(num % p != 0 for p in primes): | ||||
|             primes.append(num) | ||||
|     console.print(f"Generated {len(primes)} primes up to {n}.", style=nc.GREEN) | ||||
|     console.print( | ||||
|         f"Generated {len(primes)} primes from {start} to {end}.", style=nc.GREEN | ||||
|     ) | ||||
|     return primes | ||||
|  | ||||
|  | ||||
| # Will not block the event loop | ||||
| heavy_action = ProcessAction("Prime Generator", generate_primes, args=(100_000,)) | ||||
| actions = [ProcessTask(task=generate_primes)] | ||||
|  | ||||
| falyx.add_command("R", "Generate Primes", heavy_action, spinner=True) | ||||
| # Will not block the event loop | ||||
| heavy_action = ProcessPoolAction( | ||||
|     name="Prime Generator", | ||||
|     actions=actions, | ||||
| ) | ||||
|  | ||||
| falyx.add_command("R", "Generate Primes", heavy_action) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action | ||||
|  | ||||
|  | ||||
| async def main(): | ||||
|   | ||||
| @@ -1,22 +1,70 @@ | ||||
| import asyncio | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
| ) | ||||
| from falyx import Falyx | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
| from falyx.signals import CancelSignal | ||||
|  | ||||
| menu = { | ||||
|     "A": SelectionOption("Run diagnostics", lambda: print("Running diagnostics...")), | ||||
|     "B": SelectionOption("Deploy to staging", lambda: print("Deploying...")), | ||||
| selections = { | ||||
|     "1": SelectionOption( | ||||
|         description="Production", value="3bc2616e-3696-11f0-a139-089204eb86ac" | ||||
|     ), | ||||
|     "2": SelectionOption( | ||||
|         description="Staging", value="42f2cd84-3696-11f0-a139-089204eb86ac" | ||||
|     ), | ||||
| } | ||||
|  | ||||
| table = render_selection_dict_table( | ||||
|     title="Main Menu", | ||||
|     selections=menu, | ||||
|  | ||||
| select = SelectionAction( | ||||
|     name="Select Deployment", | ||||
|     selections=selections, | ||||
|     title="Select a Deployment", | ||||
|     columns=2, | ||||
|     prompt_message="> ", | ||||
|     return_type="value", | ||||
|     show_table=True, | ||||
| ) | ||||
|  | ||||
| key = asyncio.run(prompt_for_selection(menu.keys(), table)) | ||||
| print(f"You selected: {key}") | ||||
| list_selections = [uuid4() for _ in range(10)] | ||||
|  | ||||
| menu[key.upper()].value() | ||||
| list_select = SelectionAction( | ||||
|     name="Select Deployments", | ||||
|     selections=list_selections, | ||||
|     title="Select Deployments", | ||||
|     columns=3, | ||||
|     prompt_message="Select 3 Deployments > ", | ||||
|     return_type="value", | ||||
|     show_table=True, | ||||
|     number_selections=3, | ||||
| ) | ||||
|  | ||||
|  | ||||
| flx = Falyx() | ||||
|  | ||||
| flx.add_command( | ||||
|     key="S", | ||||
|     description="Select a deployment", | ||||
|     action=select, | ||||
|     help_text="Select a deployment from the list", | ||||
| ) | ||||
| flx.add_command( | ||||
|     key="L", | ||||
|     description="Select deployments", | ||||
|     action=list_select, | ||||
|     help_text="Select multiple deployments from the list", | ||||
| ) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|  | ||||
|     try: | ||||
|         print(asyncio.run(select())) | ||||
|     except CancelSignal: | ||||
|         print("Selection was cancelled.") | ||||
|  | ||||
|     try: | ||||
|         print(asyncio.run(list_select())) | ||||
|     except CancelSignal: | ||||
|         print("Selection was cancelled.") | ||||
|  | ||||
|     asyncio.run(flx.run()) | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| #!/usr/bin/env python | ||||
| import asyncio | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx.action import ShellAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction, ShellAction | ||||
| from falyx.hooks import ResultReporter | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| @@ -42,12 +41,12 @@ reporter = ResultReporter() | ||||
|  | ||||
| a1 = Action("a1", a1, inject_last_result=True) | ||||
| a1.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
| a2 = Action("a2", a2, inject_last_result=True) | ||||
| a2.hooks.register( | ||||
|     HookType.ON_SUCCESS, | ||||
|     "on_success", | ||||
|     reporter.report, | ||||
| ) | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
| @@ -10,15 +11,15 @@ setup_logging() | ||||
| # A flaky async step that fails randomly | ||||
| async def flaky_step() -> str: | ||||
|     await asyncio.sleep(0.2) | ||||
|     if random.random() < 0.3: | ||||
|     if random.random() < 0.5: | ||||
|         raise RuntimeError("Random failure!") | ||||
|     print("Flaky step succeeded!") | ||||
|     print("ok") | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| # Create a retry handler | ||||
| step1 = Action(name="step_1", action=flaky_step, retry=True) | ||||
| step2 = Action(name="step_2", action=flaky_step, retry=True) | ||||
| step1 = Action(name="step_1", action=flaky_step) | ||||
| step2 = Action(name="step_2", action=flaky_step) | ||||
|  | ||||
| # Chain the actions | ||||
| chain = ChainedAction(name="my_pipeline", actions=[step1, step2]) | ||||
| @@ -32,6 +33,8 @@ falyx.add_command( | ||||
|     logging_hooks=True, | ||||
|     preview_before_confirm=True, | ||||
|     confirm=True, | ||||
|     retry_all=True, | ||||
|     spinner=True, | ||||
| ) | ||||
|  | ||||
| # Entry point | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import asyncio | ||||
| import random | ||||
|  | ||||
| from falyx import Action, ChainedAction, Falyx | ||||
| from falyx import Falyx | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.utils import setup_logging | ||||
|  | ||||
| setup_logging() | ||||
|   | ||||
							
								
								
									
										100
									
								
								examples/type_validation.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								examples/type_validation.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import asyncio | ||||
| from uuid import UUID, uuid4 | ||||
|  | ||||
| from falyx import Falyx | ||||
| from falyx.parser import CommandArgumentParser | ||||
|  | ||||
| flx = Falyx("Test Type Validation") | ||||
|  | ||||
|  | ||||
| def uuid_val(value: str) -> str: | ||||
|     """Custom validator to ensure a string is a valid UUID.""" | ||||
|     UUID(value) | ||||
|     return value | ||||
|  | ||||
|  | ||||
| async def print_uuid(uuid: str) -> str: | ||||
|     """Prints the UUID if valid.""" | ||||
|     print(f"Valid UUID: {uuid}") | ||||
|     return uuid | ||||
|  | ||||
|  | ||||
| flx.add_command( | ||||
|     "U", | ||||
|     "Print a valid UUID (arguemnts)", | ||||
|     print_uuid, | ||||
|     arguments=[ | ||||
|         { | ||||
|             "flags": ["uuid"], | ||||
|             "type": uuid_val, | ||||
|             "help": "A valid UUID string", | ||||
|         } | ||||
|     ], | ||||
| ) | ||||
|  | ||||
|  | ||||
| def uuid_parser(parser: CommandArgumentParser) -> None: | ||||
|     """Custom parser to ensure the UUID argument is valid.""" | ||||
|     parser.add_argument( | ||||
|         "uuid", | ||||
|         type=uuid_val, | ||||
|         help="A valid UUID string", | ||||
|     ) | ||||
|  | ||||
|  | ||||
| flx.add_command( | ||||
|     "I", | ||||
|     "Print a valid UUID (argument_config)", | ||||
|     print_uuid, | ||||
|     argument_config=uuid_parser, | ||||
| ) | ||||
|  | ||||
| flx.add_command( | ||||
|     "D", | ||||
|     "Print a valid UUID (arg_metadata)", | ||||
|     print_uuid, | ||||
|     arg_metadata={ | ||||
|         "uuid": { | ||||
|             "type": uuid_val, | ||||
|             "help": "A valid UUID string", | ||||
|         } | ||||
|     }, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def custom_parser(arguments: list[str]) -> tuple[tuple, dict]: | ||||
|     """Custom parser to ensure the UUID argument is valid.""" | ||||
|     if len(arguments) != 1: | ||||
|         raise ValueError("Exactly one argument is required") | ||||
|     uuid_val(arguments[0]) | ||||
|     return (arguments[0],), {} | ||||
|  | ||||
|  | ||||
| flx.add_command( | ||||
|     "C", | ||||
|     "Print a valid UUID (custom_parser)", | ||||
|     print_uuid, | ||||
|     custom_parser=custom_parser, | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def generate_uuid() -> str: | ||||
|     """Generates a new UUID.""" | ||||
|     new_uuid = uuid4() | ||||
|     print(f"Generated UUID: {new_uuid}") | ||||
|     return new_uuid | ||||
|  | ||||
|  | ||||
| flx.add_command( | ||||
|     "G", | ||||
|     "Generate a new UUID", | ||||
|     lambda: print(uuid4()), | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def main() -> None: | ||||
|     await flx.run() | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     asyncio.run(main()) | ||||
| @@ -22,7 +22,7 @@ chain = ChainedAction( | ||||
|         "Name", | ||||
|         UserInputAction( | ||||
|             name="User Input", | ||||
|             prompt_text="Enter your {last_result}: ", | ||||
|             prompt_message="Enter your {last_result}: ", | ||||
|             validator=validate_alpha(), | ||||
|         ), | ||||
|         Action( | ||||
|   | ||||
| @@ -7,24 +7,13 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
|  | ||||
| import logging | ||||
|  | ||||
| from .action.action import Action, ActionGroup, ChainedAction, ProcessAction | ||||
| from .command import Command | ||||
| from .context import ExecutionContext, SharedContext | ||||
| from .execution_registry import ExecutionRegistry | ||||
| from .falyx import Falyx | ||||
| from .hook_manager import HookType | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
|  | ||||
|  | ||||
| __all__ = [ | ||||
|     "Action", | ||||
|     "ChainedAction", | ||||
|     "ActionGroup", | ||||
|     "ProcessAction", | ||||
|     "Falyx", | ||||
|     "Command", | ||||
|     "ExecutionContext", | ||||
|     "SharedContext", | ||||
|     "ExecutionRegistry", | ||||
|     "HookType", | ||||
| ] | ||||
|   | ||||
| @@ -8,13 +8,13 @@ Licensed under the MIT License. See LICENSE file for details. | ||||
| import asyncio | ||||
| import os | ||||
| import sys | ||||
| from argparse import Namespace | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.config import loader | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.parsers import FalyxParsers, get_arg_parsers | ||||
| from falyx.parser import CommandArgumentParser, get_root_parser, get_subparsers | ||||
|  | ||||
|  | ||||
| def find_falyx_config() -> Path | None: | ||||
| @@ -39,44 +39,81 @@ def bootstrap() -> Path | None: | ||||
|     return config_path | ||||
|  | ||||
|  | ||||
| def get_falyx_parsers() -> FalyxParsers: | ||||
|     falyx_parsers: FalyxParsers = get_arg_parsers() | ||||
|     init_parser = falyx_parsers.subparsers.add_parser( | ||||
|         "init", help="Create a new Falyx CLI project" | ||||
| def init_config(parser: CommandArgumentParser) -> None: | ||||
|     parser.add_argument( | ||||
|         "name", | ||||
|         type=str, | ||||
|         help="Name of the new Falyx project", | ||||
|         default=".", | ||||
|         nargs="?", | ||||
|     ) | ||||
|     init_parser.add_argument("name", nargs="?", default=".", help="Project directory") | ||||
|     falyx_parsers.subparsers.add_parser( | ||||
|         "init-global", help="Set up ~/.config/falyx with example tasks" | ||||
|     ) | ||||
|     return falyx_parsers | ||||
|  | ||||
|  | ||||
| def run(args: Namespace) -> Any: | ||||
| def init_callback(args: Namespace) -> None: | ||||
|     """Callback for the init command.""" | ||||
|     if args.command == "init": | ||||
|         from falyx.init import init_project | ||||
|  | ||||
|         init_project(args.name) | ||||
|         return | ||||
|  | ||||
|     if args.command == "init-global": | ||||
|     elif args.command == "init_global": | ||||
|         from falyx.init import init_global | ||||
|  | ||||
|         init_global() | ||||
|         return | ||||
|  | ||||
|  | ||||
| def get_parsers() -> tuple[ArgumentParser, _SubParsersAction]: | ||||
|     root_parser: ArgumentParser = get_root_parser() | ||||
|     subparsers = get_subparsers(root_parser) | ||||
|     init_parser = subparsers.add_parser( | ||||
|         "init", | ||||
|         help="Initialize a new Falyx project", | ||||
|         description="Create a new Falyx project with mock configuration files.", | ||||
|         epilog="If no name is provided, the current directory will be used.", | ||||
|     ) | ||||
|     init_parser.add_argument( | ||||
|         "name", | ||||
|         type=str, | ||||
|         help="Name of the new Falyx project", | ||||
|         default=".", | ||||
|         nargs="?", | ||||
|     ) | ||||
|     subparsers.add_parser( | ||||
|         "init-global", | ||||
|         help="Initialize Falyx global configuration", | ||||
|         description="Create a global Falyx configuration at ~/.config/falyx/.", | ||||
|     ) | ||||
|     return root_parser, subparsers | ||||
|  | ||||
|  | ||||
| def main() -> Any: | ||||
|     bootstrap_path = bootstrap() | ||||
|     if not bootstrap_path: | ||||
|         print("No Falyx config file found. Exiting.") | ||||
|         return None | ||||
|         from falyx.init import init_global, init_project | ||||
|  | ||||
|     flx: Falyx = loader(bootstrap_path) | ||||
|     return asyncio.run(flx.run()) | ||||
|         flx: Falyx = Falyx() | ||||
|         flx.add_command( | ||||
|             "I", | ||||
|             "Initialize a new Falyx project", | ||||
|             init_project, | ||||
|             aliases=["init"], | ||||
|             argument_config=init_config, | ||||
|             help_epilog="If no name is provided, the current directory will be used.", | ||||
|         ) | ||||
|         flx.add_command( | ||||
|             "G", | ||||
|             "Initialize Falyx global configuration", | ||||
|             init_global, | ||||
|             aliases=["init-global"], | ||||
|             help_text="Create a global Falyx configuration at ~/.config/falyx/.", | ||||
|         ) | ||||
|     else: | ||||
|         flx = loader(bootstrap_path) | ||||
|  | ||||
|     root_parser, subparsers = get_parsers() | ||||
|  | ||||
| def main(): | ||||
|     parsers = get_falyx_parsers() | ||||
|     args = parsers.parse_args() | ||||
|     run(args) | ||||
|     return asyncio.run( | ||||
|         flx.run(root_parser=root_parser, subparsers=subparsers, callback=init_callback) | ||||
|     ) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
							
								
								
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								falyx/action/.pytyped
									
									
									
									
									
										Normal file
									
								
							| @@ -5,21 +5,25 @@ Copyright (c) 2025 rtj.dev LLC. | ||||
| Licensed under the MIT License. See LICENSE file for details. | ||||
| """ | ||||
|  | ||||
| from .action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     BaseAction, | ||||
|     ChainedAction, | ||||
|     FallbackAction, | ||||
|     LiteralInputAction, | ||||
|     ProcessAction, | ||||
| ) | ||||
| from .action_factory import ActionFactoryAction | ||||
| from .action import Action | ||||
| from .action_factory import ActionFactory | ||||
| from .action_group import ActionGroup | ||||
| from .base_action import BaseAction | ||||
| from .chained_action import ChainedAction | ||||
| from .confirm_action import ConfirmAction | ||||
| from .fallback_action import FallbackAction | ||||
| from .http_action import HTTPAction | ||||
| from .io_action import BaseIOAction, ShellAction | ||||
| from .io_action import BaseIOAction | ||||
| from .literal_input_action import LiteralInputAction | ||||
| from .load_file_action import LoadFileAction | ||||
| from .menu_action import MenuAction | ||||
| from .process_action import ProcessAction | ||||
| from .process_pool_action import ProcessPoolAction | ||||
| from .prompt_menu_action import PromptMenuAction | ||||
| from .save_file_action import SaveFileAction | ||||
| from .select_file_action import SelectFileAction | ||||
| from .selection_action import SelectionAction | ||||
| from .shell_action import ShellAction | ||||
| from .signal_action import SignalAction | ||||
| from .user_input_action import UserInputAction | ||||
|  | ||||
| @@ -29,7 +33,7 @@ __all__ = [ | ||||
|     "BaseAction", | ||||
|     "ChainedAction", | ||||
|     "ProcessAction", | ||||
|     "ActionFactoryAction", | ||||
|     "ActionFactory", | ||||
|     "HTTPAction", | ||||
|     "BaseIOAction", | ||||
|     "ShellAction", | ||||
| @@ -40,4 +44,9 @@ __all__ = [ | ||||
|     "FallbackAction", | ||||
|     "LiteralInputAction", | ||||
|     "UserInputAction", | ||||
|     "PromptMenuAction", | ||||
|     "ProcessPoolAction", | ||||
|     "LoadFileAction", | ||||
|     "SaveFileAction", | ||||
|     "ConfirmAction", | ||||
| ] | ||||
|   | ||||
| @@ -1,167 +1,54 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action.py | ||||
| """ | ||||
| Defines `Action`, the core atomic unit in the Falyx CLI framework, used to wrap and | ||||
| execute a single callable or coroutine with structured lifecycle support. | ||||
|  | ||||
| Core action system for Falyx. | ||||
| An `Action` is the simplest building block in Falyx's execution model, enabling | ||||
| developers to turn ordinary Python functions into hookable, retryable, introspectable | ||||
| workflow steps. It supports synchronous or asynchronous callables, argument injection, | ||||
| rollback handlers, and retry policies. | ||||
|  | ||||
| This module defines the building blocks for executable actions and workflows, | ||||
| providing a structured way to compose, execute, recover, and manage sequences of | ||||
| operations. | ||||
| Key Features: | ||||
| - Lifecycle hooks: `before`, `on_success`, `on_error`, `after`, `on_teardown` | ||||
| - Optional `last_result` injection for chained workflows | ||||
| - Retry logic via configurable `RetryPolicy` and `RetryHandler` | ||||
| - Rollback function support for recovery and undo behavior | ||||
| - Rich preview output for introspection and dry-run diagnostics | ||||
|  | ||||
| All actions are callable and follow a unified signature: | ||||
|     result = action(*args, **kwargs) | ||||
| Usage Scenarios: | ||||
| - Wrapping business logic, utility functions, or external API calls | ||||
| - Converting lightweight callables into structured CLI actions | ||||
| - Composing workflows using `Action`, `ChainedAction`, or `ActionGroup` | ||||
|  | ||||
| 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. | ||||
| Example: | ||||
|     def compute(x, y): | ||||
|         return x + y | ||||
|  | ||||
| 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. | ||||
|     Action( | ||||
|         name="AddNumbers", | ||||
|         action=compute, | ||||
|         args=(2, 3), | ||||
|     ) | ||||
|  | ||||
| This design promotes clean, fault-tolerant, modular CLI and automation systems. | ||||
| This module serves as the foundation for building robust, observable, | ||||
| and composable CLI automation flows in Falyx. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
| from abc import ABC, abstractmethod | ||||
| from concurrent.futures import ProcessPoolExecutor | ||||
| from functools import cached_property, partial | ||||
| from typing import Any, Callable | ||||
| from typing import Any, Awaitable, Callable | ||||
|  | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.exceptions import EmptyChainError | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class BaseAction(ABC): | ||||
|     """ | ||||
|     Base class for actions. Actions can be simple functions or more | ||||
|     complex actions like `ChainedAction` or `ActionGroup`. They can also | ||||
|     be run independently or as part of Falyx. | ||||
|  | ||||
|     inject_last_result (bool): Whether to inject the previous action's result | ||||
|                                into kwargs. | ||||
|     inject_into (str): The name of the kwarg key to inject the result as | ||||
|                                  (default: 'last_result'). | ||||
|     _requires_injection (bool): Whether the action requires input injection. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool = False, | ||||
|         logging_hooks: bool = False, | ||||
|     ) -> None: | ||||
|         self.name = name | ||||
|         self.hooks = hooks or HookManager() | ||||
|         self.is_retryable: bool = False | ||||
|         self.shared_context: SharedContext | None = None | ||||
|         self.inject_last_result: bool = inject_last_result | ||||
|         self.inject_into: str = inject_into | ||||
|         self._never_prompt: bool = never_prompt | ||||
|         self._requires_injection: bool = False | ||||
|         self._skip_in_chain: bool = False | ||||
|         self.console = Console(color_system="auto") | ||||
|         self.options_manager: OptionsManager | None = None | ||||
|  | ||||
|         if logging_hooks: | ||||
|             register_debug_hooks(self.hooks) | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs) -> Any: | ||||
|         return await self._run(*args, **kwargs) | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         raise NotImplementedError("_run must be implemented by subclasses") | ||||
|  | ||||
|     @abstractmethod | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         raise NotImplementedError("preview must be implemented by subclasses") | ||||
|  | ||||
|     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||
|         self.options_manager = options_manager | ||||
|  | ||||
|     def set_shared_context(self, shared_context: SharedContext) -> None: | ||||
|         self.shared_context = shared_context | ||||
|  | ||||
|     def get_option(self, option_name: str, default: Any = None) -> Any: | ||||
|         """ | ||||
|         Resolve an option from the OptionsManager if present, otherwise use the fallback. | ||||
|         """ | ||||
|         if self.options_manager: | ||||
|             return self.options_manager.get(option_name, default) | ||||
|         return default | ||||
|  | ||||
|     @property | ||||
|     def last_result(self) -> Any: | ||||
|         """Return the last result from the shared context.""" | ||||
|         if self.shared_context: | ||||
|             return self.shared_context.last_result() | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def never_prompt(self) -> bool: | ||||
|         return self.get_option("never_prompt", self._never_prompt) | ||||
|  | ||||
|     def prepare( | ||||
|         self, shared_context: SharedContext, options_manager: OptionsManager | None = None | ||||
|     ) -> BaseAction: | ||||
|         """ | ||||
|         Prepare the action specifically for sequential (ChainedAction) execution. | ||||
|         Can be overridden for chain-specific logic. | ||||
|         """ | ||||
|         self.set_shared_context(shared_context) | ||||
|         if options_manager: | ||||
|             self.set_options_manager(options_manager) | ||||
|         return self | ||||
|  | ||||
|     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: | ||||
|         if self.inject_last_result and self.shared_context: | ||||
|             key = self.inject_into | ||||
|             if key in kwargs: | ||||
|                 logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key) | ||||
|             kwargs = dict(kwargs) | ||||
|             kwargs[key] = self.shared_context.last_result() | ||||
|         return kwargs | ||||
|  | ||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||
|         """Register a hook for all actions and sub-actions.""" | ||||
|         self.hooks.register(hook_type, hook) | ||||
|  | ||||
|     async def _write_stdout(self, data: str) -> None: | ||||
|         """Override in subclasses that produce terminal output.""" | ||||
|  | ||||
|     def requires_io_injection(self) -> bool: | ||||
|         """Checks to see if the action requires input injection.""" | ||||
|         return self._requires_injection | ||||
|  | ||||
|     def __repr__(self) -> str: | ||||
|         return str(self) | ||||
|  | ||||
|  | ||||
| class Action(BaseAction): | ||||
|     """ | ||||
|     Action wraps a simple function or coroutine into a standard executable unit. | ||||
| @@ -173,11 +60,11 @@ class Action(BaseAction): | ||||
|     - Optional rollback handlers for undo logic. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         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. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): 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. | ||||
| @@ -188,22 +75,36 @@ class Action(BaseAction): | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         action: Callable[..., Any], | ||||
|         action: Callable[..., Any] | Callable[..., Awaitable[Any]], | ||||
|         *, | ||||
|         rollback: Callable[..., Any] | None = None, | ||||
|         rollback: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None, | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         retry: bool = False, | ||||
|         retry_policy: RetryPolicy | None = None, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ) -> None: | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|             logging_hooks=logging_hooks, | ||||
|             spinner=spinner, | ||||
|             spinner_message=spinner_message, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|         self.action = action | ||||
|         self.rollback = rollback | ||||
| @@ -215,19 +116,19 @@ class Action(BaseAction): | ||||
|             self.enable_retry() | ||||
|  | ||||
|     @property | ||||
|     def action(self) -> Callable[..., Any]: | ||||
|     def action(self) -> Callable[..., Awaitable[Any]]: | ||||
|         return self._action | ||||
|  | ||||
|     @action.setter | ||||
|     def action(self, value: Callable[..., Any]): | ||||
|     def action(self, value: Callable[..., Awaitable[Any]]): | ||||
|         self._action = ensure_async(value) | ||||
|  | ||||
|     @property | ||||
|     def rollback(self) -> Callable[..., Any] | None: | ||||
|     def rollback(self) -> Callable[..., Awaitable[Any]] | None: | ||||
|         return self._rollback | ||||
|  | ||||
|     @rollback.setter | ||||
|     def rollback(self, value: Callable[..., Any] | None): | ||||
|     def rollback(self, value: Callable[..., Awaitable[Any]] | None): | ||||
|         if value is None: | ||||
|             self._rollback = None | ||||
|         else: | ||||
| @@ -246,6 +147,13 @@ class Action(BaseAction): | ||||
|         if policy.enabled: | ||||
|             self.enable_retry() | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||
|         """ | ||||
|         Returns the callable to be used for argument inference. | ||||
|         By default, it returns the action itself. | ||||
|         """ | ||||
|         return self.action, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||
| @@ -268,7 +176,7 @@ class Action(BaseAction): | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 logger.info("[%s] ✅ Recovered: %s", self.name, self.name) | ||||
|                 logger.info("[%s] Recovered: %s", self.name, self.name) | ||||
|                 return context.result | ||||
|             raise | ||||
|         finally: | ||||
| @@ -297,558 +205,6 @@ class Action(BaseAction): | ||||
|             f"Action(name={self.name!r}, action=" | ||||
|             f"{getattr(self._action, '__name__', repr(self._action))}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"retry={self.retry_policy.enabled})" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class LiteralInputAction(Action): | ||||
|     """ | ||||
|     LiteralInputAction injects a static value into a ChainedAction. | ||||
|  | ||||
|     This allows embedding hardcoded values mid-pipeline, useful when: | ||||
|     - Providing default or fallback inputs. | ||||
|     - Starting a pipeline with a fixed input. | ||||
|     - Supplying missing context manually. | ||||
|  | ||||
|     Args: | ||||
|         value (Any): The static value to inject. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, value: Any): | ||||
|         self._value = value | ||||
|  | ||||
|         async def literal(*_, **__): | ||||
|             return value | ||||
|  | ||||
|         super().__init__("Input", literal) | ||||
|  | ||||
|     @cached_property | ||||
|     def value(self) -> Any: | ||||
|         """Return the literal value.""" | ||||
|         return self._value | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.LIGHT_YELLOW}]📥 LiteralInput[/] '{self.name}'"] | ||||
|         label.append(f" [dim](value = {repr(self.value)})[/dim]") | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"LiteralInputAction(value={self.value!r})" | ||||
|  | ||||
|  | ||||
| class FallbackAction(Action): | ||||
|     """ | ||||
|     FallbackAction provides a default value if the previous action failed or | ||||
|     returned None. | ||||
|  | ||||
|     It injects the last result and checks: | ||||
|     - If last_result is not None, it passes it through unchanged. | ||||
|     - If last_result is None (e.g., due to failure), it replaces it with a fallback value. | ||||
|  | ||||
|     Used in ChainedAction pipelines to gracefully recover from errors or missing data. | ||||
|     When activated, it consumes the preceding error and allows the chain to continue | ||||
|     normally. | ||||
|  | ||||
|     Args: | ||||
|         fallback (Any): The fallback value to use if last_result is None. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, fallback: Any): | ||||
|         self._fallback = fallback | ||||
|  | ||||
|         async def _fallback_logic(last_result): | ||||
|             return last_result if last_result is not None else fallback | ||||
|  | ||||
|         super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True) | ||||
|  | ||||
|     @cached_property | ||||
|     def fallback(self) -> Any: | ||||
|         """Return the fallback value.""" | ||||
|         return self._fallback | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.LIGHT_RED}]🛟 Fallback[/] '{self.name}'"] | ||||
|         label.append(f" [dim](uses fallback = {repr(self.fallback)})[/dim]") | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"FallbackAction(fallback={self.fallback!r})" | ||||
|  | ||||
|  | ||||
| class ActionListMixin: | ||||
|     """Mixin for managing a list of actions.""" | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self.actions: list[BaseAction] = [] | ||||
|  | ||||
|     def set_actions(self, actions: list[BaseAction]) -> None: | ||||
|         """Replaces the current action list with a new one.""" | ||||
|         self.actions.clear() | ||||
|         for action in actions: | ||||
|             self.add_action(action) | ||||
|  | ||||
|     def add_action(self, action: BaseAction) -> None: | ||||
|         """Adds an action to the list.""" | ||||
|         self.actions.append(action) | ||||
|  | ||||
|     def remove_action(self, name: str) -> None: | ||||
|         """Removes an action by name.""" | ||||
|         self.actions = [action for action in self.actions if action.name != name] | ||||
|  | ||||
|     def has_action(self, name: str) -> bool: | ||||
|         """Checks if an action with the given name exists.""" | ||||
|         return any(action.name == name for action in self.actions) | ||||
|  | ||||
|     def get_action(self, name: str) -> BaseAction | None: | ||||
|         """Retrieves an action by name.""" | ||||
|         for action in self.actions: | ||||
|             if action.name == name: | ||||
|                 return action | ||||
|         return None | ||||
|  | ||||
|  | ||||
| class ChainedAction(BaseAction, ActionListMixin): | ||||
|     """ | ||||
|     ChainedAction executes a sequence of actions one after another. | ||||
|  | ||||
|     Features: | ||||
|     - Supports optional automatic last_result injection (auto_inject). | ||||
|     - Recovers from intermediate errors using FallbackAction if present. | ||||
|     - Rolls back all previously executed actions if a failure occurs. | ||||
|     - Handles literal values with LiteralInputAction. | ||||
|  | ||||
|     Best used for defining robust, ordered workflows where each step can depend on | ||||
|     previous results. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
|         inject_into (str, optional): Key name for injection. | ||||
|         auto_inject (bool, optional): Auto-enable injection for subsequent actions. | ||||
|         return_list (bool, optional): Whether to return a list of all results. False | ||||
|                                       returns the last result. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         actions: list[BaseAction | Any] | None = None, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         auto_inject: bool = False, | ||||
|         return_list: bool = False, | ||||
|     ) -> None: | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         ActionListMixin.__init__(self) | ||||
|         self.auto_inject = auto_inject | ||||
|         self.return_list = return_list | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction: | ||||
|         if isinstance(action, BaseAction): | ||||
|             return action | ||||
|         elif callable(action): | ||||
|             return Action(name=action.__name__, action=action) | ||||
|         else: | ||||
|             return LiteralInputAction(action) | ||||
|  | ||||
|     def add_action(self, action: BaseAction | Any) -> None: | ||||
|         action = self._wrap_if_needed(action) | ||||
|         if self.actions and self.auto_inject and not action.inject_last_result: | ||||
|             action.inject_last_result = True | ||||
|         super().add_action(action) | ||||
|         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||
|             action.register_teardown(self.hooks) | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> list[Any]: | ||||
|         if not self.actions: | ||||
|             raise EmptyChainError(f"[{self.name}] No actions to execute.") | ||||
|  | ||||
|         shared_context = SharedContext(name=self.name, action=self) | ||||
|         if self.shared_context: | ||||
|             shared_context.add_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|             extra={"results": [], "rollback_stack": []}, | ||||
|             shared_context=shared_context, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             for index, action in enumerate(self.actions): | ||||
|                 if action._skip_in_chain: | ||||
|                     logger.debug( | ||||
|                         "[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name | ||||
|                     ) | ||||
|                     continue | ||||
|                 shared_context.current_index = index | ||||
|                 prepared = action.prepare(shared_context, self.options_manager) | ||||
|                 last_result = shared_context.last_result() | ||||
|                 try: | ||||
|                     if self.requires_io_injection() and last_result is not None: | ||||
|                         result = await prepared(**{prepared.inject_into: last_result}) | ||||
|                     else: | ||||
|                         result = await prepared(*args, **updated_kwargs) | ||||
|                 except Exception as error: | ||||
|                     if index + 1 < len(self.actions) and isinstance( | ||||
|                         self.actions[index + 1], FallbackAction | ||||
|                     ): | ||||
|                         logger.warning( | ||||
|                             "[%s] ⚠️ Fallback triggered: %s, recovering with fallback " | ||||
|                             "'%s'.", | ||||
|                             self.name, | ||||
|                             error, | ||||
|                             self.actions[index + 1].name, | ||||
|                         ) | ||||
|                         shared_context.add_result(None) | ||||
|                         context.extra["results"].append(None) | ||||
|                         fallback = self.actions[index + 1].prepare(shared_context) | ||||
|                         result = await fallback() | ||||
|                         fallback._skip_in_chain = True | ||||
|                     else: | ||||
|                         raise | ||||
|                 shared_context.add_result(result) | ||||
|                 context.extra["results"].append(result) | ||||
|                 context.extra["rollback_stack"].append(prepared) | ||||
|  | ||||
|             all_results = context.extra["results"] | ||||
|             assert ( | ||||
|                 all_results | ||||
|             ), f"[{self.name}] No results captured. Something seriously went wrong." | ||||
|             context.result = all_results if self.return_list else all_results[-1] | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|  | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             shared_context.add_error(shared_context.current_index, error) | ||||
|             await self._rollback(context.extra["rollback_stack"], *args, **kwargs) | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def _rollback(self, rollback_stack, *args, **kwargs): | ||||
|         """ | ||||
|         Roll back all executed actions in reverse order. | ||||
|  | ||||
|         Rollbacks run even if a fallback recovered from failure, | ||||
|         ensuring consistent undo of all side effects. | ||||
|  | ||||
|         Actions without rollback handlers are skipped. | ||||
|  | ||||
|         Args: | ||||
|             rollback_stack (list): Actions to roll back. | ||||
|             *args, **kwargs: Passed to rollback handlers. | ||||
|         """ | ||||
|         for action in reversed(rollback_stack): | ||||
|             rollback = getattr(action, "rollback", None) | ||||
|             if rollback: | ||||
|                 try: | ||||
|                     logger.warning("[%s] ↩️ Rolling back...", action.name) | ||||
|                     await action.rollback(*args, **kwargs) | ||||
|                 except Exception as error: | ||||
|                     logger.error("[%s] ⚠️ Rollback failed: %s", action.name, error) | ||||
|  | ||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||
|         """Register a hook for all actions and sub-actions.""" | ||||
|         self.hooks.register(hook_type, hook) | ||||
|         for action in self.actions: | ||||
|             action.register_hooks_recursively(hook_type, hook) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         for action in self.actions: | ||||
|             await action.preview(parent=tree) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ChainedAction(name={self.name!r}, " | ||||
|             f"actions={[a.name for a in self.actions]!r}, " | ||||
|             f"auto_inject={self.auto_inject}, return_list={self.return_list})" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ActionGroup(BaseAction, ActionListMixin): | ||||
|     """ | ||||
|     ActionGroup executes multiple actions concurrently in parallel. | ||||
|  | ||||
|     It is ideal for independent tasks that can be safely run simultaneously, | ||||
|     improving overall throughput and responsiveness of workflows. | ||||
|  | ||||
|     Core features: | ||||
|     - Parallel execution of all contained actions. | ||||
|     - Shared last_result injection across all actions if configured. | ||||
|     - Aggregated collection of individual results as (name, result) pairs. | ||||
|     - Hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||
|     - Error aggregation: captures all action errors and reports them together. | ||||
|  | ||||
|     Behavior: | ||||
|     - If any action fails, the group collects the errors but continues executing | ||||
|       other actions without interruption. | ||||
|     - After all actions complete, ActionGroup raises a single exception summarizing | ||||
|       all failures, or returns all results if successful. | ||||
|  | ||||
|     Best used for: | ||||
|     - Batch processing multiple independent tasks. | ||||
|     - Reducing latency for workflows with parallelizable steps. | ||||
|     - Isolating errors while maximizing successful execution. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
|         inject_into (str, optional): Key name for injection. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         actions: list[BaseAction] | None = None, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         ActionListMixin.__init__(self) | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     def _wrap_if_needed(self, action: BaseAction | Any) -> BaseAction: | ||||
|         if isinstance(action, BaseAction): | ||||
|             return action | ||||
|         elif callable(action): | ||||
|             return Action(name=action.__name__, action=action) | ||||
|         else: | ||||
|             raise TypeError( | ||||
|                 "ActionGroup only accepts BaseAction or callable, got " | ||||
|                 f"{type(action).__name__}" | ||||
|             ) | ||||
|  | ||||
|     def add_action(self, action: BaseAction | Any) -> None: | ||||
|         action = self._wrap_if_needed(action) | ||||
|         super().add_action(action) | ||||
|         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||
|             action.register_teardown(self.hooks) | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: | ||||
|         shared_context = SharedContext(name=self.name, action=self, is_parallel=True) | ||||
|         if self.shared_context: | ||||
|             shared_context.set_shared_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|             extra={"results": [], "errors": []}, | ||||
|             shared_context=shared_context, | ||||
|         ) | ||||
|  | ||||
|         async def run_one(action: BaseAction): | ||||
|             try: | ||||
|                 prepared = action.prepare(shared_context, self.options_manager) | ||||
|                 result = await prepared(*args, **updated_kwargs) | ||||
|                 shared_context.add_result((action.name, result)) | ||||
|                 context.extra["results"].append((action.name, result)) | ||||
|             except Exception as error: | ||||
|                 shared_context.add_error(shared_context.current_index, error) | ||||
|                 context.extra["errors"].append((action.name, error)) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             await asyncio.gather(*[run_one(a) for a in self.actions]) | ||||
|  | ||||
|             if context.extra["errors"]: | ||||
|                 context.exception = Exception( | ||||
|                     f"{len(context.extra['errors'])} action(s) failed: " | ||||
|                     f"{' ,'.join(name for name, _ in context.extra["errors"])}" | ||||
|                 ) | ||||
|                 await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|                 raise context.exception | ||||
|  | ||||
|             context.result = context.extra["results"] | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|  | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||
|         """Register a hook for all actions and sub-actions.""" | ||||
|         super().register_hooks_recursively(hook_type, hook) | ||||
|         for action in self.actions: | ||||
|             action.register_hooks_recursively(hook_type, hook) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](receives '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         actions = self.actions.copy() | ||||
|         random.shuffle(actions) | ||||
|         await asyncio.gather(*(action.preview(parent=tree) for action in actions)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ActionGroup(name={self.name!r}, actions={[a.name for a in self.actions]!r}," | ||||
|             f" inject_last_result={self.inject_last_result})" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ProcessAction(BaseAction): | ||||
|     """ | ||||
|     ProcessAction runs a function in a separate process using ProcessPoolExecutor. | ||||
|  | ||||
|     Features: | ||||
|     - Executes CPU-bound or blocking tasks without blocking the main event loop. | ||||
|     - Supports last_result injection into the subprocess. | ||||
|     - Validates that last_result is pickleable when injection is enabled. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         func (Callable): Function to execute in a new process. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||
|         executor (ProcessPoolExecutor, optional): Custom executor if desired. | ||||
|         inject_last_result (bool, optional): Inject last result into the function. | ||||
|         inject_into (str, optional): Name of the injected key. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         action: Callable[..., Any], | ||||
|         *, | ||||
|         args: tuple = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         executor: ProcessPoolExecutor | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.action = action | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.executor = executor or ProcessPoolExecutor() | ||||
|         self.is_retryable = True | ||||
|  | ||||
|     async def _run(self, *args, **kwargs): | ||||
|         if self.inject_last_result: | ||||
|             last_result = self.shared_context.last_result() | ||||
|             if not self._validate_pickleable(last_result): | ||||
|                 raise ValueError( | ||||
|                     f"Cannot inject last result into {self.name}: " | ||||
|                     f"last result is not pickleable." | ||||
|                 ) | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = self._maybe_inject_last_result({**self.kwargs, **kwargs}) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=combined_args, | ||||
|             kwargs=combined_kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|         loop = asyncio.get_running_loop() | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             result = await loop.run_in_executor( | ||||
|                 self.executor, partial(self.action, *combined_args, **combined_kwargs) | ||||
|             ) | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 return context.result | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     def _validate_pickleable(self, obj: Any) -> bool: | ||||
|         try: | ||||
|             import pickle | ||||
|  | ||||
|             pickle.dumps(obj) | ||||
|             return True | ||||
|         except (pickle.PicklingError, TypeError): | ||||
|             return False | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [ | ||||
|             f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessAction (new process)[/] '{self.name}'" | ||||
|         ] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         if parent: | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ProcessAction(name={self.name!r}, " | ||||
|             f"action={getattr(self.action, '__name__', repr(self.action))}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r})" | ||||
|             f"retry={self.retry_policy.enabled}, " | ||||
|             f"rollback={self.rollback is not None})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,10 +1,41 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """action_factory.py""" | ||||
| from typing import Any | ||||
| """ | ||||
| Defines `ActionFactory`, a dynamic Falyx Action that defers the construction of its | ||||
| underlying logic to runtime using a user-defined factory function. | ||||
|  | ||||
| This pattern is useful when the specific Action to execute cannot be determined until | ||||
| execution time—such as when branching on data, generating parameterized HTTP requests, | ||||
| or selecting configuration-aware flows. `ActionFactory` integrates seamlessly with the | ||||
| Falyx lifecycle system and supports hook propagation, teardown registration, and | ||||
| contextual previewing. | ||||
|  | ||||
| Key Features: | ||||
| - Accepts a factory function that returns a `BaseAction` instance | ||||
| - Supports injection of `last_result` and arbitrary args/kwargs | ||||
| - Integrates into chained or standalone workflows | ||||
| - Automatically previews generated action tree | ||||
| - Propagates shared context and teardown hooks to the returned action | ||||
|  | ||||
| Common Use Cases: | ||||
| - Conditional or data-driven action generation | ||||
| - Configurable workflows with dynamic behavior | ||||
| - Adapter for factory-style dependency injection in CLI flows | ||||
|  | ||||
| Example: | ||||
|     def generate_request_action(env): | ||||
|         return HTTPAction(f"GET /status/{env}", url=f"https://api/{env}/status") | ||||
|  | ||||
|     ActionFactory( | ||||
|         name="GetEnvStatus", | ||||
|         factory=generate_request_action, | ||||
|         inject_last_result=True, | ||||
|     ) | ||||
| """ | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| @@ -14,7 +45,7 @@ from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
|  | ||||
| class ActionFactoryAction(BaseAction): | ||||
| class ActionFactory(BaseAction): | ||||
|     """ | ||||
|     Dynamically creates and runs another Action at runtime using a factory function. | ||||
|  | ||||
| @@ -22,10 +53,14 @@ class ActionFactoryAction(BaseAction): | ||||
|     where the structure of the next action depends on runtime values. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         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. | ||||
|         args (tuple, optional): Positional arguments for the factory. | ||||
|         kwargs (dict, optional): Keyword arguments for the factory. | ||||
|         preview_args (tuple, optional): Positional arguments for the preview. | ||||
|         preview_kwargs (dict, optional): Keyword arguments for the preview. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
| @@ -35,6 +70,8 @@ class ActionFactoryAction(BaseAction): | ||||
|         *, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         preview_args: tuple[Any, ...] = (), | ||||
|         preview_kwargs: dict[str, Any] | None = None, | ||||
|     ): | ||||
| @@ -44,6 +81,8 @@ class ActionFactoryAction(BaseAction): | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.factory = factory | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.preview_args = preview_args | ||||
|         self.preview_kwargs = preview_kwargs or {} | ||||
|  | ||||
| @@ -55,7 +94,12 @@ class ActionFactoryAction(BaseAction): | ||||
|     def factory(self, value: ActionFactoryProtocol): | ||||
|         self._factory = ensure_async(value) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any], None]: | ||||
|         return self.factory, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         args = (*self.args, *args) | ||||
|         kwargs = {**self.kwargs, **kwargs} | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=f"{self.name} (factory)", | ||||
| @@ -85,7 +129,7 @@ class ActionFactoryAction(BaseAction): | ||||
|                     ) | ||||
|             if self.options_manager: | ||||
|                 generated_action.set_options_manager(self.options_manager) | ||||
|             context.result = await generated_action(*args, **kwargs) | ||||
|             context.result = await generated_action() | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|         except Exception as error: | ||||
| @@ -103,7 +147,16 @@ class ActionFactoryAction(BaseAction): | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         try: | ||||
|             generated = None | ||||
|             if self.args or self.kwargs: | ||||
|                 try: | ||||
|                     generated = await self.factory(*self.args, **self.kwargs) | ||||
|                 except TypeError: | ||||
|                     ... | ||||
|  | ||||
|             if not generated: | ||||
|                 generated = await self.factory(*self.preview_args, **self.preview_kwargs) | ||||
|  | ||||
|             if isinstance(generated, BaseAction): | ||||
|                 await generated.preview(parent=tree) | ||||
|             else: | ||||
| @@ -115,3 +168,11 @@ class ActionFactoryAction(BaseAction): | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ActionFactory(name={self.name!r}, " | ||||
|             f"inject_last_result={self.inject_last_result}, " | ||||
|             f"factory={self._factory.__name__ if hasattr(self._factory, '__name__') else type(self._factory).__name__}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r})" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										248
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								falyx/action/action_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ActionGroup`, a Falyx Action that executes multiple sub-actions concurrently | ||||
| using asynchronous parallelism. | ||||
|  | ||||
| `ActionGroup` is designed for workflows where several independent actions can run | ||||
| simultaneously to improve responsiveness and reduce latency. It ensures robust error | ||||
| isolation, shared result tracking, and full lifecycle hook integration while preserving | ||||
| Falyx's introspectability and chaining capabilities. | ||||
|  | ||||
| Key Features: | ||||
| - Executes all actions in parallel via `asyncio.gather` | ||||
| - Aggregates results as a list of `(name, result)` tuples | ||||
| - Collects and reports multiple errors without interrupting execution | ||||
| - Compatible with `SharedContext`, `OptionsManager`, and `last_result` injection | ||||
| - Teardown-aware: propagates teardown registration across all child actions | ||||
| - Fully previewable via Rich tree rendering | ||||
|  | ||||
| Use Cases: | ||||
| - Batch execution of independent tasks (e.g., multiple file operations, API calls) | ||||
| - Concurrent report generation or validations | ||||
| - High-throughput CLI pipelines where latency is critical | ||||
|  | ||||
| Raises: | ||||
| - `EmptyGroupError`: If no actions are added to the group | ||||
| - `Exception`: Summarizes all failed actions after execution | ||||
|  | ||||
| Example: | ||||
|     ActionGroup( | ||||
|         name="ParallelChecks", | ||||
|         actions=[Action(...), Action(...), ChainedAction(...)], | ||||
|     ) | ||||
|  | ||||
| This module complements `ChainedAction` by offering breadth-wise (parallel) execution | ||||
| as opposed to depth-wise (sequential) execution. | ||||
| """ | ||||
| import asyncio | ||||
| import random | ||||
| from typing import Any, Awaitable, Callable, Sequence | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.action_mixins import ActionListMixin | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.exceptions import EmptyGroupError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.utils import same_argument_definitions | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
|  | ||||
| class ActionGroup(BaseAction, ActionListMixin): | ||||
|     """ | ||||
|     ActionGroup executes multiple actions concurrently in parallel. | ||||
|  | ||||
|     It is ideal for independent tasks that can be safely run simultaneously, | ||||
|     improving overall throughput and responsiveness of workflows. | ||||
|  | ||||
|     Core features: | ||||
|     - Parallel execution of all contained actions. | ||||
|     - Shared last_result injection across all actions if configured. | ||||
|     - Aggregated collection of individual results as (name, result) pairs. | ||||
|     - Hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||
|     - Error aggregation: captures all action errors and reports them together. | ||||
|  | ||||
|     Behavior: | ||||
|     - If any action fails, the group collects the errors but continues executing | ||||
|       other actions without interruption. | ||||
|     - After all actions complete, ActionGroup raises a single exception summarizing | ||||
|       all failures, or returns all results if successful. | ||||
|  | ||||
|     Best used for: | ||||
|     - Batch processing multiple independent tasks. | ||||
|     - Reducing latency for workflows with parallelizable steps. | ||||
|     - Isolating errors while maximizing successful execution. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the chain. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
|         inject_into (str, optional): Key name for injection. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         actions: ( | ||||
|             Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable]] | None | ||||
|         ) = None, | ||||
|         *, | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|             logging_hooks=logging_hooks, | ||||
|             spinner=spinner, | ||||
|             spinner_message=spinner_message, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|         ActionListMixin.__init__(self) | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     def _wrap_if_needed(self, action: BaseAction | Callable[..., Any]) -> BaseAction: | ||||
|         if isinstance(action, BaseAction): | ||||
|             return action | ||||
|         elif callable(action): | ||||
|             return Action(name=action.__name__, action=action) | ||||
|         else: | ||||
|             raise TypeError( | ||||
|                 "ActionGroup only accepts BaseAction or callable, got " | ||||
|                 f"{type(action).__name__}" | ||||
|             ) | ||||
|  | ||||
|     def add_action(self, action: BaseAction | Callable[..., Any]) -> None: | ||||
|         action = self._wrap_if_needed(action) | ||||
|         super().add_action(action) | ||||
|         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||
|             action.register_teardown(self.hooks) | ||||
|  | ||||
|     def set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None: | ||||
|         """Replaces the current action list with a new one.""" | ||||
|         self.actions.clear() | ||||
|         for action in actions: | ||||
|             self.add_action(action) | ||||
|  | ||||
|     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||
|         super().set_options_manager(options_manager) | ||||
|         for action in self.actions: | ||||
|             action.set_options_manager(options_manager) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         arg_defs = same_argument_definitions(self.actions) | ||||
|         if arg_defs: | ||||
|             return self.actions[0].get_infer_target() | ||||
|         logger.debug( | ||||
|             "[%s] auto_args disabled: mismatched ActionGroup arguments", | ||||
|             self.name, | ||||
|         ) | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: | ||||
|         if not self.actions: | ||||
|             raise EmptyGroupError(f"[{self.name}] No actions to execute.") | ||||
|  | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = {**self.kwargs, **kwargs} | ||||
|  | ||||
|         shared_context = SharedContext(name=self.name, action=self, is_parallel=True) | ||||
|         if self.shared_context: | ||||
|             shared_context.set_shared_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(combined_kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=combined_args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|             extra={"results": [], "errors": []}, | ||||
|             shared_context=shared_context, | ||||
|         ) | ||||
|  | ||||
|         async def run_one(action: BaseAction): | ||||
|             try: | ||||
|                 prepared = action.prepare(shared_context, self.options_manager) | ||||
|                 result = await prepared(*combined_args, **updated_kwargs) | ||||
|                 shared_context.add_result((action.name, result)) | ||||
|                 context.extra["results"].append((action.name, result)) | ||||
|             except Exception as error: | ||||
|                 shared_context.add_error(shared_context.current_index, error) | ||||
|                 context.extra["errors"].append((action.name, error)) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             await asyncio.gather(*[run_one(a) for a in self.actions]) | ||||
|  | ||||
|             if context.extra["errors"]: | ||||
|                 context.exception = Exception( | ||||
|                     f"{len(context.extra['errors'])} action(s) failed: " | ||||
|                     f"{' ,'.join(name for name, _ in context.extra['errors'])}" | ||||
|                 ) | ||||
|                 await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|                 raise context.exception | ||||
|  | ||||
|             context.result = context.extra["results"] | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|  | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||
|         """Register a hook for all actions and sub-actions.""" | ||||
|         super().register_hooks_recursively(hook_type, hook) | ||||
|         for action in self.actions: | ||||
|             action.register_hooks_recursively(hook_type, hook) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.MAGENTA_b}]⏩ ActionGroup (parallel)[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](receives '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         actions = self.actions.copy() | ||||
|         random.shuffle(actions) | ||||
|         await asyncio.gather(*(action.preview(parent=tree) for action in actions)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ActionGroup(name={self.name}, actions={[a.name for a in self.actions]}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"inject_last_result={self.inject_last_result}, " | ||||
|             f"inject_into={self.inject_into})" | ||||
|         ) | ||||
							
								
								
									
										60
									
								
								falyx/action/action_mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								falyx/action/action_mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides reusable mixins for managing collections of `BaseAction` instances | ||||
| within composite Falyx actions such as `ActionGroup` or `ChainedAction`. | ||||
|  | ||||
| The primary export, `ActionListMixin`, encapsulates common functionality for | ||||
| maintaining a mutable list of named actions—such as adding, removing, or retrieving | ||||
| actions by name—without duplicating logic across composite action types. | ||||
| """ | ||||
|  | ||||
| from typing import Sequence | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
|  | ||||
|  | ||||
| class ActionListMixin: | ||||
|     """ | ||||
|     Mixin for managing a list of named `BaseAction` objects. | ||||
|  | ||||
|     Provides helper methods for setting, adding, removing, checking, and | ||||
|     retrieving actions in composite Falyx constructs like `ActionGroup`. | ||||
|  | ||||
|     Attributes: | ||||
|         actions (list[BaseAction]): The internal list of managed actions. | ||||
|  | ||||
|     Methods: | ||||
|         set_actions(actions): Replaces all current actions with the given list. | ||||
|         add_action(action): Adds a new action to the list. | ||||
|         remove_action(name): Removes an action by its name. | ||||
|         has_action(name): Returns True if an action with the given name exists. | ||||
|         get_action(name): Returns the action matching the name, or None. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self.actions: list[BaseAction] = [] | ||||
|  | ||||
|     def set_actions(self, actions: Sequence[BaseAction]) -> None: | ||||
|         """Replaces the current action list with a new one.""" | ||||
|         self.actions.clear() | ||||
|         for action in actions: | ||||
|             self.add_action(action) | ||||
|  | ||||
|     def add_action(self, action: BaseAction) -> None: | ||||
|         """Adds an action to the list.""" | ||||
|         self.actions.append(action) | ||||
|  | ||||
|     def remove_action(self, name: str) -> None: | ||||
|         """Removes all actions with the given 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 a single action with the given name.""" | ||||
|         for action in self.actions: | ||||
|             if action.name == name: | ||||
|                 return action | ||||
|         return None | ||||
							
								
								
									
										205
									
								
								falyx/action/action_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								falyx/action/action_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,205 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines strongly-typed enums used throughout the Falyx CLI framework for | ||||
| representing common structured values like file formats, selection return types, | ||||
| and confirmation modes. | ||||
|  | ||||
| These enums support alias resolution, graceful coercion from string inputs, | ||||
| and are used for input validation, serialization, and CLI configuration parsing. | ||||
|  | ||||
| Exports: | ||||
| - FileType: Defines supported file formats for `LoadFileAction` and `SaveFileAction` | ||||
| - SelectionReturnType: Defines structured return modes for `SelectionAction` | ||||
| - ConfirmType: Defines selectable confirmation types for prompts and guards | ||||
|  | ||||
| Key Features: | ||||
| - Custom `_missing_()` methods for forgiving string coercion and error reporting | ||||
| - Aliases and normalization support for user-friendly config-driven workflows | ||||
| - Useful in CLI flag parsing, YAML configs, and dynamic schema validation | ||||
|  | ||||
| Example: | ||||
|     FileType("yml") → FileType.YAML | ||||
|     SelectionReturnType("value") → SelectionReturnType.VALUE | ||||
|     ConfirmType("YES_NO") → ConfirmType.YES_NO | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class FileType(Enum): | ||||
|     """ | ||||
|     Represents supported file types for reading and writing in Falyx Actions. | ||||
|  | ||||
|     Used by `LoadFileAction` and `SaveFileAction` to determine how to parse or | ||||
|     serialize file content. Includes alias resolution for common extensions like | ||||
|     `.yml`, `.txt`, and `filepath`. | ||||
|  | ||||
|     Members: | ||||
|         TEXT: Raw encoded text as a string. | ||||
|         PATH: Returns the file path (as a Path object). | ||||
|         JSON: JSON-formatted object. | ||||
|         TOML: TOML-formatted object. | ||||
|         YAML: YAML-formatted object. | ||||
|         CSV: List of rows (as lists) from a CSV file. | ||||
|         TSV: Same as CSV, but tab-delimited. | ||||
|         XML: Raw XML as a ElementTree. | ||||
|  | ||||
|     Example: | ||||
|         FileType("yml")  → FileType.YAML | ||||
|     """ | ||||
|  | ||||
|     TEXT = "text" | ||||
|     PATH = "path" | ||||
|     JSON = "json" | ||||
|     TOML = "toml" | ||||
|     YAML = "yaml" | ||||
|     CSV = "csv" | ||||
|     TSV = "tsv" | ||||
|     XML = "xml" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[FileType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "yml": "yaml", | ||||
|             "txt": "text", | ||||
|             "file": "path", | ||||
|             "filepath": "path", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> FileType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().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 {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class SelectionReturnType(Enum): | ||||
|     """ | ||||
|     Controls what is returned from a `SelectionAction` when using a selection map. | ||||
|  | ||||
|     Determines how the user's choice(s) from a `dict[str, SelectionOption]` are | ||||
|     transformed and returned by the action. | ||||
|  | ||||
|     Members: | ||||
|         KEY: Return the selected key(s) only. | ||||
|         VALUE: Return the value(s) associated with the selected key(s). | ||||
|         DESCRIPTION: Return the description(s) of the selected item(s). | ||||
|         DESCRIPTION_VALUE: Return a dict of {description: value} pairs. | ||||
|         ITEMS: Return full `SelectionOption` objects as a dict {key: SelectionOption}. | ||||
|  | ||||
|     Example: | ||||
|         return_type=SelectionReturnType.VALUE  → returns raw values like 'prod' | ||||
|     """ | ||||
|  | ||||
|     KEY = "key" | ||||
|     VALUE = "value" | ||||
|     DESCRIPTION = "description" | ||||
|     DESCRIPTION_VALUE = "description_value" | ||||
|     ITEMS = "items" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[SelectionReturnType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "desc": "description", | ||||
|             "desc_value": "description_value", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> SelectionReturnType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().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 {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class ConfirmType(Enum): | ||||
|     """ | ||||
|     Enum for defining prompt styles in confirmation dialogs. | ||||
|  | ||||
|     Used by confirmation actions to control user input behavior and available choices. | ||||
|  | ||||
|     Members: | ||||
|         YES_NO: Prompt with Yes / No options. | ||||
|         YES_CANCEL: Prompt with Yes / Cancel options. | ||||
|         YES_NO_CANCEL: Prompt with Yes / No / Cancel options. | ||||
|         TYPE_WORD: Require user to type a specific confirmation word (e.g., "delete"). | ||||
|         TYPE_WORD_CANCEL: Same as TYPE_WORD, but allows cancellation. | ||||
|         OK_CANCEL: Prompt with OK / Cancel options. | ||||
|         ACKNOWLEDGE: Single confirmation button (e.g., "Acknowledge"). | ||||
|  | ||||
|     Example: | ||||
|         ConfirmType("yes_no_cancel") → ConfirmType.YES_NO_CANCEL | ||||
|     """ | ||||
|  | ||||
|     YES_NO = "yes_no" | ||||
|     YES_CANCEL = "yes_cancel" | ||||
|     YES_NO_CANCEL = "yes_no_cancel" | ||||
|     TYPE_WORD = "type_word" | ||||
|     TYPE_WORD_CANCEL = "type_word_cancel" | ||||
|     OK_CANCEL = "ok_cancel" | ||||
|     ACKNOWLEDGE = "acknowledge" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[ConfirmType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "yes": "yes_no", | ||||
|             "ok": "ok_cancel", | ||||
|             "type": "type_word", | ||||
|             "word": "type_word", | ||||
|             "word_cancel": "type_word_cancel", | ||||
|             "ack": "acknowledge", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> ConfirmType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().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 {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the confirm type.""" | ||||
|         return self.value | ||||
							
								
								
									
										187
									
								
								falyx/action/base_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								falyx/action/base_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,187 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Core action system for Falyx. | ||||
|  | ||||
| This module defines the building blocks for executable actions and workflows, | ||||
| providing a structured way to compose, execute, recover, and manage sequences of | ||||
| operations. | ||||
|  | ||||
| All actions are callable and follow a unified signature: | ||||
|     result = action(*args, **kwargs) | ||||
|  | ||||
| Core guarantees: | ||||
| - Full hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||
| - Consistent timing and execution context tracking for each run. | ||||
| - Unified, predictable result handling and error propagation. | ||||
| - Optional last_result injection to enable flexible, data-driven workflows. | ||||
| - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback | ||||
|   recovery. | ||||
|  | ||||
| Key components: | ||||
| - Action: wraps a function or coroutine into a standard executable unit. | ||||
| - ChainedAction: runs actions sequentially, optionally injecting last results. | ||||
| - ActionGroup: runs actions in parallel and gathers results. | ||||
| - ProcessAction: executes CPU-bound functions in a separate process. | ||||
| - LiteralInputAction: injects static values into workflows. | ||||
| - FallbackAction: gracefully recovers from failures or missing data. | ||||
|  | ||||
| This design promotes clean, fault-tolerant, modular CLI and automation systems. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from abc import ABC, abstractmethod | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.context import SharedContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.hooks import spinner_before_hook, spinner_teardown_hook | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| 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. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         hooks (HookManager | None): Hook manager for lifecycle events. | ||||
|         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'). | ||||
|         never_prompt (bool | None): Whether to never prompt for input. | ||||
|         logging_hooks (bool): Whether to register debug hooks for logging. | ||||
|         ignore_in_history (bool): Whether to ignore this action in execution history last result. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         ignore_in_history: bool = False, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ) -> 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 | None = never_prompt | ||||
|         self._skip_in_chain: bool = False | ||||
|         self.console: Console = console | ||||
|         self.options_manager: OptionsManager | None = None | ||||
|         self.ignore_in_history: bool = ignore_in_history | ||||
|         self.spinner_message = spinner_message | ||||
|         self.spinner_type = spinner_type | ||||
|         self.spinner_style = spinner_style | ||||
|         self.spinner_speed = spinner_speed | ||||
|  | ||||
|         if spinner: | ||||
|             self.hooks.register(HookType.BEFORE, spinner_before_hook) | ||||
|             self.hooks.register(HookType.ON_TEARDOWN, spinner_teardown_hook) | ||||
|  | ||||
|         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: | ||||
|         if self._never_prompt is not None: | ||||
|             return self._never_prompt | ||||
|         return self.get_option("never_prompt", False) | ||||
|  | ||||
|     @property | ||||
|     def spinner_manager(self): | ||||
|         """Shortcut to access SpinnerManager via the OptionsManager.""" | ||||
|         if not self.options_manager: | ||||
|             raise RuntimeError("SpinnerManager is not available (no OptionsManager set).") | ||||
|         return self.options_manager.spinners | ||||
|  | ||||
|     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) | ||||
							
								
								
									
										322
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										322
									
								
								falyx/action/chained_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,322 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ChainedAction`, a core Falyx construct for executing a sequence of actions | ||||
| in strict order, optionally injecting results from previous steps into subsequent ones. | ||||
|  | ||||
| `ChainedAction` is designed for linear workflows where each step may depend on | ||||
| the output of the previous one. It supports rollback semantics, fallback recovery, | ||||
| and advanced error handling using `SharedContext`. Literal values are supported via | ||||
| automatic wrapping with `LiteralInputAction`. | ||||
|  | ||||
| Key Features: | ||||
| - Executes a list of actions sequentially | ||||
| - Optional `auto_inject` to forward `last_result` into each step | ||||
| - Supports fallback recovery using `FallbackAction` when an error occurs | ||||
| - Rollback stack to undo already-completed actions on failure | ||||
| - Integrates with the full Falyx hook lifecycle | ||||
| - Previews and introspects workflow structure via `Rich` | ||||
|  | ||||
| Use Cases: | ||||
| - Ordered pipelines (e.g., build → test → deploy) | ||||
| - Data transformations or ETL workflows | ||||
| - Linear decision trees or interactive wizards | ||||
|  | ||||
| Special Behaviors: | ||||
| - Literal inputs (e.g., strings, numbers) are converted to `LiteralInputAction` | ||||
| - If an action raises and is followed by a `FallbackAction`, it will be skipped and recovered | ||||
| - If a `BreakChainSignal` is raised, the chain stops early and rollbacks are triggered | ||||
|  | ||||
| Raises: | ||||
| - `EmptyChainError`: If no actions are present | ||||
| - `BreakChainSignal`: When explicitly triggered by a child action | ||||
| - `Exception`: For all unhandled failures during chained execution | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="DeployFlow", | ||||
|         actions=[ | ||||
|             ActionGroup( | ||||
|                 name="PreDeploymentChecks", | ||||
|                 actions=[ | ||||
|                     Action( | ||||
|                         name="ValidateInputs", | ||||
|                         action=validate_inputs, | ||||
|                     ), | ||||
|                     Action( | ||||
|                         name="CheckDependencies", | ||||
|                         action=check_dependencies, | ||||
|                     ), | ||||
|                 ], | ||||
|             ), | ||||
|             Action( | ||||
|                 name="BuildArtifact", | ||||
|                 action=build_artifact, | ||||
|             ), | ||||
|             Action( | ||||
|                 name="Upload", | ||||
|                 action=upload, | ||||
|             ), | ||||
|             Action( | ||||
|                 name="NotifySuccess", | ||||
|                 action=notify_success, | ||||
|             ), | ||||
|         ], | ||||
|         auto_inject=True, | ||||
|     ) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Callable, Sequence | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.action_mixins import ActionListMixin | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.action.fallback_action import FallbackAction | ||||
| from falyx.action.literal_input_action import LiteralInputAction | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.exceptions import EmptyChainError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import Hook, HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.signals import BreakChainSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ChainedAction(BaseAction, ActionListMixin): | ||||
|     """ | ||||
|     ChainedAction executes a sequence of actions one after another. | ||||
|  | ||||
|     Features: | ||||
|     - Supports optional automatic last_result injection (auto_inject). | ||||
|     - Recovers from intermediate errors using FallbackAction if present. | ||||
|     - Rolls back all previously executed actions if a failure occurs. | ||||
|     - Handles literal values with LiteralInputAction. | ||||
|  | ||||
|     Best used for defining robust, ordered workflows where each step can depend on | ||||
|     previous results. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the chain. Used for logging and debugging. | ||||
|         actions (list): List of actions or literals to execute. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hooks for lifecycle events. | ||||
|         inject_last_result (bool, optional): Whether to inject last results into kwargs | ||||
|                                              by default. | ||||
|         inject_into (str, optional): Key name for injection. | ||||
|         auto_inject (bool, optional): Auto-enable injection for subsequent actions. | ||||
|         return_list (bool, optional): Whether to return a list of all results. False | ||||
|                                       returns the last result. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         actions: ( | ||||
|             Sequence[BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]]] | ||||
|             | None | ||||
|         ) = None, | ||||
|         *, | ||||
|         args: tuple[Any, ...] = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         auto_inject: bool = False, | ||||
|         return_list: bool = False, | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ) -> None: | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|             logging_hooks=logging_hooks, | ||||
|             spinner=spinner, | ||||
|             spinner_message=spinner_message, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|         ActionListMixin.__init__(self) | ||||
|         self.args = args | ||||
|         self.kwargs = kwargs or {} | ||||
|         self.auto_inject = auto_inject | ||||
|         self.return_list = return_list | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     def _wrap_if_needed(self, action: BaseAction | Callable[..., Any]) -> BaseAction: | ||||
|         if isinstance(action, BaseAction): | ||||
|             return action | ||||
|         elif callable(action): | ||||
|             return Action(name=action.__name__, action=action) | ||||
|         else: | ||||
|             return LiteralInputAction(action) | ||||
|  | ||||
|     def add_action(self, action: BaseAction | Callable[..., Any]) -> None: | ||||
|         action = self._wrap_if_needed(action) | ||||
|         if self.actions and self.auto_inject and not action.inject_last_result: | ||||
|             action.inject_last_result = True | ||||
|         super().add_action(action) | ||||
|         if hasattr(action, "register_teardown") and callable(action.register_teardown): | ||||
|             action.register_teardown(self.hooks) | ||||
|  | ||||
|     def set_actions(self, actions: Sequence[BaseAction | Callable[..., Any]]) -> None: | ||||
|         """Replaces the current action list with a new one.""" | ||||
|         self.actions.clear() | ||||
|         for action in actions: | ||||
|             self.add_action(action) | ||||
|  | ||||
|     def set_options_manager(self, options_manager: OptionsManager) -> None: | ||||
|         super().set_options_manager(options_manager) | ||||
|         for action in self.actions: | ||||
|             action.set_options_manager(options_manager) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         if self.actions: | ||||
|             return self.actions[0].get_infer_target() | ||||
|         return None, None | ||||
|  | ||||
|     def _clear_args(self): | ||||
|         return (), {} | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         if not self.actions: | ||||
|             raise EmptyChainError(f"[{self.name}] No actions to execute.") | ||||
|  | ||||
|         combined_args = args + self.args | ||||
|         combined_kwargs = {**self.kwargs, **kwargs} | ||||
|  | ||||
|         shared_context = SharedContext(name=self.name, action=self) | ||||
|         if self.shared_context: | ||||
|             shared_context.add_result(self.shared_context.last_result()) | ||||
|         updated_kwargs = self._maybe_inject_last_result(combined_kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=combined_args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|             extra={"results": [], "rollback_stack": []}, | ||||
|             shared_context=shared_context, | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             for index, action in enumerate(self.actions): | ||||
|                 if action._skip_in_chain: | ||||
|                     logger.debug( | ||||
|                         "[%s] Skipping consumed action '%s'", self.name, action.name | ||||
|                     ) | ||||
|                     continue | ||||
|                 shared_context.current_index = index | ||||
|                 prepared = action.prepare(shared_context, self.options_manager) | ||||
|                 try: | ||||
|                     result = await prepared(*combined_args, **updated_kwargs) | ||||
|                 except Exception as error: | ||||
|                     if index + 1 < len(self.actions) and isinstance( | ||||
|                         self.actions[index + 1], FallbackAction | ||||
|                     ): | ||||
|                         logger.warning( | ||||
|                             "[%s] Fallback triggered: %s, recovering with fallback " | ||||
|                             "'%s'.", | ||||
|                             self.name, | ||||
|                             error, | ||||
|                             self.actions[index + 1].name, | ||||
|                         ) | ||||
|                         shared_context.add_result(None) | ||||
|                         context.extra["results"].append(None) | ||||
|                         fallback = self.actions[index + 1].prepare(shared_context) | ||||
|                         result = await fallback() | ||||
|                         fallback._skip_in_chain = True | ||||
|                     else: | ||||
|                         raise | ||||
|                 shared_context.add_result(result) | ||||
|                 context.extra["results"].append(result) | ||||
|                 context.extra["rollback_stack"].append( | ||||
|                     (prepared, combined_args, updated_kwargs) | ||||
|                 ) | ||||
|                 combined_args, updated_kwargs = self._clear_args() | ||||
|  | ||||
|             all_results = context.extra["results"] | ||||
|             assert ( | ||||
|                 all_results | ||||
|             ), f"[{self.name}] No results captured. Something seriously went wrong." | ||||
|             context.result = all_results if self.return_list else all_results[-1] | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return context.result | ||||
|         except BreakChainSignal as error: | ||||
|             logger.info("[%s] Chain broken: %s", self.name, error) | ||||
|             context.exception = error | ||||
|             shared_context.add_error(shared_context.current_index, error) | ||||
|             await self._rollback(context.extra["rollback_stack"]) | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             shared_context.add_error(shared_context.current_index, error) | ||||
|             await self._rollback(context.extra["rollback_stack"]) | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def _rollback( | ||||
|         self, rollback_stack: list[tuple[Action, tuple[Any, ...], dict[str, Any]]] | ||||
|     ): | ||||
|         """ | ||||
|         Roll back all executed actions in reverse order. | ||||
|  | ||||
|         Rollbacks run even if a fallback recovered from failure, | ||||
|         ensuring consistent undo of all side effects. | ||||
|  | ||||
|         Actions without rollback handlers are skipped. | ||||
|  | ||||
|         Args: | ||||
|             rollback_stack (list): Actions to roll back. | ||||
|             *args, **kwargs: Passed to rollback handlers. | ||||
|         """ | ||||
|         for action, args, kwargs in reversed(rollback_stack): | ||||
|             rollback = getattr(action, "rollback", None) | ||||
|             if rollback: | ||||
|                 try: | ||||
|                     logger.warning("[%s] Rolling back...", action.name) | ||||
|                     await rollback(*args, **kwargs) | ||||
|                 except Exception as error: | ||||
|                     logger.error("[%s] Rollback failed: %s", action.name, error) | ||||
|  | ||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||
|         """Register a hook for all actions and sub-actions.""" | ||||
|         super().register_hooks_recursively(hook_type, hook) | ||||
|         for action in self.actions: | ||||
|             action.register_hooks_recursively(hook_type, hook) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.CYAN_b}]⛓ ChainedAction[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         for action in self.actions: | ||||
|             await action.preview(parent=tree) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ChainedAction(name={self.name}, " | ||||
|             f"actions={[a.name for a in self.actions]}, " | ||||
|             f"args={self.args!r}, kwargs={self.kwargs!r}, " | ||||
|             f"auto_inject={self.auto_inject}, return_list={self.return_list})" | ||||
|         ) | ||||
							
								
								
									
										272
									
								
								falyx/action/confirm_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								falyx/action/confirm_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,272 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ConfirmAction`, a Falyx Action that prompts the user for confirmation | ||||
| before continuing execution. | ||||
|  | ||||
| `ConfirmAction` supports a wide range of confirmation strategies, including: | ||||
| - Yes/No-style prompts | ||||
| - OK/Cancel dialogs | ||||
| - Typed confirmation (e.g., "CONFIRM" or "DELETE") | ||||
| - Acknowledge-only flows | ||||
|  | ||||
| It is useful for adding safety gates, user-driven approval steps, or destructive | ||||
| operation guards in CLI workflows. This Action supports both interactive use and | ||||
| non-interactive (headless) behavior via `never_prompt`, as well as full hook lifecycle | ||||
| integration and optional result passthrough. | ||||
|  | ||||
| Key Features: | ||||
| - Supports all common confirmation types (see `ConfirmType`) | ||||
| - Integrates with `PromptSession` for prompt_toolkit-based UX | ||||
| - Configurable fallback word validation and behavior on cancel | ||||
| - Can return the injected `last_result` instead of a boolean | ||||
| - Fully compatible with Falyx hooks, preview, and result injection | ||||
|  | ||||
| Use Cases: | ||||
| - Safety checks before deleting, pushing, or overwriting resources | ||||
| - Gatekeeping interactive workflows | ||||
| - Validating irreversible or sensitive operations | ||||
|  | ||||
| Example: | ||||
|     ConfirmAction( | ||||
|         name="ConfirmDeploy", | ||||
|         message="Are you sure you want to deploy to production?", | ||||
|         confirm_type="yes_no_cancel", | ||||
|     ) | ||||
|  | ||||
| Raises: | ||||
| - `CancelSignal`: When the user chooses to abort the action | ||||
| - `ValueError`: If an invalid `confirm_type` is provided | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action_types import ConfirmType | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import ( | ||||
|     confirm_async, | ||||
|     rich_text_to_prompt_text, | ||||
|     should_prompt_user, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.validators import word_validator, words_validator | ||||
|  | ||||
|  | ||||
| class ConfirmAction(BaseAction): | ||||
|     """ | ||||
|     Action to confirm an operation with the user. | ||||
|  | ||||
|     There are several ways to confirm an action, such as using a simple | ||||
|     yes/no prompt. You can also use a confirmation type that requires the user | ||||
|     to type a specific word or phrase to confirm the action, or use an OK/Cancel | ||||
|     dialog. | ||||
|  | ||||
|     This action can be used to ensure that the user explicitly agrees to proceed | ||||
|     with an operation. | ||||
|  | ||||
|     Attributes: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         prompt_message (str): The confirmation message to display. | ||||
|         confirm_type (ConfirmType | str): The type of confirmation to use. | ||||
|             Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||
|         prompt_session (PromptSession | None): The session to use for input. | ||||
|         confirm (bool): Whether to prompt the user for confirmation. | ||||
|         word (str): The word to type for TYPE_WORD confirmation. | ||||
|         return_last_result (bool): Whether to return the last result of the action | ||||
|                                    instead of a boolean. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         prompt_message: str = "Confirm?", | ||||
|         confirm_type: ConfirmType | str = ConfirmType.YES_NO, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
|         word: str = "CONFIRM", | ||||
|         return_last_result: bool = False, | ||||
|         inject_last_result: bool = True, | ||||
|         inject_into: str = "last_result", | ||||
|     ): | ||||
|         """ | ||||
|         Initialize the ConfirmAction. | ||||
|  | ||||
|         Args: | ||||
|             message (str): The confirmation message to display. | ||||
|             confirm_type (ConfirmType): The type of confirmation to use. | ||||
|                 Options include YES_NO, YES_CANCEL, YES_NO_CANCEL, TYPE_WORD, and OK_CANCEL. | ||||
|             prompt_session (PromptSession | None): The session to use for input. | ||||
|             confirm (bool): Whether to prompt the user for confirmation. | ||||
|             word (str): The word to type for TYPE_WORD confirmation. | ||||
|             return_last_result (bool): Whether to return the last result of the action. | ||||
|         """ | ||||
|         super().__init__( | ||||
|             name=name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.prompt_message = prompt_message | ||||
|         self.confirm_type = ConfirmType(confirm_type) | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.word = word | ||||
|         self.return_last_result = return_last_result | ||||
|  | ||||
|     async def _confirm(self) -> bool: | ||||
|         """Confirm the action with the user.""" | ||||
|         match self.confirm_type: | ||||
|             case ConfirmType.YES_NO: | ||||
|                 return await confirm_async( | ||||
|                     rich_text_to_prompt_text(self.prompt_message), | ||||
|                     suffix=rich_text_to_prompt_text( | ||||
|                         f" [[{OneColors.GREEN_b}]Y[/]]es, " | ||||
|                         f"[[{OneColors.DARK_RED_b}]N[/]]o > " | ||||
|                     ), | ||||
|                     session=self.prompt_session, | ||||
|                 ) | ||||
|             case ConfirmType.YES_NO_CANCEL: | ||||
|                 error_message = "Enter 'Y', 'y' to confirm, 'N', 'n' to decline, or 'C', 'c' to abort." | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     rich_text_to_prompt_text( | ||||
|                         f"❓ {self.prompt_message} [[{OneColors.GREEN_b}]Y[/]]es, " | ||||
|                         f"[[{OneColors.DARK_YELLOW_b}]N[/]]o, " | ||||
|                         f"or [[{OneColors.DARK_RED_b}]C[/]]ancel to abort > " | ||||
|                     ), | ||||
|                     validator=words_validator( | ||||
|                         ["Y", "N", "C"], error_message=error_message | ||||
|                     ), | ||||
|                 ) | ||||
|                 if answer.upper() == "C": | ||||
|                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||
|                 return answer.upper() == "Y" | ||||
|             case ConfirmType.TYPE_WORD: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     rich_text_to_prompt_text( | ||||
|                         f"❓ {self.prompt_message} [[{OneColors.GREEN_b}]{self.word.upper()}[/]] " | ||||
|                         f"to confirm or [[{OneColors.DARK_RED}]N[/{OneColors.DARK_RED}]] > " | ||||
|                     ), | ||||
|                     validator=word_validator(self.word), | ||||
|                 ) | ||||
|                 return answer.upper().strip() != "N" | ||||
|             case ConfirmType.TYPE_WORD_CANCEL: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     rich_text_to_prompt_text( | ||||
|                         f"❓ {self.prompt_message} [[{OneColors.GREEN_b}]{self.word.upper()}[/]] " | ||||
|                         f"to confirm or [[{OneColors.DARK_RED}]N[/{OneColors.DARK_RED}]] > " | ||||
|                     ), | ||||
|                     validator=word_validator(self.word), | ||||
|                 ) | ||||
|                 if answer.upper().strip() == "N": | ||||
|                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||
|                 return answer.upper().strip() == self.word.upper().strip() | ||||
|             case ConfirmType.YES_CANCEL: | ||||
|                 answer = await confirm_async( | ||||
|                     rich_text_to_prompt_text(self.prompt_message), | ||||
|                     suffix=rich_text_to_prompt_text( | ||||
|                         f" [[{OneColors.GREEN_b}]Y[/]]es, " | ||||
|                         f"[[{OneColors.DARK_RED_b}]N[/]]o > " | ||||
|                     ), | ||||
|                     session=self.prompt_session, | ||||
|                 ) | ||||
|                 if not answer: | ||||
|                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||
|                 return answer | ||||
|             case ConfirmType.OK_CANCEL: | ||||
|                 error_message = "Enter 'O', 'o' to confirm or 'C', 'c' to abort." | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     rich_text_to_prompt_text( | ||||
|                         f"❓ {self.prompt_message} [[{OneColors.GREEN_b}]O[/]]k to confirm, " | ||||
|                         f"[[{OneColors.DARK_RED}]C[/]]ancel to abort > " | ||||
|                     ), | ||||
|                     validator=words_validator(["O", "C"], error_message=error_message), | ||||
|                 ) | ||||
|                 if answer.upper() == "C": | ||||
|                     raise CancelSignal(f"Action '{self.name}' was cancelled by the user.") | ||||
|                 return answer.upper() == "O" | ||||
|             case ConfirmType.ACKNOWLEDGE: | ||||
|                 answer = await self.prompt_session.prompt_async( | ||||
|                     rich_text_to_prompt_text( | ||||
|                         f"❓ {self.prompt_message} [[{OneColors.CYAN_b}]A[/]]cknowledge > " | ||||
|                     ), | ||||
|                     validator=word_validator("A"), | ||||
|                 ) | ||||
|                 return answer.upper().strip() == "A" | ||||
|             case _: | ||||
|                 raise ValueError(f"Unknown confirm_type: {self.confirm_type}") | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         combined_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, args=args, kwargs=combined_kwargs, action=self | ||||
|         ) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             if ( | ||||
|                 self.never_prompt | ||||
|                 or self.options_manager | ||||
|                 and not should_prompt_user(confirm=True, options=self.options_manager) | ||||
|             ): | ||||
|                 logger.debug( | ||||
|                     "Skipping confirmation for '%s' due to never_prompt or options_manager settings.", | ||||
|                     self.name, | ||||
|                 ) | ||||
|                 if self.return_last_result: | ||||
|                     result = combined_kwargs[self.inject_into] | ||||
|                 else: | ||||
|                     result = True | ||||
|             else: | ||||
|                 answer = await self._confirm() | ||||
|                 if self.return_last_result and answer: | ||||
|                     result = combined_kwargs[self.inject_into] | ||||
|                 else: | ||||
|                     result = answer | ||||
|             logger.debug("Action '%s' confirmed with result: %s", self.name, result) | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None) -> None: | ||||
|         tree = ( | ||||
|             Tree( | ||||
|                 f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}", | ||||
|                 guide_style=OneColors.BLUE_b, | ||||
|             ) | ||||
|             if not parent | ||||
|             else parent.add(f"[{OneColors.CYAN_b}]ConfirmAction[/]: {self.name}") | ||||
|         ) | ||||
|         tree.add(f"[bold]Message:[/] {self.prompt_message}") | ||||
|         tree.add(f"[bold]Type:[/] {self.confirm_type.value}") | ||||
|         tree.add(f"[bold]Prompt Required:[/] {'No' if self.never_prompt else 'Yes'}") | ||||
|         if self.confirm_type in (ConfirmType.TYPE_WORD, ConfirmType.TYPE_WORD_CANCEL): | ||||
|             tree.add(f"[bold]Confirmation Word:[/] {self.word}") | ||||
|         if parent is None: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ConfirmAction(name={self.name}, message={self.prompt_message}, " | ||||
|             f"confirm_type={self.confirm_type}, return_last_result={self.return_last_result})" | ||||
|         ) | ||||
							
								
								
									
										87
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								falyx/action/fallback_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `FallbackAction`, a lightweight recovery Action used within `ChainedAction` | ||||
| pipelines to gracefully handle errors or missing results from a preceding step. | ||||
|  | ||||
| When placed immediately after a failing or null-returning Action, `FallbackAction` | ||||
| injects the `last_result` and checks whether it is `None`. If so, it substitutes a | ||||
| predefined fallback value and allows the chain to continue. If `last_result` is valid, | ||||
| it is passed through unchanged. | ||||
|  | ||||
| This mechanism allows workflows to recover from failure or gaps in data | ||||
| without prematurely terminating the entire chain. | ||||
|  | ||||
| Key Features: | ||||
| - Injects and inspects `last_result` | ||||
| - Replaces `None` with a fallback value | ||||
| - Consumes upstream errors when used with `ChainedAction` | ||||
| - Fully compatible with Falyx's preview and hook systems | ||||
|  | ||||
| Typical Use Cases: | ||||
| - Graceful degradation in chained workflows | ||||
| - Providing default values when earlier steps are optional | ||||
| - Replacing missing data with static or precomputed values | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="FetchWithFallback", | ||||
|         actions=[ | ||||
|             Action("MaybeFetchRemoteAction", action=fetch_data), | ||||
|             FallbackAction(fallback={"data": "default"}), | ||||
|             Action("ProcessDataAction", action=process_data), | ||||
|         ], | ||||
|         auto_inject=True, | ||||
|     ) | ||||
|  | ||||
| The `FallbackAction` ensures that even if `MaybeFetchRemoteAction` fails or returns | ||||
| None, `ProcessDataAction` still receives a usable input. | ||||
| """ | ||||
| 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})" | ||||
| @@ -1,5 +1,5 @@ | ||||
| # 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: | ||||
| @@ -28,7 +28,7 @@ async def close_shared_http_session(context: ExecutionContext) -> None: | ||||
|         if session and should_close: | ||||
|             await session.close() | ||||
|     except Exception as error: | ||||
|         logger.warning("⚠️ Error closing shared HTTP session: %s", error) | ||||
|         logger.warning("Error closing shared HTTP session: %s", error) | ||||
|  | ||||
|  | ||||
| class HTTPAction(Action): | ||||
| @@ -47,7 +47,7 @@ class HTTPAction(Action): | ||||
|     - Retry and result injection compatible | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         method (str): HTTP method (e.g., 'GET', 'POST'). | ||||
|         url (str): The request URL. | ||||
|         headers (dict[str, str], optional): Request headers. | ||||
| @@ -77,6 +77,11 @@ class HTTPAction(Action): | ||||
|         inject_into: str = "last_result", | ||||
|         retry: bool = False, | ||||
|         retry_policy=None, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ): | ||||
|         self.method = method.upper() | ||||
|         self.url = url | ||||
| @@ -95,6 +100,11 @@ class HTTPAction(Action): | ||||
|             inject_into=inject_into, | ||||
|             retry=retry, | ||||
|             retry_policy=retry_policy, | ||||
|             spinner=spinner, | ||||
|             spinner_message=spinner_message, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|  | ||||
|     async def _request(self, *_, **__) -> dict[str, Any]: | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # 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` | ||||
| @@ -16,19 +16,15 @@ Common usage includes shell-like filters, input transformers, or any tool that | ||||
| needs to consume input from another process or pipeline. | ||||
| """ | ||||
| import asyncio | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| @@ -52,8 +48,11 @@ class BaseIOAction(BaseAction): | ||||
|     - `to_output(data)`: Convert result into output string or bytes. | ||||
|     - `_run(parsed_input, *args, **kwargs)`: Core execution logic. | ||||
|  | ||||
|     Attributes: | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         hooks (HookManager | None): Hook manager for lifecycle events. | ||||
|         mode (str): Either "buffered" or "stream". Controls input behavior. | ||||
|         logging_hooks (bool): Whether to register debug hooks for logging. | ||||
|         inject_last_result (bool): Whether to inject shared context input. | ||||
|     """ | ||||
|  | ||||
| @@ -73,7 +72,6 @@ class BaseIOAction(BaseAction): | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.mode = mode | ||||
|         self._requires_injection = True | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> Any: | ||||
|         raise NotImplementedError | ||||
| @@ -81,23 +79,23 @@ class BaseIOAction(BaseAction): | ||||
|     def to_output(self, result: Any) -> str | bytes: | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     async def _resolve_input(self, kwargs: dict[str, Any]) -> str | bytes: | ||||
|         last_result = kwargs.pop(self.inject_into, None) | ||||
|  | ||||
|     async def _resolve_input( | ||||
|         self, args: tuple[Any], kwargs: dict[str, Any] | ||||
|     ) -> str | bytes: | ||||
|         data = await self._read_stdin() | ||||
|         if data: | ||||
|             return self.from_input(data) | ||||
|  | ||||
|         if last_result is not None: | ||||
|             return last_result | ||||
|         if len(args) == 1: | ||||
|             return self.from_input(args[0]) | ||||
|  | ||||
|         if self.inject_last_result and self.shared_context: | ||||
|             return self.shared_context.last_result() | ||||
|  | ||||
|         logger.debug( | ||||
|             "[%s] No input provided and no last result found for injection.", self.name | ||||
|         ) | ||||
|         raise FalyxError("No input provided and no last result to inject.") | ||||
|         return "" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def __call__(self, *args, **kwargs): | ||||
|         context = ExecutionContext( | ||||
| @@ -117,8 +115,8 @@ class BaseIOAction(BaseAction): | ||||
|                     pass | ||||
|                 result = getattr(self, "_last_result", None) | ||||
|             else: | ||||
|                 parsed_input = await self._resolve_input(kwargs) | ||||
|                 result = await self._run(parsed_input, *args, **kwargs) | ||||
|                 parsed_input = await self._resolve_input(args, kwargs) | ||||
|                 result = await self._run(parsed_input) | ||||
|                 output = self.to_output(result) | ||||
|                 await self._write_stdout(output) | ||||
|             context.result = result | ||||
| @@ -172,85 +170,3 @@ class BaseIOAction(BaseAction): | ||||
|             parent.add("".join(label)) | ||||
|         else: | ||||
|             self.console.print(Tree("".join(label))) | ||||
|  | ||||
|  | ||||
| class ShellAction(BaseIOAction): | ||||
|     """ | ||||
|     ShellAction wraps a shell command template for CLI pipelines. | ||||
|  | ||||
|     This Action takes parsed input (from stdin, literal, or last_result), | ||||
|     substitutes it into the provided shell command template, and executes | ||||
|     the command asynchronously using subprocess. | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
|     - Compatible with ChainedAction and Command.requires_input detection | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
|             raise TypeError( | ||||
|                 f"{self.name} expected str or bytes input, got {type(raw).__name__}" | ||||
|             ) | ||||
|         return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() | ||||
|  | ||||
|     async def _run(self, parsed_input: str) -> str: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             args = shlex.split(command) | ||||
|             result = subprocess.run(args, capture_output=True, text=True, check=True) | ||||
|         else: | ||||
|             result = subprocess.run( | ||||
|                 command, shell=True, text=True, capture_output=True, check=True | ||||
|             ) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|  | ||||
|     def to_output(self, result: str) -> str: | ||||
|         return result | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," | ||||
|             f" safe_mode={self.safe_mode})" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										80
									
								
								falyx/action/literal_input_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								falyx/action/literal_input_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `LiteralInputAction`, a lightweight Falyx Action that injects a static, | ||||
| predefined value into a `ChainedAction` workflow. | ||||
|  | ||||
| This Action is useful for embedding literal values (e.g., strings, numbers, | ||||
| dicts) as part of a CLI pipeline without writing custom callables. It behaves | ||||
| like a constant-returning function that can serve as the starting point, | ||||
| fallback, or manual override within a sequence of actions. | ||||
|  | ||||
| Key Features: | ||||
| - Wraps any static value as a Falyx-compatible Action | ||||
| - Fully hookable and previewable like any other Action | ||||
| - Enables declarative workflows with no required user input | ||||
| - Compatible with auto-injection and shared context in `ChainedAction` | ||||
|  | ||||
| Common Use Cases: | ||||
| - Supplying default parameters or configuration values mid-pipeline | ||||
| - Starting a chain with a fixed value (e.g., base URL, credentials) | ||||
| - Bridging gaps between conditional or dynamically generated Actions | ||||
|  | ||||
| Example: | ||||
|     ChainedAction( | ||||
|         name="SendStaticMessage", | ||||
|         actions=[ | ||||
|             LiteralInputAction("hello world"), | ||||
|             SendMessageAction(), | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
| The `LiteralInputAction` is a foundational building block for pipelines that | ||||
| require predictable, declarative value injection at any stage. | ||||
| """ | ||||
| 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})" | ||||
							
								
								
									
										264
									
								
								falyx/action/load_file_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								falyx/action/load_file_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,264 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `LoadFileAction`, a Falyx Action for reading and parsing the contents of a file | ||||
| at runtime in a structured, introspectable, and lifecycle-aware manner. | ||||
|  | ||||
| This action supports multiple common file types—including plain text, structured data | ||||
| formats (JSON, YAML, TOML), tabular formats (CSV, TSV), XML, and raw Path objects— | ||||
| making it ideal for configuration loading, data ingestion, and file-driven workflows. | ||||
|  | ||||
| It integrates seamlessly with Falyx pipelines and supports `last_result` injection, | ||||
| Rich-powered previews, and lifecycle hook execution. | ||||
|  | ||||
| Key Features: | ||||
| - Format-aware parsing for structured and unstructured files | ||||
| - Supports injection of `last_result` as the target file path | ||||
| - Headless-compatible via `never_prompt` and argument overrides | ||||
| - Lifecycle hooks: before, success, error, after, teardown | ||||
| - Preview renders file metadata, size, modified timestamp, and parsed content | ||||
| - Fully typed and alias-compatible via `FileType` | ||||
|  | ||||
| Supported File Types: | ||||
| - `TEXT`: Raw text string (UTF-8) | ||||
| - `PATH`: The file path itself as a `Path` object | ||||
| - `JSON`, `YAML`, `TOML`: Parsed into `dict` or `list` | ||||
| - `CSV`, `TSV`: Parsed into `list[list[str]]` | ||||
| - `XML`: Returns the root `ElementTree.Element` | ||||
|  | ||||
| Example: | ||||
|     LoadFileAction( | ||||
|         name="LoadSettings", | ||||
|         file_path="config/settings.yaml", | ||||
|         file_type="yaml" | ||||
|     ) | ||||
|  | ||||
| This module is a foundational building block for file-driven CLI workflows in Falyx. | ||||
| It is often paired with `SaveFileAction`, `SelectionAction`, or `ConfirmAction` for | ||||
| robust and interactive pipelines. | ||||
| """ | ||||
| import csv | ||||
| import json | ||||
| import xml.etree.ElementTree as ET | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
| from typing import Any | ||||
|  | ||||
| import toml | ||||
| import yaml | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action_types import FileType | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class LoadFileAction(BaseAction): | ||||
|     """ | ||||
|     LoadFileAction loads and parses the contents of a file at runtime. | ||||
|  | ||||
|     This action supports multiple common file formats—including plain text, JSON, | ||||
|     YAML, TOML, XML, CSV, and TSV—and returns a parsed representation of the file. | ||||
|     It can be used to inject external data into a CLI workflow, load configuration files, | ||||
|     or process structured datasets interactively or in headless mode. | ||||
|  | ||||
|     Key Features: | ||||
|     - Supports rich previewing of file metadata and contents | ||||
|     - Auto-injects `last_result` as `file_path` if configured | ||||
|     - Hookable at every lifecycle stage (before, success, error, after, teardown) | ||||
|     - Supports both static and dynamic file targets (via args or injected values) | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action for tracking and logging. | ||||
|         file_path (str | Path | None): Path to the file to be loaded. Can be passed | ||||
|             directly or injected via `last_result`. | ||||
|         file_type (FileType | str): Type of file to parse. Options include: | ||||
|             TEXT, JSON, YAML, TOML, CSV, TSV, XML, PATH. | ||||
|         encoding (str): Encoding to use when reading files (default: 'UTF-8'). | ||||
|         inject_last_result (bool): Whether to use the last result as the file path. | ||||
|         inject_into (str): Name of the kwarg to inject `last_result` into (default: 'file_path'). | ||||
|  | ||||
|     Returns: | ||||
|         Any: The parsed file content. Format depends on `file_type`: | ||||
|             - TEXT: str | ||||
|             - JSON/YAML/TOML: dict or list | ||||
|             - CSV/TSV: list[list[str]] | ||||
|             - XML: xml.etree.ElementTree | ||||
|             - PATH: Path object | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If `file_path` is missing or invalid. | ||||
|         FileNotFoundError: If the file does not exist. | ||||
|         TypeError: If `file_type` is unsupported or the factory does not return a BaseAction. | ||||
|         Any parsing errors will be logged but not raised unless fatal. | ||||
|  | ||||
|     Example: | ||||
|         LoadFileAction( | ||||
|             name="LoadConfig", | ||||
|             file_path="config/settings.yaml", | ||||
|             file_type="yaml" | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         file_path: str | Path | None = None, | ||||
|         file_type: FileType | str = FileType.TEXT, | ||||
|         encoding: str = "UTF-8", | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "file_path", | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||
|         ) | ||||
|         self._file_path = self._coerce_file_path(file_path) | ||||
|         self._file_type = FileType(file_type) | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     @property | ||||
|     def file_path(self) -> Path | None: | ||||
|         """Get the file path as a Path object.""" | ||||
|         return self._file_path | ||||
|  | ||||
|     @file_path.setter | ||||
|     def file_path(self, value: str | Path): | ||||
|         """Set the file path, converting to Path if necessary.""" | ||||
|         self._file_path = self._coerce_file_path(value) | ||||
|  | ||||
|     def _coerce_file_path(self, file_path: str | Path | None) -> Path | None: | ||||
|         """Coerce the file path to a Path object.""" | ||||
|         if isinstance(file_path, Path): | ||||
|             return file_path | ||||
|         elif isinstance(file_path, str): | ||||
|             return Path(file_path) | ||||
|         elif file_path is None: | ||||
|             return None | ||||
|         else: | ||||
|             raise TypeError("file_path must be a string or Path object") | ||||
|  | ||||
|     @property | ||||
|     def file_type(self) -> FileType: | ||||
|         """Get the file type.""" | ||||
|         return self._file_type | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def load_file(self) -> Any: | ||||
|         """Load and parse the file based on its type.""" | ||||
|         if self.file_path is None: | ||||
|             raise ValueError("file_path must be set before loading a file") | ||||
|         elif not self.file_path.exists(): | ||||
|             raise FileNotFoundError(f"File not found: {self.file_path}") | ||||
|         elif not self.file_path.is_file(): | ||||
|             raise ValueError(f"Path is not a regular file: {self.file_path}") | ||||
|         value: Any = None | ||||
|         try: | ||||
|             if self.file_type == FileType.TEXT: | ||||
|                 value = self.file_path.read_text(encoding=self.encoding) | ||||
|             elif self.file_type == FileType.PATH: | ||||
|                 value = self.file_path | ||||
|             elif self.file_type == FileType.JSON: | ||||
|                 value = json.loads(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.TOML: | ||||
|                 value = toml.loads(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.YAML: | ||||
|                 value = yaml.safe_load(self.file_path.read_text(encoding=self.encoding)) | ||||
|             elif self.file_type == FileType.CSV: | ||||
|                 with open(self.file_path, newline="", encoding=self.encoding) as csvfile: | ||||
|                     reader = csv.reader(csvfile) | ||||
|                     value = list(reader) | ||||
|             elif self.file_type == FileType.TSV: | ||||
|                 with open(self.file_path, newline="", encoding=self.encoding) as tsvfile: | ||||
|                     reader = csv.reader(tsvfile, delimiter="\t") | ||||
|                     value = list(reader) | ||||
|             elif self.file_type == FileType.XML: | ||||
|                 tree = ET.parse( | ||||
|                     self.file_path, parser=ET.XMLParser(encoding=self.encoding) | ||||
|                 ) | ||||
|                 root = tree.getroot() | ||||
|                 value = root | ||||
|             else: | ||||
|                 raise ValueError(f"Unsupported return type: {self.file_type}") | ||||
|  | ||||
|         except Exception as error: | ||||
|             logger.error("Failed to parse %s: %s", self.file_path.name, error) | ||||
|         return value | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         context = ExecutionContext(name=self.name, args=args, kwargs=kwargs, action=self) | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             if "file_path" in kwargs: | ||||
|                 self.file_path = kwargs["file_path"] | ||||
|             elif self.inject_last_result and self.last_result: | ||||
|                 self.file_path = self.last_result | ||||
|  | ||||
|             result = await self.load_file() | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = f"[{OneColors.GREEN}]📄 LoadFileAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         tree.add(f"[dim]Path:[/] {self.file_path}") | ||||
|         tree.add(f"[dim]Type:[/] {self.file_type.name if self.file_type else 'None'}") | ||||
|         if self.file_path is None: | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]❌ File path is not set[/]") | ||||
|         elif not self.file_path.exists(): | ||||
|             tree.add(f"[{OneColors.DARK_RED_b}]❌ File does not exist[/]") | ||||
|         elif not self.file_path.is_file(): | ||||
|             tree.add(f"[{OneColors.LIGHT_YELLOW_b}]⚠️ Not a regular file[/]") | ||||
|         else: | ||||
|             try: | ||||
|                 stat = self.file_path.stat() | ||||
|                 tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes") | ||||
|                 tree.add( | ||||
|                     f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}" | ||||
|                 ) | ||||
|                 tree.add( | ||||
|                     f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}" | ||||
|                 ) | ||||
|                 if self.file_type == FileType.TEXT: | ||||
|                     preview_lines = self.file_path.read_text( | ||||
|                         encoding="UTF-8" | ||||
|                     ).splitlines()[:10] | ||||
|                     content_tree = tree.add("[dim]Preview (first 10 lines):[/]") | ||||
|                     for line in preview_lines: | ||||
|                         content_tree.add(f"[dim]{line}[/]") | ||||
|                 elif self.file_type in {FileType.JSON, FileType.YAML, FileType.TOML}: | ||||
|                     raw = self.load_file() | ||||
|                     if raw is not None: | ||||
|                         preview_str = ( | ||||
|                             json.dumps(raw, indent=2) | ||||
|                             if isinstance(raw, dict) | ||||
|                             else str(raw) | ||||
|                         ) | ||||
|                         preview_lines = preview_str.splitlines()[:10] | ||||
|                         content_tree = tree.add("[dim]Parsed preview:[/]") | ||||
|                         for line in preview_lines: | ||||
|                             content_tree.add(f"[dim]{line}[/]") | ||||
|             except Exception as e: | ||||
|                 tree.add(f"[{OneColors.DARK_RED_b}]❌ Error reading file:[/] {e}") | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"LoadFileAction(file_path={self.file_path}, file_type={self.file_type})" | ||||
| @@ -1,26 +1,113 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """menu_action.py""" | ||||
| """ | ||||
| Defines `MenuAction`, a one-shot, interactive menu-style Falyx Action that presents | ||||
| a set of labeled options to the user and executes the corresponding action based on | ||||
| their selection. | ||||
|  | ||||
| Unlike the persistent top-level Falyx menu, `MenuAction` is intended for embedded, | ||||
| self-contained decision points within a workflow. It supports both interactive and | ||||
| non-interactive (headless) usage, integrates fully with the Falyx hook lifecycle, | ||||
| and allows optional defaulting or input injection from previous actions. | ||||
|  | ||||
| Each selectable item is defined in a `MenuOptionMap`, mapping a single-character or | ||||
| keyword to a `MenuOption`, which includes a description and a corresponding `BaseAction`. | ||||
|  | ||||
| Key Features: | ||||
| - Renders a Rich-powered multi-column menu table | ||||
| - Accepts custom prompt sessions or tables | ||||
| - Supports `last_result` injection for context-aware defaults | ||||
| - Gracefully handles `BackSignal` and `QuitSignal` for flow control | ||||
| - Compatible with preview trees and introspection tools | ||||
|  | ||||
| Use Cases: | ||||
| - In-workflow submenus or branches | ||||
| - Interactive control points in chained or grouped workflows | ||||
| - Configurable menus for multi-step user-driven automation | ||||
|  | ||||
| Example: | ||||
|     MenuAction( | ||||
|         name="SelectEnv", | ||||
|         menu_options=MenuOptionMap(options={ | ||||
|             "D": MenuOption("Deploy to Dev", DeployDevAction()), | ||||
|             "P": MenuOption("Deploy to Prod", DeployProdAction()), | ||||
|         }), | ||||
|         default_selection="D", | ||||
|     ) | ||||
|  | ||||
| This module is ideal for enabling structured, discoverable, and declarative | ||||
| menus in both interactive and programmatic CLI automation. | ||||
| """ | ||||
| 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.action import BaseAction | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.menu import MenuOptionMap | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.selection import prompt_for_selection, render_table_base | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.signals import BackSignal, CancelSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import chunks | ||||
|  | ||||
|  | ||||
| class MenuAction(BaseAction): | ||||
|     """MenuAction class for creating single use menu actions.""" | ||||
|     """ | ||||
|     MenuAction displays a one-time interactive menu of predefined options, | ||||
|     each mapped to a corresponding Action. | ||||
|  | ||||
|     Unlike the main Falyx menu system, `MenuAction` is intended for scoped, | ||||
|     self-contained selection logic—ideal for small in-flow menus, decision branches, | ||||
|     or embedded control points in larger workflows. | ||||
|  | ||||
|     Each selectable item is defined in a `MenuOptionMap`, which maps a string key | ||||
|     to a `MenuOption`, bundling a description and a callable Action. | ||||
|  | ||||
|     Key Features: | ||||
|     - One-shot selection from labeled actions | ||||
|     - Optional default or last_result-based selection | ||||
|     - Full hook lifecycle (before, success, error, after, teardown) | ||||
|     - Works with or without rendering a table (for headless use) | ||||
|     - Compatible with `BackSignal` and `QuitSignal` for graceful control flow exits | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         menu_options (MenuOptionMap): Mapping of keys to `MenuOption` objects. | ||||
|         title (str): Table title displayed when prompting the user. | ||||
|         columns (int): Number of columns in the rendered table. | ||||
|         prompt_message (str): Prompt text displayed before selection. | ||||
|         default_selection (str): Key to use if no user input is provided. | ||||
|         inject_last_result (bool): Whether to inject `last_result` into args/kwargs. | ||||
|         inject_into (str): Key under which to inject `last_result`. | ||||
|         prompt_session (PromptSession | None): Custom session for Prompt Toolkit input. | ||||
|         never_prompt (bool): If True, skips interaction and uses default or last_result. | ||||
|         include_reserved (bool): Whether to include reserved keys (like 'X' for Exit). | ||||
|         show_table (bool): Whether to render the Rich menu table. | ||||
|         custom_table (Table | None): Pre-rendered Rich Table (bypasses auto-building). | ||||
|  | ||||
|     Returns: | ||||
|         Any: The result of the selected option's Action. | ||||
|  | ||||
|     Raises: | ||||
|         BackSignal: When the user chooses to return to a previous menu. | ||||
|         QuitSignal: When the user chooses to exit the program. | ||||
|         ValueError: If `never_prompt=True` but no default selection is resolvable. | ||||
|         Exception: Any error raised during the execution of the selected Action. | ||||
|  | ||||
|     Example: | ||||
|         MenuAction( | ||||
|             name="ChooseBranch", | ||||
|             menu_options=MenuOptionMap(options={ | ||||
|                 "A": MenuOption("Run analysis", ActionGroup(...)), | ||||
|                 "B": MenuOption("Run report", Action(...)), | ||||
|             }), | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -33,7 +120,6 @@ class MenuAction(BaseAction): | ||||
|         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, | ||||
| @@ -49,10 +135,11 @@ class MenuAction(BaseAction): | ||||
|         self.menu_options = menu_options | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.default_selection = default_selection | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.include_reserved = include_reserved | ||||
|         self.show_table = show_table | ||||
|         self.custom_table = custom_table | ||||
| @@ -73,6 +160,9 @@ class MenuAction(BaseAction): | ||||
|             table.add_row(*row) | ||||
|         return table | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
| @@ -105,15 +195,18 @@ class MenuAction(BaseAction): | ||||
|             key = effective_default | ||||
|             if not self.never_prompt: | ||||
|                 table = self._build_table() | ||||
|                 key = await prompt_for_selection( | ||||
|                 key_ = await prompt_for_selection( | ||||
|                     self.menu_options.keys(), | ||||
|                     table, | ||||
|                     default_selection=self.default_selection, | ||||
|                     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 | ||||
| @@ -121,10 +214,10 @@ class MenuAction(BaseAction): | ||||
|             return result | ||||
|  | ||||
|         except BackSignal: | ||||
|             logger.debug("[%s][BackSignal] ← Returning to previous menu", self.name) | ||||
|             logger.debug("[%s][BackSignal] <- Returning to previous menu", self.name) | ||||
|             return None | ||||
|         except QuitSignal: | ||||
|             logger.debug("[%s][QuitSignal] ← Exiting application", self.name) | ||||
|             logger.debug("[%s][QuitSignal] <- Exiting application", self.name) | ||||
|             raise | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|   | ||||
							
								
								
									
										181
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								falyx/action/process_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,181 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ProcessAction`, a Falyx Action that executes a blocking or CPU-bound function | ||||
| in a separate process using `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
| This is useful for offloading expensive computations or subprocess-compatible operations | ||||
| from the main event loop, while maintaining Falyx's composable, hookable, and injectable | ||||
| execution model. | ||||
|  | ||||
| `ProcessAction` mirrors the behavior of a normal `Action`, but ensures isolation from | ||||
| the asyncio event loop and handles serialization (pickling) of arguments and injected | ||||
| state. | ||||
|  | ||||
| Key Features: | ||||
| - Runs a callable in a separate Python process | ||||
| - Compatible with `last_result` injection for chained workflows | ||||
| - Validates that injected values are pickleable before dispatch | ||||
| - Supports hook lifecycle (`before`, `on_success`, `on_error`, etc.) | ||||
| - Custom executor support for reuse or configuration | ||||
|  | ||||
| Use Cases: | ||||
| - CPU-intensive operations (e.g., image processing, simulations, data transformations) | ||||
| - Blocking third-party libraries that don't cooperate with asyncio | ||||
| - CLI workflows that require subprocess-level parallelism or safety | ||||
|  | ||||
| Example: | ||||
|     ProcessAction( | ||||
|         name="ComputeChecksum", | ||||
|         action=calculate_sha256, | ||||
|         args=("large_file.bin",), | ||||
|     ) | ||||
|  | ||||
| Raises: | ||||
| - `ValueError`: If an injected value is not pickleable | ||||
| - `Exception`: Propagated from the subprocess on failure | ||||
|  | ||||
| This module enables structured offloading of workload in CLI pipelines while maintaining | ||||
| full introspection and lifecycle management. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| from concurrent.futures import ProcessPoolExecutor | ||||
| from functools import partial | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ProcessAction(BaseAction): | ||||
|     """ | ||||
|     ProcessAction runs a function in a separate process using ProcessPoolExecutor. | ||||
|  | ||||
|     Features: | ||||
|     - Executes CPU-bound or blocking tasks without blocking the main event loop. | ||||
|     - Supports last_result injection into the subprocess. | ||||
|     - Validates that last_result is pickleable when injection is enabled. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         func (Callable): Function to execute in a new process. | ||||
|         args (tuple, optional): Positional arguments. | ||||
|         kwargs (dict, optional): Keyword arguments. | ||||
|         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||
|         executor (ProcessPoolExecutor, optional): Custom executor if desired. | ||||
|         inject_last_result (bool, optional): Inject last result into the function. | ||||
|         inject_into (str, optional): Name of the injected key. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         action: Callable[..., Any], | ||||
|         *, | ||||
|         args: tuple = (), | ||||
|         kwargs: dict[str, Any] | None = None, | ||||
|         hooks: HookManager | None = None, | ||||
|         executor: ProcessPoolExecutor | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         never_prompt: bool | None = None, | ||||
|         logging_hooks: bool = False, | ||||
|         spinner: bool = False, | ||||
|         spinner_message: str = "Processing...", | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|             logging_hooks=logging_hooks, | ||||
|             spinner=spinner, | ||||
|             spinner_message=spinner_message, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|         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})" | ||||
|         ) | ||||
							
								
								
									
										235
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										235
									
								
								falyx/action/process_pool_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,235 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ProcessPoolAction`, a parallelized action executor that distributes | ||||
| tasks across multiple processes using Python's `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
| This module enables structured execution of CPU-bound tasks in parallel while | ||||
| retaining Falyx's core guarantees: lifecycle hooks, error isolation, execution context | ||||
| tracking, and introspectable previews. | ||||
|  | ||||
| Key Components: | ||||
| - ProcessTask: Lightweight wrapper for a task + args/kwargs | ||||
| - ProcessPoolAction: Parallel action that runs tasks concurrently in separate processes | ||||
|  | ||||
| Use this module to accelerate workflows involving expensive computation or | ||||
| external resources that benefit from true parallelism. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| import random | ||||
| from concurrent.futures import ProcessPoolExecutor | ||||
| from dataclasses import dataclass, field | ||||
| from functools import partial | ||||
| from typing import Any, Callable, Sequence | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext, SharedContext | ||||
| from falyx.exceptions import EmptyPoolError | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.parser.utils import same_argument_definitions | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class ProcessTask: | ||||
|     """ | ||||
|     Represents a callable task with its arguments for parallel execution. | ||||
|  | ||||
|     This lightweight container is used to queue individual tasks for execution | ||||
|     inside a `ProcessPoolAction`. | ||||
|  | ||||
|     Attributes: | ||||
|         task (Callable): A function to execute. | ||||
|         args (tuple): Positional arguments to pass to the function. | ||||
|         kwargs (dict): Keyword arguments to pass to the function. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `task` is not callable. | ||||
|     """ | ||||
|  | ||||
|     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): | ||||
|     """ | ||||
|     Executes a set of independent tasks in parallel using a process pool. | ||||
|  | ||||
|     `ProcessPoolAction` is ideal for CPU-bound tasks that benefit from | ||||
|     concurrent execution in separate processes. Each task is wrapped in a | ||||
|     `ProcessTask` instance and executed in a `concurrent.futures.ProcessPoolExecutor`. | ||||
|  | ||||
|     Key Features: | ||||
|     - Parallel, process-based execution | ||||
|     - Hook lifecycle support across all stages | ||||
|     - Supports argument injection (e.g., `last_result`) | ||||
|     - Compatible with retry behavior and shared context propagation | ||||
|     - Captures all task results (including exceptions) and records execution context | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         actions (Sequence[ProcessTask] | None): A list of tasks to run. | ||||
|         hooks (HookManager | None): Optional hook manager for lifecycle events. | ||||
|         executor (ProcessPoolExecutor | None): Custom executor instance (optional). | ||||
|         inject_last_result (bool): Whether to inject the last result into task kwargs. | ||||
|         inject_into (str): Name of the kwarg to use for injected result. | ||||
|  | ||||
|     Returns: | ||||
|         list[Any]: A list of task results in submission order. Exceptions are preserved. | ||||
|  | ||||
|     Raises: | ||||
|         EmptyPoolError: If no actions are registered. | ||||
|         ValueError: If injected `last_result` is not pickleable. | ||||
|  | ||||
|     Example: | ||||
|         ProcessPoolAction( | ||||
|             name="ParallelTransforms", | ||||
|             actions=[ | ||||
|                 ProcessTask(func_a, args=(1,)), | ||||
|                 ProcessTask(func_b, kwargs={"x": 2}), | ||||
|             ] | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         actions: Sequence[ProcessTask] | None = None, | ||||
|         *, | ||||
|         hooks: HookManager | None = None, | ||||
|         executor: ProcessPoolExecutor | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             hooks=hooks, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|         ) | ||||
|         self.executor = executor or ProcessPoolExecutor() | ||||
|         self.is_retryable = True | ||||
|         self.actions: list[ProcessTask] = [] | ||||
|         if actions: | ||||
|             self.set_actions(actions) | ||||
|  | ||||
|     def set_actions(self, actions: Sequence[ProcessTask]) -> None: | ||||
|         """Replaces the current action list with a new one.""" | ||||
|         self.actions.clear() | ||||
|         for action in actions: | ||||
|             self.add_action(action) | ||||
|  | ||||
|     def add_action(self, action: ProcessTask) -> None: | ||||
|         if not isinstance(action, ProcessTask): | ||||
|             raise TypeError(f"Expected a ProcessTask, got {type(action).__name__}") | ||||
|         self.actions.append(action) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, None]: | ||||
|         arg_defs = same_argument_definitions([action.task for action in self.actions]) | ||||
|         if arg_defs: | ||||
|             return self.actions[0].task, None | ||||
|         logger.debug( | ||||
|             "[%s] auto_args disabled: mismatched ProcessPoolAction arguments", | ||||
|             self.name, | ||||
|         ) | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         if not self.actions: | ||||
|             raise EmptyPoolError(f"[{self.name}] No actions to execute.") | ||||
|         shared_context = SharedContext(name=self.name, action=self, is_parallel=True) | ||||
|         if self.shared_context: | ||||
|             shared_context.set_shared_result(self.shared_context.last_result()) | ||||
|         if self.inject_last_result and self.shared_context: | ||||
|             last_result = self.shared_context.last_result() | ||||
|             if not self._validate_pickleable(last_result): | ||||
|                 raise ValueError( | ||||
|                     f"Cannot inject last result into {self.name}: " | ||||
|                     f"last result is not pickleable." | ||||
|                 ) | ||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, | ||||
|             args=args, | ||||
|             kwargs=updated_kwargs, | ||||
|             action=self, | ||||
|         ) | ||||
|         loop = asyncio.get_running_loop() | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             futures = [ | ||||
|                 loop.run_in_executor( | ||||
|                     self.executor, | ||||
|                     partial( | ||||
|                         task.task, | ||||
|                         *(*args, *task.args), | ||||
|                         **{**updated_kwargs, **task.kwargs}, | ||||
|                     ), | ||||
|                 ) | ||||
|                 for task in self.actions | ||||
|             ] | ||||
|             results = await asyncio.gather(*futures, return_exceptions=True) | ||||
|             context.result = results | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return results | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             if context.result is not None: | ||||
|                 return context.result | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     def _validate_pickleable(self, obj: Any) -> bool: | ||||
|         try: | ||||
|             import pickle | ||||
|  | ||||
|             pickle.dumps(obj) | ||||
|             return True | ||||
|         except (pickle.PicklingError, TypeError): | ||||
|             return False | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.DARK_YELLOW_b}]🧠 ProcessPoolAction[/] '{self.name}'"] | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](receives '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         actions = self.actions.copy() | ||||
|         random.shuffle(actions) | ||||
|         for action in actions: | ||||
|             label = [ | ||||
|                 f"[{OneColors.DARK_YELLOW_b}]  - {getattr(action.task, '__name__', repr(action.task))}[/] " | ||||
|                 f"[dim]({', '.join(map(repr, action.args))})[/]" | ||||
|             ] | ||||
|             if action.kwargs: | ||||
|                 label.append( | ||||
|                     f" [dim]({', '.join(f'{k}={v!r}' for k, v in action.kwargs.items())})[/]" | ||||
|                 ) | ||||
|             tree.add("".join(label)) | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"ProcessPoolAction(name={self.name!r}, " | ||||
|             f"actions={[getattr(action.task, '__name__', repr(action.task)) for action in self.actions]}, " | ||||
|             f"inject_last_result={self.inject_last_result}, " | ||||
|             f"inject_into={self.inject_into!r})" | ||||
|         ) | ||||
							
								
								
									
										191
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								falyx/action/prompt_menu_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `PromptMenuAction`, a Falyx Action that prompts the user to choose from | ||||
| a list of labeled options using a single-line prompt input. Each option corresponds | ||||
| to a `MenuOption` that wraps a description and an executable action. | ||||
|  | ||||
| Unlike `MenuAction`, this action renders a flat, inline prompt (e.g., `Option1 | Option2`) | ||||
| without using a rich table. It is ideal for compact decision points, hotkey-style menus, | ||||
| or contextual user input flows. | ||||
|  | ||||
| Key Components: | ||||
| - PromptMenuAction: Inline prompt-driven menu runner | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import FormattedText, merge_formatted_text | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.menu import MenuOptionMap | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.signals import BackSignal, CancelSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class PromptMenuAction(BaseAction): | ||||
|     """ | ||||
|     Displays a single-line interactive prompt for selecting an option from a menu. | ||||
|  | ||||
|     `PromptMenuAction` is a lightweight alternative to `MenuAction`, offering a more | ||||
|     compact selection interface. Instead of rendering a full table, it displays | ||||
|     available keys inline as a placeholder (e.g., `A | B | C`) and accepts the user's | ||||
|     input to execute the associated action. | ||||
|  | ||||
|     Each key is defined in a `MenuOptionMap`, which maps to a `MenuOption` containing | ||||
|     a description and an executable action. | ||||
|  | ||||
|     Key Features: | ||||
|     - Minimal UI: rendered as a single prompt line with placeholder | ||||
|     - Optional fallback to `default_selection` or injected `last_result` | ||||
|     - Fully hookable lifecycle (before, success, error, after, teardown) | ||||
|     - Supports reserved keys and structured error recovery | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         menu_options (MenuOptionMap): A mapping of keys to `MenuOption` objects. | ||||
|         prompt_message (str): Text displayed before user input (default: "Select > "). | ||||
|         default_selection (str): Fallback key if no input is provided. | ||||
|         inject_last_result (bool): Whether to use `last_result` as a fallback input key. | ||||
|         inject_into (str): Kwarg name under which to inject the last result. | ||||
|         prompt_session (PromptSession | None): Custom Prompt Toolkit session. | ||||
|         never_prompt (bool): If True, skips user input and uses `default_selection`. | ||||
|         include_reserved (bool): Whether to include reserved keys in logic and preview. | ||||
|  | ||||
|     Returns: | ||||
|         Any: The result of the selected option's action. | ||||
|  | ||||
|     Raises: | ||||
|         BackSignal: If the user signals to return to the previous menu. | ||||
|         QuitSignal: If the user signals to exit the CLI entirely. | ||||
|         ValueError: If `never_prompt` is enabled but no fallback is available. | ||||
|         Exception: If an error occurs during the action's execution. | ||||
|  | ||||
|     Example: | ||||
|         PromptMenuAction( | ||||
|             name="HotkeyPrompt", | ||||
|             menu_options=MenuOptionMap(options={ | ||||
|                 "R": MenuOption("Run", ChainedAction(...)), | ||||
|                 "S": MenuOption("Skip", Action(...)), | ||||
|             }), | ||||
|             prompt_message="Choose action > ", | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         menu_options: MenuOptionMap, | ||||
|         *, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
|         include_reserved: bool = True, | ||||
|     ): | ||||
|         super().__init__( | ||||
|             name, | ||||
|             inject_last_result=inject_last_result, | ||||
|             inject_into=inject_into, | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         self.menu_options = menu_options | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         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'})" | ||||
|         ) | ||||
							
								
								
									
										296
									
								
								falyx/action/save_file_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										296
									
								
								falyx/action/save_file_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,296 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `SaveFileAction`, a Falyx Action for writing structured or unstructured data | ||||
| to a file in a variety of supported formats. | ||||
|  | ||||
| Supports overwrite control, automatic directory creation, and full lifecycle hook | ||||
| integration. Compatible with chaining and injection of upstream results via | ||||
| `inject_last_result`. | ||||
|  | ||||
| Supported formats: TEXT, JSON, YAML, TOML, CSV, TSV, XML | ||||
|  | ||||
| Key Features: | ||||
| - Auto-serialization of Python data to structured formats | ||||
| - Flexible path control with directory creation and overwrite handling | ||||
| - Injection of data via chaining (`last_result`) | ||||
| - Preview mode with file metadata visualization | ||||
|  | ||||
| Common use cases: | ||||
| - Writing processed results to disk | ||||
| - Logging artifacts from batch pipelines | ||||
| - Exporting config or user input to JSON/YAML for reuse | ||||
| """ | ||||
| import csv | ||||
| import json | ||||
| import xml.etree.ElementTree as ET | ||||
| from datetime import datetime | ||||
| from pathlib import Path | ||||
| from typing import Any, Literal | ||||
|  | ||||
| import toml | ||||
| import yaml | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action_types import FileType | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SaveFileAction(BaseAction): | ||||
|     """ | ||||
|     Saves data to a file in the specified format. | ||||
|  | ||||
|     `SaveFileAction` serializes and writes input data to disk using the format | ||||
|     defined by `file_type`. It supports plain text and structured formats like | ||||
|     JSON, YAML, TOML, CSV, TSV, and XML. Files may be overwritten or appended | ||||
|     based on settings, and parent directories are created if missing. | ||||
|  | ||||
|     Data can be provided directly via the `data` argument or dynamically injected | ||||
|     from the previous Action using `inject_last_result`. | ||||
|  | ||||
|     Key Features: | ||||
|     - Format-aware saving with validation | ||||
|     - Lifecycle hook support (before, success, error, after, teardown) | ||||
|     - Chain-compatible via last_result injection | ||||
|     - Supports safe overwrite behavior and preview diagnostics | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. Used for logging and debugging. | ||||
|         file_path (str | Path): Destination file path. | ||||
|         file_type (FileType | str): Output format (e.g., "json", "yaml", "text"). | ||||
|         mode (Literal["w", "a"]): File mode—write or append. Default is "w". | ||||
|         encoding (str): Encoding to use when writing files (default: "UTF-8"). | ||||
|         data (Any): Data to save. If omitted, uses last_result injection. | ||||
|         overwrite (bool): Whether to overwrite existing files. Default is True. | ||||
|         create_dirs (bool): Whether to auto-create parent directories. | ||||
|         inject_last_result (bool): Inject previous result as input if enabled. | ||||
|         inject_into (str): Name of kwarg to inject last_result into (default: "data"). | ||||
|  | ||||
|     Returns: | ||||
|         str: The full path to the saved file. | ||||
|  | ||||
|     Raises: | ||||
|         FileExistsError: If the file exists and `overwrite` is False. | ||||
|         FileNotFoundError: If parent directory is missing and `create_dirs` is False. | ||||
|         ValueError: If data format is invalid for the target file type. | ||||
|         Exception: Any errors encountered during file writing. | ||||
|  | ||||
|     Example: | ||||
|         SaveFileAction( | ||||
|             name="SaveOutput", | ||||
|             file_path="output/data.json", | ||||
|             file_type="json", | ||||
|             inject_last_result=True | ||||
|         ) | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         file_path: str, | ||||
|         file_type: FileType | str = FileType.TEXT, | ||||
|         mode: Literal["w", "a"] = "w", | ||||
|         encoding: str = "UTF-8", | ||||
|         data: Any = None, | ||||
|         overwrite: bool = True, | ||||
|         create_dirs: bool = True, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "data", | ||||
|     ): | ||||
|         """ | ||||
|         SaveFileAction allows saving data to a file. | ||||
|  | ||||
|         Args: | ||||
|             name (str): Name of the action. | ||||
|             file_path (str | Path): Path to the file where data will be saved. | ||||
|             file_type (FileType | str): Format to write to (e.g. TEXT, JSON, YAML). | ||||
|             mode (Literal["w", "a"]): File mode (default: "w"). | ||||
|             encoding (str): Encoding to use when writing files (default: "UTF-8"). | ||||
|             data (Any): Data to be saved (if not using inject_last_result). | ||||
|             overwrite (bool): Whether to overwrite the file if it exists. | ||||
|             create_dirs (bool): Whether to create parent directories if they do not exist. | ||||
|             inject_last_result (bool): Whether to inject result from previous action. | ||||
|             inject_into (str): Kwarg name to inject the last result as. | ||||
|         """ | ||||
|         super().__init__( | ||||
|             name=name, inject_last_result=inject_last_result, inject_into=inject_into | ||||
|         ) | ||||
|         self._file_path = self._coerce_file_path(file_path) | ||||
|         self._file_type = FileType(file_type) | ||||
|         self.data = data | ||||
|         self.overwrite = overwrite | ||||
|         self.mode = mode | ||||
|         self.create_dirs = create_dirs | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     @property | ||||
|     def file_path(self) -> Path | None: | ||||
|         """Get the file path as a Path object.""" | ||||
|         return self._file_path | ||||
|  | ||||
|     @file_path.setter | ||||
|     def file_path(self, value: str | Path): | ||||
|         """Set the file path, converting to Path if necessary.""" | ||||
|         self._file_path = self._coerce_file_path(value) | ||||
|  | ||||
|     def _coerce_file_path(self, file_path: str | Path | None) -> Path | None: | ||||
|         """Coerce the file path to a Path object.""" | ||||
|         if isinstance(file_path, Path): | ||||
|             return file_path | ||||
|         elif isinstance(file_path, str): | ||||
|             return Path(file_path) | ||||
|         elif file_path is None: | ||||
|             return None | ||||
|         else: | ||||
|             raise TypeError("file_path must be a string or Path object") | ||||
|  | ||||
|     @property | ||||
|     def file_type(self) -> FileType: | ||||
|         """Get the file type.""" | ||||
|         return self._file_type | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     def _dict_to_xml(self, data: dict, root: ET.Element) -> None: | ||||
|         """Convert a dictionary to XML format.""" | ||||
|         for key, value in data.items(): | ||||
|             if isinstance(value, dict): | ||||
|                 sub_element = ET.SubElement(root, key) | ||||
|                 self._dict_to_xml(value, sub_element) | ||||
|             elif isinstance(value, list): | ||||
|                 for item in value: | ||||
|                     item_element = ET.SubElement(root, key) | ||||
|                     if isinstance(item, dict): | ||||
|                         self._dict_to_xml(item, item_element) | ||||
|                     else: | ||||
|                         item_element.text = str(item) | ||||
|             else: | ||||
|                 element = ET.SubElement(root, key) | ||||
|                 element.text = str(value) | ||||
|  | ||||
|     async def save_file(self, data: Any) -> None: | ||||
|         """Save data to the specified file in the desired format.""" | ||||
|         if self.file_path is None: | ||||
|             raise ValueError("file_path must be set before saving a file") | ||||
|         elif self.file_path.exists() and not self.overwrite: | ||||
|             raise FileExistsError(f"File already exists: {self.file_path}") | ||||
|  | ||||
|         if self.file_path.parent and not self.file_path.parent.exists(): | ||||
|             if self.create_dirs: | ||||
|                 self.file_path.parent.mkdir(parents=True, exist_ok=True) | ||||
|             else: | ||||
|                 raise FileNotFoundError( | ||||
|                     f"Directory does not exist: {self.file_path.parent}" | ||||
|                 ) | ||||
|  | ||||
|         try: | ||||
|             if self.file_type == FileType.TEXT: | ||||
|                 self.file_path.write_text(data, encoding=self.encoding) | ||||
|             elif self.file_type == FileType.JSON: | ||||
|                 self.file_path.write_text( | ||||
|                     json.dumps(data, indent=4), encoding=self.encoding | ||||
|                 ) | ||||
|             elif self.file_type == FileType.TOML: | ||||
|                 self.file_path.write_text(toml.dumps(data), encoding=self.encoding) | ||||
|             elif self.file_type == FileType.YAML: | ||||
|                 self.file_path.write_text(yaml.dump(data), encoding=self.encoding) | ||||
|             elif self.file_type == FileType.CSV: | ||||
|                 if not isinstance(data, list) or not all( | ||||
|                     isinstance(row, list) for row in data | ||||
|                 ): | ||||
|                     raise ValueError( | ||||
|                         f"{self.file_type.name} file type requires a list of lists" | ||||
|                     ) | ||||
|                 with open( | ||||
|                     self.file_path, mode=self.mode, newline="", encoding=self.encoding | ||||
|                 ) as csvfile: | ||||
|                     writer = csv.writer(csvfile) | ||||
|                     writer.writerows(data) | ||||
|             elif self.file_type == FileType.TSV: | ||||
|                 if not isinstance(data, list) or not all( | ||||
|                     isinstance(row, list) for row in data | ||||
|                 ): | ||||
|                     raise ValueError( | ||||
|                         f"{self.file_type.name} file type requires a list of lists" | ||||
|                     ) | ||||
|                 with open( | ||||
|                     self.file_path, mode=self.mode, newline="", encoding=self.encoding | ||||
|                 ) as tsvfile: | ||||
|                     writer = csv.writer(tsvfile, delimiter="\t") | ||||
|                     writer.writerows(data) | ||||
|             elif self.file_type == FileType.XML: | ||||
|                 if not isinstance(data, dict): | ||||
|                     raise ValueError("XML file type requires data to be a dictionary") | ||||
|                 root = ET.Element("root") | ||||
|                 self._dict_to_xml(data, root) | ||||
|                 tree = ET.ElementTree(root) | ||||
|                 tree.write(self.file_path, encoding=self.encoding, xml_declaration=True) | ||||
|             else: | ||||
|                 raise ValueError(f"Unsupported file type: {self.file_type}") | ||||
|  | ||||
|         except Exception as error: | ||||
|             logger.error("Failed to save %s: %s", self.file_path.name, error) | ||||
|             raise | ||||
|  | ||||
|     async def _run(self, *args, **kwargs): | ||||
|         combined_kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         data = self.data or combined_kwargs.get(self.inject_into) | ||||
|  | ||||
|         context = ExecutionContext( | ||||
|             name=self.name, args=args, kwargs=combined_kwargs, action=self | ||||
|         ) | ||||
|         context.start_timer() | ||||
|  | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             await self.save_file(data) | ||||
|             logger.debug("File saved successfully: %s", self.file_path) | ||||
|  | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return str(self.file_path) | ||||
|  | ||||
|         except Exception as error: | ||||
|             context.exception = error | ||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||
|             raise | ||||
|         finally: | ||||
|             context.stop_timer() | ||||
|             await self.hooks.trigger(HookType.AFTER, context) | ||||
|             await self.hooks.trigger(HookType.ON_TEARDOWN, context) | ||||
|             er.record(context) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = f"[{OneColors.CYAN}]💾 SaveFileAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         tree.add(f"[dim]Path:[/] {self.file_path}") | ||||
|         tree.add(f"[dim]Type:[/] {self.file_type.name}") | ||||
|         tree.add(f"[dim]Overwrite:[/] {self.overwrite}") | ||||
|  | ||||
|         if self.file_path and self.file_path.exists(): | ||||
|             if self.overwrite: | ||||
|                 tree.add(f"[{OneColors.LIGHT_YELLOW}]⚠️ File will be overwritten[/]") | ||||
|             else: | ||||
|                 tree.add( | ||||
|                     f"[{OneColors.DARK_RED}]❌ File exists and overwrite is disabled[/]" | ||||
|                 ) | ||||
|             stat = self.file_path.stat() | ||||
|             tree.add(f"[dim]Size:[/] {stat.st_size:,} bytes") | ||||
|             tree.add( | ||||
|                 f"[dim]Modified:[/] {datetime.fromtimestamp(stat.st_mtime):%Y-%m-%d %H:%M:%S}" | ||||
|             ) | ||||
|             tree.add( | ||||
|                 f"[dim]Created:[/] {datetime.fromtimestamp(stat.st_ctime):%Y-%m-%d %H:%M:%S}" | ||||
|             ) | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"SaveFileAction(file_path={self.file_path}, file_type={self.file_type})" | ||||
| @@ -1,5 +1,47 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """select_file_action.py""" | ||||
| """ | ||||
| Defines `SelectFileAction`, a Falyx Action that allows users to select one or more | ||||
| files from a target directory and optionally return either their content or path, | ||||
| parsed based on a selected `FileType`. | ||||
|  | ||||
| This action combines rich interactive selection (via `SelectionOption`) with | ||||
| format-aware parsing, making it ideal for loading external resources, injecting | ||||
| config files, or dynamically selecting inputs mid-pipeline. | ||||
|  | ||||
| Supports filtering by file suffix, customizable prompt layout, multi-select mode, | ||||
| and automatic content parsing for common formats. | ||||
|  | ||||
| Key Features: | ||||
| - Lists files from a directory and renders them in a Rich-powered menu | ||||
| - Supports suffix filtering (e.g., only `.yaml` or `.json` files) | ||||
| - Returns content parsed as `str`, `dict`, `list`, or raw `Path` depending on `FileType` | ||||
| - Works in single or multi-selection mode | ||||
| - Fully compatible with Falyx hooks and context system | ||||
| - Graceful cancellation via `CancelSignal` | ||||
|  | ||||
| Supported Return Types (`FileType`): | ||||
| - `TEXT`: UTF-8 string content | ||||
| - `PATH`: File path object (`Path`) | ||||
| - `JSON`, `YAML`, `TOML`: Parsed dictionaries or lists | ||||
| - `CSV`, `TSV`: `list[list[str]]` from structured rows | ||||
| - `XML`: `ElementTree.Element` root object | ||||
|  | ||||
| Use Cases: | ||||
| - Prompting users to select a config file during setup | ||||
| - Dynamically loading data into chained workflows | ||||
| - CLI interfaces that require structured file ingestion | ||||
|  | ||||
| Example: | ||||
|     SelectFileAction( | ||||
|         name="ChooseConfigFile", | ||||
|         directory="configs/", | ||||
|         suffix_filter=".yaml", | ||||
|         return_type="yaml", | ||||
|     ) | ||||
|  | ||||
| This module is ideal for use cases where file choice is deferred to runtime | ||||
| and needs to feed into structured automation pipelines. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import csv | ||||
| @@ -11,26 +53,27 @@ 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.action import BaseAction | ||||
| from falyx.action.types import FileReturnType | ||||
| from falyx.action.action_types import FileType | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| 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: | ||||
|     SelectFileAction allows users to select a file(s) from a directory and return: | ||||
|     - file content (as text, JSON, CSV, etc.) | ||||
|     - or the file path itself. | ||||
|  | ||||
| @@ -49,8 +92,10 @@ class SelectFileAction(BaseAction): | ||||
|         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. | ||||
|         return_type (FileType): What to return (path, content, parsed). | ||||
|         number_selections (int | str): How many files to select (1, 2, '*'). | ||||
|         separator (str): Separator for multiple selections. | ||||
|         allow_duplicates (bool): Allow selecting the same file multiple times. | ||||
|         prompt_session (PromptSession | None): Prompt session for user input. | ||||
|     """ | ||||
|  | ||||
| @@ -64,62 +109,93 @@ class SelectFileAction(BaseAction): | ||||
|         prompt_message: str = "Choose > ", | ||||
|         style: str = OneColors.WHITE, | ||||
|         suffix_filter: str | None = None, | ||||
|         return_type: FileReturnType | str = FileReturnType.PATH, | ||||
|         console: Console | None = None, | ||||
|         return_type: FileType | str = FileType.PATH, | ||||
|         encoding: str = "UTF-8", | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|     ): | ||||
|         super().__init__(name) | ||||
|         self.directory = Path(directory).resolve() | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.prompt_message = prompt_message | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.suffix_filter = suffix_filter | ||||
|         self.style = style | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.return_type = self._coerce_return_type(return_type) | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.return_type = FileType(return_type) | ||||
|         self.encoding = encoding | ||||
|  | ||||
|     def _coerce_return_type(self, return_type: FileReturnType | str) -> FileReturnType: | ||||
|         if isinstance(return_type, FileReturnType): | ||||
|             return return_type | ||||
|         return FileReturnType(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 get_options(self, files: list[Path]) -> dict[str, SelectionOption]: | ||||
|         value: Any | ||||
|         options = {} | ||||
|         for index, file in enumerate(files): | ||||
|             options[str(index)] = SelectionOption( | ||||
|                 description=file.name, | ||||
|                 value=file,  # Store the Path only — parsing will happen later | ||||
|                 style=self.style, | ||||
|             ) | ||||
|         return options | ||||
|  | ||||
|     def parse_file(self, file: Path) -> Any: | ||||
|         value: Any | ||||
|         try: | ||||
|                 if self.return_type == FileReturnType.TEXT: | ||||
|                     value = file.read_text(encoding="UTF-8") | ||||
|                 elif self.return_type == FileReturnType.PATH: | ||||
|             if self.return_type == FileType.TEXT: | ||||
|                 value = file.read_text(encoding=self.encoding) | ||||
|             elif self.return_type == FileType.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: | ||||
|             elif self.return_type == FileType.JSON: | ||||
|                 value = json.loads(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.TOML: | ||||
|                 value = toml.loads(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.YAML: | ||||
|                 value = yaml.safe_load(file.read_text(encoding=self.encoding)) | ||||
|             elif self.return_type == FileType.CSV: | ||||
|                 with open(file, newline="", encoding=self.encoding) as csvfile: | ||||
|                     reader = csv.reader(csvfile) | ||||
|                     value = list(reader) | ||||
|                 elif self.return_type == FileReturnType.TSV: | ||||
|                     with open(file, newline="", encoding="UTF-8") as tsvfile: | ||||
|             elif self.return_type == FileType.TSV: | ||||
|                 with open(file, newline="", encoding=self.encoding) 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") | ||||
|             elif self.return_type == FileType.XML: | ||||
|                 tree = ET.parse(file, parser=ET.XMLParser(encoding=self.encoding)) | ||||
|                 value = tree.getroot() | ||||
|             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.warning("[ERROR] Failed to parse %s: %s", file.name, error) | ||||
|         return options | ||||
|             logger.error("Failed to parse %s: %s", file.name, error) | ||||
|         return value | ||||
|  | ||||
|     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) | ||||
| @@ -127,30 +203,51 @@ class SelectFileAction(BaseAction): | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             if not self.directory.exists(): | ||||
|                 raise FileNotFoundError(f"Directory {self.directory} does not exist.") | ||||
|             elif not self.directory.is_dir(): | ||||
|                 raise NotADirectoryError(f"{self.directory} is not a directory.") | ||||
|  | ||||
|             files = [ | ||||
|                 f | ||||
|                 for f in self.directory.iterdir() | ||||
|                 if f.is_file() | ||||
|                 and (self.suffix_filter is None or f.suffix == self.suffix_filter) | ||||
|                 file | ||||
|                 for file in self.directory.iterdir() | ||||
|                 if file.is_file() | ||||
|                 and (self.suffix_filter is None or file.suffix == self.suffix_filter) | ||||
|             ] | ||||
|             if not files: | ||||
|                 raise FileNotFoundError("No files found in directory.") | ||||
|  | ||||
|             options = self.get_options(files) | ||||
|  | ||||
|             cancel_key = self._find_cancel_key(options) | ||||
|             cancel_option = { | ||||
|                 cancel_key: SelectionOption( | ||||
|                     description="Cancel", value=CancelSignal(), style=OneColors.DARK_RED | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             table = render_selection_dict_table( | ||||
|                 title=self.title, selections=options, columns=self.columns | ||||
|                 title=self.title, selections=options | cancel_option, columns=self.columns | ||||
|             ) | ||||
|  | ||||
|             key = await prompt_for_selection( | ||||
|                 options.keys(), | ||||
|             keys = await prompt_for_selection( | ||||
|                 (options | cancel_option).keys(), | ||||
|                 table, | ||||
|                 console=self.console, | ||||
|                 prompt_session=self.prompt_session, | ||||
|                 prompt_message=self.prompt_message, | ||||
|                 number_selections=self.number_selections, | ||||
|                 separator=self.separator, | ||||
|                 allow_duplicates=self.allow_duplicates, | ||||
|                 cancel_key=cancel_key, | ||||
|             ) | ||||
|  | ||||
|             result = options[key].value | ||||
|             if isinstance(keys, str): | ||||
|                 if keys == cancel_key: | ||||
|                     raise CancelSignal("User canceled the selection.") | ||||
|                 result = self.parse_file(options[keys].value) | ||||
|             elif isinstance(keys, list): | ||||
|                 result = [self.parse_file(options[key].value) for key in keys] | ||||
|  | ||||
|             context.result = result | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
|             return result | ||||
| @@ -165,7 +262,7 @@ class SelectFileAction(BaseAction): | ||||
|             er.record(context) | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = f"[{OneColors.GREEN}]📁 SelectFilesAction[/] '{self.name}'" | ||||
|         label = f"[{OneColors.GREEN}]📁 SelectFileAction[/] '{self.name}'" | ||||
|         tree = parent.add(label) if parent else Tree(label) | ||||
|  | ||||
|         tree.add(f"[dim]Directory:[/] {str(self.directory)}") | ||||
| @@ -173,14 +270,15 @@ class SelectFileAction(BaseAction): | ||||
|         tree.add(f"[dim]Return type:[/] {self.return_type}") | ||||
|         tree.add(f"[dim]Prompt:[/] {self.prompt_message}") | ||||
|         tree.add(f"[dim]Columns:[/] {self.columns}") | ||||
|         tree.add("[dim]Loading:[/] Lazy (parsing occurs after selection)") | ||||
|         try: | ||||
|             files = list(self.directory.iterdir()) | ||||
|             if self.suffix_filter: | ||||
|                 files = [f for f in files if f.suffix == self.suffix_filter] | ||||
|                 files = [file for file in files if file.suffix == self.suffix_filter] | ||||
|             sample = files[:10] | ||||
|             file_list = tree.add("[dim]Files:[/]") | ||||
|             for f in sample: | ||||
|                 file_list.add(f"[dim]{f.name}[/]") | ||||
|             for file in sample: | ||||
|                 file_list.add(f"[dim]{file.name}[/]") | ||||
|             if len(files) > 10: | ||||
|                 file_list.add(f"[dim]... ({len(files) - 10} more)[/]") | ||||
|         except Exception as error: | ||||
| @@ -191,6 +289,6 @@ class SelectFileAction(BaseAction): | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return ( | ||||
|             f"SelectFilesAction(name={self.name!r}, dir={str(self.directory)!r}, " | ||||
|             f"SelectFileAction(name={self.name!r}, dir={str(self.directory)!r}, " | ||||
|             f"suffix_filter={self.suffix_filter!r}, return_type={self.return_type})" | ||||
|         ) | ||||
|   | ||||
| @@ -1,49 +1,139 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection_action.py""" | ||||
| """ | ||||
| Defines `SelectionAction`, a highly flexible Falyx Action for interactive or headless | ||||
| selection from a list or dictionary of user-defined options. | ||||
|  | ||||
| This module powers workflows that require prompting the user for input, selecting | ||||
| configuration presets, branching execution paths, or collecting multiple values | ||||
| in a type-safe, hook-compatible, and composable way. | ||||
|  | ||||
| Key Features: | ||||
| - Supports both flat lists and structured dictionaries (`SelectionOptionMap`) | ||||
| - Handles single or multi-selection with configurable separators | ||||
| - Returns results in various formats (key, value, description, item, or mapping) | ||||
| - Integrates fully with Falyx lifecycle hooks and `last_result` injection | ||||
| - Works in interactive (`prompt_toolkit`) and non-interactive (headless) modes | ||||
| - Renders a Rich-based table preview for diagnostics or dry runs | ||||
|  | ||||
| Usage Scenarios: | ||||
| - Guided CLI wizards or configuration menus | ||||
| - Dynamic branching or conditional step logic | ||||
| - User-driven parameterization in chained workflows | ||||
| - Reusable pickers for environments, files, datasets, etc. | ||||
|  | ||||
| Example: | ||||
|     SelectionAction( | ||||
|         name="ChooseMode", | ||||
|         selections={"dev": "Development", "prod": "Production"}, | ||||
|         return_type="key" | ||||
|     ) | ||||
|  | ||||
| This module is foundational to creating expressive, user-centered CLI experiences | ||||
| within Falyx while preserving reproducibility and automation friendliness. | ||||
| """ | ||||
| from typing import Any | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.action_types import SelectionReturnType | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.selection import ( | ||||
|     SelectionOption, | ||||
|     SelectionOptionMap, | ||||
|     prompt_for_index, | ||||
|     prompt_for_selection, | ||||
|     render_selection_dict_table, | ||||
|     render_selection_indexed_table, | ||||
| ) | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
|  | ||||
| class SelectionAction(BaseAction): | ||||
|     """ | ||||
|     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. | ||||
|     A Falyx Action for interactively or programmatically selecting one or more items | ||||
|     from a list or dictionary of options. | ||||
|  | ||||
|     If return_key is True, the key of the selected option is returned instead of | ||||
|     the value. | ||||
|     `SelectionAction` supports both `list[str]` and `dict[str, SelectionOption]` | ||||
|     inputs. It renders a prompt (unless `never_prompt=True`), validates user input | ||||
|     or injected defaults, and returns a structured result based on the specified | ||||
|     `return_type`. | ||||
|  | ||||
|     It is commonly used for item pickers, confirmation flows, dynamic parameterization, | ||||
|     or guided workflows in interactive or headless CLI pipelines. | ||||
|  | ||||
|     Features: | ||||
|     - Supports single or multiple selections (`number_selections`) | ||||
|     - Dictionary mode allows rich metadata (description, value, style) | ||||
|     - Flexible return values: key(s), value(s), item(s), description(s), or mappings | ||||
|     - Fully hookable lifecycle (`before`, `on_success`, `on_error`, `after`, `on_teardown`) | ||||
|     - Default selection logic supports previous results (`last_result`) | ||||
|     - Can run in headless mode using `never_prompt` and fallback defaults | ||||
|  | ||||
|     Args: | ||||
|         name (str): Action name for tracking and logging. | ||||
|         selections (list[str] | dict[str, SelectionOption] | dict[str, Any]): | ||||
|             The available choices. If a plain dict is passed, values are converted | ||||
|             into `SelectionOption` instances. | ||||
|         title (str): Title shown in the selection UI (default: "Select an option"). | ||||
|         columns (int): Number of columns in the selection table. | ||||
|         prompt_message (str): Input prompt for the user (default: "Select > "). | ||||
|         default_selection (str | list[str]): Key(s) or index(es) used as fallback selection. | ||||
|         number_selections (int | str): Max number of choices allowed (or "*" for unlimited). | ||||
|         separator (str): Character used to separate multi-selections (default: ","). | ||||
|         allow_duplicates (bool): Whether duplicate selections are allowed. | ||||
|         inject_last_result (bool): If True, attempts to inject the last result as default. | ||||
|         inject_into (str): The keyword name for injected value (default: "last_result"). | ||||
|         return_type (SelectionReturnType | str): The type of result to return. | ||||
|         prompt_session (PromptSession | None): Reused or customized prompt_toolkit session. | ||||
|         never_prompt (bool): If True, skips prompting and uses default_selection or last_result. | ||||
|         show_table (bool): Whether to render the selection table before prompting. | ||||
|  | ||||
|     Returns: | ||||
|         Any: The selected result(s), shaped according to `return_type`. | ||||
|  | ||||
|     Raises: | ||||
|         CancelSignal: If the user chooses the cancel option. | ||||
|         ValueError: If configuration is invalid or no selection can be resolved. | ||||
|         TypeError: If `selections` is not a supported type. | ||||
|  | ||||
|     Example: | ||||
|         SelectionAction( | ||||
|             name="PickEnv", | ||||
|             selections={"dev": "Development", "prod": "Production"}, | ||||
|             return_type="key", | ||||
|         ) | ||||
|  | ||||
|     This Action supports use in both interactive menus and chained, non-interactive CLI flows. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         selections: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption], | ||||
|         selections: ( | ||||
|             list[str] | ||||
|             | set[str] | ||||
|             | tuple[str, ...] | ||||
|             | dict[str, SelectionOption] | ||||
|             | dict[str, Any] | ||||
|         ), | ||||
|         *, | ||||
|         title: str = "Select an option", | ||||
|         columns: int = 5, | ||||
|         prompt_message: str = "Select > ", | ||||
|         default_selection: str = "", | ||||
|         default_selection: str | list[str] = "", | ||||
|         number_selections: int | str = 1, | ||||
|         separator: str = ",", | ||||
|         allow_duplicates: bool = False, | ||||
|         inject_last_result: bool = False, | ||||
|         inject_into: str = "last_result", | ||||
|         return_key: bool = False, | ||||
|         console: Console | None = None, | ||||
|         return_type: SelectionReturnType | str = "value", | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         never_prompt: bool = False, | ||||
|         show_table: bool = True, | ||||
| @@ -55,18 +145,37 @@ class SelectionAction(BaseAction): | ||||
|             never_prompt=never_prompt, | ||||
|         ) | ||||
|         # Setter normalizes to correct type, mypy can't infer that | ||||
|         self.selections: list[str] | CaseInsensitiveDict = selections  # type: ignore[assignment] | ||||
|         self.return_key = return_key | ||||
|         self.selections: list[str] | SelectionOptionMap = selections  # type: ignore[assignment] | ||||
|         self.return_type: SelectionReturnType = SelectionReturnType(return_type) | ||||
|         self.title = title | ||||
|         self.columns = columns | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|         self.default_selection = default_selection | ||||
|         self.prompt_message = prompt_message | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.prompt_message = rich_text_to_prompt_text(prompt_message) | ||||
|         self.show_table = show_table | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | CaseInsensitiveDict: | ||||
|     def number_selections(self) -> int | str: | ||||
|         return self._number_selections | ||||
|  | ||||
|     @number_selections.setter | ||||
|     def number_selections(self, value: int | str): | ||||
|         if isinstance(value, int) and value > 0: | ||||
|             self._number_selections: int | str = value | ||||
|         elif isinstance(value, str): | ||||
|             if value not in ("*"): | ||||
|                 raise ValueError("number_selections string must be '*'") | ||||
|             self._number_selections = value | ||||
|         else: | ||||
|             raise ValueError("number_selections must be a positive integer or '*'") | ||||
|  | ||||
|     @property | ||||
|     def selections(self) -> list[str] | SelectionOptionMap: | ||||
|         return self._selections | ||||
|  | ||||
|     @selections.setter | ||||
| @@ -74,17 +183,190 @@ class SelectionAction(BaseAction): | ||||
|         self, value: list[str] | set[str] | tuple[str, ...] | dict[str, SelectionOption] | ||||
|     ): | ||||
|         if isinstance(value, (list, tuple, set)): | ||||
|             self._selections: list[str] | CaseInsensitiveDict = list(value) | ||||
|             self._selections: list[str] | SelectionOptionMap = list(value) | ||||
|         elif isinstance(value, dict): | ||||
|             cid = CaseInsensitiveDict() | ||||
|             cid.update(value) | ||||
|             self._selections = cid | ||||
|             som = SelectionOptionMap() | ||||
|             if all(isinstance(key, str) for key in value) and all( | ||||
|                 not isinstance(value[key], SelectionOption) for key in value | ||||
|             ): | ||||
|                 som.update( | ||||
|                     { | ||||
|                         str(index): SelectionOption(key, option) | ||||
|                         for index, (key, option) in enumerate(value.items()) | ||||
|                     } | ||||
|                 ) | ||||
|             elif all(isinstance(key, str) for key in value) and all( | ||||
|                 isinstance(value[key], SelectionOption) for key in value | ||||
|             ): | ||||
|                 som.update(value) | ||||
|             else: | ||||
|                 raise ValueError("Invalid dictionary format. Keys must be strings") | ||||
|             self._selections = som | ||||
|         else: | ||||
|             raise TypeError( | ||||
|                 "'selections' must be a list[str] or dict[str, SelectionOption], " | ||||
|                 f"got {type(value).__name__}" | ||||
|             ) | ||||
|  | ||||
|     def _find_cancel_key(self) -> str: | ||||
|         """Find the cancel key in the selections.""" | ||||
|         if isinstance(self.selections, dict): | ||||
|             for index in range(len(self.selections) + 1): | ||||
|                 if str(index) not in self.selections: | ||||
|                     return str(index) | ||||
|         return str(len(self.selections)) | ||||
|  | ||||
|     @property | ||||
|     def cancel_key(self) -> str: | ||||
|         return self._cancel_key | ||||
|  | ||||
|     @cancel_key.setter | ||||
|     def cancel_key(self, value: str) -> None: | ||||
|         """Set the cancel key for the selection.""" | ||||
|         if not isinstance(value, str): | ||||
|             raise TypeError("Cancel key must be a string.") | ||||
|         if isinstance(self.selections, dict) and value in self.selections: | ||||
|             raise ValueError( | ||||
|                 "Cancel key cannot be one of the selection keys. " | ||||
|                 f"Current selections: {self.selections}" | ||||
|             ) | ||||
|         if isinstance(self.selections, list): | ||||
|             if not value.isdigit() or int(value) > len(self.selections): | ||||
|                 raise ValueError( | ||||
|                     "cancel_key must be a digit and not greater than the number of selections." | ||||
|                 ) | ||||
|         self._cancel_key = value | ||||
|  | ||||
|     def cancel_formatter(self, index: int, selection: str) -> str: | ||||
|         """Format the cancel option for display.""" | ||||
|         if self.cancel_key == str(index): | ||||
|             return f"[{index}] [{OneColors.DARK_RED}]Cancel[/]" | ||||
|         return f"[{index}] {selection}" | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     def _get_result_from_keys(self, keys: str | list[str]) -> Any: | ||||
|         if not isinstance(self.selections, dict): | ||||
|             raise TypeError("Selections must be a dictionary to get result by keys.") | ||||
|         if self.return_type == SelectionReturnType.KEY: | ||||
|             result: Any = keys | ||||
|         elif self.return_type == SelectionReturnType.VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].value for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].value | ||||
|         elif self.return_type == SelectionReturnType.ITEMS: | ||||
|             if isinstance(keys, list): | ||||
|                 result = {key: self.selections[key] for key in keys} | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {keys: self.selections[keys]} | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION: | ||||
|             if isinstance(keys, list): | ||||
|                 result = [self.selections[key].description for key in keys] | ||||
|             elif isinstance(keys, str): | ||||
|                 result = self.selections[keys].description | ||||
|         elif self.return_type == SelectionReturnType.DESCRIPTION_VALUE: | ||||
|             if isinstance(keys, list): | ||||
|                 result = { | ||||
|                     self.selections[key].description: self.selections[key].value | ||||
|                     for key in keys | ||||
|                 } | ||||
|             elif isinstance(keys, str): | ||||
|                 result = {self.selections[keys].description: self.selections[keys].value} | ||||
|         else: | ||||
|             raise ValueError(f"Unsupported return type: {self.return_type}") | ||||
|         return result | ||||
|  | ||||
|     async def _resolve_effective_default(self) -> str: | ||||
|         effective_default: str | list[str] = self.default_selection | ||||
|         maybe_result = self.last_result | ||||
|         if self.number_selections == 1: | ||||
|             if isinstance(effective_default, list): | ||||
|                 effective_default = effective_default[0] if effective_default else "" | ||||
|             elif isinstance(maybe_result, list): | ||||
|                 maybe_result = maybe_result[0] if maybe_result else "" | ||||
|             default = await self._resolve_single_default(maybe_result) | ||||
|             if not default: | ||||
|                 default = await self._resolve_single_default(effective_default) | ||||
|             if not default and self.inject_last_result: | ||||
|                 logger.warning( | ||||
|                     "[%s] Injected last result '%s' not found in selections", | ||||
|                     self.name, | ||||
|                     maybe_result, | ||||
|                 ) | ||||
|             return default | ||||
|  | ||||
|         if maybe_result and isinstance(maybe_result, list): | ||||
|             maybe_result = [ | ||||
|                 await self._resolve_single_default(item) for item in maybe_result | ||||
|             ] | ||||
|             if ( | ||||
|                 maybe_result | ||||
|                 and self.number_selections != "*" | ||||
|                 and len(maybe_result) != self.number_selections | ||||
|             ): | ||||
|                 raise ValueError( | ||||
|                     f"[{self.name}] 'number_selections' is {self.number_selections}, " | ||||
|                     f"but last_result has a different length: {len(maybe_result)}." | ||||
|                 ) | ||||
|             return self.separator.join(maybe_result) | ||||
|         elif effective_default and isinstance(effective_default, list): | ||||
|             effective_default = [ | ||||
|                 await self._resolve_single_default(item) for item in effective_default | ||||
|             ] | ||||
|             if ( | ||||
|                 effective_default | ||||
|                 and self.number_selections != "*" | ||||
|                 and len(effective_default) != self.number_selections | ||||
|             ): | ||||
|                 raise ValueError( | ||||
|                     f"[{self.name}] 'number_selections' is {self.number_selections}, " | ||||
|                     f"but default_selection has a different length: {len(effective_default)}." | ||||
|                 ) | ||||
|             return self.separator.join(effective_default) | ||||
|         if self.inject_last_result: | ||||
|             logger.warning( | ||||
|                 "[%s] Injected last result '%s' not found in selections", | ||||
|                 self.name, | ||||
|                 maybe_result, | ||||
|             ) | ||||
|         return "" | ||||
|  | ||||
|     async def _resolve_single_default(self, maybe_result: str) -> str: | ||||
|         effective_default = "" | ||||
|         if isinstance(self.selections, dict): | ||||
|             if str(maybe_result) in self.selections: | ||||
|                 effective_default = str(maybe_result) | ||||
|             elif maybe_result in ( | ||||
|                 selection.value for selection in self.selections.values() | ||||
|             ): | ||||
|                 selection = [ | ||||
|                     key | ||||
|                     for key, sel in self.selections.items() | ||||
|                     if sel.value == maybe_result | ||||
|                 ] | ||||
|                 if selection: | ||||
|                     effective_default = selection[0] | ||||
|             elif maybe_result in ( | ||||
|                 selection.description for selection in self.selections.values() | ||||
|             ): | ||||
|                 selection = [ | ||||
|                     key | ||||
|                     for key, sel in self.selections.items() | ||||
|                     if sel.description == maybe_result | ||||
|                 ] | ||||
|                 if selection: | ||||
|                     effective_default = selection[0] | ||||
|         elif isinstance(self.selections, list): | ||||
|             if str(maybe_result).isdigit() and int(maybe_result) in range( | ||||
|                 len(self.selections) | ||||
|             ): | ||||
|                 effective_default = maybe_result | ||||
|             elif maybe_result in self.selections: | ||||
|                 effective_default = str(self.selections.index(maybe_result)) | ||||
|         return effective_default | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> Any: | ||||
|         kwargs = self._maybe_inject_last_result(kwargs) | ||||
|         context = ExecutionContext( | ||||
| @@ -94,77 +376,106 @@ class SelectionAction(BaseAction): | ||||
|             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, | ||||
|                 ) | ||||
|         effective_default = await self._resolve_effective_default() | ||||
|  | ||||
|         if self.never_prompt and not effective_default: | ||||
|             raise ValueError( | ||||
|                 f"[{self.name}] 'never_prompt' is True but no valid default_selection " | ||||
|                 "was provided." | ||||
|                 "or usable last_result was available." | ||||
|             ) | ||||
|  | ||||
|         context.start_timer() | ||||
|         try: | ||||
|             self.cancel_key = self._find_cancel_key() | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             if isinstance(self.selections, list): | ||||
|                 table = render_selection_indexed_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections, | ||||
|                     selections=self.selections + ["Cancel"], | ||||
|                     columns=self.columns, | ||||
|                     formatter=self.cancel_formatter, | ||||
|                 ) | ||||
|                 if effective_default is None or isinstance(effective_default, int): | ||||
|                     effective_default = "" | ||||
|  | ||||
|                 if not self.never_prompt: | ||||
|                     indices: int | list[int] = await prompt_for_index( | ||||
|                         len(self.selections), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                         number_selections=self.number_selections, | ||||
|                         separator=self.separator, | ||||
|                         allow_duplicates=self.allow_duplicates, | ||||
|                         cancel_key=self.cancel_key, | ||||
|                     ) | ||||
|                 else: | ||||
|                     if effective_default and self.number_selections == 1: | ||||
|                         indices = int(effective_default) | ||||
|                     elif effective_default: | ||||
|                         indices = [ | ||||
|                             int(index) | ||||
|                             for index in effective_default.split(self.separator) | ||||
|                         ] | ||||
|                     else: | ||||
|                         raise ValueError( | ||||
|                             f"[{self.name}] 'never_prompt' is True but no valid " | ||||
|                             "default_selection was provided." | ||||
|                         ) | ||||
|  | ||||
|                 if indices == int(self.cancel_key): | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|                 if isinstance(indices, list): | ||||
|                     result: str | list[str] = [ | ||||
|                         self.selections[index] for index in indices | ||||
|                     ] | ||||
|                 elif isinstance(indices, int): | ||||
|                     result = self.selections[indices] | ||||
|                 else: | ||||
|                     assert False, "unreachable" | ||||
|             elif isinstance(self.selections, dict): | ||||
|                 cancel_option = { | ||||
|                     self.cancel_key: SelectionOption( | ||||
|                         description="Cancel", value=CancelSignal, style=OneColors.DARK_RED | ||||
|                     ) | ||||
|                 } | ||||
|                 table = render_selection_dict_table( | ||||
|                     title=self.title, | ||||
|                     selections=self.selections | cancel_option, | ||||
|                     columns=self.columns, | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     index = await prompt_for_index( | ||||
|                         len(self.selections) - 1, | ||||
|                     keys = await prompt_for_selection( | ||||
|                         (self.selections | cancel_option).keys(), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         console=self.console, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                         number_selections=self.number_selections, | ||||
|                         separator=self.separator, | ||||
|                         allow_duplicates=self.allow_duplicates, | ||||
|                         cancel_key=self.cancel_key, | ||||
|                     ) | ||||
|                 else: | ||||
|                     index = effective_default | ||||
|                 result = self.selections[int(index)] | ||||
|             elif isinstance(self.selections, dict): | ||||
|                 table = render_selection_dict_table( | ||||
|                     title=self.title, selections=self.selections, columns=self.columns | ||||
|                 ) | ||||
|                 if not self.never_prompt: | ||||
|                     key = await prompt_for_selection( | ||||
|                         self.selections.keys(), | ||||
|                         table, | ||||
|                         default_selection=effective_default, | ||||
|                         console=self.console, | ||||
|                         prompt_session=self.prompt_session, | ||||
|                         prompt_message=self.prompt_message, | ||||
|                         show_table=self.show_table, | ||||
|                     ) | ||||
|                     if effective_default and self.number_selections == 1: | ||||
|                         keys = effective_default | ||||
|                     elif effective_default: | ||||
|                         keys = effective_default.split(self.separator) | ||||
|                     else: | ||||
|                     key = effective_default | ||||
|                 result = key if self.return_key else self.selections[key].value | ||||
|                         raise ValueError( | ||||
|                             f"[{self.name}] 'never_prompt' is True but no valid " | ||||
|                             "default_selection was provided." | ||||
|                         ) | ||||
|                 if keys == self.cancel_key: | ||||
|                     raise CancelSignal("User cancelled the selection.") | ||||
|  | ||||
|                 result = self._get_result_from_keys(keys) | ||||
|             else: | ||||
|                 raise TypeError( | ||||
|                     "'selections' must be a list[str] or dict[str, tuple[str, Any]], " | ||||
|                     "'selections' must be a list[str] or dict[str, Any], " | ||||
|                     f"got {type(self.selections).__name__}" | ||||
|                 ) | ||||
|             context.result = result | ||||
| @@ -186,13 +497,13 @@ class SelectionAction(BaseAction): | ||||
|  | ||||
|         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 | ||||
|             for i, item in enumerate(self.selections[: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)" | ||||
|                 f"[dim]Type:[/] Dict[str, SelectionOption] ({len(self.selections)} items)" | ||||
|             ) | ||||
|             for i, (key, option) in enumerate(list(self.selections.items())[:10]): | ||||
|                 sub.add(f"[dim]{key}[/]: {option.description}") | ||||
| @@ -202,9 +513,30 @@ class SelectionAction(BaseAction): | ||||
|             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:[/] {'Key' if self.return_key else 'Value'}") | ||||
|         default = self.default_selection or self.last_result | ||||
|         if isinstance(default, list): | ||||
|             default_display = self.separator.join(str(d) for d in default) | ||||
|         else: | ||||
|             default_display = str(default or "") | ||||
|  | ||||
|         tree.add(f"[dim]Default:[/] '{default_display}'") | ||||
|  | ||||
|         return_behavior = { | ||||
|             "KEY": "selected key(s)", | ||||
|             "VALUE": "mapped value(s)", | ||||
|             "DESCRIPTION": "description(s)", | ||||
|             "ITEMS": "SelectionOption object(s)", | ||||
|             "DESCRIPTION_VALUE": "{description: value}", | ||||
|         }.get(self.return_type.name, self.return_type.name) | ||||
|  | ||||
|         tree.add( | ||||
|             f"[dim]Return:[/] {self.return_type.name.capitalize()} → {return_behavior}" | ||||
|         ) | ||||
|         tree.add(f"[dim]Prompt:[/] {'Disabled' if self.never_prompt else 'Enabled'}") | ||||
|         tree.add(f"[dim]Columns:[/] {self.columns}") | ||||
|         tree.add( | ||||
|             f"[dim]Multi-select:[/] {'Yes' if self.number_selections != 1 else 'No'}" | ||||
|         ) | ||||
|  | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
| @@ -218,6 +550,6 @@ class SelectionAction(BaseAction): | ||||
|         return ( | ||||
|             f"SelectionAction(name={self.name!r}, type={selection_type}, " | ||||
|             f"default_selection={self.default_selection!r}, " | ||||
|             f"return_key={self.return_key}, " | ||||
|             f"return_type={self.return_type!r}, " | ||||
|             f"prompt={'off' if self.never_prompt else 'on'})" | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										104
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								falyx/action/shell_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """Execute shell commands with input substitution.""" | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| import subprocess | ||||
| import sys | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.io_action import BaseIOAction | ||||
| from falyx.exceptions import FalyxError | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class ShellAction(BaseIOAction): | ||||
|     """ | ||||
|     ShellAction wraps a shell command template for CLI pipelines. | ||||
|  | ||||
|     This Action takes parsed input (from stdin, literal, or last_result), | ||||
|     substitutes it into the provided shell command template, and executes | ||||
|     the command asynchronously using subprocess. | ||||
|  | ||||
|     Designed for quick integration with shell tools like `grep`, `ping`, `jq`, etc. | ||||
|  | ||||
|     ⚠️ Security Warning: | ||||
|     By default, ShellAction uses `shell=True`, which can be dangerous with | ||||
|     unsanitized input. To mitigate this, set `safe_mode=True` to use `shell=False` | ||||
|     with `shlex.split()`. | ||||
|  | ||||
|     Features: | ||||
|     - Automatically handles input parsing (str/bytes) | ||||
|     - `safe_mode=True` disables shell interpretation and runs with `shell=False` | ||||
|     - Captures stdout and stderr from shell execution | ||||
|     - Raises on non-zero exit codes with stderr as the error | ||||
|     - Result is returned as trimmed stdout string | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action. | ||||
|         command_template (str): Shell command to execute. Must include `{}` to include | ||||
|                                 input. If no placeholder is present, the input is not | ||||
|                                 included. | ||||
|         safe_mode (bool): If True, runs with `shell=False` using shlex parsing | ||||
|                           (default: False). | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, name: str, command_template: str, safe_mode: bool = False, **kwargs | ||||
|     ): | ||||
|         super().__init__(name=name, **kwargs) | ||||
|         self.command_template = command_template | ||||
|         self.safe_mode = safe_mode | ||||
|  | ||||
|     def from_input(self, raw: str | bytes) -> str: | ||||
|         if not isinstance(raw, (str, bytes)): | ||||
|             raise TypeError( | ||||
|                 f"{self.name} expected str or bytes input, got {type(raw).__name__}" | ||||
|             ) | ||||
|         return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[Callable[..., Any] | None, dict[str, Any] | None]: | ||||
|         if sys.stdin.isatty(): | ||||
|             return self._run, {"parsed_input": {"help": self.command_template}} | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, parsed_input: str) -> str: | ||||
|         # Replace placeholder in template, or use raw input as full command | ||||
|         command = self.command_template.format(parsed_input) | ||||
|         if self.safe_mode: | ||||
|             try: | ||||
|                 args = shlex.split(command) | ||||
|             except ValueError as error: | ||||
|                 raise FalyxError(f"Invalid command template: {error}") | ||||
|             result = subprocess.run(args, capture_output=True, text=True, check=True) | ||||
|         else: | ||||
|             result = subprocess.run( | ||||
|                 command, shell=True, text=True, capture_output=True, check=True | ||||
|             ) | ||||
|         if result.returncode != 0: | ||||
|             raise RuntimeError(result.stderr.strip()) | ||||
|         return result.stdout.strip() | ||||
|  | ||||
|     def to_output(self, result: str) -> str: | ||||
|         return result | ||||
|  | ||||
|     async def preview(self, parent: Tree | None = None): | ||||
|         label = [f"[{OneColors.GREEN_b}]⚙ ShellAction[/] '{self.name}'"] | ||||
|         label.append(f"\n[dim]Template:[/] {self.command_template}") | ||||
|         label.append( | ||||
|             f"\n[dim]Safe mode:[/] {'Enabled' if self.safe_mode else 'Disabled'}" | ||||
|         ) | ||||
|         if self.inject_last_result: | ||||
|             label.append(f" [dim](injects '{self.inject_into}')[/dim]") | ||||
|         tree = parent.add("".join(label)) if parent else Tree("".join(label)) | ||||
|         if not parent: | ||||
|             self.console.print(tree) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return ( | ||||
|             f"ShellAction(name={self.name!r}, command_template={self.command_template!r}," | ||||
|             f" safe_mode={self.safe_mode})" | ||||
|         ) | ||||
| @@ -1,32 +1,85 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signal_action.py""" | ||||
| """ | ||||
| Defines `SignalAction`, a lightweight Falyx Action that raises a `FlowSignal` | ||||
| (such as `BackSignal`, `QuitSignal`, or `BreakChainSignal`) during execution to | ||||
| alter or exit the CLI flow. | ||||
|  | ||||
| Unlike traditional actions, `SignalAction` does not return a result—instead, it raises | ||||
| a signal to break, back out, or exit gracefully. Despite its minimal behavior, | ||||
| it fully supports Falyx's hook lifecycle, including `before`, `on_error`, `after`, | ||||
| and `on_teardown`—allowing it to trigger logging, audit events, UI updates, or custom | ||||
| telemetry before halting flow. | ||||
|  | ||||
| Key Features: | ||||
| - Declaratively raises a `FlowSignal` from within any Falyx workflow | ||||
| - Works in menus, chained actions, or conditionals | ||||
| - Hook-compatible: can run pre- and post-signal lifecycle hooks | ||||
| - Supports previewing and structured introspection | ||||
|  | ||||
| Use Cases: | ||||
| - Implementing "Back", "Cancel", or "Quit" options in `MenuAction` or `PromptMenuAction` | ||||
| - Triggering an intentional early exit from a `ChainedAction` | ||||
| - Running cleanup hooks before stopping execution | ||||
|  | ||||
| Example: | ||||
|     SignalAction("ExitApp", QuitSignal(), hooks=my_hook_manager) | ||||
| """ | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action | ||||
| from falyx.hook_manager import HookManager | ||||
| from falyx.signals import FlowSignal | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SignalAction(Action): | ||||
|     """ | ||||
|     An action that raises a control flow signal when executed. | ||||
|     A hook-compatible action that raises a control flow signal when invoked. | ||||
|  | ||||
|     Useful for exiting a menu, going back, or halting execution gracefully. | ||||
|     `SignalAction` raises a `FlowSignal` (e.g., `BackSignal`, `QuitSignal`, | ||||
|     `BreakChainSignal`) during execution. It is commonly used to exit menus, | ||||
|     break from chained actions, or halt workflows intentionally. | ||||
|  | ||||
|     Even though the signal interrupts normal flow, all registered lifecycle hooks | ||||
|     (`before`, `on_error`, `after`, `on_teardown`) are triggered as expected— | ||||
|     allowing structured behavior such as logging, analytics, or UI changes | ||||
|     before the signal is raised. | ||||
|  | ||||
|     Args: | ||||
|         name (str): Name of the action (used for logging and debugging). | ||||
|         signal (FlowSignal): A subclass of `FlowSignal` to raise (e.g., QuitSignal). | ||||
|         hooks (HookManager | None): Optional hook manager to attach lifecycle hooks. | ||||
|  | ||||
|     Raises: | ||||
|         FlowSignal: Always raises the provided signal when the action is run. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, name: str, signal: Exception): | ||||
|     def __init__(self, name: str, signal: FlowSignal, hooks: HookManager | None = None): | ||||
|         self.signal = signal | ||||
|         super().__init__(name, action=self.raise_signal) | ||||
|         super().__init__(name, action=self.raise_signal, hooks=hooks) | ||||
|  | ||||
|     async def raise_signal(self, *args, **kwargs): | ||||
|         """ | ||||
|         Raises the configured `FlowSignal`. | ||||
|  | ||||
|         This method is called internally by the Falyx runtime and is the core | ||||
|         behavior of the action. All hooks surrounding execution are still triggered. | ||||
|         """ | ||||
|         raise self.signal | ||||
|  | ||||
|     @property | ||||
|     def signal(self): | ||||
|         """Returns the configured `FlowSignal` instance.""" | ||||
|         return self._signal | ||||
|  | ||||
|     @signal.setter | ||||
|     def signal(self, value: FlowSignal): | ||||
|         """ | ||||
|         Validates that the provided value is a `FlowSignal`. | ||||
|  | ||||
|         Raises: | ||||
|             TypeError: If `value` is not an instance of `FlowSignal`. | ||||
|         """ | ||||
|         if not isinstance(value, FlowSignal): | ||||
|             raise TypeError( | ||||
|                 f"Signal must be an FlowSignal instance, got {type(value).__name__}" | ||||
|   | ||||
| @@ -1,37 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class FileReturnType(Enum): | ||||
|     """Enum for file return types.""" | ||||
|  | ||||
|     TEXT = "text" | ||||
|     PATH = "path" | ||||
|     JSON = "json" | ||||
|     TOML = "toml" | ||||
|     YAML = "yaml" | ||||
|     CSV = "csv" | ||||
|     TSV = "tsv" | ||||
|     XML = "xml" | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "yml": "yaml", | ||||
|             "txt": "text", | ||||
|             "file": "path", | ||||
|             "filepath": "path", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> FileReturnType: | ||||
|         if isinstance(value, str): | ||||
|             normalized = value.lower() | ||||
|             alias = cls._get_alias(normalized) | ||||
|             for member in cls: | ||||
|                 if member.value == alias: | ||||
|                     return member | ||||
|         valid = ", ".join(member.value for member in cls) | ||||
|         raise ValueError(f"Invalid FileReturnType: '{value}'. Must be one of: {valid}") | ||||
| @@ -1,36 +1,70 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `UserInputAction`, a Falyx Action that prompts the user for input using | ||||
| Prompt Toolkit and returns the result as a string. | ||||
|  | ||||
| This action is ideal for interactive CLI workflows that require user input mid-pipeline. | ||||
| It supports dynamic prompt interpolation, prompt validation, default text fallback, | ||||
| and full lifecycle hook execution. | ||||
|  | ||||
| Key Features: | ||||
| - Rich Prompt Toolkit integration for input and validation | ||||
| - Dynamic prompt formatting using `last_result` injection | ||||
| - Optional `Validator` support for structured input (e.g., emails, numbers) | ||||
| - Hook lifecycle compatibility (before, on_success, on_error, after, teardown) | ||||
| - Preview support for introspection or dry-run flows | ||||
|  | ||||
| Use Cases: | ||||
| - Asking for confirmation text or field input mid-chain | ||||
| - Injecting user-provided variables into automated pipelines | ||||
| - Interactive menu or wizard experiences | ||||
|  | ||||
| Example: | ||||
|     UserInputAction( | ||||
|         name="GetUsername", | ||||
|         prompt_message="Enter your username > ", | ||||
|         validator=Validator.from_callable(lambda s: len(s) > 0), | ||||
|     ) | ||||
| """ | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.validation import Validator | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action import BaseAction | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.signals import CancelSignal | ||||
| from falyx.themes.colors import OneColors | ||||
|  | ||||
|  | ||||
| class UserInputAction(BaseAction): | ||||
|     """ | ||||
|     Prompts the user for input via PromptSession and returns the result. | ||||
|     Prompts the user for textual input and returns their response. | ||||
|  | ||||
|     `UserInputAction` uses Prompt Toolkit to gather input with optional validation, | ||||
|     lifecycle hook compatibility, and support for default text. If `inject_last_result` | ||||
|     is enabled, the prompt message can interpolate `{last_result}` dynamically. | ||||
|  | ||||
|     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'). | ||||
|         name (str): Name of the action (used for introspection and logging). | ||||
|         prompt_message (str): The prompt message shown to the user. | ||||
|             Can include `{last_result}` if `inject_last_result=True`. | ||||
|         default_text (str): Optional default value shown in the prompt. | ||||
|         validator (Validator | None): Prompt Toolkit validator for input constraints. | ||||
|         prompt_session (PromptSession | None): Optional custom prompt session. | ||||
|         inject_last_result (bool): Whether to inject `last_result` into the prompt. | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         name: str, | ||||
|         *, | ||||
|         prompt_text: str = "Input > ", | ||||
|         prompt_message: str = "Input > ", | ||||
|         default_text: str = "", | ||||
|         multiline: bool = False, | ||||
|         validator: Validator | None = None, | ||||
|         console: Console | None = None, | ||||
|         prompt_session: PromptSession | None = None, | ||||
|         inject_last_result: bool = False, | ||||
|     ): | ||||
| @@ -38,10 +72,16 @@ class UserInputAction(BaseAction): | ||||
|             name=name, | ||||
|             inject_last_result=inject_last_result, | ||||
|         ) | ||||
|         self.prompt_text = prompt_text | ||||
|         self.prompt_message = prompt_message | ||||
|         self.default_text = default_text | ||||
|         self.multiline = multiline | ||||
|         self.validator = validator | ||||
|         self.console = console or Console(color_system="auto") | ||||
|         self.prompt_session = prompt_session or PromptSession() | ||||
|         self.prompt_session = prompt_session or PromptSession( | ||||
|             interrupt_exception=CancelSignal | ||||
|         ) | ||||
|  | ||||
|     def get_infer_target(self) -> tuple[None, None]: | ||||
|         return None, None | ||||
|  | ||||
|     async def _run(self, *args, **kwargs) -> str: | ||||
|         context = ExecutionContext( | ||||
| @@ -54,13 +94,15 @@ class UserInputAction(BaseAction): | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|  | ||||
|             prompt_text = self.prompt_text | ||||
|             prompt_message = self.prompt_message | ||||
|             if self.inject_last_result and self.last_result: | ||||
|                 prompt_text = prompt_text.format(last_result=self.last_result) | ||||
|                 prompt_message = prompt_message.format(last_result=self.last_result) | ||||
|  | ||||
|             answer = await self.prompt_session.prompt_async( | ||||
|                 prompt_text, | ||||
|                 rich_text_to_prompt_text(prompt_message), | ||||
|                 validator=self.validator, | ||||
|                 default=kwargs.get("default_text", self.default_text), | ||||
|                 multiline=self.multiline, | ||||
|             ) | ||||
|             context.result = answer | ||||
|             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||
| @@ -79,12 +121,12 @@ class UserInputAction(BaseAction): | ||||
|         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 | ||||
|         prompt_message = ( | ||||
|             self.prompt_message.replace("{last_result}", "<last_result>") | ||||
|             if "{last_result}" in self.prompt_message | ||||
|             else self.prompt_message | ||||
|         ) | ||||
|         tree.add(f"[dim]Prompt:[/] {prompt_text}") | ||||
|         tree.add(f"[dim]Prompt:[/] {prompt_message}") | ||||
|         if self.validator: | ||||
|             tree.add("[dim]Validator:[/] Yes") | ||||
|         if not parent: | ||||
|   | ||||
| @@ -1,596 +0,0 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| from copy import deepcopy | ||||
| from dataclasses import dataclass | ||||
| from enum import Enum | ||||
| from typing import Any, Iterable | ||||
|  | ||||
| from rich.console import Console | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.signals import HelpSignal | ||||
|  | ||||
|  | ||||
| class ArgumentAction(Enum): | ||||
|     """Defines the action to be taken when the argument is encountered.""" | ||||
|  | ||||
|     STORE = "store" | ||||
|     STORE_TRUE = "store_true" | ||||
|     STORE_FALSE = "store_false" | ||||
|     APPEND = "append" | ||||
|     EXTEND = "extend" | ||||
|     COUNT = "count" | ||||
|     HELP = "help" | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class Argument: | ||||
|     """Represents a command-line argument.""" | ||||
|  | ||||
|     flags: list[str] | ||||
|     dest: str  # Destination name for the argument | ||||
|     action: ArgumentAction = ( | ||||
|         ArgumentAction.STORE | ||||
|     )  # Action to be taken when the argument is encountered | ||||
|     type: Any = str  # Type of the argument (e.g., str, int, float) or callable | ||||
|     default: Any = None  # Default value if the argument is not provided | ||||
|     choices: list[str] | None = None  # List of valid choices for the argument | ||||
|     required: bool = False  # True if the argument is required | ||||
|     help: str = ""  # Help text for the argument | ||||
|     nargs: int | str = 1  # int, '?', '*', '+' | ||||
|     positional: bool = False  # True if no leading - or -- in flags | ||||
|  | ||||
|  | ||||
| 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) -> None: | ||||
|         """Initialize the CommandArgumentParser.""" | ||||
|         self.command_description: str = "" | ||||
|         self._arguments: list[Argument] = [] | ||||
|         self._flag_map: dict[str, Argument] = {} | ||||
|         self._dest_set: set[str] = set() | ||||
|         self._add_help() | ||||
|         self.console = Console(color_system="auto") | ||||
|  | ||||
|     def _add_help(self): | ||||
|         """Add help argument to the parser.""" | ||||
|         self.add_argument( | ||||
|             "--help", | ||||
|             "-h", | ||||
|             action=ArgumentAction.HELP, | ||||
|             help="Show this help message and exit.", | ||||
|             dest="help", | ||||
|         ) | ||||
|  | ||||
|     def _is_positional(self, flags: tuple[str, ...]) -> bool: | ||||
|         """Check if the flags are positional.""" | ||||
|         positional = False | ||||
|         if any(not flag.startswith("-") for flag in flags): | ||||
|             positional = True | ||||
|  | ||||
|         if positional and len(flags) > 1: | ||||
|             raise CommandArgumentError("Positional arguments cannot have multiple flags") | ||||
|         return positional | ||||
|  | ||||
|     def _get_dest_from_flags( | ||||
|         self, flags: tuple[str, ...], dest: str | None | ||||
|     ) -> str | None: | ||||
|         """Convert flags to a destination name.""" | ||||
|         if dest: | ||||
|             if not dest.replace("_", "").isalnum(): | ||||
|                 raise CommandArgumentError( | ||||
|                     "dest must be a valid identifier (letters, digits, and underscores only)" | ||||
|                 ) | ||||
|             if dest[0].isdigit(): | ||||
|                 raise CommandArgumentError("dest must not start with a digit") | ||||
|             return dest | ||||
|         dest = None | ||||
|         for flag in flags: | ||||
|             if flag.startswith("--"): | ||||
|                 dest = flag.lstrip("-").replace("-", "_").lower() | ||||
|                 break | ||||
|             elif flag.startswith("-"): | ||||
|                 dest = flag.lstrip("-").replace("-", "_").lower() | ||||
|             else: | ||||
|                 dest = flag.replace("-", "_").lower() | ||||
|         assert dest is not None, "dest should not be None" | ||||
|         if not dest.replace("_", "").isalnum(): | ||||
|             raise CommandArgumentError( | ||||
|                 "dest must be a valid identifier (letters, digits, and underscores only)" | ||||
|             ) | ||||
|         if dest[0].isdigit(): | ||||
|             raise CommandArgumentError("dest must not start with a digit") | ||||
|         return dest | ||||
|  | ||||
|     def _determine_required( | ||||
|         self, required: bool, positional: bool, nargs: int | str | ||||
|     ) -> bool: | ||||
|         """Determine if the argument is required.""" | ||||
|         if required: | ||||
|             return True | ||||
|         if positional: | ||||
|             if isinstance(nargs, int): | ||||
|                 return nargs > 0 | ||||
|             elif isinstance(nargs, str): | ||||
|                 if nargs in ("+"): | ||||
|                     return True | ||||
|                 elif nargs in ("*", "?"): | ||||
|                     return False | ||||
|                 else: | ||||
|                     raise CommandArgumentError(f"Invalid nargs value: {nargs}") | ||||
|  | ||||
|         return required | ||||
|  | ||||
|     def _validate_nargs(self, nargs: int | str) -> int | str: | ||||
|         allowed_nargs = ("?", "*", "+") | ||||
|         if isinstance(nargs, int): | ||||
|             if nargs <= 0: | ||||
|                 raise CommandArgumentError("nargs must be a positive integer") | ||||
|         elif isinstance(nargs, str): | ||||
|             if nargs not in allowed_nargs: | ||||
|                 raise CommandArgumentError(f"Invalid nargs value: {nargs}") | ||||
|         else: | ||||
|             raise CommandArgumentError(f"nargs must be an int or one of {allowed_nargs}") | ||||
|         return nargs | ||||
|  | ||||
|     def _normalize_choices(self, choices: Iterable, expected_type: Any) -> list[Any]: | ||||
|         if choices is not None: | ||||
|             if isinstance(choices, dict): | ||||
|                 raise CommandArgumentError("choices cannot be a dict") | ||||
|             try: | ||||
|                 choices = list(choices) | ||||
|             except TypeError: | ||||
|                 raise CommandArgumentError( | ||||
|                     "choices must be iterable (like list, tuple, or set)" | ||||
|                 ) | ||||
|         else: | ||||
|             choices = [] | ||||
|         for choice in choices: | ||||
|             if not isinstance(choice, expected_type): | ||||
|                 try: | ||||
|                     expected_type(choice) | ||||
|                 except Exception: | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid choice {choice!r}: not coercible to {expected_type.__name__}" | ||||
|                     ) | ||||
|         return choices | ||||
|  | ||||
|     def _validate_default_type( | ||||
|         self, default: Any, expected_type: type, dest: str | ||||
|     ) -> None: | ||||
|         """Validate the default value type.""" | ||||
|         if default is not None and not isinstance(default, expected_type): | ||||
|             try: | ||||
|                 expected_type(default) | ||||
|             except Exception: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Default value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" | ||||
|                 ) | ||||
|  | ||||
|     def _validate_default_list_type( | ||||
|         self, default: list[Any], expected_type: type, dest: str | ||||
|     ) -> None: | ||||
|         if isinstance(default, list): | ||||
|             for item in default: | ||||
|                 if not isinstance(item, expected_type): | ||||
|                     try: | ||||
|                         expected_type(item) | ||||
|                     except Exception: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Default list value {default!r} for '{dest}' cannot be coerced to {expected_type.__name__}" | ||||
|                         ) | ||||
|  | ||||
|     def _resolve_default( | ||||
|         self, action: ArgumentAction, default: Any, nargs: str | int | ||||
|     ) -> Any: | ||||
|         """Get the default value for the argument.""" | ||||
|         if default is None: | ||||
|             if action == ArgumentAction.STORE_TRUE: | ||||
|                 return False | ||||
|             elif action == ArgumentAction.STORE_FALSE: | ||||
|                 return True | ||||
|             elif action == ArgumentAction.COUNT: | ||||
|                 return 0 | ||||
|             elif action in (ArgumentAction.APPEND, ArgumentAction.EXTEND): | ||||
|                 return [] | ||||
|             elif nargs in ("+", "*"): | ||||
|                 return [] | ||||
|             else: | ||||
|                 return None | ||||
|         return default | ||||
|  | ||||
|     def _validate_flags(self, flags: tuple[str, ...]) -> None: | ||||
|         """Validate the flags provided for the argument.""" | ||||
|         if not flags: | ||||
|             raise CommandArgumentError("No flags provided") | ||||
|         for flag in flags: | ||||
|             if not isinstance(flag, str): | ||||
|                 raise CommandArgumentError(f"Flag '{flag}' must be a string") | ||||
|             if flag.startswith("--") and len(flag) < 3: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Flag '{flag}' must be at least 3 characters long" | ||||
|                 ) | ||||
|             if flag.startswith("-") and not flag.startswith("--") and len(flag) > 2: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Flag '{flag}' must be a single character or start with '--'" | ||||
|                 ) | ||||
|  | ||||
|     def add_argument(self, *flags, **kwargs): | ||||
|         """Add an argument to the parser. | ||||
|         Args: | ||||
|             name or flags: Either a name or prefixed flags (e.g. 'faylx', '-f', '--falyx'). | ||||
|             action: The action to be taken when the argument is encountered. | ||||
|             nargs: The number of arguments expected. | ||||
|             default: The default value if the argument is not provided. | ||||
|             type: The type to which the command-line argument should be converted. | ||||
|             choices: A container of the allowable values for the argument. | ||||
|             required: Whether or not the argument is required. | ||||
|             help: A brief description of the argument. | ||||
|             dest: The name of the attribute to be added to the object returned by parse_args(). | ||||
|         """ | ||||
|         self._validate_flags(flags) | ||||
|         positional = self._is_positional(flags) | ||||
|         dest = self._get_dest_from_flags(flags, kwargs.get("dest")) | ||||
|         if dest in self._dest_set: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Destination '{dest}' is already defined.\n" | ||||
|                 "Merging multiple arguments into the same dest (e.g. positional + flagged) " | ||||
|                 "is not supported. Define a unique 'dest' for each argument." | ||||
|             ) | ||||
|         self._dest_set.add(dest) | ||||
|         action = kwargs.get("action", ArgumentAction.STORE) | ||||
|         if not isinstance(action, ArgumentAction): | ||||
|             try: | ||||
|                 action = ArgumentAction(action) | ||||
|             except ValueError: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid action '{action}' is not a valid ArgumentAction" | ||||
|                 ) | ||||
|         flags = list(flags) | ||||
|         nargs = self._validate_nargs(kwargs.get("nargs", 1)) | ||||
|         default = self._resolve_default(action, kwargs.get("default"), nargs) | ||||
|         expected_type = kwargs.get("type", str) | ||||
|         if ( | ||||
|             action in (ArgumentAction.STORE, ArgumentAction.APPEND, ArgumentAction.EXTEND) | ||||
|             and default is not None | ||||
|         ): | ||||
|             if isinstance(default, list): | ||||
|                 self._validate_default_list_type(default, expected_type, dest) | ||||
|             else: | ||||
|                 self._validate_default_type(default, expected_type, dest) | ||||
|         choices = self._normalize_choices(kwargs.get("choices"), expected_type) | ||||
|         if default is not None and choices and default not in choices: | ||||
|             raise CommandArgumentError( | ||||
|                 f"Default value '{default}' not in allowed choices: {choices}" | ||||
|             ) | ||||
|         required = self._determine_required( | ||||
|             kwargs.get("required", False), positional, nargs | ||||
|         ) | ||||
|         argument = Argument( | ||||
|             flags=flags, | ||||
|             dest=dest, | ||||
|             action=action, | ||||
|             type=expected_type, | ||||
|             default=default, | ||||
|             choices=choices, | ||||
|             required=required, | ||||
|             help=kwargs.get("help", ""), | ||||
|             nargs=nargs, | ||||
|             positional=positional, | ||||
|         ) | ||||
|         for flag in flags: | ||||
|             if flag in self._flag_map: | ||||
|                 existing = self._flag_map[flag] | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Flag '{flag}' is already used by argument '{existing.dest}'" | ||||
|                 ) | ||||
|             self._flag_map[flag] = argument | ||||
|         self._arguments.append(argument) | ||||
|  | ||||
|     def get_argument(self, dest: str) -> Argument | None: | ||||
|         return next((a for a in self._arguments if a.dest == dest), None) | ||||
|  | ||||
|     def _consume_nargs( | ||||
|         self, args: list[str], start: int, spec: Argument | ||||
|     ) -> tuple[list[str], int]: | ||||
|         values = [] | ||||
|         i = start | ||||
|         if isinstance(spec.nargs, int): | ||||
|             # assert i + spec.nargs <= len( | ||||
|             #     args | ||||
|             # ), "Not enough arguments provided: shouldn't happen" | ||||
|             values = args[i : i + spec.nargs] | ||||
|             return values, i + spec.nargs | ||||
|         elif spec.nargs == "+": | ||||
|             if i >= len(args): | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Expected at least one value for '{spec.dest}'" | ||||
|                 ) | ||||
|             while i < len(args) and not args[i].startswith("-"): | ||||
|                 values.append(args[i]) | ||||
|                 i += 1 | ||||
|             assert values, "Expected at least one value for '+' nargs: shouldn't happen" | ||||
|             return values, i | ||||
|         elif spec.nargs == "*": | ||||
|             while i < len(args) and not args[i].startswith("-"): | ||||
|                 values.append(args[i]) | ||||
|                 i += 1 | ||||
|             return values, i | ||||
|         elif spec.nargs == "?": | ||||
|             if i < len(args) and not args[i].startswith("-"): | ||||
|                 return [args[i]], i + 1 | ||||
|             return [], i | ||||
|         else: | ||||
|             assert False, "Invalid nargs value: shouldn't happen" | ||||
|  | ||||
|     def _consume_all_positional_args( | ||||
|         self, | ||||
|         args: list[str], | ||||
|         result: dict[str, Any], | ||||
|         positional_args: list[Argument], | ||||
|         consumed_positional_indicies: set[int], | ||||
|     ) -> int: | ||||
|         remaining_positional_args = [ | ||||
|             (j, spec) | ||||
|             for j, spec in enumerate(positional_args) | ||||
|             if j not in consumed_positional_indicies | ||||
|         ] | ||||
|         i = 0 | ||||
|  | ||||
|         for j, spec in remaining_positional_args: | ||||
|             # estimate how many args the remaining specs might need | ||||
|             is_last = j == len(positional_args) - 1 | ||||
|             remaining = len(args) - i | ||||
|             min_required = 0 | ||||
|             for next_spec in positional_args[j + 1 :]: | ||||
|                 if isinstance(next_spec.nargs, int): | ||||
|                     min_required += next_spec.nargs | ||||
|                 elif next_spec.nargs == "+": | ||||
|                     min_required += 1 | ||||
|                 elif next_spec.nargs == "?": | ||||
|                     min_required += 0 | ||||
|                 elif next_spec.nargs == "*": | ||||
|                     min_required += 0 | ||||
|                 else: | ||||
|                     assert False, "Invalid nargs value: shouldn't happen" | ||||
|  | ||||
|             slice_args = args[i:] if is_last else args[i : i + (remaining - min_required)] | ||||
|             values, new_i = self._consume_nargs(slice_args, 0, spec) | ||||
|             i += new_i | ||||
|  | ||||
|             try: | ||||
|                 typed = [spec.type(v) for v in values] | ||||
|             except Exception: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                 ) | ||||
|  | ||||
|             if spec.action == ArgumentAction.APPEND: | ||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                 if spec.nargs in (None, 1): | ||||
|                     result[spec.dest].append(typed[0]) | ||||
|                 else: | ||||
|                     result[spec.dest].append(typed) | ||||
|             elif spec.action == ArgumentAction.EXTEND: | ||||
|                 assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                 result[spec.dest].extend(typed) | ||||
|             elif spec.nargs in (None, 1, "?"): | ||||
|                 result[spec.dest] = typed[0] if len(typed) == 1 else typed | ||||
|             else: | ||||
|                 result[spec.dest] = typed | ||||
|  | ||||
|             if spec.nargs not in ("*", "+"): | ||||
|                 consumed_positional_indicies.add(j) | ||||
|  | ||||
|         if i < len(args): | ||||
|             raise CommandArgumentError(f"Unexpected positional argument: {args[i:]}") | ||||
|  | ||||
|         return i | ||||
|  | ||||
|     def parse_args(self, args: list[str] | None = None) -> dict[str, Any]: | ||||
|         """Parse Falyx Command arguments.""" | ||||
|         if args is None: | ||||
|             args = [] | ||||
|  | ||||
|         result = {arg.dest: deepcopy(arg.default) for arg in self._arguments} | ||||
|         positional_args = [arg for arg in self._arguments if arg.positional] | ||||
|         consumed_positional_indices: set[int] = set() | ||||
|  | ||||
|         consumed_indices: set[int] = set() | ||||
|         i = 0 | ||||
|         while i < len(args): | ||||
|             token = args[i] | ||||
|             if token in self._flag_map: | ||||
|                 spec = self._flag_map[token] | ||||
|                 action = spec.action | ||||
|  | ||||
|                 if action == ArgumentAction.HELP: | ||||
|                     self.render_help() | ||||
|                     raise HelpSignal() | ||||
|                 elif action == ArgumentAction.STORE_TRUE: | ||||
|                     result[spec.dest] = True | ||||
|                     consumed_indices.add(i) | ||||
|                     i += 1 | ||||
|                 elif action == ArgumentAction.STORE_FALSE: | ||||
|                     result[spec.dest] = False | ||||
|                     consumed_indices.add(i) | ||||
|                     i += 1 | ||||
|                 elif action == ArgumentAction.COUNT: | ||||
|                     result[spec.dest] = result.get(spec.dest, 0) + 1 | ||||
|                     consumed_indices.add(i) | ||||
|                     i += 1 | ||||
|                 elif action == ArgumentAction.APPEND: | ||||
|                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(value) for value in values] | ||||
|                     except ValueError: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                         ) | ||||
|                     if spec.nargs in (None, 1): | ||||
|                         try: | ||||
|                             result[spec.dest].append(spec.type(values[0])) | ||||
|                         except ValueError: | ||||
|                             raise CommandArgumentError( | ||||
|                                 f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                             ) | ||||
|                     else: | ||||
|                         result[spec.dest].append(typed_values) | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
|                     i = new_i | ||||
|                 elif action == ArgumentAction.EXTEND: | ||||
|                     assert result.get(spec.dest) is not None, "dest should not be None" | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(value) for value in values] | ||||
|                     except ValueError: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                         ) | ||||
|                     result[spec.dest].extend(typed_values) | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
|                     i = new_i | ||||
|                 else: | ||||
|                     values, new_i = self._consume_nargs(args, i + 1, spec) | ||||
|                     try: | ||||
|                         typed_values = [spec.type(v) for v in values] | ||||
|                     except ValueError: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for '{spec.dest}': expected {spec.type.__name__}" | ||||
|                         ) | ||||
|                     if ( | ||||
|                         spec.nargs in (None, 1, "?") | ||||
|                         and spec.action != ArgumentAction.APPEND | ||||
|                     ): | ||||
|                         result[spec.dest] = ( | ||||
|                             typed_values[0] if len(typed_values) == 1 else typed_values | ||||
|                         ) | ||||
|                     else: | ||||
|                         result[spec.dest] = typed_values | ||||
|                     consumed_indices.update(range(i, new_i)) | ||||
|                     i = new_i | ||||
|             else: | ||||
|                 # Get the next flagged argument index if it exists | ||||
|                 next_flagged_index = -1 | ||||
|                 for index, arg in enumerate(args[i:], start=i): | ||||
|                     if arg.startswith("-"): | ||||
|                         next_flagged_index = index | ||||
|                         break | ||||
|                 if next_flagged_index == -1: | ||||
|                     next_flagged_index = len(args) | ||||
|  | ||||
|                 args_consumed = self._consume_all_positional_args( | ||||
|                     args[i:next_flagged_index], | ||||
|                     result, | ||||
|                     positional_args, | ||||
|                     consumed_positional_indices, | ||||
|                 ) | ||||
|                 i += args_consumed | ||||
|  | ||||
|         # Required validation | ||||
|         for spec in self._arguments: | ||||
|             if spec.dest == "help": | ||||
|                 continue | ||||
|             if spec.required and not result.get(spec.dest): | ||||
|                 raise CommandArgumentError(f"Missing required argument: {spec.dest}") | ||||
|  | ||||
|             if spec.choices and result.get(spec.dest) not in spec.choices: | ||||
|                 raise CommandArgumentError( | ||||
|                     f"Invalid value for {spec.dest}: must be one of {spec.choices}" | ||||
|                 ) | ||||
|  | ||||
|             if isinstance(spec.nargs, int) and spec.nargs > 1: | ||||
|                 if not isinstance(result.get(spec.dest), list): | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid value for {spec.dest}: expected a list" | ||||
|                     ) | ||||
|                 if spec.action == ArgumentAction.APPEND: | ||||
|                     if not isinstance(result[spec.dest], list): | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for {spec.dest}: expected a list" | ||||
|                         ) | ||||
|                     for group in result[spec.dest]: | ||||
|                         if len(group) % spec.nargs != 0: | ||||
|                             raise CommandArgumentError( | ||||
|                                 f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||
|                             ) | ||||
|                 elif spec.action == ArgumentAction.EXTEND: | ||||
|                     if not isinstance(result[spec.dest], list): | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid value for {spec.dest}: expected a list" | ||||
|                         ) | ||||
|                     if len(result[spec.dest]) % spec.nargs != 0: | ||||
|                         raise CommandArgumentError( | ||||
|                             f"Invalid number of values for {spec.dest}: expected a multiple of {spec.nargs}" | ||||
|                         ) | ||||
|                 elif len(result[spec.dest]) != spec.nargs: | ||||
|                     raise CommandArgumentError( | ||||
|                         f"Invalid number of values for {spec.dest}: expected {spec.nargs}, got {len(result[spec.dest])}" | ||||
|                     ) | ||||
|  | ||||
|         result.pop("help", None) | ||||
|         return result | ||||
|  | ||||
|     def parse_args_split(self, args: list[str]) -> tuple[tuple[Any, ...], dict[str, Any]]: | ||||
|         """ | ||||
|         Returns: | ||||
|             tuple[args, kwargs] - Positional arguments in defined order, | ||||
|             followed by keyword argument mapping. | ||||
|         """ | ||||
|         parsed = self.parse_args(args) | ||||
|         args_list = [] | ||||
|         kwargs_dict = {} | ||||
|         for arg in self._arguments: | ||||
|             if arg.dest == "help": | ||||
|                 continue | ||||
|             if arg.positional: | ||||
|                 args_list.append(parsed[arg.dest]) | ||||
|             else: | ||||
|                 kwargs_dict[arg.dest] = parsed[arg.dest] | ||||
|         return tuple(args_list), kwargs_dict | ||||
|  | ||||
|     def render_help(self): | ||||
|         table = Table(title=f"{self.command_description} Help") | ||||
|         table.add_column("Flags") | ||||
|         table.add_column("Help") | ||||
|         for arg in self._arguments: | ||||
|             if arg.dest == "help": | ||||
|                 continue | ||||
|             flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest | ||||
|             table.add_row(flag_str, arg.help or "") | ||||
|         table.add_section() | ||||
|         arg = self.get_argument("help") | ||||
|         flag_str = ", ".join(arg.flags) if not arg.positional else arg.dest | ||||
|         table.add_row(flag_str, arg.help or "") | ||||
|         self.console.print(table) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         positional = sum(arg.positional for arg in self._arguments) | ||||
|         required = sum(arg.required for arg in self._arguments) | ||||
|         return ( | ||||
|             f"CommandArgumentParser(args={len(self._arguments)}, " | ||||
|             f"flags={len(self._flag_map)}, dests={len(self._dest_set)}, " | ||||
|             f"required={required}, positional={positional})" | ||||
|         ) | ||||
|  | ||||
|     def __repr__(self) -> str: | ||||
|         return str(self) | ||||
| @@ -1,12 +1,45 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """bottom_bar.py""" | ||||
| """ | ||||
| Provides the `BottomBar` class for managing a customizable bottom status bar in | ||||
| Falyx-based CLI applications. | ||||
|  | ||||
| The bottom bar is rendered using `prompt_toolkit` and supports: | ||||
| - Rich-formatted static content | ||||
| - Live-updating value trackers and counters | ||||
| - Toggle switches activated via Ctrl+<key> bindings | ||||
| - Config-driven visual and behavioral controls | ||||
|  | ||||
| Each item in the bar is registered by name and rendered in columns across the | ||||
| bottom of the terminal. Toggles are linked to user-defined state accessors and | ||||
| mutators, and can be automatically bound to `OptionsManager` values for full | ||||
| integration with Falyx CLI argument parsing. | ||||
|  | ||||
| Key Features: | ||||
| - Live rendering of structured status items using Rich-style HTML | ||||
| - Custom or built-in item types: static text, dynamic counters, toggles, value displays | ||||
| - Ctrl+key toggle handling via `prompt_toolkit.KeyBindings` | ||||
| - Columnar layout with automatic width scaling | ||||
| - Optional integration with `OptionsManager` for dynamic state toggling | ||||
|  | ||||
| Usage Example: | ||||
|     bar = BottomBar(columns=3) | ||||
|     bar.add_static("env", "ENV: dev") | ||||
|     bar.add_toggle("d", "Debug", get_debug, toggle_debug) | ||||
|     bar.add_value_tracker("attempts", "Retries", get_retry_count) | ||||
|     bar.render() | ||||
|  | ||||
| Used by Falyx to provide a persistent UI element showing toggles, system state, | ||||
| and runtime telemetry below the input prompt. | ||||
| """ | ||||
|  | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from prompt_toolkit.formatted_text import HTML, merge_formatted_text | ||||
| from prompt_toolkit.key_binding import KeyBindings | ||||
| from prompt_toolkit.key_binding.key_processor import KeyPressEvent | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
| @@ -23,19 +56,19 @@ class BottomBar: | ||||
|             Must return True if key is available, otherwise False. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_CTRL_KEYS = {"c", "d", "z", "v"} | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         columns: int = 3, | ||||
|         key_bindings: KeyBindings | None = None, | ||||
|         key_validator: Callable[[str], bool] | None = None, | ||||
|     ) -> None: | ||||
|         self.columns = columns | ||||
|         self.console = Console(color_system="auto") | ||||
|         self.console: Console = console | ||||
|         self._named_items: dict[str, Callable[[], HTML]] = {} | ||||
|         self._value_getters: dict[str, Callable[[], Any]] = CaseInsensitiveDict() | ||||
|         self.toggle_keys: list[str] = [] | ||||
|         self.key_bindings = key_bindings or KeyBindings() | ||||
|         self.key_validator = key_validator | ||||
|  | ||||
|     @staticmethod | ||||
|     def default_render(label: str, value: Any, fg: str, bg: str, width: int) -> HTML: | ||||
| @@ -120,17 +153,31 @@ class BottomBar: | ||||
|         bg_on: str = OneColors.GREEN, | ||||
|         bg_off: str = OneColors.DARK_RED, | ||||
|     ) -> None: | ||||
|         """ | ||||
|         Add a toggle to the bottom bar. | ||||
|         Always uses the ctrl + key combination for toggling. | ||||
|  | ||||
|         Args: | ||||
|             key (str): The key to toggle the state. | ||||
|             label (str): The label for the toggle. | ||||
|             get_state (Callable[[], bool]): Function to get the current state. | ||||
|             toggle_state (Callable[[], None]): Function to toggle the state. | ||||
|             fg (str): Foreground color for the label. | ||||
|             bg_on (str): Background color when the toggle is ON. | ||||
|             bg_off (str): Background color when the toggle is OFF. | ||||
|         """ | ||||
|         key = key.lower() | ||||
|         if key in self.RESERVED_CTRL_KEYS: | ||||
|             raise ValueError( | ||||
|                 f"'{key}' is a reserved terminal control key and cannot be used for toggles." | ||||
|             ) | ||||
|         if not callable(get_state): | ||||
|             raise ValueError("`get_state` must be a callable returning bool") | ||||
|         if not callable(toggle_state): | ||||
|             raise ValueError("`toggle_state` must be a callable") | ||||
|         key = key.upper() | ||||
|         if key in self.toggle_keys: | ||||
|             raise ValueError(f"Key {key} is already used as a toggle") | ||||
|         if self.key_validator and not self.key_validator(key): | ||||
|             raise ValueError( | ||||
|                 f"Key '{key}' conflicts with existing command, toggle, or reserved key." | ||||
|             ) | ||||
|  | ||||
|         self._value_getters[key] = get_state | ||||
|         self.toggle_keys.append(key) | ||||
|  | ||||
| @@ -138,15 +185,13 @@ class BottomBar: | ||||
|             get_state_ = self._value_getters[key] | ||||
|             color = bg_on if get_state_() else bg_off | ||||
|             status = "ON" if get_state_() else "OFF" | ||||
|             text = f"({key.upper()}) {label}: {status}" | ||||
|             text = f"(^{key.lower()}) {label}: {status}" | ||||
|             return HTML(f"<style bg='{color}' fg='{fg}'>{text:^{self.space}}</style>") | ||||
|  | ||||
|         self._add_named(key, render) | ||||
|  | ||||
|         for k in (key.upper(), key.lower()): | ||||
|  | ||||
|             @self.key_bindings.add(k) | ||||
|             def _(_): | ||||
|         @self.key_bindings.add(f"c-{key.lower()}", eager=True) | ||||
|         def _(_: KeyPressEvent): | ||||
|             toggle_state() | ||||
|  | ||||
|     def add_toggle_from_option( | ||||
|   | ||||
							
								
								
									
										191
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										191
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -1,6 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """command.py | ||||
|  | ||||
| """ | ||||
| Defines the Command class for Falyx CLI. | ||||
|  | ||||
| Commands are callable units representing a menu option or CLI task, | ||||
| @@ -19,23 +18,24 @@ in building robust interactive menus. | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| from functools import cached_property | ||||
| from typing import Any, Callable | ||||
| from typing import Any, Awaitable, Callable | ||||
|  | ||||
| from prompt_toolkit.formatted_text import FormattedText | ||||
| from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | ||||
| from rich.console import Console | ||||
| from rich.tree import Tree | ||||
|  | ||||
| from falyx.action.action import Action, ActionGroup, BaseAction, ChainedAction | ||||
| from falyx.action.io_action import BaseIOAction | ||||
| from falyx.argparse import CommandArgumentParser | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.console import console | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.debug import register_debug_hooks | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| from falyx.mode import FalyxMode | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.parser.command_argument_parser import CommandArgumentParser | ||||
| from falyx.parser.signature import infer_args_from_func | ||||
| from falyx.prompt_utils import confirm_async, should_prompt_user | ||||
| from falyx.protocols import ArgParserProtocol | ||||
| from falyx.retry import RetryPolicy | ||||
| @@ -44,8 +44,6 @@ from falyx.signals import CancelSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import ensure_async | ||||
|  | ||||
| console = Console(color_system="auto") | ||||
|  | ||||
|  | ||||
| class Command(BaseModel): | ||||
|     """ | ||||
| @@ -82,14 +80,26 @@ class Command(BaseModel): | ||||
|         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. | ||||
|         spinner_speed (float): Speed of the spinner animation. | ||||
|         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. | ||||
|         requires_input (bool | None): Indicates if the action needs input. | ||||
|         options_manager (OptionsManager): Manages global command-line options. | ||||
|         arg_parser (CommandArgumentParser): Parses command arguments. | ||||
|         arguments (list[dict[str, Any]]): Argument definitions for the command. | ||||
|         argument_config (Callable[[CommandArgumentParser], None] | None): Function to configure arguments | ||||
|             for the command parser. | ||||
|         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. | ||||
|         arg_metadata (dict[str, str | dict[str, Any]]): Metadata for arguments, | ||||
|             such as help text or choices. | ||||
|         simple_help_signature (bool): Whether to use a simplified help signature. | ||||
|         ignore_in_history (bool): Whether to ignore this command in execution history last result. | ||||
|         program: (str | None): The parent program name. | ||||
|  | ||||
|     Methods: | ||||
|         __call__(): Executes the command, respecting hooks and retries. | ||||
| @@ -101,12 +111,13 @@ class Command(BaseModel): | ||||
|  | ||||
|     key: str | ||||
|     description: str | ||||
|     action: BaseAction | Callable[[], Any] | ||||
|     action: BaseAction | Callable[..., Any] | Callable[..., Awaitable[Any]] | ||||
|     args: tuple = () | ||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     hidden: bool = False | ||||
|     aliases: list[str] = Field(default_factory=list) | ||||
|     help_text: str = "" | ||||
|     help_epilog: str = "" | ||||
|     style: str = OneColors.WHITE | ||||
|     confirm: bool = False | ||||
|     confirm_message: str = "Are you sure?" | ||||
| @@ -115,32 +126,64 @@ class Command(BaseModel): | ||||
|     spinner_message: str = "Processing..." | ||||
|     spinner_type: str = "dots" | ||||
|     spinner_style: str = OneColors.CYAN | ||||
|     spinner_kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     spinner_speed: float = 1.0 | ||||
|     hooks: "HookManager" = Field(default_factory=HookManager) | ||||
|     retry: bool = False | ||||
|     retry_all: bool = False | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     tags: list[str] = Field(default_factory=list) | ||||
|     logging_hooks: bool = False | ||||
|     requires_input: bool | None = None | ||||
|     options_manager: OptionsManager = Field(default_factory=OptionsManager) | ||||
|     arg_parser: CommandArgumentParser = Field(default_factory=CommandArgumentParser) | ||||
|     arg_parser: CommandArgumentParser | None = None | ||||
|     arguments: list[dict[str, Any]] = Field(default_factory=list) | ||||
|     argument_config: Callable[[CommandArgumentParser], None] | None = None | ||||
|     custom_parser: ArgParserProtocol | None = None | ||||
|     custom_help: Callable[[], str | None] | None = None | ||||
|     auto_args: bool = True | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] = Field(default_factory=dict) | ||||
|     simple_help_signature: bool = False | ||||
|     ignore_in_history: bool = False | ||||
|     program: str | None = None | ||||
|  | ||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||
|  | ||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||
|  | ||||
|     def parse_args(self, raw_args: list[str] | str) -> tuple[tuple, dict]: | ||||
|         if self.custom_parser: | ||||
|     async def parse_args( | ||||
|         self, raw_args: list[str] | str, from_validate: bool = False | ||||
|     ) -> tuple[tuple, dict]: | ||||
|         if callable(self.custom_parser): | ||||
|             if isinstance(raw_args, str): | ||||
|                 try: | ||||
|                     raw_args = shlex.split(raw_args) | ||||
|                 except ValueError: | ||||
|                     logger.warning( | ||||
|                         "[Command:%s] Failed to split arguments: %s", | ||||
|                         self.key, | ||||
|                         raw_args, | ||||
|                     ) | ||||
|                     return ((), {}) | ||||
|             return self.custom_parser(raw_args) | ||||
|  | ||||
|         if isinstance(raw_args, str): | ||||
|             try: | ||||
|                 raw_args = shlex.split(raw_args) | ||||
|         return self.arg_parser.parse_args_split(raw_args) | ||||
|             except ValueError: | ||||
|                 logger.warning( | ||||
|                     "[Command:%s] Failed to split arguments: %s", | ||||
|                     self.key, | ||||
|                     raw_args, | ||||
|                 ) | ||||
|                 return ((), {}) | ||||
|         if not isinstance(self.arg_parser, CommandArgumentParser): | ||||
|             logger.warning( | ||||
|                 "[Command:%s] No argument parser configured, using default parsing.", | ||||
|                 self.key, | ||||
|             ) | ||||
|             return ((), {}) | ||||
|         return await self.arg_parser.parse_args_split( | ||||
|             raw_args, from_validate=from_validate | ||||
|         ) | ||||
|  | ||||
|     @field_validator("action", mode="before") | ||||
|     @classmethod | ||||
| @@ -151,11 +194,26 @@ class Command(BaseModel): | ||||
|             return ensure_async(action) | ||||
|         raise TypeError("Action must be a callable or an instance of BaseAction") | ||||
|  | ||||
|     def get_argument_definitions(self) -> list[dict[str, Any]]: | ||||
|         if self.arguments: | ||||
|             return self.arguments | ||||
|         elif callable(self.argument_config) and isinstance( | ||||
|             self.arg_parser, CommandArgumentParser | ||||
|         ): | ||||
|             self.argument_config(self.arg_parser) | ||||
|         elif self.auto_args: | ||||
|             if isinstance(self.action, BaseAction): | ||||
|                 infer_target, maybe_metadata = self.action.get_infer_target() | ||||
|                 # merge metadata with the action's metadata if not already in self.arg_metadata | ||||
|                 if maybe_metadata: | ||||
|                     self.arg_metadata = {**maybe_metadata, **self.arg_metadata} | ||||
|                 return infer_args_from_func(infer_target, self.arg_metadata) | ||||
|             elif callable(self.action): | ||||
|                 return infer_args_from_func(self.action, self.arg_metadata) | ||||
|         return [] | ||||
|  | ||||
|     def model_post_init(self, _: Any) -> None: | ||||
|         """Post-initialization to set up the action and hooks.""" | ||||
|         if isinstance(self.arg_parser, CommandArgumentParser): | ||||
|             self.arg_parser.command_description = self.description | ||||
|  | ||||
|         if self.retry and isinstance(self.action, Action): | ||||
|             self.action.enable_retry() | ||||
|         elif self.retry_policy and isinstance(self.action, Action): | ||||
| @@ -177,26 +235,22 @@ class Command(BaseModel): | ||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||
|             register_debug_hooks(self.action.hooks) | ||||
|  | ||||
|         if self.requires_input is None and self.detect_requires_input: | ||||
|             self.requires_input = True | ||||
|             self.hidden = True | ||||
|         elif self.requires_input is None: | ||||
|             self.requires_input = False | ||||
|  | ||||
|     @cached_property | ||||
|     def detect_requires_input(self) -> bool: | ||||
|         """Detect if the action requires input based on its type.""" | ||||
|         if isinstance(self.action, BaseIOAction): | ||||
|             return True | ||||
|         elif isinstance(self.action, ChainedAction): | ||||
|             return ( | ||||
|                 isinstance(self.action.actions[0], BaseIOAction) | ||||
|                 if self.action.actions | ||||
|                 else False | ||||
|         if self.arg_parser is None and not self.custom_parser: | ||||
|             self.arg_parser = CommandArgumentParser( | ||||
|                 command_key=self.key, | ||||
|                 command_description=self.description, | ||||
|                 command_style=self.style, | ||||
|                 help_text=self.help_text, | ||||
|                 help_epilog=self.help_epilog, | ||||
|                 aliases=self.aliases, | ||||
|                 program=self.program, | ||||
|                 options_manager=self.options_manager, | ||||
|             ) | ||||
|         elif isinstance(self.action, ActionGroup): | ||||
|             return any(isinstance(action, BaseIOAction) for action in self.action.actions) | ||||
|         return False | ||||
|             for arg_def in self.get_argument_definitions(): | ||||
|                 self.arg_parser.add_argument(*arg_def.pop("flags"), **arg_def) | ||||
|  | ||||
|         if self.ignore_in_history and isinstance(self.action, BaseAction): | ||||
|             self.action.ignore_in_history = True | ||||
|  | ||||
|     def _inject_options_manager(self) -> None: | ||||
|         """Inject the options manager into the action if applicable.""" | ||||
| @@ -223,22 +277,13 @@ class Command(BaseModel): | ||||
|             if self.preview_before_confirm: | ||||
|                 await self.preview() | ||||
|             if not await confirm_async(self.confirmation_prompt): | ||||
|                 logger.info("[Command:%s] ❌ Cancelled by user.", self.key) | ||||
|                 logger.info("[Command:%s] Cancelled by user.", self.key) | ||||
|                 raise CancelSignal(f"[Command:{self.key}] Cancelled by confirmation.") | ||||
|  | ||||
|         context.start_timer() | ||||
|  | ||||
|         try: | ||||
|             await self.hooks.trigger(HookType.BEFORE, context) | ||||
|             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 | ||||
| @@ -284,13 +329,53 @@ class Command(BaseModel): | ||||
|  | ||||
|         return FormattedText(prompt) | ||||
|  | ||||
|     @property | ||||
|     def usage(self) -> str: | ||||
|         """Generate a help string for the command arguments.""" | ||||
|         if not self.arg_parser: | ||||
|             return "No arguments defined." | ||||
|  | ||||
|         command_keys_text = self.arg_parser.get_command_keys_text(plain_text=True) | ||||
|         options_text = self.arg_parser.get_options_text(plain_text=True) | ||||
|         return f"  {command_keys_text:<20}  {options_text} " | ||||
|  | ||||
|     @property | ||||
|     def help_signature(self) -> tuple[str, str, str]: | ||||
|         """Generate a help signature for the command.""" | ||||
|         is_cli_mode = self.options_manager.get("mode") in { | ||||
|             FalyxMode.RUN, | ||||
|             FalyxMode.PREVIEW, | ||||
|             FalyxMode.RUN_ALL, | ||||
|         } | ||||
|  | ||||
|         program = f"{self.program} run " if is_cli_mode else "" | ||||
|  | ||||
|         if self.arg_parser and not self.simple_help_signature: | ||||
|             usage = f"[{self.style}]{program}[/]{self.arg_parser.get_usage()}" | ||||
|             description = f"[dim]{self.help_text or self.description}[/dim]" | ||||
|             if self.tags: | ||||
|                 tags = f"[dim]Tags: {', '.join(self.tags)}[/dim]" | ||||
|             else: | ||||
|                 tags = "" | ||||
|             return usage, description, tags | ||||
|  | ||||
|         command_keys = " | ".join( | ||||
|             [f"[{self.style}]{self.key}[/{self.style}]"] | ||||
|             + [f"[{self.style}]{alias}[/{self.style}]" for alias in self.aliases] | ||||
|         ) | ||||
|         return ( | ||||
|             f"[{self.style}]{program}[/]{command_keys}", | ||||
|             f"[dim]{self.description}[/dim]", | ||||
|             "", | ||||
|         ) | ||||
|  | ||||
|     def log_summary(self) -> None: | ||||
|         if self._context: | ||||
|             self._context.log_summary() | ||||
|  | ||||
|     def show_help(self) -> bool: | ||||
|     def render_help(self) -> bool: | ||||
|         """Display the help message for the command.""" | ||||
|         if self.custom_help: | ||||
|         if callable(self.custom_help): | ||||
|             output = self.custom_help() | ||||
|             if output: | ||||
|                 console.print(output) | ||||
|   | ||||
							
								
								
									
										128
									
								
								falyx/completer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								falyx/completer.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,128 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides `FalyxCompleter`, an intelligent autocompletion engine for Falyx CLI | ||||
| menus using Prompt Toolkit. | ||||
|  | ||||
| This completer supports: | ||||
| - Command key and alias completion (e.g. `R`, `HELP`, `X`) | ||||
| - Argument flag completion for registered commands (e.g. `--tag`, `--name`) | ||||
| - Context-aware suggestions based on cursor position and argument structure | ||||
| - Interactive value completions (e.g. choices and suggestions defined per argument) | ||||
|  | ||||
| Completions are sourced from `CommandArgumentParser.suggest_next`, which analyzes | ||||
| parsed tokens to determine appropriate next arguments, flags, or values. | ||||
|  | ||||
| Integrated with the `Falyx.prompt_session` to enhance the interactive experience. | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| import shlex | ||||
| from typing import TYPE_CHECKING, Iterable | ||||
|  | ||||
| from prompt_toolkit.completion import Completer, Completion | ||||
| from prompt_toolkit.document import Document | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from falyx import Falyx | ||||
|  | ||||
|  | ||||
| class FalyxCompleter(Completer): | ||||
|     """ | ||||
|     Prompt Toolkit completer for Falyx CLI command input. | ||||
|  | ||||
|     This completer provides real-time, context-aware suggestions for: | ||||
|     - Command keys and aliases (resolved via Falyx._name_map) | ||||
|     - CLI argument flags and values for each command | ||||
|     - Suggestions and choices defined in the associated CommandArgumentParser | ||||
|  | ||||
|     It leverages `CommandArgumentParser.suggest_next()` to compute valid completions | ||||
|     based on current argument state, including: | ||||
|         - Remaining required or optional flags | ||||
|         - Flag value suggestions (choices or custom completions) | ||||
|         - Next positional argument hints | ||||
|  | ||||
|     Args: | ||||
|         falyx (Falyx): The Falyx menu instance containing all command mappings and parsers. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, falyx: "Falyx"): | ||||
|         self.falyx = falyx | ||||
|  | ||||
|     def get_completions(self, document: Document, complete_event) -> Iterable[Completion]: | ||||
|         """ | ||||
|         Yield completions based on the current document input. | ||||
|  | ||||
|         Args: | ||||
|             document (Document): The prompt_toolkit document containing the input buffer. | ||||
|             complete_event: The completion trigger event (unused). | ||||
|  | ||||
|         Yields: | ||||
|             Completion objects matching command keys or argument suggestions. | ||||
|         """ | ||||
|         text = document.text_before_cursor | ||||
|         try: | ||||
|             tokens = shlex.split(text) | ||||
|             cursor_at_end_of_token = document.text_before_cursor.endswith((" ", "\t")) | ||||
|         except ValueError: | ||||
|             return | ||||
|  | ||||
|         if not tokens or (len(tokens) == 1 and not cursor_at_end_of_token): | ||||
|             # Suggest command keys and aliases | ||||
|             yield from self._suggest_commands(tokens[0] if tokens else "") | ||||
|             return | ||||
|  | ||||
|         # Identify command | ||||
|         command_key = tokens[0].upper() | ||||
|         command = self.falyx._name_map.get(command_key) | ||||
|         if not command or not command.arg_parser: | ||||
|             return | ||||
|  | ||||
|         # If at end of token, e.g., "--t" vs "--tag ", add a stub so suggest_next sees it | ||||
|         parsed_args = tokens[1:] if cursor_at_end_of_token else tokens[1:-1] | ||||
|         stub = "" if cursor_at_end_of_token else tokens[-1] | ||||
|  | ||||
|         try: | ||||
|             if not command.arg_parser: | ||||
|                 return | ||||
|             suggestions = command.arg_parser.suggest_next( | ||||
|                 parsed_args + ([stub] if stub else []), cursor_at_end_of_token | ||||
|             ) | ||||
|             for suggestion in suggestions: | ||||
|                 if suggestion.startswith(stub): | ||||
|                     if len(suggestion.split()) > 1: | ||||
|                         yield Completion( | ||||
|                             f'"{suggestion}"', | ||||
|                             start_position=-len(stub), | ||||
|                             display=suggestion, | ||||
|                         ) | ||||
|                     else: | ||||
|                         yield Completion(suggestion, start_position=-len(stub)) | ||||
|         except Exception: | ||||
|             return | ||||
|  | ||||
|     def _suggest_commands(self, prefix: str) -> Iterable[Completion]: | ||||
|         """ | ||||
|         Suggest top-level command keys and aliases based on the given prefix. | ||||
|  | ||||
|         Args: | ||||
|             prefix (str): The user input to match against available commands. | ||||
|  | ||||
|         Yields: | ||||
|             Completion: Matching keys or aliases from all registered commands. | ||||
|         """ | ||||
|         prefix = prefix.upper() | ||||
|         keys = [self.falyx.exit_command.key] | ||||
|         keys.extend(self.falyx.exit_command.aliases) | ||||
|         if self.falyx.history_command: | ||||
|             keys.append(self.falyx.history_command.key) | ||||
|             keys.extend(self.falyx.history_command.aliases) | ||||
|         if self.falyx.help_command: | ||||
|             keys.append(self.falyx.help_command.key) | ||||
|             keys.extend(self.falyx.help_command.aliases) | ||||
|         for cmd in self.falyx.commands.values(): | ||||
|             keys.append(cmd.key) | ||||
|             keys.extend(cmd.aliases) | ||||
|         for key in keys: | ||||
|             if key.upper().startswith(prefix): | ||||
|                 yield Completion(key, start_position=-len(prefix)) | ||||
| @@ -1,6 +1,41 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """config.py | ||||
| Configuration loader for Falyx CLI commands.""" | ||||
| """ | ||||
| Configuration loader and schema definitions for the Falyx CLI framework. | ||||
|  | ||||
| This module supports config-driven initialization of CLI commands and submenus | ||||
| from YAML or TOML files. It enables declarative command definitions, auto-imports | ||||
| Python callables from dotted paths, and wraps them in `Action` or `Command` objects | ||||
| as needed. | ||||
|  | ||||
| Features: | ||||
| - Parses Falyx command and submenu definitions from YAML or TOML. | ||||
| - Supports hooks, retry policies, confirm prompts, spinners, aliases, and tags. | ||||
| - Dynamically imports Python functions/classes from `action:` strings. | ||||
| - Wraps user callables into Falyx `Command` or `Action` instances. | ||||
| - Validates prompt and retry configuration using `pydantic` models. | ||||
|  | ||||
| Main Components: | ||||
| - `FalyxConfig`: Pydantic model for top-level config structure. | ||||
| - `RawCommand`: Intermediate command definition model from raw config. | ||||
| - `Submenu`: Schema for nested CLI menus. | ||||
| - `loader(path)`: Loads and returns a fully constructed `Falyx` instance. | ||||
|  | ||||
| Typical Config (YAML): | ||||
| ```yaml | ||||
| title: My CLI | ||||
| commands: | ||||
|   - key: A | ||||
|     description: Say hello | ||||
|     action: my_package.tasks.hello | ||||
|     aliases: [hi] | ||||
|     tags: [example] | ||||
| ``` | ||||
|  | ||||
| Example: | ||||
|     from falyx.config import loader | ||||
|     cli = loader("falyx.yaml") | ||||
|     cli.run() | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import importlib | ||||
| @@ -11,17 +46,16 @@ from typing import Any, Callable | ||||
| import toml | ||||
| import yaml | ||||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.action.action import Action, BaseAction | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.command import Command | ||||
| from falyx.console import console | ||||
| from falyx.falyx import Falyx | ||||
| from falyx.logger import logger | ||||
| from falyx.retry import RetryPolicy | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
| console = Console(color_system="auto") | ||||
|  | ||||
|  | ||||
| def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command: | ||||
|     if isinstance(obj, (BaseAction, Command)): | ||||
| @@ -86,7 +120,7 @@ class RawCommand(BaseModel): | ||||
|     spinner_message: str = "Processing..." | ||||
|     spinner_type: str = "dots" | ||||
|     spinner_style: str = OneColors.CYAN | ||||
|     spinner_kwargs: dict[str, Any] = Field(default_factory=dict) | ||||
|     spinner_speed: float = 1.0 | ||||
|  | ||||
|     before_hooks: list[Callable] = Field(default_factory=list) | ||||
|     success_hooks: list[Callable] = Field(default_factory=list) | ||||
| @@ -98,9 +132,9 @@ class RawCommand(BaseModel): | ||||
|     retry: bool = False | ||||
|     retry_all: bool = False | ||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||
|     requires_input: bool | None = None | ||||
|     hidden: bool = False | ||||
|     help_text: str = "" | ||||
|     help_epilog: str = "" | ||||
|  | ||||
|     @field_validator("retry_policy") | ||||
|     @classmethod | ||||
| @@ -126,6 +160,7 @@ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]: | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     return commands | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										7
									
								
								falyx/console.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								falyx/console.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """Global console instance for Falyx CLI applications.""" | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.themes import get_nord_theme | ||||
|  | ||||
| console = Console(color_system="truecolor", theme=get_nord_theme()) | ||||
| @@ -19,11 +19,14 @@ from __future__ import annotations | ||||
|  | ||||
| import time | ||||
| from datetime import datetime | ||||
| from traceback import format_exception | ||||
| from typing import Any | ||||
|  | ||||
| from pydantic import BaseModel, ConfigDict, Field | ||||
| from rich.console import Console | ||||
|  | ||||
| from falyx.console import console | ||||
|  | ||||
|  | ||||
| class ExecutionContext(BaseModel): | ||||
|     """ | ||||
| @@ -40,7 +43,7 @@ class ExecutionContext(BaseModel): | ||||
|         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. | ||||
|         exception (BaseException | None): The exception raised, if execution failed. | ||||
|         start_time (float | None): High-resolution performance start time. | ||||
|         end_time (float | None): High-resolution performance end time. | ||||
|         start_wall (datetime | None): Wall-clock timestamp when execution began. | ||||
| @@ -70,18 +73,21 @@ class ExecutionContext(BaseModel): | ||||
|  | ||||
|     name: str | ||||
|     args: tuple = () | ||||
|     kwargs: dict = {} | ||||
|     kwargs: dict = Field(default_factory=dict) | ||||
|     action: Any | ||||
|     result: Any | None = None | ||||
|     exception: Exception | None = None | ||||
|     traceback: str | None = None | ||||
|     _exception: BaseException | None = None | ||||
|  | ||||
|     start_time: float | None = None | ||||
|     end_time: float | None = None | ||||
|     start_wall: datetime | None = None | ||||
|     end_wall: datetime | None = None | ||||
|  | ||||
|     index: int | None = None | ||||
|  | ||||
|     extra: dict[str, Any] = Field(default_factory=dict) | ||||
|     console: Console = Field(default_factory=lambda: Console(color_system="auto")) | ||||
|     console: Console = console | ||||
|  | ||||
|     shared_context: SharedContext | None = None | ||||
|  | ||||
| @@ -118,11 +124,33 @@ class ExecutionContext(BaseModel): | ||||
|     def status(self) -> str: | ||||
|         return "OK" if self.success else "ERROR" | ||||
|  | ||||
|     @property | ||||
|     def exception(self) -> BaseException | None: | ||||
|         return self._exception | ||||
|  | ||||
|     @exception.setter | ||||
|     def exception(self, exc: BaseException | None): | ||||
|         self._exception = exc | ||||
|         if exc is not None: | ||||
|             self.traceback = "".join(format_exception(exc)).strip() | ||||
|  | ||||
|     @property | ||||
|     def signature(self) -> str: | ||||
|         """ | ||||
|         Returns a string representation of the action signature, including | ||||
|         its name and arguments. | ||||
|         """ | ||||
|         args = ", ".join(map(repr, self.args)) | ||||
|         kwargs = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items()) | ||||
|         signature = ", ".join(filter(None, [args, kwargs])) | ||||
|         return f"{self.action} ({signature})" | ||||
|  | ||||
|     def as_dict(self) -> dict: | ||||
|         return { | ||||
|             "name": self.name, | ||||
|             "result": self.result, | ||||
|             "exception": repr(self.exception) if self.exception else None, | ||||
|             "traceback": self.traceback, | ||||
|             "duration": self.duration, | ||||
|             "extra": self.extra, | ||||
|         } | ||||
| @@ -140,9 +168,9 @@ class ExecutionContext(BaseModel): | ||||
|         message.append(f"Duration: {summary['duration']:.3f}s | ") | ||||
|  | ||||
|         if summary["exception"]: | ||||
|             message.append(f"❌ Exception: {summary['exception']}") | ||||
|             message.append(f"Exception: {summary['exception']}") | ||||
|         else: | ||||
|             message.append(f"✅ Result: {summary['result']}") | ||||
|             message.append(f"Result: {summary['result']}") | ||||
|         (logger or self.console.print)("".join(message)) | ||||
|  | ||||
|     def to_log_line(self) -> str: | ||||
| @@ -192,7 +220,7 @@ class SharedContext(BaseModel): | ||||
|     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. | ||||
|         errors (list[tuple[int, BaseException]]): Indexed list of errors from failed actions. | ||||
|         current_index (int): Index of the currently executing action (used in chains). | ||||
|         is_parallel (bool): Whether the context is used in parallel mode (ActionGroup). | ||||
|         shared_result (Any | None): Optional shared value available to all actions in | ||||
| @@ -217,7 +245,7 @@ class SharedContext(BaseModel): | ||||
|     name: str | ||||
|     action: Any | ||||
|     results: list[Any] = Field(default_factory=list) | ||||
|     errors: list[tuple[int, Exception]] = Field(default_factory=list) | ||||
|     errors: list[tuple[int, BaseException]] = Field(default_factory=list) | ||||
|     current_index: int = -1 | ||||
|     is_parallel: bool = False | ||||
|     shared_result: Any | None = None | ||||
| @@ -229,7 +257,7 @@ class SharedContext(BaseModel): | ||||
|     def add_result(self, result: Any) -> None: | ||||
|         self.results.append(result) | ||||
|  | ||||
|     def add_error(self, index: int, error: Exception) -> None: | ||||
|     def add_error(self, index: int, error: BaseException) -> None: | ||||
|         self.errors.append((index, error)) | ||||
|  | ||||
|     def set_shared_result(self, result: Any) -> None: | ||||
|   | ||||
| @@ -1,5 +1,18 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """debug.py""" | ||||
| """ | ||||
| Provides debug logging hooks for Falyx action execution. | ||||
|  | ||||
| This module defines lifecycle hook functions (`log_before`, `log_success`, `log_after`, `log_error`) | ||||
| that can be registered with a `HookManager` to trace command execution. | ||||
|  | ||||
| Logs include: | ||||
| - Action invocation with argument signature | ||||
| - Success result (with truncation for large outputs) | ||||
| - Errors with full exception info | ||||
| - Total runtime duration after execution | ||||
|  | ||||
| Also exports `register_debug_hooks()` to register all log hooks in bulk. | ||||
| """ | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.hook_manager import HookManager, HookType | ||||
| from falyx.logger import logger | ||||
| @@ -8,9 +21,9 @@ from falyx.logger import logger | ||||
| def log_before(context: ExecutionContext): | ||||
|     """Log the start of an action.""" | ||||
|     args = ", ".join(map(repr, context.args)) | ||||
|     kwargs = ", ".join(f"{k}={v!r}" for k, v in context.kwargs.items()) | ||||
|     kwargs = ", ".join(f"{key}={value!r}" for key, value in context.kwargs.items()) | ||||
|     signature = ", ".join(filter(None, [args, kwargs])) | ||||
|     logger.info("[%s] 🚀 Starting → %s(%s)", context.name, context.action, signature) | ||||
|     logger.info("[%s] Starting -> %s(%s)", context.name, context.action, signature) | ||||
|  | ||||
|  | ||||
| def log_success(context: ExecutionContext): | ||||
| @@ -18,18 +31,18 @@ def log_success(context: ExecutionContext): | ||||
|     result_str = repr(context.result) | ||||
|     if len(result_str) > 100: | ||||
|         result_str = f"{result_str[:100]} ..." | ||||
|     logger.debug("[%s] ✅ Success → Result: %s", context.name, result_str) | ||||
|     logger.debug("[%s] Success -> Result: %s", context.name, result_str) | ||||
|  | ||||
|  | ||||
| def log_after(context: ExecutionContext): | ||||
|     """Log the completion of an action, regardless of success or failure.""" | ||||
|     logger.debug("[%s] ⏱️ Finished in %.3fs", context.name, context.duration) | ||||
|     logger.debug("[%s] Finished in %.3fs", context.name, context.duration) | ||||
|  | ||||
|  | ||||
| def log_error(context: ExecutionContext): | ||||
|     """Log an error that occurred during the action.""" | ||||
|     logger.error( | ||||
|         "[%s] ❌ Error (%s): %s", | ||||
|         "[%s] Error (%s): %s", | ||||
|         context.name, | ||||
|         type(context.exception).__name__, | ||||
|         context.exception, | ||||
|   | ||||
| @@ -1,5 +1,28 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """exceptions.py""" | ||||
| """ | ||||
| Defines all custom exception classes used in the Falyx CLI framework. | ||||
|  | ||||
| These exceptions provide structured error handling for common failure cases, | ||||
| including command conflicts, invalid actions or hooks, parser errors, and execution guards | ||||
| like circuit breakers or empty workflows. | ||||
|  | ||||
| All exceptions inherit from `FalyxError`, the base exception for the framework. | ||||
|  | ||||
| Exception Hierarchy: | ||||
| - FalyxError | ||||
|     ├── CommandAlreadyExistsError | ||||
|     ├── InvalidHookError | ||||
|     ├── InvalidActionError | ||||
|     ├── NotAFalyxError | ||||
|     ├── CircuitBreakerOpen | ||||
|     ├── EmptyChainError | ||||
|     ├── EmptyGroupError | ||||
|     ├── EmptyPoolError | ||||
|     └── CommandArgumentError | ||||
|  | ||||
| These are raised internally throughout the Falyx system to signal user-facing or | ||||
| developer-facing problems that should be caught and reported. | ||||
| """ | ||||
|  | ||||
|  | ||||
| class FalyxError(Exception): | ||||
| @@ -30,5 +53,13 @@ class EmptyChainError(FalyxError): | ||||
|     """Exception raised when the chain is empty.""" | ||||
|  | ||||
|  | ||||
| class EmptyGroupError(FalyxError): | ||||
|     """Exception raised when the chain is empty.""" | ||||
|  | ||||
|  | ||||
| class EmptyPoolError(FalyxError): | ||||
|     """Exception raised when the chain is empty.""" | ||||
|  | ||||
|  | ||||
| class CommandArgumentError(FalyxError): | ||||
|     """Exception raised when there is an error in the command argument parser.""" | ||||
|   | ||||
| @@ -1,40 +1,62 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| execution_registry.py | ||||
| Provides the `ExecutionRegistry`, a centralized runtime store for capturing and inspecting | ||||
| the execution history of Falyx actions. | ||||
|  | ||||
| This module provides the `ExecutionRegistry`, a global class for tracking and | ||||
| introspecting the execution history of Falyx actions. | ||||
| The registry automatically records every `ExecutionContext` created during action | ||||
| execution—including context metadata, results, exceptions, duration, and tracebacks. | ||||
| It supports filtering, summarization, and visual inspection via a Rich-rendered table. | ||||
|  | ||||
| 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. | ||||
| Designed for: | ||||
| - Workflow debugging and CLI diagnostics | ||||
| - Interactive history browsing or replaying previous runs | ||||
| - Providing user-visible `history` or `last-result` commands inside CLI apps | ||||
|  | ||||
| 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 | ||||
| Key Features: | ||||
| - Global, in-memory store of all `ExecutionContext` objects (by name, index, or full list) | ||||
| - Thread-safe indexing and summary display | ||||
| - Traceback-aware result inspection and filtering by status (success/error) | ||||
| - Used by built-in `History` command in Falyx CLI | ||||
|  | ||||
| Example: | ||||
|     from falyx.execution_registry import ExecutionRegistry as er | ||||
|  | ||||
|     # Record a context | ||||
|     er.record(context) | ||||
|  | ||||
|     # Display a rich table summary | ||||
|     er.summary() | ||||
|  | ||||
|     # Print the last non-ignored result | ||||
|     er.summary(last_result=True) | ||||
|  | ||||
|     # Clear execution history | ||||
|     er.summary(clear=True) | ||||
|  | ||||
| Note: | ||||
|     The registry is volatile and cleared on each process restart or when `clear()` is called. | ||||
|     All data is retained in memory only. | ||||
|  | ||||
| Public Interface: | ||||
| - record(context): Log an ExecutionContext and assign index. | ||||
| - get_all(): List all stored contexts. | ||||
| - get_by_name(name): Retrieve all contexts by action name. | ||||
| - get_latest(): Retrieve the most recent context. | ||||
| - clear(): Reset the registry. | ||||
| - summary(...): Rich console summary of stored execution results. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from collections import defaultdict | ||||
| from datetime import datetime | ||||
| from typing import Dict, List | ||||
| from threading import Lock | ||||
| from typing import Literal | ||||
|  | ||||
| from rich import box | ||||
| from rich.console import Console | ||||
| from rich.table import Table | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
| @@ -44,64 +66,181 @@ class ExecutionRegistry: | ||||
|     """ | ||||
|     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. | ||||
|     This class captures every `ExecutionContext` created by Falyx Actions, | ||||
|     tracking metadata, results, exceptions, and performance metrics. It enables | ||||
|     rich introspection, post-execution inspection, and formatted summaries | ||||
|     suitable for interactive and headless CLI use. | ||||
|  | ||||
|     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. | ||||
|     Data is retained in memory until cleared or process exit. | ||||
|  | ||||
|     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 | ||||
|         - Auditing chained or dynamic workflows | ||||
|         - Rendering execution history in a help/debug menu | ||||
|         - Accessing previous results or errors for reuse | ||||
|  | ||||
|     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() | ||||
|     Attributes: | ||||
|         _store_by_name (dict): Maps action name → list of ExecutionContext objects. | ||||
|         _store_by_index (dict): Maps numeric index → ExecutionContext. | ||||
|         _store_all (list): Ordered list of all contexts. | ||||
|         _index (int): Global counter for assigning unique execution indices. | ||||
|         _lock (Lock): Thread lock for atomic writes to the registry. | ||||
|         _console (Console): Rich console used for rendering summaries. | ||||
|     """ | ||||
|  | ||||
|     _store_by_name: Dict[str, List[ExecutionContext]] = defaultdict(list) | ||||
|     _store_all: List[ExecutionContext] = [] | ||||
|     _console = Console(color_system="auto") | ||||
|     _store_by_name: dict[str, list[ExecutionContext]] = defaultdict(list) | ||||
|     _store_by_index: dict[int, ExecutionContext] = {} | ||||
|     _store_all: list[ExecutionContext] = [] | ||||
|     _console: Console = console | ||||
|     _index = 0 | ||||
|     _lock = Lock() | ||||
|  | ||||
|     @classmethod | ||||
|     def record(cls, context: ExecutionContext): | ||||
|         """Record an execution context.""" | ||||
|         """ | ||||
|         Record an execution context and assign a unique index. | ||||
|  | ||||
|         This method logs the context, appends it to the registry, | ||||
|         and makes it available for future summary or filtering. | ||||
|  | ||||
|         Args: | ||||
|             context (ExecutionContext): The context to be tracked. | ||||
|         """ | ||||
|         logger.debug(context.to_log_line()) | ||||
|         with cls._lock: | ||||
|             context.index = cls._index | ||||
|             cls._store_by_index[cls._index] = context | ||||
|             cls._index += 1 | ||||
|         cls._store_by_name[context.name].append(context) | ||||
|         cls._store_all.append(context) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_all(cls) -> List[ExecutionContext]: | ||||
|     def get_all(cls) -> list[ExecutionContext]: | ||||
|         """ | ||||
|         Return all recorded execution contexts in order of execution. | ||||
|  | ||||
|         Returns: | ||||
|             list[ExecutionContext]: All stored action contexts. | ||||
|         """ | ||||
|         return cls._store_all | ||||
|  | ||||
|     @classmethod | ||||
|     def get_by_name(cls, name: str) -> List[ExecutionContext]: | ||||
|     def get_by_name(cls, name: str) -> list[ExecutionContext]: | ||||
|         """ | ||||
|         Retrieve all executions recorded under a given action name. | ||||
|  | ||||
|         Args: | ||||
|             name (str): The name of the action. | ||||
|  | ||||
|         Returns: | ||||
|             list[ExecutionContext]: Matching contexts, or empty if none found. | ||||
|         """ | ||||
|         return cls._store_by_name.get(name, []) | ||||
|  | ||||
|     @classmethod | ||||
|     def get_latest(cls) -> ExecutionContext: | ||||
|         """ | ||||
|         Return the most recent execution context. | ||||
|  | ||||
|         Returns: | ||||
|             ExecutionContext: The last recorded context. | ||||
|         """ | ||||
|         return cls._store_all[-1] | ||||
|  | ||||
|     @classmethod | ||||
|     def clear(cls): | ||||
|         """ | ||||
|         Clear all stored execution data and reset internal indices. | ||||
|  | ||||
|         This operation is destructive and cannot be undone. | ||||
|         """ | ||||
|         cls._store_by_name.clear() | ||||
|         cls._store_all.clear() | ||||
|         cls._store_by_index.clear() | ||||
|  | ||||
|     @classmethod | ||||
|     def summary(cls): | ||||
|         table = Table(title="📊 Execution History", expand=True, box=box.SIMPLE) | ||||
|     def summary( | ||||
|         cls, | ||||
|         name: str = "", | ||||
|         index: int | None = None, | ||||
|         result_index: int | None = None, | ||||
|         clear: bool = False, | ||||
|         last_result: bool = False, | ||||
|         status: Literal["all", "success", "error"] = "all", | ||||
|     ): | ||||
|         """ | ||||
|         Display a formatted Rich table of recorded executions. | ||||
|  | ||||
|         Supports filtering by action name, index, or execution status. | ||||
|         Can optionally show only the last result or a specific indexed result. | ||||
|         Also supports clearing the registry interactively. | ||||
|  | ||||
|         Args: | ||||
|             name (str): Filter by action name. | ||||
|             index (int | None): Filter by specific execution index. | ||||
|             result_index (int | None): Print result (or traceback) of a specific index. | ||||
|             clear (bool): If True, clears the registry and exits. | ||||
|             last_result (bool): If True, prints only the most recent result. | ||||
|             status (Literal): One of "all", "success", or "error" to filter displayed rows. | ||||
|         """ | ||||
|         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 not ctx.action.ignore_in_history: | ||||
|                     cls._console.print(f"{ctx.signature}:") | ||||
|                     if ctx.traceback: | ||||
|                         cls._console.print(ctx.traceback) | ||||
|                     else: | ||||
|                         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.traceback: | ||||
|                 cls._console.print(result_context.traceback) | ||||
|             else: | ||||
|                 cls._console.print(result_context.result) | ||||
|             return | ||||
|  | ||||
|         if name: | ||||
|             contexts = cls.get_by_name(name) | ||||
|             if not contexts: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No executions found for action '{name}'." | ||||
|                 ) | ||||
|                 return | ||||
|             title = f"📊 Execution History for '{contexts[0].name}'" | ||||
|         elif index is not None and index >= 0: | ||||
|             try: | ||||
|                 contexts = [cls._store_by_index[index]] | ||||
|                 print(contexts) | ||||
|             except KeyError: | ||||
|                 cls._console.print( | ||||
|                     f"[{OneColors.DARK_RED}]❌ No execution found for index {index}." | ||||
|                 ) | ||||
|                 return | ||||
|             title = f"📊 Execution History for Index {index}" | ||||
|         else: | ||||
|             contexts = cls.get_all() | ||||
|             title = "📊 Execution History" | ||||
|  | ||||
|         table = Table(title=title, expand=True, box=box.SIMPLE) | ||||
|  | ||||
|         table.add_column("Index", justify="right", style="dim") | ||||
|         table.add_column("Name", style="bold cyan") | ||||
|         table.add_column("Start", justify="right", style="dim") | ||||
|         table.add_column("End", justify="right", style="dim") | ||||
| @@ -109,7 +248,7 @@ class ExecutionRegistry: | ||||
|         table.add_column("Status", style="bold") | ||||
|         table.add_column("Result / Exception", overflow="fold") | ||||
|  | ||||
|         for ctx in cls.get_all(): | ||||
|         for ctx in contexts: | ||||
|             start = ( | ||||
|                 datetime.fromtimestamp(ctx.start_time).strftime("%H:%M:%S") | ||||
|                 if ctx.start_time | ||||
| @@ -122,15 +261,19 @@ class ExecutionRegistry: | ||||
|             ) | ||||
|             duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a" | ||||
|  | ||||
|             if ctx.exception: | ||||
|                 status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 result = repr(ctx.exception) | ||||
|             if ctx.exception and status.lower() in ["all", "error"]: | ||||
|                 final_status = f"[{OneColors.DARK_RED}]❌ Error" | ||||
|                 final_result = repr(ctx.exception) | ||||
|             elif status.lower() in ["all", "success"]: | ||||
|                 final_status = f"[{OneColors.GREEN}]✅ Success" | ||||
|                 final_result = repr(ctx.result) | ||||
|                 if len(final_result) > 50: | ||||
|                     final_result = f"{final_result[:50]}..." | ||||
|             else: | ||||
|                 status = f"[{OneColors.GREEN}]✅ Success" | ||||
|                 result = repr(ctx.result) | ||||
|                 if len(result) > 1000: | ||||
|                     result = f"{result[:1000]}..." | ||||
|                 continue | ||||
|  | ||||
|             table.add_row(ctx.name, start, end, duration, status, result) | ||||
|             table.add_row( | ||||
|                 str(ctx.index), ctx.name, start, end, duration, final_status, final_result | ||||
|             ) | ||||
|  | ||||
|         cls._console.print(table) | ||||
|   | ||||
							
								
								
									
										687
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										687
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,10 +1,26 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hook_manager.py""" | ||||
| """ | ||||
| Defines the `HookManager` and `HookType` used in the Falyx CLI framework to manage | ||||
| execution lifecycle hooks around actions and commands. | ||||
|  | ||||
| The hook system enables structured callbacks for important stages in a Falyx action's | ||||
| execution, such as before execution, after success, upon error, and teardown. These | ||||
| can be used for logging, side effects, diagnostics, metrics, and rollback logic. | ||||
|  | ||||
| Key Components: | ||||
| - HookType: Enum categorizing supported hook lifecycle stages | ||||
| - HookManager: Core class for registering and invoking hooks during action execution | ||||
| - Hook: Union of sync and async callables accepting an `ExecutionContext` | ||||
|  | ||||
| Usage: | ||||
|     hooks = HookManager() | ||||
|     hooks.register(HookType.BEFORE, log_before) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import inspect | ||||
| from enum import Enum | ||||
| from typing import Awaitable, Callable, Dict, List, Optional, Union | ||||
| from typing import Awaitable, Callable, Union | ||||
|  | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.logger import logger | ||||
| @@ -15,7 +31,27 @@ Hook = Union[ | ||||
|  | ||||
|  | ||||
| class HookType(Enum): | ||||
|     """Enum for hook types to categorize the hooks.""" | ||||
|     """ | ||||
|     Enum for supported hook lifecycle phases in Falyx. | ||||
|  | ||||
|     HookType is used to classify lifecycle events that can be intercepted | ||||
|     with user-defined callbacks. | ||||
|  | ||||
|     Members: | ||||
|         BEFORE: Run before the action is invoked. | ||||
|         ON_SUCCESS: Run after successful completion. | ||||
|         ON_ERROR: Run when an exception occurs. | ||||
|         AFTER: Run after success or failure (always runs). | ||||
|         ON_TEARDOWN: Run at the very end, for resource cleanup. | ||||
|  | ||||
|     Aliases: | ||||
|         "success" → "on_success" | ||||
|         "error" → "on_error" | ||||
|         "teardown" → "on_teardown" | ||||
|  | ||||
|     Example: | ||||
|         HookType("error") → HookType.ON_ERROR | ||||
|     """ | ||||
|  | ||||
|     BEFORE = "before" | ||||
|     ON_SUCCESS = "on_success" | ||||
| @@ -24,29 +60,80 @@ class HookType(Enum): | ||||
|     ON_TEARDOWN = "on_teardown" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> List[HookType]: | ||||
|     def choices(cls) -> list[HookType]: | ||||
|         """Return a list of all hook type choices.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "success": "on_success", | ||||
|             "error": "on_error", | ||||
|             "teardown": "on_teardown", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> HookType: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().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 {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the hook type.""" | ||||
|         return self.value | ||||
|  | ||||
|  | ||||
| class HookManager: | ||||
|     """HookManager""" | ||||
|     """ | ||||
|     Manages lifecycle hooks for a command or action. | ||||
|  | ||||
|     `HookManager` tracks user-defined callbacks to be run at key points in a command's | ||||
|     lifecycle: before execution, on success, on error, after completion, and during | ||||
|     teardown. Both sync and async hooks are supported. | ||||
|  | ||||
|     Methods: | ||||
|         register(hook_type, hook): Register a callable for a given HookType. | ||||
|         clear(hook_type): Remove hooks for one or all lifecycle stages. | ||||
|         trigger(hook_type, context): Execute all hooks of a given type. | ||||
|  | ||||
|     Example: | ||||
|         hooks = HookManager() | ||||
|         hooks.register(HookType.BEFORE, my_logger) | ||||
|     """ | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         self._hooks: Dict[HookType, List[Hook]] = { | ||||
|         self._hooks: dict[HookType, list[Hook]] = { | ||||
|             hook_type: [] for hook_type in HookType | ||||
|         } | ||||
|  | ||||
|     def register(self, hook_type: HookType, hook: Hook): | ||||
|         if hook_type not in HookType: | ||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") | ||||
|     def register(self, hook_type: HookType | str, hook: Hook): | ||||
|         """ | ||||
|         Register a new hook for a given lifecycle phase. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType | str): The hook category (e.g. "before", "on_success"). | ||||
|             hook (Callable): The hook function to register. | ||||
|  | ||||
|         Raises: | ||||
|             ValueError: If the hook type is invalid. | ||||
|         """ | ||||
|         hook_type = HookType(hook_type) | ||||
|         self._hooks[hook_type].append(hook) | ||||
|  | ||||
|     def clear(self, hook_type: Optional[HookType] = None): | ||||
|     def clear(self, hook_type: HookType | None = None): | ||||
|         """ | ||||
|         Clear registered hooks for one or all hook types. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType | None): If None, clears all hooks. | ||||
|         """ | ||||
|         if hook_type: | ||||
|             self._hooks[hook_type] = [] | ||||
|         else: | ||||
| @@ -54,6 +141,17 @@ class HookManager: | ||||
|                 self._hooks[ht] = [] | ||||
|  | ||||
|     async def trigger(self, hook_type: HookType, context: ExecutionContext): | ||||
|         """ | ||||
|         Invoke all hooks registered for a given lifecycle phase. | ||||
|  | ||||
|         Args: | ||||
|             hook_type (HookType): The lifecycle phase to trigger. | ||||
|             context (ExecutionContext): The execution context passed to each hook. | ||||
|  | ||||
|         Raises: | ||||
|             Exception: Re-raises the original context.exception if a hook fails during | ||||
|                        ON_ERROR. Other hook exceptions are logged and skipped. | ||||
|         """ | ||||
|         if hook_type not in self._hooks: | ||||
|             raise ValueError(f"Unsupported hook type: {hook_type}") | ||||
|         for hook in self._hooks[hook_type]: | ||||
| @@ -64,13 +162,12 @@ class HookManager: | ||||
|                     hook(context) | ||||
|             except Exception as hook_error: | ||||
|                 logger.warning( | ||||
|                     "⚠️ Hook '%s' raised an exception during '%s' for '%s': %s", | ||||
|                     "[Hook:%s] raised an exception during '%s' for '%s': %s", | ||||
|                     hook.__name__, | ||||
|                     hook_type, | ||||
|                     context.name, | ||||
|                     hook_error, | ||||
|                 ) | ||||
|  | ||||
|                 if hook_type == HookType.ON_ERROR: | ||||
|                     assert isinstance( | ||||
|                         context.exception, Exception | ||||
|   | ||||
| @@ -1,5 +1,32 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """hooks.py""" | ||||
| """ | ||||
| Defines reusable lifecycle hooks for Falyx Actions and Commands. | ||||
|  | ||||
| This module includes: | ||||
| - `spinner_before_hook`: Automatically starts a spinner before an action runs. | ||||
| - `spinner_teardown_hook`: Stops and clears the spinner after the action completes. | ||||
| - `ResultReporter`: A success hook that displays a formatted result with duration. | ||||
| - `CircuitBreaker`: A failure-aware hook manager that prevents repeated execution | ||||
|   after a configurable number of failures. | ||||
|  | ||||
| These hooks can be registered on `HookManager` instances via lifecycle stages | ||||
| (`before`, `on_error`, `after`, etc.) to enhance resiliency and observability. | ||||
|  | ||||
| Intended for use with: | ||||
| - Actions that require user feedback during long-running operations. | ||||
| - Retryable or unstable actions | ||||
| - Interactive CLI feedback | ||||
| - Safety checks prior to execution | ||||
|  | ||||
| Example usage: | ||||
|     breaker = CircuitBreaker(max_failures=3) | ||||
|     hooks.register(HookType.BEFORE, breaker.before_hook) | ||||
|     hooks.register(HookType.ON_ERROR, breaker.error_hook) | ||||
|     hooks.register(HookType.AFTER, breaker.after_hook) | ||||
|  | ||||
|     reporter = ResultReporter() | ||||
|     hooks.register(HookType.ON_SUCCESS, reporter.report) | ||||
| """ | ||||
| import time | ||||
| from typing import Any, Callable | ||||
|  | ||||
| @@ -9,6 +36,38 @@ from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| async def spinner_before_hook(context: ExecutionContext): | ||||
|     """Adds a spinner before the action starts.""" | ||||
|     cmd = context.action | ||||
|     if cmd.options_manager is None: | ||||
|         return | ||||
|     sm = context.action.options_manager.spinners | ||||
|     if hasattr(cmd, "name"): | ||||
|         cmd_name = cmd.name | ||||
|     else: | ||||
|         cmd_name = cmd.key | ||||
|     await sm.add( | ||||
|         cmd_name, | ||||
|         cmd.spinner_message, | ||||
|         cmd.spinner_type, | ||||
|         cmd.spinner_style, | ||||
|         cmd.spinner_speed, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| async def spinner_teardown_hook(context: ExecutionContext): | ||||
|     """Removes the spinner after the action finishes (success or failure).""" | ||||
|     cmd = context.action | ||||
|     if cmd.options_manager is None: | ||||
|         return | ||||
|     if hasattr(cmd, "name"): | ||||
|         cmd_name = cmd.name | ||||
|     else: | ||||
|         cmd_name = cmd.key | ||||
|     sm = context.action.options_manager.spinners | ||||
|     await sm.remove(cmd_name) | ||||
|  | ||||
|  | ||||
| class ResultReporter: | ||||
|     """Reports the success of an action.""" | ||||
|  | ||||
| @@ -56,10 +115,10 @@ class CircuitBreaker: | ||||
|         if self.open_until: | ||||
|             if time.time() < self.open_until: | ||||
|                 raise CircuitBreakerOpen( | ||||
|                     f"🔴 Circuit open for '{name}' until {time.ctime(self.open_until)}." | ||||
|                     f"Circuit open for '{name}' until {time.ctime(self.open_until)}." | ||||
|                 ) | ||||
|             else: | ||||
|                 logger.info("🟢 Circuit closed again for '%s'.") | ||||
|                 logger.info("Circuit closed again for '%s'.") | ||||
|                 self.failures = 0 | ||||
|                 self.open_until = None | ||||
|  | ||||
| @@ -67,7 +126,7 @@ class CircuitBreaker: | ||||
|         name = context.name | ||||
|         self.failures += 1 | ||||
|         logger.warning( | ||||
|             "⚠️ CircuitBreaker: '%s' failure %s/%s.", | ||||
|             "CircuitBreaker: '%s' failure %s/%s.", | ||||
|             name, | ||||
|             self.failures, | ||||
|             self.max_failures, | ||||
| @@ -75,7 +134,7 @@ class CircuitBreaker: | ||||
|         if self.failures >= self.max_failures: | ||||
|             self.open_until = time.time() + self.reset_timeout | ||||
|             logger.error( | ||||
|                 "🔴 Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) | ||||
|                 "Circuit opened for '%s' until %s.", name, time.ctime(self.open_until) | ||||
|             ) | ||||
|  | ||||
|     def after_hook(self, _: ExecutionContext): | ||||
| @@ -87,4 +146,4 @@ class CircuitBreaker: | ||||
|     def reset(self): | ||||
|         self.failures = 0 | ||||
|         self.open_until = None | ||||
|         logger.info("🔄 Circuit reset.") | ||||
|         logger.info("Circuit reset.") | ||||
|   | ||||
| @@ -1,8 +1,26 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """init.py""" | ||||
| """ | ||||
| Project and global initializer for Falyx CLI environments. | ||||
|  | ||||
| This module defines functions to bootstrap a new Falyx-based CLI project or | ||||
| create a global user-level configuration in `~/.config/falyx`. | ||||
|  | ||||
| Functions: | ||||
| - `init_project(name: str)`: Creates a new CLI project folder with `tasks.py` | ||||
|   and `falyx.yaml` using example actions and config structure. | ||||
| - `init_global()`: Creates a shared config in the user's home directory for | ||||
|   defining reusable or always-available CLI commands. | ||||
|  | ||||
| Generated files include: | ||||
| - `tasks.py`: Python module with `Action`, `ChainedAction`, and async examples | ||||
| - `falyx.yaml`: YAML config with command definitions for CLI entry points | ||||
|  | ||||
| Used by: | ||||
| - The `falyx init` and `falyx init --global` commands | ||||
| """ | ||||
| from pathlib import Path | ||||
|  | ||||
| from rich.console import Console | ||||
| from falyx.console import console | ||||
|  | ||||
| TEMPLATE_TASKS = """\ | ||||
| # This file is used by falyx.yaml to define CLI actions. | ||||
| @@ -11,9 +29,7 @@ TEMPLATE_TASKS = """\ | ||||
| import asyncio | ||||
| import json | ||||
|  | ||||
| from falyx.action import Action, ChainedAction | ||||
| from falyx.io_action import ShellAction | ||||
| from falyx.selection_action import SelectionAction | ||||
| from falyx.action import Action, ChainedAction, ShellAction, SelectionAction | ||||
|  | ||||
|  | ||||
| post_ids = ["1", "2", "3", "4", "5"] | ||||
| @@ -100,10 +116,8 @@ commands: | ||||
|     aliases: [clean, cleanup] | ||||
| """ | ||||
|  | ||||
| console = Console(color_system="auto") | ||||
|  | ||||
|  | ||||
| def init_project(name: str = ".") -> None: | ||||
| def init_project(name: str) -> None: | ||||
|     target = Path(name).resolve() | ||||
|     target.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """logger.py""" | ||||
| """Global logger instance for Falyx CLI applications.""" | ||||
| import logging | ||||
|  | ||||
| logger = logging.getLogger("falyx") | ||||
| logger: logging.Logger = logging.getLogger("falyx") | ||||
|   | ||||
| @@ -1,8 +1,27 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `MenuOption` and `MenuOptionMap`, core components used to construct | ||||
| interactive menus within Falyx Actions such as `MenuAction` and `PromptMenuAction`. | ||||
|  | ||||
| Each `MenuOption` represents a single actionable choice with a description, | ||||
| styling, and a bound `BaseAction`. `MenuOptionMap` manages collections of these | ||||
| options, including support for reserved keys like `B` (Back) and `X` (Exit), which | ||||
| can trigger navigation signals when selected. | ||||
|  | ||||
| These constructs enable declarative and reusable menu definitions in both code and config. | ||||
|  | ||||
| Key Components: | ||||
| - MenuOption: A user-facing label and action binding | ||||
| - MenuOptionMap: A key-aware container for menu options, with reserved entry support | ||||
| """ | ||||
|  | ||||
| from __future__ import annotations | ||||
|  | ||||
| from dataclasses import dataclass | ||||
|  | ||||
| from falyx.action import BaseAction | ||||
| from prompt_toolkit.formatted_text import FormattedText | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.signals import BackSignal, QuitSignal | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import CaseInsensitiveDict | ||||
| @@ -10,7 +29,25 @@ from falyx.utils import CaseInsensitiveDict | ||||
|  | ||||
| @dataclass | ||||
| class MenuOption: | ||||
|     """Represents a single menu option with a description and an action to execute.""" | ||||
|     """ | ||||
|     Represents a single menu entry, including its label and associated action. | ||||
|  | ||||
|     Used in conjunction with `MenuOptionMap` to define interactive command menus. | ||||
|     Each `MenuOption` contains a description (shown to the user), a `BaseAction` | ||||
|     to execute when selected, and an optional Rich-compatible style. | ||||
|  | ||||
|     Attributes: | ||||
|         description (str): The label shown next to the menu key. | ||||
|         action (BaseAction): The action to invoke when selected. | ||||
|         style (str): A Rich-compatible color/style string for UI display. | ||||
|  | ||||
|     Methods: | ||||
|         render(key): Returns a Rich-formatted string for menu display. | ||||
|         render_prompt(key): Returns a `FormattedText` object for use in prompt placeholders. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `description` is not a string or `action` is not a `BaseAction`. | ||||
|     """ | ||||
|  | ||||
|     description: str | ||||
|     action: BaseAction | ||||
| @@ -26,14 +63,39 @@ class MenuOption: | ||||
|         """Render the menu option for display.""" | ||||
|         return f"[{OneColors.WHITE}][{key}][/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|     def render_prompt(self, key: str) -> FormattedText: | ||||
|         """Render the menu option for prompt display.""" | ||||
|         return FormattedText( | ||||
|             [(OneColors.WHITE, f"[{key}] "), (self.style, self.description)] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class MenuOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|     Manages menu options including validation, reserved key protection, | ||||
|     and special signal entries like Quit and Back. | ||||
|     A container for storing and managing `MenuOption` objects by key. | ||||
|  | ||||
|     `MenuOptionMap` is used to define the set of available choices in a | ||||
|     Falyx menu. Keys are case-insensitive and mapped to `MenuOption` instances. | ||||
|     The map supports special reserved keys—`B` for Back and `X` for Exit—unless | ||||
|     explicitly disabled via `allow_reserved=False`. | ||||
|  | ||||
|     This class enforces strict typing of menu options and prevents accidental | ||||
|     overwrites of reserved keys. | ||||
|  | ||||
|     Args: | ||||
|         options (dict[str, MenuOption] | None): Initial options to populate the menu. | ||||
|         allow_reserved (bool): If True, allows overriding reserved keys. | ||||
|  | ||||
|     Methods: | ||||
|         items(include_reserved): Returns an iterable of menu options, | ||||
|                                  optionally filtering out reserved keys. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If non-`MenuOption` values are assigned. | ||||
|         ValueError: If attempting to use or delete a reserved key without permission. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS = {"Q", "B"} | ||||
|     RESERVED_KEYS = {"B", "X"} | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -49,14 +111,14 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|     def _inject_reserved_defaults(self): | ||||
|         from falyx.action import SignalAction | ||||
|  | ||||
|         self._add_reserved( | ||||
|             "Q", | ||||
|             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), | ||||
|         ) | ||||
|         self._add_reserved( | ||||
|             "B", | ||||
|             MenuOption("Back", SignalAction("Back", BackSignal()), OneColors.DARK_YELLOW), | ||||
|         ) | ||||
|         self._add_reserved( | ||||
|             "X", | ||||
|             MenuOption("Exit", SignalAction("Quit", QuitSignal()), OneColors.DARK_RED), | ||||
|         ) | ||||
|  | ||||
|     def _add_reserved(self, key: str, option: MenuOption) -> None: | ||||
|         """Add a reserved key, bypassing validation.""" | ||||
| @@ -78,8 +140,20 @@ class MenuOptionMap(CaseInsensitiveDict): | ||||
|             raise ValueError(f"Cannot delete reserved option '{key}'.") | ||||
|         super().__delitem__(key) | ||||
|  | ||||
|     def update(self, other=None, **kwargs): | ||||
|         """Update the selection options with another dictionary.""" | ||||
|         if other: | ||||
|             for key, option in other.items(): | ||||
|                 if not isinstance(option, MenuOption): | ||||
|                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|                 self[key] = option | ||||
|         for key, option in kwargs.items(): | ||||
|             if not isinstance(option, MenuOption): | ||||
|                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|             self[key] = option | ||||
|  | ||||
|     def items(self, include_reserved: bool = True): | ||||
|         for k, v in super().items(): | ||||
|             if not include_reserved and k in self.RESERVED_KEYS: | ||||
|         for key, option in super().items(): | ||||
|             if not include_reserved and key in self.RESERVED_KEYS: | ||||
|                 continue | ||||
|             yield k, v | ||||
|             yield key, option | ||||
|   | ||||
							
								
								
									
										12
									
								
								falyx/mode.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								falyx/mode.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `FalyxMode`, an enum representing the different modes of operation for Falyx. | ||||
| """ | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class FalyxMode(Enum): | ||||
|     MENU = "menu" | ||||
|     RUN = "run" | ||||
|     PREVIEW = "preview" | ||||
|     RUN_ALL = "run-all" | ||||
| @@ -1,18 +1,55 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """options_manager.py""" | ||||
| """ | ||||
| Manages global or scoped CLI options across namespaces for Falyx commands. | ||||
|  | ||||
| The `OptionsManager` provides a centralized interface for retrieving, setting, toggling, | ||||
| and introspecting options defined in `argparse.Namespace` objects. It is used internally | ||||
| by Falyx to pass and resolve runtime flags like `--verbose`, `--force-confirm`, etc. | ||||
|  | ||||
| Each option is stored under a namespace key (e.g., "cli_args", "user_config") to | ||||
| support multiple sources of configuration. | ||||
|  | ||||
| Key Features: | ||||
| - Safe getter/setter for typed option resolution | ||||
| - Toggle support for boolean options (used by bottom bar toggles, etc.) | ||||
| - Callable getter/toggler wrappers for dynamic UI bindings | ||||
| - Namespace merging via `from_namespace` | ||||
|  | ||||
| Typical Usage: | ||||
|     options = OptionsManager() | ||||
|     options.from_namespace(args, namespace_name="cli_args") | ||||
|     if options.get("verbose"): | ||||
|         ... | ||||
|     options.toggle("force_confirm") | ||||
|     value_fn = options.get_value_getter("dry_run") | ||||
|     toggle_fn = options.get_toggle_function("debug") | ||||
|  | ||||
| Used by: | ||||
| - Falyx CLI runtime configuration | ||||
| - Bottom bar toggles | ||||
| - Dynamic flag injection into commands and actions | ||||
| """ | ||||
|  | ||||
| from argparse import Namespace | ||||
| from collections import defaultdict | ||||
| from typing import Any, Callable | ||||
|  | ||||
| from falyx.logger import logger | ||||
| from falyx.spinner_manager import SpinnerManager | ||||
|  | ||||
|  | ||||
| class OptionsManager: | ||||
|     """OptionsManager""" | ||||
|     """ | ||||
|     Manages CLI option state across multiple argparse namespaces. | ||||
|  | ||||
|     Allows dynamic retrieval, setting, toggling, and introspection of command-line | ||||
|     options. Supports named namespaces (e.g., "cli_args") and is used throughout | ||||
|     Falyx for runtime configuration and bottom bar toggle integration. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, namespaces: list[tuple[str, Namespace]] | None = None) -> None: | ||||
|         self.options: defaultdict = defaultdict(Namespace) | ||||
|         self.spinners = SpinnerManager() | ||||
|         if namespaces: | ||||
|             for namespace_name, namespace in namespaces: | ||||
|                 self.from_namespace(namespace, namespace_name) | ||||
|   | ||||
							
								
								
									
										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", | ||||
| ] | ||||
							
								
								
									
										151
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								falyx/parser/argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines the `Argument` dataclass used by `CommandArgumentParser` to represent | ||||
| individual command-line parameters in a structured, introspectable format. | ||||
|  | ||||
| Each `Argument` instance describes one CLI input, including its flags, type, | ||||
| default behavior, action semantics, help text, and optional resolver integration | ||||
| for dynamic evaluation. | ||||
|  | ||||
| Falyx uses this structure to support a declarative CLI design, providing flexible | ||||
| argument parsing with full support for positional and keyword arguments, coercion, | ||||
| completion, and help rendering. | ||||
|  | ||||
| Arguments should be created using `CommandArgumentParser.add_argument()` | ||||
| or defined in YAML configurations, allowing for rich introspection and validation. | ||||
|  | ||||
| Key Attributes: | ||||
| - `flags`: One or more short/long flags (e.g. `-v`, `--verbose`) | ||||
| - `dest`: Internal name used as the key in parsed results | ||||
| - `action`: `ArgumentAction` enum describing behavior (store, count, resolve, etc.) | ||||
| - `type`: Type coercion or callable converter | ||||
| - `default`: Optional fallback value | ||||
| - `choices`: Allowed values, if restricted | ||||
| - `nargs`: Number of expected values (`int`, `'?'`, `'*'`, `'+'`) | ||||
| - `positional`: Whether this argument is positional (no flag) | ||||
| - `resolver`: Optional `BaseAction` to resolve argument value dynamically | ||||
| - `lazy_resolver`: Whether to defer resolution until needed | ||||
| - `suggestions`: Optional completions for interactive shells | ||||
|  | ||||
| Used By: | ||||
| - `CommandArgumentParser` | ||||
| - `Falyx` runtime parsing | ||||
| - Rich-based CLI help generation | ||||
| - Completion and preview suggestions | ||||
| """ | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.parser.argument_action import ArgumentAction | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class Argument: | ||||
|     """ | ||||
|     Represents a command-line argument. | ||||
|  | ||||
|     Attributes: | ||||
|         flags (tuple[str, ...]): Short and long flags for the argument. | ||||
|         dest (str): The destination name for the argument. | ||||
|         action (ArgumentAction): The action to be taken when the argument is encountered. | ||||
|         type (Any): The type of the argument (e.g., str, int, float) or a callable that converts the argument value. | ||||
|         default (Any): The default value if the argument is not provided. | ||||
|         choices (list[str] | None): A list of valid choices for the argument. | ||||
|         required (bool): True if the argument is required, False otherwise. | ||||
|         help (str): Help text for the argument. | ||||
|         nargs (int | str | None): Number of arguments expected. Can be an int, '?', '*', '+', or None. | ||||
|         positional (bool): True if the argument is positional (no leading - or -- in flags), False otherwise. | ||||
|         resolver (BaseAction | None): | ||||
|             An action object that resolves the argument, if applicable. | ||||
|         lazy_resolver (bool): True if the resolver should be called lazily, False otherwise | ||||
|         suggestions (list[str] | None): Optional completions for interactive shells | ||||
|     """ | ||||
|  | ||||
|     flags: tuple[str, ...] | ||||
|     dest: str | ||||
|     action: ArgumentAction = ArgumentAction.STORE | ||||
|     type: Any = str | ||||
|     default: Any = None | ||||
|     choices: list[str] | None = None | ||||
|     required: bool = False | ||||
|     help: str = "" | ||||
|     nargs: int | str | None = None | ||||
|     positional: bool = False | ||||
|     resolver: BaseAction | None = None | ||||
|     lazy_resolver: bool = False | ||||
|     suggestions: list[str] | None = None | ||||
|  | ||||
|     def get_positional_text(self) -> str: | ||||
|         """Get the positional text for the argument.""" | ||||
|         text = "" | ||||
|         if self.positional: | ||||
|             if self.choices: | ||||
|                 text = f"{{{','.join([str(choice) for choice in self.choices])}}}" | ||||
|             else: | ||||
|                 text = self.dest | ||||
|         return text | ||||
|  | ||||
|     def get_choice_text(self) -> str: | ||||
|         """Get the choice text for the argument.""" | ||||
|         choice_text = "" | ||||
|         if self.choices: | ||||
|             choice_text = f"{{{','.join([str(choice) for choice in self.choices])}}}" | ||||
|         elif ( | ||||
|             self.action | ||||
|             in ( | ||||
|                 ArgumentAction.STORE, | ||||
|                 ArgumentAction.APPEND, | ||||
|                 ArgumentAction.EXTEND, | ||||
|                 ArgumentAction.ACTION, | ||||
|             ) | ||||
|             and not self.positional | ||||
|         ): | ||||
|             choice_text = self.dest.upper() | ||||
|         elif self.action in ( | ||||
|             ArgumentAction.STORE, | ||||
|             ArgumentAction.APPEND, | ||||
|             ArgumentAction.EXTEND, | ||||
|             ArgumentAction.ACTION, | ||||
|         ) or isinstance(self.nargs, str): | ||||
|             choice_text = self.dest | ||||
|  | ||||
|         if self.nargs == "?": | ||||
|             choice_text = f"[{choice_text}]" | ||||
|         elif self.nargs == "*": | ||||
|             choice_text = f"[{choice_text} ...]" | ||||
|         elif self.nargs == "+": | ||||
|             choice_text = f"{choice_text} [{choice_text} ...]" | ||||
|         return choice_text | ||||
|  | ||||
|     def __eq__(self, other: object) -> bool: | ||||
|         if not isinstance(other, Argument): | ||||
|             return False | ||||
|         return ( | ||||
|             self.flags == other.flags | ||||
|             and self.dest == other.dest | ||||
|             and self.action == other.action | ||||
|             and self.type == other.type | ||||
|             and self.choices == other.choices | ||||
|             and self.required == other.required | ||||
|             and self.nargs == other.nargs | ||||
|             and self.positional == other.positional | ||||
|             and self.default == other.default | ||||
|             and self.help == other.help | ||||
|         ) | ||||
|  | ||||
|     def __hash__(self) -> int: | ||||
|         return hash( | ||||
|             ( | ||||
|                 tuple(self.flags), | ||||
|                 self.dest, | ||||
|                 self.action, | ||||
|                 self.type, | ||||
|                 tuple(self.choices or []), | ||||
|                 self.required, | ||||
|                 self.nargs, | ||||
|                 self.positional, | ||||
|                 self.default, | ||||
|                 self.help, | ||||
|             ) | ||||
|         ) | ||||
							
								
								
									
										94
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								falyx/parser/argument_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Defines `ArgumentAction`, an enum used to standardize the behavior of CLI arguments | ||||
| defined within Falyx command configurations. | ||||
|  | ||||
| Each member of this enum maps to a valid `argparse` like actions or Falyx-specific | ||||
| behavior used during command argument parsing. This allows declarative configuration | ||||
| of argument behavior when building CLI commands via `CommandArgumentParser`. | ||||
|  | ||||
| Supports alias coercion for shorthand or config-friendly values, and provides | ||||
| a consistent interface for downstream argument handling logic. | ||||
|  | ||||
| Exports: | ||||
|     - ArgumentAction: Enum of allowed actions for command arguments. | ||||
|  | ||||
| Example: | ||||
|     ArgumentAction("store_true") → ArgumentAction.STORE_TRUE | ||||
|     ArgumentAction("true")       → ArgumentAction.STORE_TRUE (via alias) | ||||
|     ArgumentAction("optional")   → ArgumentAction.STORE_BOOL_OPTIONAL | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
|  | ||||
| class ArgumentAction(Enum): | ||||
|     """ | ||||
|     Defines the action to be taken when the argument is encountered. | ||||
|  | ||||
|     This enum mirrors the core behavior of Python's `argparse` actions, with a few | ||||
|     Falyx-specific extensions. It is used when defining command-line arguments for | ||||
|     `CommandArgumentParser` or YAML-based argument definitions. | ||||
|  | ||||
|     Members: | ||||
|         ACTION: Invoke a callable as the argument handler (Falyx extension). | ||||
|         STORE: Store the provided value (default). | ||||
|         STORE_TRUE: Store `True` if the flag is present. | ||||
|         STORE_FALSE: Store `False` if the flag is present. | ||||
|         STORE_BOOL_OPTIONAL: Accept an optional bool (e.g., `--debug` or `--no-debug`). | ||||
|         APPEND: Append the value to a list. | ||||
|         EXTEND: Extend a list with multiple values. | ||||
|         COUNT: Count the number of occurrences. | ||||
|         HELP: Display help and exit. | ||||
|         TLDR: Display brief examples and exit. | ||||
|  | ||||
|     Aliases: | ||||
|         - "true" → "store_true" | ||||
|         - "false" → "store_false" | ||||
|         - "optional" → "store_bool_optional" | ||||
|  | ||||
|     Example: | ||||
|         ArgumentAction("true") → ArgumentAction.STORE_TRUE | ||||
|     """ | ||||
|  | ||||
|     ACTION = "action" | ||||
|     STORE = "store" | ||||
|     STORE_TRUE = "store_true" | ||||
|     STORE_FALSE = "store_false" | ||||
|     STORE_BOOL_OPTIONAL = "store_bool_optional" | ||||
|     APPEND = "append" | ||||
|     EXTEND = "extend" | ||||
|     COUNT = "count" | ||||
|     HELP = "help" | ||||
|     TLDR = "tldr" | ||||
|  | ||||
|     @classmethod | ||||
|     def choices(cls) -> list[ArgumentAction]: | ||||
|         """Return a list of all argument actions.""" | ||||
|         return list(cls) | ||||
|  | ||||
|     @classmethod | ||||
|     def _get_alias(cls, value: str) -> str: | ||||
|         aliases = { | ||||
|             "optional": "store_bool_optional", | ||||
|             "true": "store_true", | ||||
|             "false": "store_false", | ||||
|         } | ||||
|         return aliases.get(value, value) | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value: object) -> ArgumentAction: | ||||
|         if not isinstance(value, str): | ||||
|             raise ValueError(f"Invalid {cls.__name__}: {value!r}") | ||||
|         normalized = value.strip().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 {cls.__name__}: '{value}'. Must be one of: {valid}") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         """Return the string representation of the argument action.""" | ||||
|         return self.value | ||||
							
								
								
									
										1413
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1413
									
								
								falyx/parser/command_argument_parser.py
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										52
									
								
								falyx/parser/parser_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								falyx/parser/parser_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Type utilities and argument state models for Falyx's custom CLI argument parser. | ||||
|  | ||||
| This module provides specialized helpers and data structures used by | ||||
| the `CommandArgumentParser` to handle non-standard parsing behavior. | ||||
|  | ||||
| Contents: | ||||
| - `true_none` / `false_none`: Type coercion utilities that allow tri-state boolean | ||||
|   semantics (True, False, None). These are especially useful for supporting | ||||
|   `--flag` / `--no-flag` optional booleans in CLI arguments. | ||||
| - `ArgumentState`: Tracks whether an `Argument` has been consumed during parsing. | ||||
| - `TLDRExample`: A structured example for showing usage snippets and descriptions, | ||||
|    used in TLDR views. | ||||
|  | ||||
| These tools support richer expressiveness and user-friendly ergonomics in | ||||
| Falyx's declarative command-line interfaces. | ||||
| """ | ||||
| from dataclasses import dataclass | ||||
| from typing import Any | ||||
|  | ||||
| from falyx.parser.argument import Argument | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class ArgumentState: | ||||
|     """Tracks an argument and whether it has been consumed.""" | ||||
|  | ||||
|     arg: Argument | ||||
|     consumed: bool = False | ||||
|  | ||||
|  | ||||
| @dataclass(frozen=True) | ||||
| class TLDRExample: | ||||
|     """Represents a usage example for TLDR output.""" | ||||
|  | ||||
|     usage: str | ||||
|     description: str | ||||
|  | ||||
|  | ||||
| def true_none(value: Any) -> bool | None: | ||||
|     """Return True if value is not None, else None.""" | ||||
|     if value is None: | ||||
|         return None | ||||
|     return True | ||||
|  | ||||
|  | ||||
| def false_none(value: Any) -> bool | None: | ||||
|     """Return False if value is not None, else None.""" | ||||
|     if value is None: | ||||
|         return None | ||||
|     return False | ||||
							
								
								
									
										396
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										396
									
								
								falyx/parser/parsers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,396 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides the argument parser infrastructure for the Falyx CLI. | ||||
|  | ||||
| This module defines the `FalyxParsers` dataclass and related utilities for building | ||||
| structured CLI interfaces with argparse. It supports top-level CLI commands like | ||||
| `run`, `run-all`, `preview`, `list`, and `version`, and integrates seamlessly with | ||||
| registered `Command` objects for dynamic help, usage generation, and argument handling. | ||||
|  | ||||
| Key Components: | ||||
| - `FalyxParsers`: Container for all CLI subparsers. | ||||
| - `get_arg_parsers()`: Factory for generating full parser suite. | ||||
| - `get_root_parser()`: Creates the root-level CLI parser with global options. | ||||
| - `get_subparsers()`: Helper to attach subcommand parsers to the root parser. | ||||
|  | ||||
| Used internally by the Falyx CLI `run()` entry point to parse arguments and route | ||||
| execution across commands and workflows. | ||||
| """ | ||||
|  | ||||
| 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 ?' to show available commands.", | ||||
|     parents: Sequence[ArgumentParser] | None = None, | ||||
|     prefix_chars: str = "-", | ||||
|     fromfile_prefix_chars: str | None = None, | ||||
|     argument_default: Any = None, | ||||
|     conflict_handler: str = "error", | ||||
|     add_help: bool = True, | ||||
|     allow_abbrev: bool = True, | ||||
|     exit_on_error: bool = True, | ||||
| ) -> ArgumentParser: | ||||
|     """ | ||||
|     Construct the root-level ArgumentParser for the Falyx CLI. | ||||
|  | ||||
|     This parser handles global arguments shared across subcommands and can serve | ||||
|     as the base parser for the Falyx CLI or standalone applications. It includes | ||||
|     options for verbosity, debug logging, and version output. | ||||
|  | ||||
|     Args: | ||||
|         prog (str | None): Name of the program (e.g., 'falyx'). | ||||
|         usage (str | None): Optional custom usage string. | ||||
|         description (str | None): Description shown in the CLI help. | ||||
|         epilog (str | None): Message displayed at the end of help output. | ||||
|         parents (Sequence[ArgumentParser] | None): Optional parent parsers. | ||||
|         prefix_chars (str): Characters to denote optional arguments (default: "-"). | ||||
|         fromfile_prefix_chars (str | None): Prefix to indicate argument file input. | ||||
|         argument_default (Any): Global default value for arguments. | ||||
|         conflict_handler (str): Strategy to resolve conflicting argument names. | ||||
|         add_help (bool): Whether to include help (`-h/--help`) in this parser. | ||||
|         allow_abbrev (bool): Allow abbreviated long options. | ||||
|         exit_on_error (bool): Exit immediately on error or raise an exception. | ||||
|  | ||||
|     Returns: | ||||
|         ArgumentParser: The root parser with global options attached. | ||||
|  | ||||
|     Notes: | ||||
|         ``` | ||||
|         Includes the following arguments: | ||||
|             --never-prompt       : Run in non-interactive mode. | ||||
|             -v / --verbose       : Enable debug logging. | ||||
|             --debug-hooks        : Enable hook lifecycle debug logs. | ||||
|             --version            : Print the Falyx version. | ||||
|         ``` | ||||
|     """ | ||||
|     parser = ArgumentParser( | ||||
|         prog=prog, | ||||
|         usage=usage, | ||||
|         description=description, | ||||
|         epilog=epilog, | ||||
|         parents=parents if parents else [], | ||||
|         prefix_chars=prefix_chars, | ||||
|         fromfile_prefix_chars=fromfile_prefix_chars, | ||||
|         argument_default=argument_default, | ||||
|         conflict_handler=conflict_handler, | ||||
|         add_help=add_help, | ||||
|         allow_abbrev=allow_abbrev, | ||||
|         exit_on_error=exit_on_error, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--never-prompt", | ||||
|         action="store_true", | ||||
|         help="Run in non-interactive mode with all prompts bypassed.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-v", "--verbose", action="store_true", help=f"Enable debug logging for {prog}." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--debug-hooks", | ||||
|         action="store_true", | ||||
|         help="Enable default lifecycle debug logging", | ||||
|     ) | ||||
|     parser.add_argument("--version", action="store_true", help=f"Show {prog} version") | ||||
|     return parser | ||||
|  | ||||
|  | ||||
| def get_subparsers( | ||||
|     parser: ArgumentParser, | ||||
|     title: str = "Falyx Commands", | ||||
|     description: str | None = "Available commands for the Falyx CLI.", | ||||
| ) -> _SubParsersAction: | ||||
|     """ | ||||
|     Create and return a subparsers object for registering Falyx CLI subcommands. | ||||
|  | ||||
|     This function adds a `subparsers` block to the given root parser, enabling | ||||
|     structured subcommands such as `run`, `run-all`, `preview`, etc. | ||||
|  | ||||
|     Args: | ||||
|         parser (ArgumentParser): The root parser to attach the subparsers to. | ||||
|         title (str): Title used in help output to group subcommands. | ||||
|         description (str | None): Optional text describing the group of subcommands. | ||||
|  | ||||
|     Returns: | ||||
|         _SubParsersAction: The subparsers object that can be used to add new CLI subcommands. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `parser` is not an instance of `ArgumentParser`. | ||||
|  | ||||
|     Example: | ||||
|         ```python | ||||
|         >>> parser = get_root_parser() | ||||
|         >>> subparsers = get_subparsers(parser, title="Available Commands") | ||||
|         >>> subparsers.add_parser("run", help="Run a Falyx command") | ||||
|         ``` | ||||
|     """ | ||||
|     if not isinstance(parser, ArgumentParser): | ||||
|         raise TypeError("parser must be an instance of ArgumentParser") | ||||
|     subparsers = parser.add_subparsers( | ||||
|         title=title, | ||||
|         description=description, | ||||
|         dest="command", | ||||
|     ) | ||||
|     return subparsers | ||||
|  | ||||
|  | ||||
| def get_arg_parsers( | ||||
|     prog: str | None = "falyx", | ||||
|     usage: str | None = None, | ||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||
|     epilog: ( | ||||
|         str | None | ||||
|     ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", | ||||
|     parents: Sequence[ArgumentParser] | None = None, | ||||
|     prefix_chars: str = "-", | ||||
|     fromfile_prefix_chars: str | None = None, | ||||
|     argument_default: Any = None, | ||||
|     conflict_handler: str = "error", | ||||
|     add_help: bool = True, | ||||
|     allow_abbrev: bool = True, | ||||
|     exit_on_error: bool = True, | ||||
|     commands: dict[str, Command] | None = None, | ||||
|     root_parser: ArgumentParser | None = None, | ||||
|     subparsers: _SubParsersAction | None = None, | ||||
| ) -> FalyxParsers: | ||||
|     """ | ||||
|     Create and return the full suite of argument parsers used by the Falyx CLI. | ||||
|  | ||||
|     This function builds the root parser and all subcommand parsers used for structured | ||||
|     CLI workflows in Falyx. It supports standard subcommands including `run`, `run-all`, | ||||
|     `preview`, `list`, and `version`, and integrates with registered `Command` objects | ||||
|     to populate dynamic help and usage documentation. | ||||
|  | ||||
|     Args: | ||||
|         prog (str | None): Program name to display in help and usage messages. | ||||
|         usage (str | None): Optional usage message to override the default. | ||||
|         description (str | None): Description for the CLI root parser. | ||||
|         epilog (str | None): Epilog message shown after the help text. | ||||
|         parents (Sequence[ArgumentParser] | None): Optional parent parsers. | ||||
|         prefix_chars (str): Characters that prefix optional arguments. | ||||
|         fromfile_prefix_chars (str | None): Prefix character for reading args from file. | ||||
|         argument_default (Any): Default value for arguments if not specified. | ||||
|         conflict_handler (str): Strategy for resolving conflicting arguments. | ||||
|         add_help (bool): Whether to add the `-h/--help` option to the root parser. | ||||
|         allow_abbrev (bool): Whether to allow abbreviated long options. | ||||
|         exit_on_error (bool): Whether the parser exits on error or raises. | ||||
|         commands (dict[str, Command] | None): Optional dictionary of registered commands | ||||
|             to populate help and subcommand descriptions dynamically. | ||||
|         root_parser (ArgumentParser | None): Custom root parser to use instead of building one. | ||||
|         subparsers (_SubParsersAction | None): Optional existing subparser object to extend. | ||||
|  | ||||
|     Returns: | ||||
|         FalyxParsers: A structured container of all parsers, including `run`, `run-all`, | ||||
|                       `preview`, `list`, `version`, and the root parser. | ||||
|  | ||||
|     Raises: | ||||
|         TypeError: If `root_parser` is not an instance of ArgumentParser or | ||||
|                    `subparsers` is not an instance of _SubParsersAction. | ||||
|  | ||||
|     Example: | ||||
|         ```python | ||||
|         >>> parsers = get_arg_parsers(commands=my_command_dict) | ||||
|         >>> args = parsers.root.parse_args() | ||||
|         ``` | ||||
|  | ||||
|     Notes: | ||||
|         - This function integrates dynamic command usage and descriptions if the | ||||
|           `commands` argument is provided. | ||||
|         - The `run` parser supports additional options for retry logic and confirmation | ||||
|           prompts. | ||||
|         - The `run-all` parser executes all commands matching a tag. | ||||
|         - Use `falyx run ?[COMMAND]` from the CLI to preview a command. | ||||
|     """ | ||||
|     if epilog is None: | ||||
|         epilog = f"Tip: Use '{prog} run ?' to show available commands." | ||||
|     if root_parser is None: | ||||
|         parser = get_root_parser( | ||||
|             prog=prog, | ||||
|             usage=usage, | ||||
|             description=description, | ||||
|             epilog=epilog, | ||||
|             parents=parents, | ||||
|             prefix_chars=prefix_chars, | ||||
|             fromfile_prefix_chars=fromfile_prefix_chars, | ||||
|             argument_default=argument_default, | ||||
|             conflict_handler=conflict_handler, | ||||
|             add_help=add_help, | ||||
|             allow_abbrev=allow_abbrev, | ||||
|             exit_on_error=exit_on_error, | ||||
|         ) | ||||
|     else: | ||||
|         if not isinstance(root_parser, ArgumentParser): | ||||
|             raise TypeError("root_parser must be an instance of ArgumentParser") | ||||
|         parser = root_parser | ||||
|  | ||||
|     if subparsers is None: | ||||
|         if prog == "falyx": | ||||
|             subparsers = get_subparsers( | ||||
|                 parser, | ||||
|                 title="Falyx Commands", | ||||
|                 description="Available commands for the Falyx CLI.", | ||||
|             ) | ||||
|         else: | ||||
|             subparsers = get_subparsers(parser, title="subcommands", description=None) | ||||
|     if not isinstance(subparsers, _SubParsersAction): | ||||
|         raise TypeError("subparsers must be an instance of _SubParsersAction") | ||||
|  | ||||
|     run_description = ["Run a command by its key or alias.\n"] | ||||
|     run_description.append("commands:") | ||||
|     if isinstance(commands, dict): | ||||
|         for command in commands.values(): | ||||
|             run_description.append(command.usage) | ||||
|             command_description = command.help_text or command.description | ||||
|             run_description.append(f"{' '*24}{command_description}") | ||||
|     run_epilog = ( | ||||
|         f"Tip: Use '{prog} run ?[COMMAND]' to preview commands by their key or alias." | ||||
|     ) | ||||
|     run_parser = subparsers.add_parser( | ||||
|         "run", | ||||
|         help="Run a specific command", | ||||
|         description="\n".join(run_description), | ||||
|         epilog=run_epilog, | ||||
|         formatter_class=RawDescriptionHelpFormatter, | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "name", help="Run a command by its key or alias", metavar="COMMAND" | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--summary", | ||||
|         action="store_true", | ||||
|         help="Print an execution summary after command completes", | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retries", type=int, help="Number of retries on failure", default=0 | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retry-delay", | ||||
|         type=float, | ||||
|         help="Initial delay between retries in (seconds)", | ||||
|         default=0, | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||
|     ) | ||||
|     run_group = run_parser.add_mutually_exclusive_group(required=False) | ||||
|     run_group.add_argument( | ||||
|         "-c", | ||||
|         "--confirm", | ||||
|         dest="force_confirm", | ||||
|         action="store_true", | ||||
|         help="Force confirmation prompts", | ||||
|     ) | ||||
|     run_group.add_argument( | ||||
|         "-s", | ||||
|         "--skip-confirm", | ||||
|         dest="skip_confirm", | ||||
|         action="store_true", | ||||
|         help="Skip confirmation prompts", | ||||
|     ) | ||||
|  | ||||
|     run_parser.add_argument( | ||||
|         "command_args", | ||||
|         nargs=REMAINDER, | ||||
|         help="Arguments to pass to the command (if applicable)", | ||||
|         metavar="ARGS", | ||||
|     ) | ||||
|  | ||||
|     run_all_parser = subparsers.add_parser( | ||||
|         "run-all", help="Run all commands with a given tag" | ||||
|     ) | ||||
|     run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") | ||||
|     run_all_parser.add_argument( | ||||
|         "--summary", | ||||
|         action="store_true", | ||||
|         help="Print a summary after all tagged commands run", | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retries", type=int, help="Number of retries on failure", default=0 | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retry-delay", | ||||
|         type=float, | ||||
|         help="Initial delay between retries in (seconds)", | ||||
|         default=0, | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||
|     ) | ||||
|     run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) | ||||
|     run_all_group.add_argument( | ||||
|         "-c", | ||||
|         "--confirm", | ||||
|         dest="force_confirm", | ||||
|         action="store_true", | ||||
|         help="Force confirmation prompts", | ||||
|     ) | ||||
|     run_all_group.add_argument( | ||||
|         "-s", | ||||
|         "--skip-confirm", | ||||
|         dest="skip_confirm", | ||||
|         action="store_true", | ||||
|         help="Skip confirmation prompts", | ||||
|     ) | ||||
|  | ||||
|     preview_parser = subparsers.add_parser( | ||||
|         "preview", help="Preview a command without running it" | ||||
|     ) | ||||
|     preview_parser.add_argument("name", help="Key, alias, or description of the command") | ||||
|  | ||||
|     list_parser = subparsers.add_parser( | ||||
|         "list", help="List all available commands with tags" | ||||
|     ) | ||||
|  | ||||
|     list_parser.add_argument( | ||||
|         "-t", "--tag", help="Filter commands by tag (case-insensitive)", default=None | ||||
|     ) | ||||
|  | ||||
|     version_parser = subparsers.add_parser("version", help=f"Show {prog} version") | ||||
|  | ||||
|     return FalyxParsers( | ||||
|         root=parser, | ||||
|         subparsers=subparsers, | ||||
|         run=run_parser, | ||||
|         run_all=run_all_parser, | ||||
|         preview=preview_parser, | ||||
|         list=list_parser, | ||||
|         version=version_parser, | ||||
|     ) | ||||
							
								
								
									
										105
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								falyx/parser/signature.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Provides utilities for introspecting Python callables and extracting argument | ||||
| metadata compatible with Falyx's `CommandArgumentParser`. | ||||
|  | ||||
| This module is primarily used to auto-generate command argument definitions from | ||||
| function signatures, enabling seamless integration of plain functions into the | ||||
| Falyx CLI with minimal boilerplate. | ||||
|  | ||||
| Functions: | ||||
| - infer_args_from_func: Generate a list of argument definitions based on a function's signature. | ||||
| """ | ||||
| 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 CLI-style argument definitions from a function signature. | ||||
|  | ||||
|     This utility inspects the parameters of a function and returns a list of dictionaries, | ||||
|     each of which can be passed to `CommandArgumentParser.add_argument()`. | ||||
|  | ||||
|     Args: | ||||
|         func (Callable | None): The function to inspect. | ||||
|         arg_metadata (dict | None): Optional metadata overrides for help text, type hints, | ||||
|                                     choices, and suggestions for each parameter. | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]]: A list of argument definitions inferred from the function. | ||||
|     """ | ||||
|     if not callable(func): | ||||
|         logger.debug("Provided argument is not callable: %s", func) | ||||
|         return [] | ||||
|     arg_metadata = arg_metadata or {} | ||||
|     signature = inspect.signature(func) | ||||
|     arg_defs = [] | ||||
|  | ||||
|     for name, param in signature.parameters.items(): | ||||
|         raw_metadata = arg_metadata.get(name, {}) | ||||
|         metadata = ( | ||||
|             {"help": raw_metadata} if isinstance(raw_metadata, str) else raw_metadata | ||||
|         ) | ||||
|         if param.kind not in ( | ||||
|             inspect.Parameter.POSITIONAL_ONLY, | ||||
|             inspect.Parameter.POSITIONAL_OR_KEYWORD, | ||||
|             inspect.Parameter.KEYWORD_ONLY, | ||||
|         ): | ||||
|             continue | ||||
|  | ||||
|         if metadata.get("type"): | ||||
|             arg_type = metadata["type"] | ||||
|         else: | ||||
|             arg_type = ( | ||||
|                 param.annotation | ||||
|                 if param.annotation is not inspect.Parameter.empty | ||||
|                 else str | ||||
|             ) | ||||
|             if isinstance(arg_type, str): | ||||
|                 arg_type = str | ||||
|         default = param.default if param.default is not inspect.Parameter.empty else None | ||||
|         is_required = param.default is inspect.Parameter.empty | ||||
|         if is_required: | ||||
|             flags = [f"{name.replace('_', '-')}"] | ||||
|         else: | ||||
|             flags = [f"--{name.replace('_', '-')}"] | ||||
|         action = "store" | ||||
|         nargs: int | str | None = None | ||||
|  | ||||
|         if arg_type is bool: | ||||
|             if param.default is False: | ||||
|                 action = "store_true" | ||||
|                 default = None | ||||
|             elif param.default is True: | ||||
|                 action = "store_false" | ||||
|                 default = None | ||||
|  | ||||
|         if arg_type is list: | ||||
|             action = "append" | ||||
|             if is_required: | ||||
|                 nargs = "+" | ||||
|             else: | ||||
|                 nargs = "*" | ||||
|  | ||||
|         arg_defs.append( | ||||
|             { | ||||
|                 "flags": flags, | ||||
|                 "dest": name, | ||||
|                 "type": arg_type, | ||||
|                 "default": default, | ||||
|                 "required": is_required, | ||||
|                 "nargs": nargs, | ||||
|                 "action": action, | ||||
|                 "help": metadata.get("help", ""), | ||||
|                 "choices": metadata.get("choices"), | ||||
|                 "suggestions": metadata.get("suggestions"), | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     return arg_defs | ||||
							
								
								
									
										164
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								falyx/parser/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,164 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Contains value coercion and signature comparison utilities for Falyx argument parsing. | ||||
|  | ||||
| This module provides type coercion functions for converting string input into expected | ||||
| Python types, including `Enum`, `bool`, `datetime`, and `Literal`. It also supports | ||||
| checking whether multiple actions share identical inferred argument definitions. | ||||
|  | ||||
| Functions: | ||||
| - coerce_bool: Convert a string to a boolean. | ||||
| - coerce_enum: Convert a string or raw value to an Enum instance. | ||||
| - coerce_value: General-purpose coercion to a target type (including nested unions, enums, etc.). | ||||
| - same_argument_definitions: Check if multiple callables share the same argument structure. | ||||
| """ | ||||
| import types | ||||
| from datetime import datetime | ||||
| from enum import EnumMeta | ||||
| from typing import Any, Literal, Union, get_args, get_origin | ||||
|  | ||||
| from dateutil import parser as date_parser | ||||
|  | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.logger import logger | ||||
| from falyx.parser.signature import infer_args_from_func | ||||
|  | ||||
|  | ||||
| def coerce_bool(value: str) -> bool: | ||||
|     """ | ||||
|     Convert a string to a boolean. | ||||
|  | ||||
|     Accepts various truthy and falsy representations such as 'true', 'yes', '0', 'off', etc. | ||||
|  | ||||
|     Args: | ||||
|         value (str): The input string or boolean. | ||||
|  | ||||
|     Returns: | ||||
|         bool: Parsed boolean result. | ||||
|     """ | ||||
|     if isinstance(value, bool): | ||||
|         return value | ||||
|     value = value.strip().lower() | ||||
|     if value in {"true", "t", "1", "yes", "on"}: | ||||
|         return True | ||||
|     elif value in {"false", "f", "0", "no", "off"}: | ||||
|         return False | ||||
|     return bool(value) | ||||
|  | ||||
|  | ||||
| def coerce_enum(value: Any, enum_type: EnumMeta) -> Any: | ||||
|     """ | ||||
|     Convert a raw value or string to an Enum instance. | ||||
|  | ||||
|     Tries to resolve by name, value, or coerced base type. | ||||
|  | ||||
|     Args: | ||||
|         value (Any): The input value to convert. | ||||
|         enum_type (EnumMeta): The target Enum class. | ||||
|  | ||||
|     Returns: | ||||
|         Enum: The corresponding Enum instance. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If the value cannot be resolved to a valid Enum member. | ||||
|     """ | ||||
|     if isinstance(value, enum_type): | ||||
|         return value | ||||
|  | ||||
|     if isinstance(value, str): | ||||
|         try: | ||||
|             return enum_type[value] | ||||
|         except KeyError: | ||||
|             pass | ||||
|  | ||||
|     base_type = type(next(iter(enum_type)).value) | ||||
|     try: | ||||
|         coerced_value = base_type(value) | ||||
|         return enum_type(coerced_value) | ||||
|     except (ValueError, TypeError): | ||||
|         values = [str(enum.value) for enum in enum_type] | ||||
|         raise ValueError(f"'{value}' should be one of {{{', '.join(values)}}}") from None | ||||
|  | ||||
|  | ||||
| def coerce_value(value: str, target_type: type) -> Any: | ||||
|     """ | ||||
|     Attempt to convert a string to the given target type. | ||||
|  | ||||
|     Handles complex typing constructs such as Union, Literal, Enum, and datetime. | ||||
|  | ||||
|     Args: | ||||
|         value (str): The input string to convert. | ||||
|         target_type (type): The desired type. | ||||
|  | ||||
|     Returns: | ||||
|         Any: The coerced value. | ||||
|  | ||||
|     Raises: | ||||
|         ValueError: If conversion fails or the value is invalid. | ||||
|     """ | ||||
|     origin = get_origin(target_type) | ||||
|     args = get_args(target_type) | ||||
|  | ||||
|     if origin is Literal: | ||||
|         if value not in args: | ||||
|             raise ValueError( | ||||
|                 f"Value '{value}' is not a valid literal for type {target_type}" | ||||
|             ) | ||||
|         return value | ||||
|  | ||||
|     if isinstance(target_type, types.UnionType) or get_origin(target_type) is Union: | ||||
|         for arg in args: | ||||
|             try: | ||||
|                 return coerce_value(value, arg) | ||||
|             except Exception: | ||||
|                 continue | ||||
|         raise ValueError(f"Value '{value}' could not be coerced to any of {args}") | ||||
|  | ||||
|     if isinstance(target_type, EnumMeta): | ||||
|         return coerce_enum(value, target_type) | ||||
|  | ||||
|     if target_type is bool: | ||||
|         return coerce_bool(value) | ||||
|  | ||||
|     if target_type is datetime: | ||||
|         try: | ||||
|             return date_parser.parse(value) | ||||
|         except ValueError as e: | ||||
|             raise ValueError(f"Value '{value}' could not be parsed as a datetime") from e | ||||
|  | ||||
|     return target_type(value) | ||||
|  | ||||
|  | ||||
| def same_argument_definitions( | ||||
|     actions: list[Any], | ||||
|     arg_metadata: dict[str, str | dict[str, Any]] | None = None, | ||||
| ) -> list[dict[str, Any]] | None: | ||||
|     """ | ||||
|     Determine if multiple callables resolve to the same argument definitions. | ||||
|  | ||||
|     This is used to infer whether actions in an ActionGroup or ProcessPool can share | ||||
|     a unified argument parser. | ||||
|  | ||||
|     Args: | ||||
|         actions (list[Any]): A list of BaseAction instances or callables. | ||||
|         arg_metadata (dict | None): Optional overrides for argument help or type info. | ||||
|  | ||||
|     Returns: | ||||
|         list[dict[str, Any]] | None: The shared argument definitions if consistent, else 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 | ||||
							
								
								
									
										179
									
								
								falyx/parsers.py
									
									
									
									
									
								
							
							
						
						
									
										179
									
								
								falyx/parsers.py
									
									
									
									
									
								
							| @@ -1,179 +0,0 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """parsers.py | ||||
| This module contains the argument parsers used for the Falyx CLI. | ||||
| """ | ||||
| from argparse import REMAINDER, ArgumentParser, Namespace, _SubParsersAction | ||||
| from dataclasses import asdict, dataclass | ||||
| from typing import Any, Sequence | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| class FalyxParsers: | ||||
|     """Defines the argument parsers for the Falyx CLI.""" | ||||
|  | ||||
|     root: ArgumentParser | ||||
|     subparsers: _SubParsersAction | ||||
|     run: ArgumentParser | ||||
|     run_all: ArgumentParser | ||||
|     preview: ArgumentParser | ||||
|     list: ArgumentParser | ||||
|     version: ArgumentParser | ||||
|  | ||||
|     def parse_args(self, args: Sequence[str] | None = None) -> Namespace: | ||||
|         """Parse the command line arguments.""" | ||||
|         return self.root.parse_args(args) | ||||
|  | ||||
|     def as_dict(self) -> dict[str, ArgumentParser]: | ||||
|         """Convert the FalyxParsers instance to a dictionary.""" | ||||
|         return asdict(self) | ||||
|  | ||||
|     def get_parser(self, name: str) -> ArgumentParser | None: | ||||
|         """Get the parser by name.""" | ||||
|         return self.as_dict().get(name) | ||||
|  | ||||
|  | ||||
| def get_arg_parsers( | ||||
|     prog: str | None = "falyx", | ||||
|     usage: str | None = None, | ||||
|     description: str | None = "Falyx CLI - Run structured async command workflows.", | ||||
|     epilog: ( | ||||
|         str | None | ||||
|     ) = "Tip: Use 'falyx run ?[COMMAND]' to preview any command from the CLI.", | ||||
|     parents: Sequence[ArgumentParser] | None = None, | ||||
|     prefix_chars: str = "-", | ||||
|     fromfile_prefix_chars: str | None = None, | ||||
|     argument_default: Any = None, | ||||
|     conflict_handler: str = "error", | ||||
|     add_help: bool = True, | ||||
|     allow_abbrev: bool = True, | ||||
|     exit_on_error: bool = True, | ||||
| ) -> FalyxParsers: | ||||
|     """Returns the argument parser for the CLI.""" | ||||
|     parser = ArgumentParser( | ||||
|         prog=prog, | ||||
|         usage=usage, | ||||
|         description=description, | ||||
|         epilog=epilog, | ||||
|         parents=parents if parents else [], | ||||
|         prefix_chars=prefix_chars, | ||||
|         fromfile_prefix_chars=fromfile_prefix_chars, | ||||
|         argument_default=argument_default, | ||||
|         conflict_handler=conflict_handler, | ||||
|         add_help=add_help, | ||||
|         allow_abbrev=allow_abbrev, | ||||
|         exit_on_error=exit_on_error, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--never-prompt", | ||||
|         action="store_true", | ||||
|         help="Run in non-interactive mode with all prompts bypassed.", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-v", "--verbose", action="store_true", help="Enable debug logging for Falyx." | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--debug-hooks", | ||||
|         action="store_true", | ||||
|         help="Enable default lifecycle debug logging", | ||||
|     ) | ||||
|     parser.add_argument("--version", action="store_true", help="Show Falyx version") | ||||
|     subparsers = parser.add_subparsers(dest="command") | ||||
|  | ||||
|     run_parser = subparsers.add_parser("run", help="Run a specific command") | ||||
|     run_parser.add_argument("name", help="Key, alias, or description of the command") | ||||
|     run_parser.add_argument( | ||||
|         "--summary", | ||||
|         action="store_true", | ||||
|         help="Print an execution summary after command completes", | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retries", type=int, help="Number of retries on failure", default=0 | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retry-delay", | ||||
|         type=float, | ||||
|         help="Initial delay between retries in (seconds)", | ||||
|         default=0, | ||||
|     ) | ||||
|     run_parser.add_argument( | ||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||
|     ) | ||||
|     run_group = run_parser.add_mutually_exclusive_group(required=False) | ||||
|     run_group.add_argument( | ||||
|         "-c", | ||||
|         "--confirm", | ||||
|         dest="force_confirm", | ||||
|         action="store_true", | ||||
|         help="Force confirmation prompts", | ||||
|     ) | ||||
|     run_group.add_argument( | ||||
|         "-s", | ||||
|         "--skip-confirm", | ||||
|         dest="skip_confirm", | ||||
|         action="store_true", | ||||
|         help="Skip confirmation prompts", | ||||
|     ) | ||||
|  | ||||
|     run_group.add_argument( | ||||
|         "command_args", | ||||
|         nargs=REMAINDER, | ||||
|         help="Arguments to pass to the command (if applicable)", | ||||
|     ) | ||||
|  | ||||
|     run_all_parser = subparsers.add_parser( | ||||
|         "run-all", help="Run all commands with a given tag" | ||||
|     ) | ||||
|     run_all_parser.add_argument("-t", "--tag", required=True, help="Tag to match") | ||||
|     run_all_parser.add_argument( | ||||
|         "--summary", | ||||
|         action="store_true", | ||||
|         help="Print a summary after all tagged commands run", | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retries", type=int, help="Number of retries on failure", default=0 | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retry-delay", | ||||
|         type=float, | ||||
|         help="Initial delay between retries in (seconds)", | ||||
|         default=0, | ||||
|     ) | ||||
|     run_all_parser.add_argument( | ||||
|         "--retry-backoff", type=float, help="Backoff factor for retries", default=0 | ||||
|     ) | ||||
|     run_all_group = run_all_parser.add_mutually_exclusive_group(required=False) | ||||
|     run_all_group.add_argument( | ||||
|         "-c", | ||||
|         "--confirm", | ||||
|         dest="force_confirm", | ||||
|         action="store_true", | ||||
|         help="Force confirmation prompts", | ||||
|     ) | ||||
|     run_all_group.add_argument( | ||||
|         "-s", | ||||
|         "--skip-confirm", | ||||
|         dest="skip_confirm", | ||||
|         action="store_true", | ||||
|         help="Skip confirmation prompts", | ||||
|     ) | ||||
|  | ||||
|     preview_parser = subparsers.add_parser( | ||||
|         "preview", help="Preview a command without running it" | ||||
|     ) | ||||
|     preview_parser.add_argument("name", help="Key, alias, or description of the command") | ||||
|  | ||||
|     list_parser = subparsers.add_parser( | ||||
|         "list", help="List all available commands with tags" | ||||
|     ) | ||||
|  | ||||
|     version_parser = subparsers.add_parser("version", help="Show the Falyx version") | ||||
|  | ||||
|     return FalyxParsers( | ||||
|         root=parser, | ||||
|         subparsers=subparsers, | ||||
|         run=run_parser, | ||||
|         run_all=run_all_parser, | ||||
|         preview=preview_parser, | ||||
|         list=list_parser, | ||||
|         version=version_parser, | ||||
|     ) | ||||
| @@ -1,11 +1,24 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """prompt_utils.py""" | ||||
| """ | ||||
| Utilities for user interaction prompts in the Falyx CLI framework. | ||||
|  | ||||
| Provides asynchronous confirmation dialogs and helper logic to determine | ||||
| whether a user should be prompted based on command-line options. | ||||
|  | ||||
| Includes: | ||||
| - `should_prompt_user()` for conditional prompt logic. | ||||
| - `confirm_async()` for interactive yes/no confirmation. | ||||
| """ | ||||
|  | ||||
| from prompt_toolkit import PromptSession | ||||
| from prompt_toolkit.formatted_text import ( | ||||
|     AnyFormattedText, | ||||
|     FormattedText, | ||||
|     StyleAndTextTuples, | ||||
|     merge_formatted_text, | ||||
| ) | ||||
| from rich.console import Console | ||||
| from rich.text import Text | ||||
|  | ||||
| from falyx.options_manager import OptionsManager | ||||
| from falyx.themes import OneColors | ||||
| @@ -46,3 +59,31 @@ async def confirm_async( | ||||
|         validator=yes_no_validator(), | ||||
|     ) | ||||
|     return answer.upper() == "Y" | ||||
|  | ||||
|  | ||||
| def rich_text_to_prompt_text(text: Text | str | StyleAndTextTuples) -> StyleAndTextTuples: | ||||
|     """ | ||||
|     Convert a Rich Text object to a list of (style, text) tuples | ||||
|     compatible with prompt_toolkit. | ||||
|     """ | ||||
|     if isinstance(text, list): | ||||
|         if all(isinstance(pair, tuple) and len(pair) == 2 for pair in text): | ||||
|             return text | ||||
|         raise TypeError("Expected list of (style, text) tuples") | ||||
|  | ||||
|     if isinstance(text, str): | ||||
|         text = Text.from_markup(text) | ||||
|  | ||||
|     if not isinstance(text, Text): | ||||
|         raise TypeError("Expected str, rich.text.Text, or list of (style, text) tuples") | ||||
|  | ||||
|     console = Console(color_system=None, file=None, width=999, legacy_windows=False) | ||||
|     segments = text.render(console) | ||||
|  | ||||
|     prompt_fragments: StyleAndTextTuples = [] | ||||
|     for segment in segments: | ||||
|         style = segment.style or "" | ||||
|         string = segment.text | ||||
|         if string: | ||||
|             prompt_fragments.append((str(style), string)) | ||||
|     return prompt_fragments | ||||
|   | ||||
| @@ -1,15 +1,30 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """protocols.py""" | ||||
| """ | ||||
| Defines structural protocols for advanced Falyx features. | ||||
|  | ||||
| These runtime-checkable `Protocol` classes specify the expected interfaces for: | ||||
| - Factories that asynchronously return actions | ||||
| - Argument parsers used in dynamic command execution | ||||
|  | ||||
| Used to support type-safe extensibility and plugin-like behavior without requiring | ||||
| explicit base classes. | ||||
|  | ||||
| Protocols: | ||||
| - ActionFactoryProtocol: Async callable that returns a coroutine yielding a BaseAction. | ||||
| - ArgParserProtocol: Callable that accepts CLI-style args and returns (args, kwargs) tuple. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Any, Awaitable, Protocol, runtime_checkable | ||||
| from typing import Any, Awaitable, Callable, Protocol, runtime_checkable | ||||
|  | ||||
| from falyx.action.action import BaseAction | ||||
| from falyx.action.base_action import BaseAction | ||||
|  | ||||
|  | ||||
| @runtime_checkable | ||||
| class ActionFactoryProtocol(Protocol): | ||||
|     async def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[BaseAction]: ... | ||||
|     async def __call__( | ||||
|         self, *args: Any, **kwargs: Any | ||||
|     ) -> Callable[..., Awaitable[BaseAction]]: ... | ||||
|  | ||||
|  | ||||
| @runtime_checkable | ||||
|   | ||||
| @@ -1,5 +1,23 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry.py""" | ||||
| """ | ||||
| Implements retry logic for Falyx Actions using configurable retry policies. | ||||
|  | ||||
| This module defines: | ||||
| - `RetryPolicy`: A configurable model controlling retry behavior (delay, backoff, jitter). | ||||
| - `RetryHandler`: A hook-compatible class that manages retry attempts for failed actions. | ||||
|  | ||||
| Used to automatically retry transient failures in leaf-level `Action` objects | ||||
| when marked as retryable. Integrates with the Falyx hook lifecycle via `on_error`. | ||||
|  | ||||
| Supports: | ||||
| - Exponential backoff with optional jitter | ||||
| - Manual or declarative policy control | ||||
| - Per-action retry logging and recovery | ||||
|  | ||||
| Example: | ||||
|     handler = RetryHandler(RetryPolicy(max_retries=5, delay=1.0)) | ||||
|     action.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import asyncio | ||||
| @@ -12,7 +30,28 @@ from falyx.logger import logger | ||||
|  | ||||
|  | ||||
| class RetryPolicy(BaseModel): | ||||
|     """RetryPolicy""" | ||||
|     """ | ||||
|     Defines a retry strategy for Falyx `Action` objects. | ||||
|  | ||||
|     This model controls whether an action should be retried on failure, and how: | ||||
|     - `max_retries`: Maximum number of retry attempts. | ||||
|     - `delay`: Initial wait time before the first retry (in seconds). | ||||
|     - `backoff`: Multiplier applied to the delay after each failure (≥ 1.0). | ||||
|     - `jitter`: Optional random noise added/subtracted from delay to reduce thundering herd issues. | ||||
|     - `enabled`: Whether this policy is currently active. | ||||
|  | ||||
|     Retry is only triggered for leaf-level `Action` instances marked with `is_retryable=True` | ||||
|     and registered with an appropriate `RetryHandler`. | ||||
|  | ||||
|     Example: | ||||
|         RetryPolicy(max_retries=3, delay=1.0, backoff=2.0, jitter=0.2, enabled=True) | ||||
|  | ||||
|     Use `enable_policy()` to activate the policy after construction. | ||||
|  | ||||
|     See Also: | ||||
|         - `RetryHandler`: Executes retry logic based on this configuration. | ||||
|         - `HookType.ON_ERROR`: The hook type used to trigger retries. | ||||
|     """ | ||||
|  | ||||
|     max_retries: int = Field(default=3, ge=0) | ||||
|     delay: float = Field(default=1.0, ge=0.0) | ||||
| @@ -36,7 +75,27 @@ class RetryPolicy(BaseModel): | ||||
|  | ||||
|  | ||||
| class RetryHandler: | ||||
|     """RetryHandler class to manage retry policies for actions.""" | ||||
|     """ | ||||
|     Executes retry logic for Falyx actions using a provided `RetryPolicy`. | ||||
|  | ||||
|     This class is intended to be registered as an `on_error` hook. It will | ||||
|     re-attempt the failed `Action`'s `action` method using the args/kwargs from | ||||
|     the failed context, following exponential backoff and optional jitter. | ||||
|  | ||||
|     Only supports retrying leaf `Action` instances (not ChainedAction or ActionGroup) | ||||
|     where `is_retryable=True`. | ||||
|  | ||||
|     Attributes: | ||||
|         policy (RetryPolicy): The retry configuration controlling timing and limits. | ||||
|  | ||||
|     Example: | ||||
|         handler = RetryHandler(RetryPolicy(max_retries=3, delay=1.0, enabled=True)) | ||||
|         action.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||
|  | ||||
|     Notes: | ||||
|         - Retries are not triggered if the policy is disabled or `max_retries=0`. | ||||
|         - All retry attempts and final failure are logged automatically. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, policy: RetryPolicy = RetryPolicy()): | ||||
|         self.policy = policy | ||||
| @@ -53,7 +112,7 @@ class RetryHandler: | ||||
|         self.policy.delay = delay | ||||
|         self.policy.backoff = backoff | ||||
|         self.policy.jitter = jitter | ||||
|         logger.info("🔄 Retry policy enabled: %s", self.policy) | ||||
|         logger.info("Retry policy enabled: %s", self.policy) | ||||
|  | ||||
|     async def retry_on_error(self, context: ExecutionContext) -> None: | ||||
|         from falyx.action import Action | ||||
| @@ -67,21 +126,21 @@ class RetryHandler: | ||||
|         last_error = error | ||||
|  | ||||
|         if not target: | ||||
|             logger.warning("[%s] ⚠️ No action target. Cannot retry.", name) | ||||
|             logger.warning("[%s] No action target. Cannot retry.", name) | ||||
|             return None | ||||
|  | ||||
|         if not isinstance(target, Action): | ||||
|             logger.warning( | ||||
|                 "[%s] ❌ RetryHandler only supports only supports Action objects.", name | ||||
|                 "[%s] RetryHandler only supports only supports Action objects.", name | ||||
|             ) | ||||
|             return None | ||||
|  | ||||
|         if not getattr(target, "is_retryable", False): | ||||
|             logger.warning("[%s] ❌ Not retryable.", name) | ||||
|             logger.warning("[%s] Not retryable.", name) | ||||
|             return None | ||||
|  | ||||
|         if not self.policy.enabled: | ||||
|             logger.warning("[%s] ❌ Retry policy is disabled.", name) | ||||
|             logger.warning("[%s] Retry policy is disabled.", name) | ||||
|             return None | ||||
|  | ||||
|         while retries_done < self.policy.max_retries: | ||||
| @@ -90,32 +149,41 @@ class RetryHandler: | ||||
|             sleep_delay = current_delay | ||||
|             if self.policy.jitter > 0: | ||||
|                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||
|  | ||||
|             logger.debug( | ||||
|                 "[%s] Error: %s", | ||||
|                 name, | ||||
|                 last_error, | ||||
|             ) | ||||
|             logger.info( | ||||
|                 "[%s] 🔄 Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 "[%s] Retrying (%s/%s) in %ss due to '%s'...", | ||||
|                 name, | ||||
|                 retries_done, | ||||
|                 self.policy.max_retries, | ||||
|                 current_delay, | ||||
|                 last_error, | ||||
|                 last_error.__class__.__name__, | ||||
|             ) | ||||
|             await asyncio.sleep(current_delay) | ||||
|             try: | ||||
|                 result = await target.action(*context.args, **context.kwargs) | ||||
|                 context.result = result | ||||
|                 context.exception = None | ||||
|                 logger.info("[%s] ✅ Retry succeeded on attempt %s.", name, retries_done) | ||||
|                 logger.info("[%s] Retry succeeded on attempt %s.", name, retries_done) | ||||
|                 return None | ||||
|             except Exception as retry_error: | ||||
|                 last_error = retry_error | ||||
|                 current_delay *= self.policy.backoff | ||||
|                 logger.debug( | ||||
|                     "[%s] Error: %s", | ||||
|                     name, | ||||
|                     retry_error, | ||||
|                 ) | ||||
|                 logger.warning( | ||||
|                     "[%s] ⚠️ Retry attempt %s/%s failed due to '%s'.", | ||||
|                     "[%s] Retry attempt %s/%s failed due to '%s'.", | ||||
|                     name, | ||||
|                     retries_done, | ||||
|                     self.policy.max_retries, | ||||
|                     retry_error, | ||||
|                     retry_error.__class__.__name__, | ||||
|                 ) | ||||
|  | ||||
|         context.exception = last_error | ||||
|         logger.error("[%s] ❌ All %s retries failed.", name, self.policy.max_retries) | ||||
|         logger.error("[%s] All %s retries failed.", name, self.policy.max_retries) | ||||
|   | ||||
| @@ -1,6 +1,16 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """retry_utils.py""" | ||||
| from falyx.action.action import Action, BaseAction | ||||
| """ | ||||
| Utilities for enabling retry behavior across Falyx actions. | ||||
|  | ||||
| This module provides a helper to recursively apply a `RetryPolicy` to an action and its | ||||
| nested children (e.g. `ChainedAction`, `ActionGroup`), and register the appropriate | ||||
| `RetryHandler` to hook into error handling. | ||||
|  | ||||
| Includes: | ||||
| - `enable_retries_recursively`: Attaches a retry policy and error hook to all eligible actions. | ||||
| """ | ||||
| from falyx.action.action import Action | ||||
| from falyx.action.base_action import BaseAction | ||||
| from falyx.hook_manager import HookType | ||||
| from falyx.retry import RetryHandler, RetryPolicy | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,30 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """selection.py""" | ||||
| """ | ||||
| Provides interactive selection utilities for Falyx CLI actions. | ||||
|  | ||||
| This module defines `SelectionOption` objects, selection maps, and rich-powered | ||||
| rendering functions to build interactive selection prompts using `prompt_toolkit`. | ||||
| It supports: | ||||
| - Grid-based and dictionary-based selection menus | ||||
| - Index- or key-driven multi-select prompts | ||||
| - Formatted Rich tables for CLI visual menus | ||||
| - Cancel keys, defaults, and duplication control | ||||
|  | ||||
| Used by `SelectionAction` and other prompt-driven workflows within Falyx. | ||||
| """ | ||||
| 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.console import console | ||||
| from falyx.prompt_utils import rich_text_to_prompt_text | ||||
| from falyx.themes import OneColors | ||||
| from falyx.utils import chunks | ||||
| from falyx.validators import int_range_validator, key_validator | ||||
| from falyx.utils import CaseInsensitiveDict, chunks | ||||
| from falyx.validators import MultiIndexValidator, MultiKeyValidator | ||||
|  | ||||
|  | ||||
| @dataclass | ||||
| @@ -32,6 +45,62 @@ class SelectionOption: | ||||
|         return f"[{OneColors.WHITE}]{key}[/] [{self.style}]{self.description}[/]" | ||||
|  | ||||
|  | ||||
| class SelectionOptionMap(CaseInsensitiveDict): | ||||
|     """ | ||||
|     Manages selection options including validation and reserved key protection. | ||||
|     """ | ||||
|  | ||||
|     RESERVED_KEYS: set[str] = set() | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
|         options: dict[str, SelectionOption] | None = None, | ||||
|         allow_reserved: bool = False, | ||||
|     ): | ||||
|         super().__init__() | ||||
|         self.allow_reserved = allow_reserved | ||||
|         if options: | ||||
|             self.update(options) | ||||
|  | ||||
|     def _add_reserved(self, key: str, option: SelectionOption) -> None: | ||||
|         """Add a reserved key, bypassing validation.""" | ||||
|         norm_key = key.upper() | ||||
|         super().__setitem__(norm_key, option) | ||||
|  | ||||
|     def __setitem__(self, key: str, option: SelectionOption) -> None: | ||||
|         if not isinstance(option, SelectionOption): | ||||
|             raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|         norm_key = key.upper() | ||||
|         if norm_key in self.RESERVED_KEYS and not self.allow_reserved: | ||||
|             raise ValueError( | ||||
|                 f"Key '{key}' is reserved and cannot be used in SelectionOptionMap." | ||||
|             ) | ||||
|         super().__setitem__(norm_key, option) | ||||
|  | ||||
|     def __delitem__(self, key: str) -> None: | ||||
|         if key.upper() in self.RESERVED_KEYS and not self.allow_reserved: | ||||
|             raise ValueError(f"Cannot delete reserved option '{key}'.") | ||||
|         super().__delitem__(key) | ||||
|  | ||||
|     def update(self, other=None, **kwargs): | ||||
|         """Update the selection options with another dictionary.""" | ||||
|         if other: | ||||
|             for key, option in other.items(): | ||||
|                 if not isinstance(option, SelectionOption): | ||||
|                     raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|                 self[key] = option | ||||
|         for key, option in kwargs.items(): | ||||
|             if not isinstance(option, SelectionOption): | ||||
|                 raise TypeError(f"Value for key '{key}' must be a SelectionOption.") | ||||
|             self[key] = option | ||||
|  | ||||
|     def items(self, include_reserved: bool = True): | ||||
|         for k, v in super().items(): | ||||
|             if not include_reserved and k in self.RESERVED_KEYS: | ||||
|                 continue | ||||
|             yield k, v | ||||
|  | ||||
|  | ||||
| def render_table_base( | ||||
|     title: str, | ||||
|     *, | ||||
| @@ -211,23 +280,37 @@ async def prompt_for_index( | ||||
|     *, | ||||
|     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="auto") | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selection = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=int_range_validator(min_index, max_index), | ||||
|         message=rich_text_to_prompt_text(prompt_message), | ||||
|         validator=MultiIndexValidator( | ||||
|             min_index, | ||||
|             max_index, | ||||
|             number_selections, | ||||
|             separator, | ||||
|             allow_duplicates, | ||||
|             cancel_key, | ||||
|         ), | ||||
|         default=default_selection, | ||||
|     ) | ||||
|     return int(selection) | ||||
|  | ||||
|     if selection.strip() == cancel_key: | ||||
|         return int(cancel_key) | ||||
|     if isinstance(number_selections, int) and number_selections == 1: | ||||
|         return int(selection.strip()) | ||||
|     return [int(index.strip()) for index in selection.strip().split(separator)] | ||||
|  | ||||
|  | ||||
| async def prompt_for_selection( | ||||
| @@ -235,35 +318,46 @@ async def prompt_for_selection( | ||||
|     table: Table, | ||||
|     *, | ||||
|     default_selection: str = "", | ||||
|     console: Console | None = None, | ||||
|     prompt_session: PromptSession | None = None, | ||||
|     prompt_message: str = "Select an option > ", | ||||
|     show_table: bool = True, | ||||
| ) -> str: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt the user to select a key from a set of options. Return the selected key.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     if show_table: | ||||
|         console.print(table, justify="center") | ||||
|  | ||||
|     selected = await prompt_session.prompt_async( | ||||
|         message=prompt_message, | ||||
|         validator=key_validator(keys), | ||||
|         message=rich_text_to_prompt_text(prompt_message), | ||||
|         validator=MultiKeyValidator( | ||||
|             keys, number_selections, separator, allow_duplicates, cancel_key | ||||
|         ), | ||||
|         default=default_selection, | ||||
|     ) | ||||
|  | ||||
|     return selected | ||||
|     if selected.strip() == cancel_key: | ||||
|         return cancel_key | ||||
|     if isinstance(number_selections, int) and number_selections == 1: | ||||
|         return selected.strip() | ||||
|     return [key.strip() for key in selected.strip().split(separator)] | ||||
|  | ||||
|  | ||||
| async def select_value_from_list( | ||||
|     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, | ||||
| @@ -276,7 +370,7 @@ async def select_value_from_list( | ||||
|     title_style: str = "", | ||||
|     caption_style: str = "", | ||||
|     highlight: bool = False, | ||||
| ): | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a selection. Return the selected item.""" | ||||
|     table = render_selection_indexed_table( | ||||
|         title=title, | ||||
| @@ -295,17 +389,21 @@ async def select_value_from_list( | ||||
|         highlight=highlight, | ||||
|     ) | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     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] | ||||
|  | ||||
|  | ||||
| @@ -313,14 +411,16 @@ 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 = "", | ||||
| ) -> Any: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> str | list[str]: | ||||
|     """Prompt for a key from a dict, returns the key.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
| @@ -328,9 +428,12 @@ async def select_key_from_dict( | ||||
|         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, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -338,14 +441,16 @@ 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 = "", | ||||
| ) -> Any: | ||||
|     number_selections: int | str = 1, | ||||
|     separator: str = ",", | ||||
|     allow_duplicates: bool = False, | ||||
|     cancel_key: str = "", | ||||
| ) -> Any | list[Any]: | ||||
|     """Prompt for a key from a dict, but return the value.""" | ||||
|     prompt_session = prompt_session or PromptSession() | ||||
|     console = console or Console(color_system="auto") | ||||
|  | ||||
|     console.print(table, justify="center") | ||||
|  | ||||
| @@ -353,11 +458,16 @@ async def select_value_from_dict( | ||||
|         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 | ||||
|  | ||||
|  | ||||
| @@ -365,11 +475,14 @@ 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, | ||||
| @@ -379,8 +492,11 @@ async def get_selection_from_dict_menu( | ||||
|     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, | ||||
|     ) | ||||
|   | ||||
| @@ -1,5 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """signals.py""" | ||||
| """ | ||||
| Defines flow control signals used internally by the Falyx CLI framework. | ||||
|  | ||||
| These signals are raised to interrupt or redirect CLI execution flow | ||||
| (e.g., returning to a menu, quitting, or displaying help) without | ||||
| being treated as traditional exceptions. | ||||
|  | ||||
| All signals inherit from `FlowSignal`, which is a subclass of `BaseException` | ||||
| to ensure they bypass standard `except Exception` blocks. | ||||
|  | ||||
| Signals: | ||||
| - BreakChainSignal: Exit a chained action early. | ||||
| - QuitSignal: Terminate the CLI session. | ||||
| - BackSignal: Return to the previous menu or caller. | ||||
| - CancelSignal: Cancel the current operation. | ||||
| - HelpSignal: Trigger help output in interactive flows. | ||||
| """ | ||||
|  | ||||
|  | ||||
| class FlowSignal(BaseException): | ||||
| @@ -10,6 +26,13 @@ class FlowSignal(BaseException): | ||||
|     """ | ||||
|  | ||||
|  | ||||
| class BreakChainSignal(FlowSignal): | ||||
|     """Raised to break the current action chain and return to the previous context.""" | ||||
|  | ||||
|     def __init__(self, message: str = "Break chain signal received."): | ||||
|         super().__init__(message) | ||||
|  | ||||
|  | ||||
| class QuitSignal(FlowSignal): | ||||
|     """Raised to signal an immediate exit from the CLI framework.""" | ||||
|  | ||||
|   | ||||
							
								
								
									
										248
									
								
								falyx/spinner_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								falyx/spinner_manager.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| Centralized spinner rendering for Falyx CLI. | ||||
|  | ||||
| This module provides the `SpinnerManager` class, which manages a collection of | ||||
| Rich spinners that can be displayed concurrently during long-running tasks. | ||||
|  | ||||
| Key Features: | ||||
|     • Automatic lifecycle management: | ||||
|         - Starts a single Rich `Live` loop when the first spinner is added. | ||||
|         - Stops and clears the display when the last spinner is removed. | ||||
|     • Thread/async-safe start logic via a lightweight lock to prevent | ||||
|       duplicate Live loops from being launched. | ||||
|     • Supports multiple spinners running simultaneously, each with its own | ||||
|       text, style, type, and speed. | ||||
|     • Integrates with Falyx's OptionsManager so actions and commands can | ||||
|       declaratively request spinners without directly managing terminal state. | ||||
|  | ||||
| Classes: | ||||
|     SpinnerData: | ||||
|         Lightweight container for individual spinner settings (message, | ||||
|         type, style, speed) and its underlying Rich `Spinner` object. | ||||
|     SpinnerManager: | ||||
|         Manages all active spinners, handles Live rendering, and provides | ||||
|         methods to add, update, and remove spinners. | ||||
|  | ||||
| Example: | ||||
|     ```python | ||||
|     >>> manager = SpinnerManager() | ||||
|     >>> await manager.add("build", "Building project…", spinner_type="dots") | ||||
|     >>> await manager.add("deploy", "Deploying to AWS…", spinner_type="earth") | ||||
|     # Both spinners animate in one unified Live panel | ||||
|     >>> manager.remove("build") | ||||
|     >>> manager.remove("deploy") | ||||
|     ``` | ||||
|  | ||||
| Design Notes: | ||||
|     • SpinnerManager should only create **one** Live loop at a time. | ||||
|     • When no spinners remain, the Live panel is cleared (`transient=True`) | ||||
|       so the CLI output returns to a clean state. | ||||
|     • Hooks in `falyx.hooks` (spinner_before_hook / spinner_teardown_hook) | ||||
|       call into this manager automatically when `spinner=True` is set on | ||||
|       an Action or Command. | ||||
| """ | ||||
|  | ||||
| import asyncio | ||||
|  | ||||
| from rich.console import Group | ||||
| from rich.live import Live | ||||
| from rich.spinner import Spinner | ||||
|  | ||||
| from falyx.console import console | ||||
| from falyx.logger import logger | ||||
| from falyx.themes import OneColors | ||||
|  | ||||
|  | ||||
| class SpinnerData: | ||||
|     """ | ||||
|     Holds the configuration and Rich spinner object for a single task. | ||||
|  | ||||
|     This class is a lightweight container for spinner metadata, storing the | ||||
|     message text, spinner type, style, and speed. It also initializes the | ||||
|     corresponding Rich `Spinner` instance used by `SpinnerManager` for | ||||
|     rendering. | ||||
|  | ||||
|     Attributes: | ||||
|         text (str): The message displayed next to the spinner. | ||||
|         spinner_type (str): The Rich spinner preset to use (e.g., "dots", | ||||
|             "bouncingBall", "earth"). | ||||
|         spinner_style (str): Rich color/style for the spinner animation. | ||||
|         spinner (Spinner): The instantiated Rich spinner object. | ||||
|  | ||||
|     Example: | ||||
|         ``` | ||||
|         >>> data = SpinnerData("Deploying...", spinner_type="earth", | ||||
|         ...                    spinner_style="cyan", spinner_speed=1.0) | ||||
|         >>> data.spinner | ||||
|         <rich.spinner.Spinner object ...> | ||||
|         ``` | ||||
|     """ | ||||
|  | ||||
|     def __init__( | ||||
|         self, text: str, spinner_type: str, spinner_style: str, spinner_speed: float | ||||
|     ): | ||||
|         """Initialize a spinner with text, type, style, and speed.""" | ||||
|         self.text = text | ||||
|         self.spinner_type = spinner_type | ||||
|         self.spinner_style = spinner_style | ||||
|         self.spinner = Spinner( | ||||
|             spinner_type, text=text, style=spinner_style, speed=spinner_speed | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class SpinnerManager: | ||||
|     """ | ||||
|     Manages multiple Rich spinners and handles their terminal rendering. | ||||
|  | ||||
|     SpinnerManager maintains a registry of active spinners and a single | ||||
|     Rich `Live` display loop to render them. When the first spinner is added, | ||||
|     the Live loop starts automatically. When the last spinner is removed, | ||||
|     the Live loop stops and the panel clears (via `transient=True`). | ||||
|  | ||||
|     This class is designed for integration with Falyx's `OptionsManager` | ||||
|     so any Action or Command can declaratively register spinners without | ||||
|     directly controlling terminal state. | ||||
|  | ||||
|     Key Behaviors: | ||||
|         • Starts exactly one `Live` loop, protected by a start lock to prevent | ||||
|           duplicate launches in async/threaded contexts. | ||||
|         • Supports multiple simultaneous spinners, each with independent | ||||
|           text, style, and type. | ||||
|         • Clears the display when all spinners are removed. | ||||
|  | ||||
|     Attributes: | ||||
|         console (Console): The Rich console used for rendering. | ||||
|         _spinners (dict[str, SpinnerData]): Internal store of active spinners. | ||||
|         _task (asyncio.Task | None): The running Live loop task, if any. | ||||
|         _running (bool): Indicates if the Live loop is currently active. | ||||
|  | ||||
|     Example: | ||||
|         ``` | ||||
|         >>> manager = SpinnerManager() | ||||
|         >>> await manager.add("build", "Building project…") | ||||
|         >>> await manager.add("deploy", "Deploying services…", spinner_type="earth") | ||||
|         >>> manager.remove("build") | ||||
|         >>> manager.remove("deploy") | ||||
|         ``` | ||||
|     """ | ||||
|  | ||||
|     def __init__(self) -> None: | ||||
|         """Initialize the SpinnerManager with an empty spinner registry.""" | ||||
|         self.console = console | ||||
|         self._spinners: dict[str, SpinnerData] = {} | ||||
|         self._task: asyncio.Task | None = None | ||||
|         self._running: bool = False | ||||
|  | ||||
|         self._lock = asyncio.Lock() | ||||
|  | ||||
|     async def add( | ||||
|         self, | ||||
|         name: str, | ||||
|         text: str, | ||||
|         spinner_type: str = "dots", | ||||
|         spinner_style: str = OneColors.CYAN, | ||||
|         spinner_speed: float = 1.0, | ||||
|     ): | ||||
|         """Add a new spinner and start the Live loop if not already running.""" | ||||
|         self._spinners[name] = SpinnerData( | ||||
|             text=text, | ||||
|             spinner_type=spinner_type, | ||||
|             spinner_style=spinner_style, | ||||
|             spinner_speed=spinner_speed, | ||||
|         ) | ||||
|         async with self._lock: | ||||
|             if not self._running: | ||||
|                 logger.debug("[%s] Starting spinner manager Live loop.", name) | ||||
|                 await self._start_live() | ||||
|  | ||||
|     def update( | ||||
|         self, | ||||
|         name: str, | ||||
|         text: str | None = None, | ||||
|         spinner_type: str | None = None, | ||||
|         spinner_style: str | None = None, | ||||
|     ): | ||||
|         """Update an existing spinner's message, style, or type.""" | ||||
|         if name in self._spinners: | ||||
|             data = self._spinners[name] | ||||
|             if text: | ||||
|                 data.text = text | ||||
|                 data.spinner.text = text | ||||
|             if spinner_style: | ||||
|                 data.spinner_style = spinner_style | ||||
|                 data.spinner.style = spinner_style | ||||
|             if spinner_type: | ||||
|                 data.spinner_type = spinner_type | ||||
|                 data.spinner = Spinner(spinner_type, text=data.text) | ||||
|  | ||||
|     async def remove(self, name: str): | ||||
|         """Remove a spinner and stop the Live loop if no spinners remain.""" | ||||
|         self._spinners.pop(name, None) | ||||
|         async with self._lock: | ||||
|             if not self._spinners: | ||||
|                 logger.debug("[%s] Stopping spinner manager, no spinners left.", name) | ||||
|                 if self._task: | ||||
|                     self._task.cancel() | ||||
|                 self._running = False | ||||
|  | ||||
|     async def _start_live(self): | ||||
|         """Start the Live rendering loop in the background.""" | ||||
|         self._running = True | ||||
|         self._task = asyncio.create_task(self._live_loop()) | ||||
|  | ||||
|     def render_panel(self): | ||||
|         """Render all active spinners as a grouped Rich panel.""" | ||||
|         rows = [] | ||||
|         for data in self._spinners.values(): | ||||
|             rows.append(data.spinner) | ||||
|         return Group(*rows) | ||||
|  | ||||
|     async def _live_loop(self): | ||||
|         """Continuously refresh the spinner display until stopped.""" | ||||
|         with Live( | ||||
|             self.render_panel(), | ||||
|             refresh_per_second=12.5, | ||||
|             console=self.console, | ||||
|             transient=True, | ||||
|         ) as live: | ||||
|             while self._spinners: | ||||
|                 live.update(self.render_panel()) | ||||
|                 await asyncio.sleep(0.1) | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     spinner_manager = SpinnerManager() | ||||
|  | ||||
|     async def demo(): | ||||
|         # Add multiple spinners | ||||
|         await spinner_manager.add("task1", "Loading configs…") | ||||
|         await spinner_manager.add( | ||||
|             "task2", "Building containers…", spinner_type="bouncingBall" | ||||
|         ) | ||||
|         await spinner_manager.add("task3", "Deploying services…", spinner_type="earth") | ||||
|  | ||||
|         # Simulate work | ||||
|         await asyncio.sleep(2) | ||||
|         spinner_manager.update("task1", text="Configs loaded ✅") | ||||
|         await asyncio.sleep(1) | ||||
|         spinner_manager.remove("task1") | ||||
|  | ||||
|         await spinner_manager.add("task4", "Running Tests...") | ||||
|  | ||||
|         await asyncio.sleep(2) | ||||
|         spinner_manager.update("task2", text="Build complete ✅") | ||||
|         spinner_manager.remove("task2") | ||||
|  | ||||
|         await asyncio.sleep(1) | ||||
|         spinner_manager.update("task3", text="Deployed! 🎉") | ||||
|         await asyncio.sleep(1) | ||||
|         spinner_manager.remove("task3") | ||||
|  | ||||
|         await asyncio.sleep(5) | ||||
|  | ||||
|         spinner_manager.update("task4", "Tests Complete!") | ||||
|         spinner_manager.remove("task4") | ||||
|         console.print("Done!") | ||||
|  | ||||
|     asyncio.run(demo()) | ||||
| @@ -1,5 +1,15 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """tagged_table.py""" | ||||
| """ | ||||
| Generates a Rich table view of Falyx commands grouped by their tags. | ||||
|  | ||||
| This module defines a utility function for rendering a custom CLI command | ||||
| table that organizes commands into groups based on their first tag. It is | ||||
| used to visually separate commands in interactive menus for better clarity | ||||
| and discoverability. | ||||
|  | ||||
| Functions: | ||||
| - build_tagged_table(flx): Returns a `rich.Table` of commands grouped by tag. | ||||
| """ | ||||
| from collections import defaultdict | ||||
|  | ||||
| from rich import box | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """ | ||||
| colors.py | ||||
|  | ||||
| A Python module that integrates the Nord color palette with the Rich library. | ||||
| It defines a metaclass-based NordColors class allowing dynamic attribute lookups | ||||
| (e.g., NORD12bu -> "#D08770 bold underline") and provides a comprehensive Nord-based | ||||
|   | ||||
| @@ -1,5 +1,21 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """utils.py""" | ||||
| """ | ||||
| General-purpose utilities and helpers for the Falyx CLI framework. | ||||
|  | ||||
| This module includes asynchronous wrappers, logging setup, formatting utilities, | ||||
| and small type-safe enhancements such as `CaseInsensitiveDict` and coroutine enforcement. | ||||
|  | ||||
| Features: | ||||
| - `ensure_async`: Wraps sync functions as async coroutines. | ||||
| - `chunks`: Splits an iterable into fixed-size chunks. | ||||
| - `CaseInsensitiveDict`: Dict subclass with case-insensitive string keys. | ||||
| - `setup_logging`: Configures Rich or JSON logging based on environment or container detection. | ||||
| - `get_program_invocation`: Returns the recommended CLI command to invoke the program. | ||||
| - `running_in_container`: Detects if the process is running inside a container. | ||||
|  | ||||
| These utilities support consistent behavior across CLI rendering, logging, | ||||
| command parsing, and compatibility layers. | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import functools | ||||
| @@ -14,6 +30,8 @@ from typing import Any, Awaitable, Callable, TypeVar | ||||
| import pythonjsonlogger.json | ||||
| from rich.logging import RichHandler | ||||
|  | ||||
| from falyx.console import console | ||||
|  | ||||
| T = TypeVar("T") | ||||
|  | ||||
|  | ||||
| @@ -164,6 +182,7 @@ def setup_logging( | ||||
|  | ||||
|     if mode == "cli": | ||||
|         console_handler: RichHandler | logging.StreamHandler = RichHandler( | ||||
|             console=console, | ||||
|             rich_tracebacks=True, | ||||
|             show_time=True, | ||||
|             show_level=True, | ||||
| @@ -184,7 +203,7 @@ def setup_logging( | ||||
|     console_handler.setLevel(console_log_level) | ||||
|     root.addHandler(console_handler) | ||||
|  | ||||
|     file_handler = logging.FileHandler(log_filename) | ||||
|     file_handler = logging.FileHandler(log_filename, "a", "UTF-8") | ||||
|     file_handler.setLevel(file_log_level) | ||||
|     if json_log_to_file: | ||||
|         file_handler.setFormatter( | ||||
|   | ||||
| @@ -1,8 +1,61 @@ | ||||
| # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||
| """validators.py""" | ||||
| from typing import KeysView, Sequence | ||||
| """ | ||||
| Input validators for use with Prompt Toolkit and interactive Falyx CLI workflows. | ||||
|  | ||||
| from prompt_toolkit.validation import Validator | ||||
| This module defines reusable `Validator` instances and subclasses that enforce valid | ||||
| user input during prompts—especially for selection actions, confirmations, and | ||||
| argument parsing. | ||||
|  | ||||
| Included Validators: | ||||
| - CommandValidator: Validates if the input matches a known command. | ||||
| - int_range_validator: Enforces numeric input within a range. | ||||
| - key_validator: Ensures the entered value matches a valid selection key. | ||||
| - yes_no_validator: Restricts input to 'Y' or 'N'. | ||||
| - word_validator / words_validator: Accepts specific valid words (case-insensitive). | ||||
| - MultiIndexValidator: Validates numeric list input (e.g. "1,2,3"). | ||||
| - MultiKeyValidator: Validates string key list input (e.g. "A,B,C"). | ||||
|  | ||||
| These validators integrate directly into `PromptSession.prompt_async()` to | ||||
| enforce correctness and provide helpful error messages. | ||||
| """ | ||||
| from typing import TYPE_CHECKING, KeysView, Sequence | ||||
|  | ||||
| from prompt_toolkit.validation import ValidationError, Validator | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from falyx.falyx import Falyx | ||||
|  | ||||
|  | ||||
| class CommandValidator(Validator): | ||||
|     """Validator to check if the input is a valid command.""" | ||||
|  | ||||
|     def __init__(self, falyx: "Falyx", error_message: str) -> None: | ||||
|         super().__init__() | ||||
|         self.falyx = falyx | ||||
|         self.error_message = error_message | ||||
|  | ||||
|     def validate(self, document) -> None: | ||||
|         if not document.text: | ||||
|             raise ValidationError( | ||||
|                 message=self.error_message, | ||||
|                 cursor_position=len(document.text), | ||||
|             ) | ||||
|  | ||||
|     async def validate_async(self, document) -> None: | ||||
|         text = document.text | ||||
|         if not text: | ||||
|             raise ValidationError( | ||||
|                 message=self.error_message, | ||||
|                 cursor_position=len(text), | ||||
|             ) | ||||
|         is_preview, choice, _, __ = await self.falyx.get_command(text, from_validate=True) | ||||
|         if is_preview: | ||||
|             return None | ||||
|         if not choice: | ||||
|             raise ValidationError( | ||||
|                 message=self.error_message, | ||||
|                 cursor_position=len(text), | ||||
|             ) | ||||
|  | ||||
|  | ||||
| def int_range_validator(minimum: int, maximum: int) -> Validator: | ||||
| @@ -44,4 +97,119 @@ def yes_no_validator() -> Validator: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|     return Validator.from_callable(validate, error_message="Enter 'Y' or 'n'.") | ||||
|     return Validator.from_callable(validate, error_message="Enter 'Y', 'y' or 'N', 'n'.") | ||||
|  | ||||
|  | ||||
| def words_validator( | ||||
|     keys: Sequence[str] | KeysView[str], error_message: str | None = None | ||||
| ) -> Validator: | ||||
|     """Validator for specific word inputs.""" | ||||
|  | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper() not in [key.upper() for key in keys]: | ||||
|             return False | ||||
|         return True | ||||
|  | ||||
|     if error_message is None: | ||||
|         error_message = f"Invalid input. Choices: {{{', '.join(keys)}}}." | ||||
|  | ||||
|     return Validator.from_callable(validate, error_message=error_message) | ||||
|  | ||||
|  | ||||
| def word_validator(word: str) -> Validator: | ||||
|     """Validator for specific word inputs.""" | ||||
|  | ||||
|     def validate(text: str) -> bool: | ||||
|         if text.upper().strip() == "N": | ||||
|             return True | ||||
|         return text.upper().strip() == word.upper() | ||||
|  | ||||
|     return Validator.from_callable(validate, error_message=f"Enter '{word}' or 'N', 'n'.") | ||||
|  | ||||
|  | ||||
| class MultiIndexValidator(Validator): | ||||
|     def __init__( | ||||
|         self, | ||||
|         minimum: int, | ||||
|         maximum: int, | ||||
|         number_selections: int | str, | ||||
|         separator: str, | ||||
|         allow_duplicates: bool, | ||||
|         cancel_key: str, | ||||
|     ) -> None: | ||||
|         self.minimum = minimum | ||||
|         self.maximum = maximum | ||||
|         self.number_selections = number_selections | ||||
|         self.separator = separator | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.cancel_key = cancel_key | ||||
|         super().__init__() | ||||
|  | ||||
|     def validate(self, document): | ||||
|         selections = [ | ||||
|             index.strip() for index in document.text.strip().split(self.separator) | ||||
|         ] | ||||
|         if not selections or selections == [""]: | ||||
|             raise ValidationError(message="Select at least 1 item.") | ||||
|         if self.cancel_key in selections and len(selections) == 1: | ||||
|             return | ||||
|         elif self.cancel_key in selections: | ||||
|             raise ValidationError(message="Cancel key must be selected alone.") | ||||
|         for selection in selections: | ||||
|             try: | ||||
|                 index = int(selection) | ||||
|                 if not self.minimum <= index <= self.maximum: | ||||
|                     raise ValidationError( | ||||
|                         message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||
|                     ) | ||||
|             except ValueError: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Invalid selection: {selection}. Select a number between {self.minimum} and {self.maximum}." | ||||
|                 ) | ||||
|             if not self.allow_duplicates and selections.count(selection) > 1: | ||||
|                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||
|         if isinstance(self.number_selections, int): | ||||
|             if self.number_selections == 1 and len(selections) > 1: | ||||
|                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||
|             if len(selections) != self.number_selections: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class MultiKeyValidator(Validator): | ||||
|     def __init__( | ||||
|         self, | ||||
|         keys: Sequence[str] | KeysView[str], | ||||
|         number_selections: int | str, | ||||
|         separator: str, | ||||
|         allow_duplicates: bool, | ||||
|         cancel_key: str, | ||||
|     ) -> None: | ||||
|         self.keys = keys | ||||
|         self.separator = separator | ||||
|         self.number_selections = number_selections | ||||
|         self.allow_duplicates = allow_duplicates | ||||
|         self.cancel_key = cancel_key | ||||
|         super().__init__() | ||||
|  | ||||
|     def validate(self, document): | ||||
|         selections = [key.strip() for key in document.text.strip().split(self.separator)] | ||||
|         if not selections or selections == [""]: | ||||
|             raise ValidationError(message="Select at least 1 item.") | ||||
|         if self.cancel_key in selections and len(selections) == 1: | ||||
|             return | ||||
|         elif self.cancel_key in selections: | ||||
|             raise ValidationError(message="Cancel key must be selected alone.") | ||||
|         for selection in selections: | ||||
|             if selection.upper() not in [key.upper() for key in self.keys]: | ||||
|                 raise ValidationError(message=f"Invalid selection: {selection}") | ||||
|             if not self.allow_duplicates and selections.count(selection) > 1: | ||||
|                 raise ValidationError(message=f"Duplicate selection: {selection}") | ||||
|         if isinstance(self.number_selections, int): | ||||
|             if self.number_selections == 1 and len(selections) > 1: | ||||
|                 raise ValidationError(message="Invalid selection. Select only 1 item.") | ||||
|             if len(selections) != self.number_selections: | ||||
|                 raise ValidationError( | ||||
|                     message=f"Select exactly {self.number_selections} items separated by '{self.separator}'" | ||||
|                 ) | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| __version__ = "0.1.28" | ||||
| __version__ = "0.1.78" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "falyx" | ||||
| version = "0.1.28" | ||||
| version = "0.1.78" | ||||
| description = "Reliable and introspectable async CLI action framework." | ||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||
| license = "MIT" | ||||
| @@ -10,12 +10,13 @@ packages = [{ include = "falyx" }] | ||||
| [tool.poetry.dependencies] | ||||
| python = ">=3.10" | ||||
| prompt_toolkit = "^3.0" | ||||
| rich = "^13.0" | ||||
| rich = "^14.0" | ||||
| pydantic = "^2.0" | ||||
| python-json-logger = "^3.3.0" | ||||
| toml = "^0.10" | ||||
| pyyaml = "^6.0" | ||||
| aiohttp = "^3.11" | ||||
| python-dateutil = "^2.8" | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| pytest = "^8.3.5" | ||||
| @@ -26,6 +27,10 @@ black = { version = "^25.0", allow-prereleases = true } | ||||
| mypy = { version = "^1.0", allow-prereleases = true } | ||||
| isort = { version = "^5.0", allow-prereleases = true } | ||||
| pytest-cov = "^4.0" | ||||
| mkdocs = "^1.6.1" | ||||
| mkdocs-material = "^9.6.14" | ||||
| mkdocstrings = {extras = ["python"], version = "^0.29.1"} | ||||
| mike = "^2.1.3" | ||||
|  | ||||
| [tool.poetry.scripts] | ||||
| falyx = "falyx.__main__:main" | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ChainedAction, FallbackAction, LiteralInputAction | ||||
| from falyx.action import ( | ||||
|     Action, | ||||
|     ActionGroup, | ||||
|     ChainedAction, | ||||
|     FallbackAction, | ||||
|     LiteralInputAction, | ||||
| ) | ||||
| from falyx.context import ExecutionContext | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
|  | ||||
| @@ -40,11 +46,11 @@ async def test_action_async_callable(): | ||||
|     assert result == "Hello, World!" | ||||
|     assert ( | ||||
|         str(action) | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)" | ||||
|     ) | ||||
|     assert ( | ||||
|         repr(action) | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False)" | ||||
|         == "Action(name='test_action', action=async_callable, args=(), kwargs={}, retry=False, rollback=False)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -59,11 +65,12 @@ async def test_chained_action(): | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     print(chain) | ||||
|     result = await chain() | ||||
|     assert result == [1, 2] | ||||
|     assert ( | ||||
|         str(chain) | ||||
|         == "ChainedAction(name='Simple Chain', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|         == "ChainedAction(name=Simple Chain, actions=['one', 'two'], args=(), kwargs={}, auto_inject=False, return_list=True)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -72,17 +79,17 @@ 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( | ||||
|     group = ActionGroup( | ||||
|         name="Simple Group", | ||||
|         actions=[action1, action2], | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|     print(group) | ||||
|     result = await group() | ||||
|     assert result == [1, 2] | ||||
|     assert result == [("one", 1), ("two", 2)] | ||||
|     assert ( | ||||
|         str(group) | ||||
|         == "ChainedAction(name='Simple Group', actions=['one', 'two'], auto_inject=False, return_list=True)" | ||||
|         == "ActionGroup(name=Simple Group, actions=['one', 'two'], args=(), kwargs={}, inject_last_result=False, inject_into=last_result)" | ||||
|     ) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										23
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								tests/test_actions/test_action_factory.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ActionFactory, ChainedAction | ||||
|  | ||||
|  | ||||
| def make_chain(value) -> ChainedAction: | ||||
|     return ChainedAction( | ||||
|         "test_chain", | ||||
|         [ | ||||
|             Action("action1", lambda: value + "_1"), | ||||
|             Action("action2", lambda: value + "_2"), | ||||
|         ], | ||||
|         return_list=True, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_factory_action(): | ||||
|     action = ActionFactory(name="test_action", factory=make_chain, args=("test_value",)) | ||||
|  | ||||
|     result = await action() | ||||
|  | ||||
|     assert result == ["test_value_1", "test_value_2"] | ||||
							
								
								
									
										145
									
								
								tests/test_actions/test_action_types.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								tests/test_actions/test_action_types.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,145 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action.action_types import ConfirmType, FileType, SelectionReturnType | ||||
|  | ||||
|  | ||||
| def test_file_type_enum(): | ||||
|     """Test if the FileType enum has all expected members.""" | ||||
|     assert FileType.TEXT.value == "text" | ||||
|     assert FileType.PATH.value == "path" | ||||
|     assert FileType.JSON.value == "json" | ||||
|     assert FileType.TOML.value == "toml" | ||||
|     assert FileType.YAML.value == "yaml" | ||||
|     assert FileType.CSV.value == "csv" | ||||
|     assert FileType.TSV.value == "tsv" | ||||
|     assert FileType.XML.value == "xml" | ||||
|  | ||||
|     assert str(FileType.TEXT) == "text" | ||||
|  | ||||
|  | ||||
| def test_file_type_choices(): | ||||
|     """Test if the FileType choices method returns all enum members.""" | ||||
|     choices = FileType.choices() | ||||
|     assert len(choices) == 8 | ||||
|     assert all(isinstance(choice, FileType) for choice in choices) | ||||
|  | ||||
|  | ||||
| def test_file_type_missing(): | ||||
|     """Test if the _missing_ method raises ValueError for invalid values.""" | ||||
|     with pytest.raises(ValueError, match="Invalid FileType: 'invalid'"): | ||||
|         FileType._missing_("invalid") | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid FileType: 123"): | ||||
|         FileType._missing_(123) | ||||
|  | ||||
|  | ||||
| def test_file_type_aliases(): | ||||
|     """Test if the _get_alias method returns correct aliases.""" | ||||
|     assert FileType._get_alias("file") == "path" | ||||
|     assert FileType._get_alias("filepath") == "path" | ||||
|     assert FileType._get_alias("unknown") == "unknown" | ||||
|  | ||||
|  | ||||
| def test_file_type_missing_aliases(): | ||||
|     """Test if the _missing_ method handles aliases correctly.""" | ||||
|     assert FileType._missing_("file") == FileType.PATH | ||||
|     assert FileType._missing_("filepath") == FileType.PATH | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid FileType: 'unknown'"): | ||||
|         FileType._missing_("unknown") | ||||
|  | ||||
|  | ||||
| def test_confirm_type_enum(): | ||||
|     """Test if the ConfirmType enum has all expected members.""" | ||||
|     assert ConfirmType.YES_NO.value == "yes_no" | ||||
|     assert ConfirmType.YES_CANCEL.value == "yes_cancel" | ||||
|     assert ConfirmType.YES_NO_CANCEL.value == "yes_no_cancel" | ||||
|     assert ConfirmType.TYPE_WORD.value == "type_word" | ||||
|     assert ConfirmType.TYPE_WORD_CANCEL.value == "type_word_cancel" | ||||
|     assert ConfirmType.OK_CANCEL.value == "ok_cancel" | ||||
|     assert ConfirmType.ACKNOWLEDGE.value == "acknowledge" | ||||
|  | ||||
|     assert str(ConfirmType.YES_NO) == "yes_no" | ||||
|  | ||||
|  | ||||
| def test_confirm_type_choices(): | ||||
|     """Test if the ConfirmType choices method returns all enum members.""" | ||||
|     choices = ConfirmType.choices() | ||||
|     assert len(choices) == 7 | ||||
|     assert all(isinstance(choice, ConfirmType) for choice in choices) | ||||
|  | ||||
|  | ||||
| def test_confirm_type_missing(): | ||||
|     """Test if the _missing_ method raises ValueError for invalid values.""" | ||||
|     with pytest.raises(ValueError, match="Invalid ConfirmType: 'invalid'"): | ||||
|         ConfirmType._missing_("invalid") | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid ConfirmType: 123"): | ||||
|         ConfirmType._missing_(123) | ||||
|  | ||||
|  | ||||
| def test_confirm_type_aliases(): | ||||
|     """Test if the _get_alias method returns correct aliases.""" | ||||
|     assert ConfirmType._get_alias("yes") == "yes_no" | ||||
|     assert ConfirmType._get_alias("ok") == "ok_cancel" | ||||
|     assert ConfirmType._get_alias("type") == "type_word" | ||||
|     assert ConfirmType._get_alias("word") == "type_word" | ||||
|     assert ConfirmType._get_alias("word_cancel") == "type_word_cancel" | ||||
|     assert ConfirmType._get_alias("ack") == "acknowledge" | ||||
|  | ||||
|  | ||||
| def test_confirm_type_missing_aliases(): | ||||
|     """Test if the _missing_ method handles aliases correctly.""" | ||||
|     assert ConfirmType("yes") == ConfirmType.YES_NO | ||||
|     assert ConfirmType("ok") == ConfirmType.OK_CANCEL | ||||
|     assert ConfirmType("word") == ConfirmType.TYPE_WORD | ||||
|     assert ConfirmType("ack") == ConfirmType.ACKNOWLEDGE | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid ConfirmType: 'unknown'"): | ||||
|         ConfirmType._missing_("unknown") | ||||
|  | ||||
|  | ||||
| def test_selection_return_type_enum(): | ||||
|     """Test if the SelectionReturnType enum has all expected members.""" | ||||
|     assert SelectionReturnType.KEY.value == "key" | ||||
|     assert SelectionReturnType.VALUE.value == "value" | ||||
|     assert SelectionReturnType.DESCRIPTION.value == "description" | ||||
|     assert SelectionReturnType.DESCRIPTION_VALUE.value == "description_value" | ||||
|     assert SelectionReturnType.ITEMS.value == "items" | ||||
|  | ||||
|     assert str(SelectionReturnType.KEY) == "key" | ||||
|  | ||||
|  | ||||
| def test_selection_return_type_choices(): | ||||
|     """Test if the SelectionReturnType choices method returns all enum members.""" | ||||
|     choices = SelectionReturnType.choices() | ||||
|     assert len(choices) == 5 | ||||
|     assert all(isinstance(choice, SelectionReturnType) for choice in choices) | ||||
|  | ||||
|  | ||||
| def test_selection_return_type_missing(): | ||||
|     """Test if the _missing_ method raises ValueError for invalid values.""" | ||||
|     with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'invalid'"): | ||||
|         SelectionReturnType._missing_("invalid") | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid SelectionReturnType: 123"): | ||||
|         SelectionReturnType._missing_(123) | ||||
|  | ||||
|  | ||||
| def test_selection_return_type_aliases(): | ||||
|     """Test if the _get_alias method returns correct aliases.""" | ||||
|     assert SelectionReturnType._get_alias("desc") == "description" | ||||
|     assert SelectionReturnType._get_alias("desc_value") == "description_value" | ||||
|     assert SelectionReturnType._get_alias("unknown") == "unknown" | ||||
|  | ||||
|  | ||||
| def test_selection_return_type_missing_aliases(): | ||||
|     """Test if the _missing_ method handles aliases correctly.""" | ||||
|     assert SelectionReturnType._missing_("desc") == SelectionReturnType.DESCRIPTION | ||||
|     assert ( | ||||
|         SelectionReturnType._missing_("desc_value") | ||||
|         == SelectionReturnType.DESCRIPTION_VALUE | ||||
|     ) | ||||
|  | ||||
|     with pytest.raises(ValueError, match="Invalid SelectionReturnType: 'unknown'"): | ||||
|         SelectionReturnType._missing_("unknown") | ||||
							
								
								
									
										94
									
								
								tests/test_actions/test_confirm_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								tests/test_actions/test_confirm_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import ConfirmAction | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_no(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_yes_no_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="yes_no_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_type_word(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_type_word_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="type_word_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_ok_cancel(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="ok_cancel", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_confirm_action_acknowledge(): | ||||
|     action = ConfirmAction( | ||||
|         name="test", | ||||
|         prompt_message="Are you sure?", | ||||
|         never_prompt=True, | ||||
|         confirm_type="acknowledge", | ||||
|     ) | ||||
|  | ||||
|     result = await action() | ||||
|     assert result is True | ||||
							
								
								
									
										287
									
								
								tests/test_actions/test_selection_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								tests/test_actions/test_selection_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,287 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import SelectionAction | ||||
| from falyx.selection import SelectionOption | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_list_never_prompt_by_value(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=["a", "b", "c"], | ||||
|         default_selection="b", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "b" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "b" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_list_never_prompt_by_index(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=["a", "b", "c"], | ||||
|         default_selection="2", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "2" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "c" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_list_never_prompt_by_value_multi_select(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=["a", "b", "c"], | ||||
|         default_selection=["b", "c"], | ||||
|         never_prompt=True, | ||||
|         number_selections=2, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["b", "c"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["b", "c"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_list_never_prompt_by_index_multi_select(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=["a", "b", "c"], | ||||
|         default_selection=["1", "2"], | ||||
|         never_prompt=True, | ||||
|         number_selections=2, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["1", "2"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["b", "c"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_dict_never_prompt(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections={"a": "Alpha", "b": "Beta", "c": "Gamma"}, | ||||
|         default_selection="b", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "b" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Beta" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_dict_never_prompt_by_value(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections={"a": "Alpha", "b": "Beta", "c": "Gamma"}, | ||||
|         default_selection="Beta", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "Beta" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Beta" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_dict_never_prompt_by_key(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections={"a": "Alpha", "b": "Beta", "c": "Gamma"}, | ||||
|         default_selection="b", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "b" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Beta" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_key(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection="c", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "c" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Gamma Service" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_description(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection="Alpha", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "Alpha" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Alpha Service" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_value(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection="Beta Service", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == "Beta Service" | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == "Beta Service" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_dict_never_prompt_by_value_multi_select(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections={"a": "Alpha", "b": "Beta", "c": "Gamma"}, | ||||
|         default_selection=["Beta", "Gamma"], | ||||
|         number_selections=2, | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["Beta", "Gamma"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Beta", "Gamma"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_dict_never_prompt_by_key_multi_select(): | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections={"a": "Alpha", "b": "Beta", "c": "Gamma"}, | ||||
|         default_selection=["a", "b"], | ||||
|         number_selections=2, | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["a", "b"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Alpha", "Beta"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_key_multi_select(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection=["b", "c"], | ||||
|         number_selections=2, | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["b", "c"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Beta Service", "Gamma Service"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_description_multi_select(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection=["Alpha", "Gamma"], | ||||
|         number_selections=2, | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["Alpha", "Gamma"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Alpha Service", "Gamma Service"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_value_multi_select(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection=["Beta Service", "Alpha Service"], | ||||
|         number_selections=2, | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["Beta Service", "Alpha Service"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Beta Service", "Alpha Service"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_selection_prompt_map_never_prompt_by_value_wildcard(): | ||||
|     prompt_map = { | ||||
|         "a": SelectionOption(description="Alpha", value="Alpha Service"), | ||||
|         "b": SelectionOption(description="Beta", value="Beta Service"), | ||||
|         "c": SelectionOption(description="Gamma", value="Gamma Service"), | ||||
|     } | ||||
|     action = SelectionAction( | ||||
|         name="test", | ||||
|         selections=prompt_map, | ||||
|         default_selection=["Beta Service", "Alpha Service"], | ||||
|         number_selections="*", | ||||
|         never_prompt=True, | ||||
|     ) | ||||
|     assert action.never_prompt is True | ||||
|     assert action.default_selection == ["Beta Service", "Alpha Service"] | ||||
|  | ||||
|     result = await action() | ||||
|     assert result == ["Beta Service", "Alpha Service"] | ||||
| @@ -1,7 +1,7 @@ | ||||
| # test_command.py | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action, ActionGroup, BaseIOAction, ChainedAction | ||||
| from falyx.action import Action, BaseIOAction, ChainedAction | ||||
| from falyx.command import Command | ||||
| from falyx.execution_registry import ExecutionRegistry as er | ||||
| from falyx.retry import RetryPolicy | ||||
| @@ -50,108 +50,13 @@ def test_command_str(): | ||||
|     """Test if Command string representation is correct.""" | ||||
|     action = Action("test_action", dummy_action) | ||||
|     cmd = Command(key="TEST", description="Test Command", action=action) | ||||
|     print(cmd) | ||||
|     assert ( | ||||
|         str(cmd) | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False)')" | ||||
|         == "Command(key='TEST', description='Test Command' action='Action(name='test_action', action=dummy_action, args=(), kwargs={}, retry=False, rollback=False)')" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "action_factory, expected_requires_input", | ||||
|     [ | ||||
|         (lambda: Action(name="normal", action=dummy_action), False), | ||||
|         (lambda: DummyInputAction(name="io"), True), | ||||
|         ( | ||||
|             lambda: ChainedAction(name="chain", actions=[DummyInputAction(name="io")]), | ||||
|             True, | ||||
|         ), | ||||
|         ( | ||||
|             lambda: ActionGroup(name="group", actions=[DummyInputAction(name="io")]), | ||||
|             True, | ||||
|         ), | ||||
|     ], | ||||
| ) | ||||
| def test_command_requires_input_detection(action_factory, expected_requires_input): | ||||
|     action = action_factory() | ||||
|     cmd = Command(key="TEST", description="Test Command", action=action) | ||||
|  | ||||
|     assert cmd.requires_input == expected_requires_input | ||||
|     if expected_requires_input: | ||||
|         assert cmd.hidden is True | ||||
|     else: | ||||
|         assert cmd.hidden is False | ||||
|  | ||||
|  | ||||
| def test_requires_input_flag_detected_for_baseioaction(): | ||||
|     """Command should automatically detect requires_input=True for BaseIOAction.""" | ||||
|     cmd = Command( | ||||
|         key="X", | ||||
|         description="Echo input", | ||||
|         action=DummyInputAction(name="dummy"), | ||||
|     ) | ||||
|     assert cmd.requires_input is True | ||||
|     assert cmd.hidden is True | ||||
|  | ||||
|  | ||||
| def test_requires_input_manual_override(): | ||||
|     """Command manually set requires_input=False should not auto-hide.""" | ||||
|     cmd = Command( | ||||
|         key="Y", | ||||
|         description="Custom input command", | ||||
|         action=DummyInputAction(name="dummy"), | ||||
|         requires_input=False, | ||||
|     ) | ||||
|     assert cmd.requires_input is False | ||||
|     assert cmd.hidden is False | ||||
|  | ||||
|  | ||||
| def test_default_command_does_not_require_input(): | ||||
|     """Normal Command without IO Action should not require input.""" | ||||
|     cmd = Command( | ||||
|         key="Z", | ||||
|         description="Simple action", | ||||
|         action=lambda: 42, | ||||
|     ) | ||||
|     assert cmd.requires_input is False | ||||
|     assert cmd.hidden is False | ||||
|  | ||||
|  | ||||
| def test_chain_requires_input(): | ||||
|     """If first action in a chain requires input, the command should require input.""" | ||||
|     chain = ChainedAction( | ||||
|         name="ChainWithInput", | ||||
|         actions=[ | ||||
|             DummyInputAction(name="dummy"), | ||||
|             Action(name="action1", action=lambda: 1), | ||||
|         ], | ||||
|     ) | ||||
|     cmd = Command( | ||||
|         key="A", | ||||
|         description="Chain with input", | ||||
|         action=chain, | ||||
|     ) | ||||
|     assert cmd.requires_input is True | ||||
|     assert cmd.hidden is True | ||||
|  | ||||
|  | ||||
| def test_group_requires_input(): | ||||
|     """If any action in a group requires input, the command should require input.""" | ||||
|     group = ActionGroup( | ||||
|         name="GroupWithInput", | ||||
|         actions=[ | ||||
|             Action(name="action1", action=lambda: 1), | ||||
|             DummyInputAction(name="dummy"), | ||||
|         ], | ||||
|     ) | ||||
|     cmd = Command( | ||||
|         key="B", | ||||
|         description="Group with input", | ||||
|         action=group, | ||||
|     ) | ||||
|     assert cmd.requires_input is True | ||||
|     assert cmd.hidden is True | ||||
|  | ||||
|  | ||||
| def test_enable_retry(): | ||||
|     """Command should enable retry if action is an Action and  retry is set to True.""" | ||||
|     cmd = Command( | ||||
| @@ -193,13 +98,17 @@ def test_enable_retry_not_action(): | ||||
|     cmd = Command( | ||||
|         key="C", | ||||
|         description="Retry action", | ||||
|         action=DummyInputAction, | ||||
|         action=DummyInputAction( | ||||
|             name="dummy_input_action", | ||||
|         ), | ||||
|         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) | ||||
|     assert "'DummyInputAction' object has no attribute 'retry_policy'" in str( | ||||
|         exc_info.value | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def test_chain_retry_all(): | ||||
| @@ -229,13 +138,17 @@ def test_chain_retry_all_not_base_action(): | ||||
|     cmd = Command( | ||||
|         key="E", | ||||
|         description="Chain with retry", | ||||
|         action=DummyInputAction, | ||||
|         action=DummyInputAction( | ||||
|             name="dummy_input_action", | ||||
|         ), | ||||
|         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) | ||||
|     assert "'DummyInputAction' object has no attribute 'retry_policy'" in str( | ||||
|         exc_info.value | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
|   | ||||
| @@ -1,102 +1,113 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.argparse import ArgumentAction, CommandArgumentParser | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser import ArgumentAction, CommandArgumentParser | ||||
| from falyx.signals import HelpSignal | ||||
|  | ||||
|  | ||||
| def build_parser_and_parse(args, config): | ||||
| async def build_parser_and_parse(args, config): | ||||
|     cap = CommandArgumentParser() | ||||
|     config(cap) | ||||
|     return cap.parse_args(args) | ||||
|     return await cap.parse_args(args) | ||||
|  | ||||
|  | ||||
| def test_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_none(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--foo", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(None, config) | ||||
|     parsed = await build_parser_and_parse(None, config) | ||||
|     assert parsed["foo"] is None | ||||
|  | ||||
|  | ||||
| def test_append_multiple_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_multiple_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--tag", action=ArgumentAction.APPEND, type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--tag", "a", "--tag", "b", "--tag", "c"], config) | ||||
|     parsed = await build_parser_and_parse( | ||||
|         ["--tag", "a", "--tag", "b", "--tag", "c"], config | ||||
|     ) | ||||
|     assert parsed["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_positional_nargs_plus_and_single(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_positional_nargs_plus_and_single(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("files", nargs="+", type=str) | ||||
|         parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     parsed = await build_parser_and_parse(["a", "b", "c", "prod"], config) | ||||
|     assert parsed["files"] == ["a", "b", "c"] | ||||
|     assert parsed["mode"] == "prod" | ||||
|  | ||||
|  | ||||
| def test_type_validation_failure(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_type_validation_failure(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--count", type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--count", "abc"], config) | ||||
|         await build_parser_and_parse(["--count", "abc"], config) | ||||
|  | ||||
|  | ||||
| def test_required_field_missing(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_required_field_missing(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str, required=True) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse([], config) | ||||
|         await build_parser_and_parse([], config) | ||||
|  | ||||
|  | ||||
| def test_choices_enforced(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_choices_enforced(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--mode", choices=["dev", "prod"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         build_parser_and_parse(["--mode", "staging"], config) | ||||
|         await build_parser_and_parse(["--mode", "staging"], config) | ||||
|  | ||||
|  | ||||
| def test_boolean_flags(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_boolean_flags(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--debug", action=ArgumentAction.STORE_TRUE) | ||||
|         parser.add_argument("--no-debug", action=ArgumentAction.STORE_FALSE) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     parsed = await build_parser_and_parse(["--debug", "--no-debug"], config) | ||||
|     assert parsed["debug"] is True | ||||
|     assert parsed["no_debug"] is False | ||||
|     parsed = build_parser_and_parse([], config) | ||||
|     print(parsed) | ||||
|     parsed = await build_parser_and_parse([], config) | ||||
|     assert parsed["debug"] is False | ||||
|     assert parsed["no_debug"] is True | ||||
|  | ||||
|  | ||||
| def test_count_action(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_count_action(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("-v", action=ArgumentAction.COUNT) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     parsed = await build_parser_and_parse(["-v", "-v", "-v"], config) | ||||
|     assert parsed["v"] == 3 | ||||
|  | ||||
|  | ||||
| def test_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_nargs_star(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("args", nargs="*", type=str) | ||||
|  | ||||
|     parsed = build_parser_and_parse(["one", "two", "three"], config) | ||||
|     parsed = await build_parser_and_parse(["one", "two", "three"], config) | ||||
|     assert parsed["args"] == ["one", "two", "three"] | ||||
|  | ||||
|  | ||||
| def test_flag_and_positional_mix(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_flag_and_positional_mix(): | ||||
|     def config(parser): | ||||
|         parser.add_argument("--env", type=str) | ||||
|         parser.add_argument("tasks", nargs="+") | ||||
|  | ||||
|     parsed = build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     parsed = await build_parser_and_parse(["--env", "prod", "build", "test"], config) | ||||
|     assert parsed["env"] == "prod" | ||||
|     assert parsed["tasks"] == ["build", "test"] | ||||
|  | ||||
| @@ -134,7 +145,7 @@ def test_add_argument_multiple_optional_flags_same_dest(): | ||||
|     parser.add_argument("-f", "--falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx"] | ||||
|     assert arg.flags == ("-f", "--falyx") | ||||
|  | ||||
|  | ||||
| def test_add_argument_flag_dest_conflict(): | ||||
| @@ -165,7 +176,7 @@ def test_add_argument_multiple_flags_custom_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test", dest="falyx") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_multiple_flags_dest(): | ||||
| @@ -175,7 +186,7 @@ def test_add_argument_multiple_flags_dest(): | ||||
|     parser.add_argument("-f", "--falyx", "--test") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["-f", "--falyx", "--test"] | ||||
|     assert arg.flags == ("-f", "--falyx", "--test") | ||||
|  | ||||
|  | ||||
| def test_add_argument_single_flag_dest(): | ||||
| @@ -185,7 +196,7 @@ def test_add_argument_single_flag_dest(): | ||||
|     parser.add_argument("-f") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "f" | ||||
|     assert arg.flags == ["-f"] | ||||
|     assert arg.flags == ("-f",) | ||||
|  | ||||
|  | ||||
| def test_add_argument_bad_dest(): | ||||
| @@ -257,7 +268,7 @@ def test_add_argument_default_value(): | ||||
|     parser.add_argument("--falyx", default="default_value") | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| @@ -297,20 +308,21 @@ def test_add_argument_default_not_in_choices(): | ||||
|         parser.add_argument("--falyx", choices=["a", "b"], default="c") | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_add_argument_choices(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     # ✅ Choices provided | ||||
|     parser.add_argument("--falyx", choices=["a", "b", "c"]) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.choices == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--falyx", "a"]) | ||||
|     args = await parser.parse_args(["--falyx", "a"]) | ||||
|     assert args["falyx"] == "a" | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--falyx", "d"]) | ||||
|         await parser.parse_args(["--falyx", "d"]) | ||||
|  | ||||
|  | ||||
| def test_add_argument_choices_invalid(): | ||||
| @@ -333,26 +345,28 @@ def test_add_argument_choices_invalid(): | ||||
| def test_add_argument_bad_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     # ❌ Invalid nargs value | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--falyx", nargs="invalid") | ||||
|  | ||||
|     # ❌ Invalid nargs type | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--falyx", nargs=123) | ||||
|         parser.add_argument("--foo", nargs="123") | ||||
|  | ||||
|     # ❌ Invalid nargs type | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--falyx", nargs=None) | ||||
|         parser.add_argument("--foo", nargs=[1, 2]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("--too", action="count", nargs=5) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("falyx", action="store_true", nargs=5) | ||||
|  | ||||
|  | ||||
| def test_add_argument_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     # ✅ Valid nargs value | ||||
|     parser.add_argument("--falyx", nargs=2) | ||||
|     arg = parser._arguments[-1] | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.nargs == 2 | ||||
|  | ||||
|  | ||||
| @@ -377,56 +391,63 @@ def test_get_argument(): | ||||
|     parser.add_argument("--falyx", type=str, default="default_value") | ||||
|     arg = parser.get_argument("falyx") | ||||
|     assert arg.dest == "falyx" | ||||
|     assert arg.flags == ["--falyx"] | ||||
|     assert arg.flags == ("--falyx",) | ||||
|     assert arg.default == "default_value" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|     parser.add_argument("--action", action="store_true") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|  | ||||
|     args = await parser.parse_args(["a", "b", "c", "--action"]) | ||||
|     assert args["files"] == ["a", "b"] | ||||
|     assert args["mode"] == "c" | ||||
|     args = await parser.parse_args(["--action", "a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b"] | ||||
|     assert args["mode"] == "c" | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_flagged_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_flagged_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     args = await parser.parse_args(["--files", "a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args(["--files", "a"]) | ||||
|     args = await parser.parse_args(["--files", "a"]) | ||||
|     print(args) | ||||
|     assert args["files"] == ["a"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_numbered_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_numbered_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b"]) | ||||
|     args = await parser.parse_args(["a", "b"]) | ||||
|     assert args["files"] == ["a", "b"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args(["a"]) | ||||
|         args = await parser.parse_args(["a"]) | ||||
|         print(args) | ||||
|  | ||||
|  | ||||
| @@ -436,48 +457,53 @@ def test_parse_args_nargs_zero(): | ||||
|         parser.add_argument("files", nargs=0, type=str) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_more_than_expected(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_more_than_expected(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs=2, type=str) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["a", "b", "c", "d"]) | ||||
|         await parser.parse_args(["a", "b", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_one_or_none(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_one_or_none(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="?", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a"]) | ||||
|     args = await parser.parse_args(["a"]) | ||||
|     assert args["files"] == "a" | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] is None | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="*", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_positional_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_positional_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         args = parser.parse_args([]) | ||||
|         args = await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_multiple_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_multiple_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", nargs="+", type=str) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
| @@ -485,7 +511,7 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     parser.add_argument("target", nargs="*") | ||||
|     parser.add_argument("extra", nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     args = await parser.parse_args(["a", "b", "c", "d", "e"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|     assert args["mode"] == "d" | ||||
|     assert args["action"] == [] | ||||
| @@ -493,186 +519,311 @@ def test_parse_args_nargs_multiple_positional(): | ||||
|     assert args["extra"] == ["e"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_invalid_positional_arguments(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_none(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", type=int) | ||||
|     parser.add_argument("mode") | ||||
|  | ||||
|     await parser.parse_args(["1", "2"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_invalid_positional_arguments(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", nargs="*", type=int) | ||||
|     parser.add_argument("mode", nargs=1) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["1", "2", "c", "d"]) | ||||
|         await parser.parse_args(["1", "2", "c", "d"]) | ||||
|  | ||||
|  | ||||
| def test_parse_args_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     assert args["numbers"] == [1, 2, 3] | ||||
|  | ||||
|     args = parser.parse_args(["--numbers", "1"]) | ||||
|     args = await parser.parse_args(["--numbers", "1"]) | ||||
|     assert args["numbers"] == [1] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_nargs_append(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_int_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||
|  | ||||
|     args = await parser.parse_args(["--numbers", "1", "--numbers", "2", "--numbers", "3"]) | ||||
|     assert args["numbers"] == [[1], [2], [3]] | ||||
|  | ||||
|     args = await parser.parse_args(["--numbers", "1"]) | ||||
|     assert args["numbers"] == [[1]] | ||||
|  | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs="*") | ||||
|     parser.add_argument("--mode") | ||||
|  | ||||
|     args = parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||
|     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||
|  | ||||
|     args = parser.parse_args(["1"]) | ||||
|     args = await parser.parse_args(["1"]) | ||||
|     assert args["numbers"] == [[1]] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args(["1", "2", "3", "--mode", "numbers", "4", "5"]) | ||||
|     assert args["numbers"] == [[1, 2, 3], [4, 5]] | ||||
|     assert args["mode"] == "numbers" | ||||
|  | ||||
|     args = await parser.parse_args(["1", "2", "3"]) | ||||
|     assert args["numbers"] == [[1, 2, 3]] | ||||
|  | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["numbers"] == [] | ||||
|  | ||||
|  | ||||
| def test_parse_args_append_flagged_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_int_optional_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     args = await parser.parse_args(["1"]) | ||||
|     assert args["numbers"] == [1] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_int_optional_append_multiple_values(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["1", "2"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_nargs_int_positional_append(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=1) | ||||
|  | ||||
|     args = await parser.parse_args(["1"]) | ||||
|     assert args["numbers"] == [[1]] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["1", "2", "3"]) | ||||
|  | ||||
|     parser2 = CommandArgumentParser() | ||||
|     parser2.add_argument("numbers", action=ArgumentAction.APPEND, type=int, nargs=2) | ||||
|  | ||||
|     args = await parser2.parse_args(["1", "2"]) | ||||
|     assert args["numbers"] == [[1, 2]] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser2.parse_args(["1", "2", "3"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_append_flagged_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--numbers", action=ArgumentAction.APPEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--numbers", "a"]) | ||||
|         await parser.parse_args(["--numbers", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_groups_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_groups_nargs(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("--item", action=ArgumentAction.APPEND, type=str, nargs=2) | ||||
|  | ||||
|     parsed = cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     parsed = await cap.parse_args(["--item", "a", "b", "--item", "c", "d"]) | ||||
|     assert parsed["item"] == [["a", "b"], ["c", "d"]] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await cap.parse_args(["--item", "a", "b", "--item", "c"]) | ||||
|  | ||||
| def test_extend_flattened(): | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_flattened(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("--value", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     parsed = cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     parsed = await cap.parse_args(["--value", "x", "--value", "y"]) | ||||
|     assert parsed["value"] == ["x", "y"] | ||||
|  | ||||
|  | ||||
| def test_parse_args_split_order(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_parse_args_split_order(): | ||||
|     cap = CommandArgumentParser() | ||||
|     cap.add_argument("a") | ||||
|     cap.add_argument("--x") | ||||
|     cap.add_argument("b", nargs="*") | ||||
|     args, kwargs = cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     args, kwargs = await cap.parse_args_split(["1", "--x", "100", "2"]) | ||||
|     assert args == ("1", ["2"]) | ||||
|     assert kwargs == {"x": "100"} | ||||
|  | ||||
|  | ||||
| def test_help_signal_triggers(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_help_signal_triggers(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--foo") | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_empty_parser_defaults(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_empty_parser_defaults(): | ||||
|     parser = CommandArgumentParser() | ||||
|     with pytest.raises(HelpSignal): | ||||
|         parser.parse_args(["--help"]) | ||||
|         await parser.parse_args(["--help"]) | ||||
|  | ||||
|  | ||||
| def test_extend_basic(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_basic(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--tag", action=ArgumentAction.EXTEND, type=str) | ||||
|  | ||||
|     args = parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     args = await parser.parse_args(["--tag", "a", "--tag", "b", "--tag", "c"]) | ||||
|     assert args["tag"] == ["a", "b", "c"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_2(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_2(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--pair", action=ArgumentAction.EXTEND, type=str, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     args = await parser.parse_args(["--pair", "a", "b", "--pair", "c", "d"]) | ||||
|     assert args["pair"] == ["a", "b", "c", "d"] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_star(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_star(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     args = await parser.parse_args(["--files", "x", "y", "z"]) | ||||
|     assert args["files"] == ["x", "y", "z"] | ||||
|  | ||||
|     args = parser.parse_args(["--files"]) | ||||
|     args = await parser.parse_args(["--files"]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_nargs_plus(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_nargs_plus(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--inputs", action=ArgumentAction.EXTEND, type=int, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     args = await parser.parse_args(["--inputs", "1", "2", "3", "--inputs", "4"]) | ||||
|     assert args["inputs"] == [1, 2, 3, 4] | ||||
|  | ||||
|  | ||||
| def test_extend_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", action=ArgumentAction.EXTEND, type=int) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_greedy_invalid_type(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_greedy_invalid_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--nums", nargs="*", type=int) | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--nums", "a"]) | ||||
|         await parser.parse_args(["--nums", "a"]) | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     args = parser.parse_args( | ||||
|     args = await parser.parse_args( | ||||
|         ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3", "4"] | ||||
|     ) | ||||
|     assert args["x"] == [["a", "b"], ["c", "d"]] | ||||
|     assert args["y"] == ["1", "2", "3", "4"] | ||||
|  | ||||
|  | ||||
| def test_append_vs_extend_behavior_error(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_append_vs_extend_behavior_error(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--x", action=ArgumentAction.APPEND, nargs=2) | ||||
|     parser.add_argument("--y", action=ArgumentAction.EXTEND, nargs=2) | ||||
|  | ||||
|     # This should raise an error because the last argument is not a valid pair | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "d", "--y", "1", "2", "--y", "3"] | ||||
|         ) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args(["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"]) | ||||
|         await parser.parse_args( | ||||
|             ["--x", "a", "b", "--x", "c", "--y", "1", "--y", "3", "4"] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| def test_extend_positional(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="*") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     args = parser.parse_args([]) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["files"] == [] | ||||
|  | ||||
|  | ||||
| def test_extend_positional_nargs(): | ||||
| @pytest.mark.asyncio | ||||
| async def test_extend_positional_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("files", action=ArgumentAction.EXTEND, type=str, nargs="+") | ||||
|  | ||||
|     args = parser.parse_args(["a", "b", "c"]) | ||||
|     args = await parser.parse_args(["a", "b", "c"]) | ||||
|     assert args["files"] == ["a", "b", "c"] | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.parse_args([]) | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|  | ||||
| def test_command_argument_parser_equality(): | ||||
|     parser1 = CommandArgumentParser() | ||||
|     parser2 = CommandArgumentParser() | ||||
|  | ||||
|     parser1.add_argument("--foo", type=str) | ||||
|     parser2.add_argument("--foo", type=str) | ||||
|  | ||||
|     assert parser1 == parser2 | ||||
|  | ||||
|     parser1.add_argument("--bar", type=int) | ||||
|     assert parser1 != parser2 | ||||
|  | ||||
|     parser2.add_argument("--bar", type=int) | ||||
|     assert parser1 == parser2 | ||||
|  | ||||
|     assert parser1 != "not a parser" | ||||
|     assert parser1 is not None | ||||
|     assert parser1 != object() | ||||
|  | ||||
|     assert parser1.to_definition_list() == parser2.to_definition_list() | ||||
|     assert hash(parser1) == hash(parser2) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_render_help(): | ||||
|     parser = CommandArgumentParser() | ||||
|     parser.add_argument("--foo", type=str, help="Foo help") | ||||
|     parser.add_argument("--bar", action=ArgumentAction.APPEND, type=str, help="Bar help") | ||||
|  | ||||
|     assert parser.render_help() is None | ||||
|   | ||||
| @@ -1,11 +1,40 @@ | ||||
| import os | ||||
| import shutil | ||||
| import sys | ||||
| import tempfile | ||||
| from argparse import ArgumentParser, Namespace, _SubParsersAction | ||||
| from pathlib import Path | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from falyx.__main__ import bootstrap, find_falyx_config, get_falyx_parsers, run | ||||
| from falyx.__main__ import ( | ||||
|     bootstrap, | ||||
|     find_falyx_config, | ||||
|     get_parsers, | ||||
|     init_callback, | ||||
|     init_config, | ||||
|     main, | ||||
| ) | ||||
| from falyx.parser import CommandArgumentParser | ||||
|  | ||||
|  | ||||
| @pytest.fixture(autouse=True) | ||||
| def fake_home(monkeypatch): | ||||
|     """Redirect Path.home() to a temporary directory for all tests.""" | ||||
|     temp_home = Path(tempfile.mkdtemp()) | ||||
|     monkeypatch.setattr(Path, "home", lambda: temp_home) | ||||
|     yield temp_home | ||||
|     shutil.rmtree(temp_home, ignore_errors=True) | ||||
|  | ||||
|  | ||||
| @pytest.fixture(autouse=True) | ||||
| def setup_teardown(): | ||||
|     """Fixture to set up and tear down the environment for each test.""" | ||||
|     cwd = Path.cwd() | ||||
|     yield | ||||
|     for file in cwd.glob("falyx.yaml"): | ||||
|         file.unlink(missing_ok=True) | ||||
|     for file in cwd.glob("falyx.toml"): | ||||
|         file.unlink(missing_ok=True) | ||||
|  | ||||
|  | ||||
| def test_find_falyx_config(): | ||||
| @@ -52,61 +81,52 @@ def test_bootstrap_with_global_config(): | ||||
|     sys.path = sys_path_before | ||||
|  | ||||
|  | ||||
| def test_parse_args(): | ||||
|     """Test if the parse_args function works correctly.""" | ||||
|     falyx_parsers = get_falyx_parsers() | ||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) | ||||
| @pytest.mark.asyncio | ||||
| async def test_init_config(): | ||||
|     """Test if the init_config function adds the correct argument.""" | ||||
|     parser = CommandArgumentParser() | ||||
|     init_config(parser) | ||||
|     args = await parser.parse_args(["test_project"]) | ||||
|     assert args["name"] == "test_project" | ||||
|  | ||||
|     assert args.command == "init" | ||||
|     assert args.name == "test_project" | ||||
|  | ||||
|     args = falyx_parsers.parse_args(["init-global"]) | ||||
|     assert args.command == "init-global" | ||||
|     # Test with default value | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["name"] == "." | ||||
|  | ||||
|  | ||||
| def test_run(): | ||||
|     """Test if the run function works correctly.""" | ||||
|     falyx_parsers = get_falyx_parsers() | ||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) | ||||
|     run(args) | ||||
|     assert args.command == "init" | ||||
|     assert args.name == "test_project" | ||||
|     # Check if the project directory was created | ||||
|     assert Path("test_project").exists() | ||||
|     # Clean up | ||||
|     (Path("test_project") / "falyx.yaml").unlink() | ||||
|     (Path("test_project") / "tasks.py").unlink() | ||||
|     Path("test_project").rmdir() | ||||
|     # Test init-global | ||||
|     args = falyx_parsers.parse_args(["init-global"]) | ||||
|     run(args) | ||||
|     # Check if the global config directory was created | ||||
| def test_init_callback(tmp_path): | ||||
|     """Test if the init_callback function works correctly.""" | ||||
|     # Test project initialization | ||||
|     args = Namespace(command="init", name=str(tmp_path)) | ||||
|     init_callback(args) | ||||
|     assert (tmp_path / "falyx.yaml").exists() | ||||
|  | ||||
|  | ||||
| def test_init_global_callback(): | ||||
|     # Test global initialization | ||||
|     args = Namespace(command="init_global") | ||||
|     init_callback(args) | ||||
|     assert (Path.home() / ".config" / "falyx" / "tasks.py").exists() | ||||
|     assert (Path.home() / ".config" / "falyx" / "falyx.yaml").exists() | ||||
|     # Clean up | ||||
|     (Path.home() / ".config" / "falyx" / "falyx.yaml").unlink() | ||||
|     (Path.home() / ".config" / "falyx" / "tasks.py").unlink() | ||||
|     (Path.home() / ".config" / "falyx").rmdir() | ||||
|  | ||||
|  | ||||
| def test_no_bootstrap(): | ||||
|     """Test if the main function works correctly when no config file is found.""" | ||||
|     falyx_parsers = get_falyx_parsers() | ||||
|     args = falyx_parsers.parse_args(["list"]) | ||||
|     assert run(args) is None | ||||
|     # Check if the task was run | ||||
|     assert args.command == "list" | ||||
| def test_get_parsers(): | ||||
|     """Test if the get_parsers function returns the correct parsers.""" | ||||
|     root_parser, subparsers = get_parsers() | ||||
|     assert isinstance(root_parser, ArgumentParser) | ||||
|     assert isinstance(subparsers, _SubParsersAction) | ||||
|  | ||||
|     # Check if the 'init' command is available | ||||
|     init_parser = subparsers.choices.get("init") | ||||
|     assert init_parser is not None | ||||
|     assert "name" == init_parser._get_positional_actions()[0].dest | ||||
|  | ||||
|  | ||||
| def test_run_test_project(): | ||||
|     """Test if the main function works correctly with a test project.""" | ||||
|     falyx_parsers = get_falyx_parsers() | ||||
|     args = falyx_parsers.parse_args(["init", "test_project"]) | ||||
|     run(args) | ||||
| def test_main(): | ||||
|     """Test if the main function runs with the correct arguments.""" | ||||
|  | ||||
|     args = falyx_parsers.parse_args(["run", "B"]) | ||||
|     os.chdir("test_project") | ||||
|     with pytest.raises(SystemExit): | ||||
|         assert run(args) == "Build complete!" | ||||
|     os.chdir("..") | ||||
|     shutil.rmtree("test_project") | ||||
|     assert not Path("test_project").exists() | ||||
|     sys.argv = ["falyx", "run", "?"] | ||||
|  | ||||
|     with pytest.raises(SystemExit) as exc_info: | ||||
|         main() | ||||
|     assert exc_info.value.code == 0 | ||||
|   | ||||
							
								
								
									
										219
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								tests/test_parsers/test_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.action import Action | ||||
| from falyx.exceptions import CommandArgumentError | ||||
| from falyx.parser import ArgumentAction, CommandArgumentParser | ||||
|  | ||||
|  | ||||
| def test_add_argument(): | ||||
|     """Test the add_argument method.""" | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("test_action", lambda: "value") | ||||
|     parser.add_argument( | ||||
|         "test", action=ArgumentAction.ACTION, help="Test argument", resolver=action | ||||
|     ) | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument("test1", action=ArgumentAction.ACTION, help="Test argument") | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         parser.add_argument( | ||||
|             "test2", | ||||
|             action=ArgumentAction.ACTION, | ||||
|             help="Test argument", | ||||
|             resolver="Not an action", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_falyx_actions(): | ||||
|     """Test the Falyx actions.""" | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("test_action", lambda: "value") | ||||
|     parser.add_argument( | ||||
|         "-a", | ||||
|         "--alpha", | ||||
|         action=ArgumentAction.ACTION, | ||||
|         resolver=action, | ||||
|         help="Alpha option", | ||||
|     ) | ||||
|  | ||||
|     # Test valid cases | ||||
|     args = await parser.parse_args(["-a"]) | ||||
|     assert args["alpha"] == "value" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_basic(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("hello", lambda: "hi") | ||||
|     parser.add_argument("--greet", action=ArgumentAction.ACTION, resolver=action) | ||||
|     args = await parser.parse_args(["--greet"]) | ||||
|     assert args["greet"] == "hi" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_nargs(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def multiply(a, b): | ||||
|         return int(a) * int(b) | ||||
|  | ||||
|     action = Action("multiply", multiply) | ||||
|     parser.add_argument("--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2) | ||||
|     args = await parser.parse_args(["--mul", "3", "4"]) | ||||
|     assert args["mul"] == 12 | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_nargs_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def multiply(a, b): | ||||
|         return int(a) * int(b) | ||||
|  | ||||
|     action = Action("multiply", multiply) | ||||
|     parser.add_argument("mul", action=ArgumentAction.ACTION, resolver=action, nargs=2) | ||||
|     args = await parser.parse_args(["3", "4"]) | ||||
|     assert args["mul"] == 12 | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["3"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["3", "4", "5"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["--mul", "3", "4"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_nargs_positional_int(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def multiply(a, b): | ||||
|         return a * b | ||||
|  | ||||
|     action = Action("multiply", multiply) | ||||
|     parser.add_argument( | ||||
|         "mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int | ||||
|     ) | ||||
|     args = await parser.parse_args(["3", "4"]) | ||||
|     assert args["mul"] == 12 | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["3"]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["abc", "3"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_nargs_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def multiply(a, b): | ||||
|         return a * b | ||||
|  | ||||
|     action = Action("multiply", multiply) | ||||
|     parser.add_argument( | ||||
|         "--mul", action=ArgumentAction.ACTION, resolver=action, nargs=2, type=int | ||||
|     ) | ||||
|     args = await parser.parse_args(["--mul", "3", "4"]) | ||||
|     assert args["mul"] == 12 | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["--mul", "abc", "3"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_custom_type(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def upcase(s): | ||||
|         return s.upper() | ||||
|  | ||||
|     action = Action("upcase", upcase) | ||||
|     parser.add_argument("--word", action=ArgumentAction.ACTION, resolver=action, type=str) | ||||
|     args = await parser.parse_args(["--word", "hello"]) | ||||
|     assert args["word"] == "HELLO" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_nargs_star(): | ||||
|     parser = CommandArgumentParser() | ||||
|  | ||||
|     def joiner(*args): | ||||
|         return "-".join(args) | ||||
|  | ||||
|     action = Action("join", joiner) | ||||
|     parser.add_argument( | ||||
|         "--tags", action=ArgumentAction.ACTION, resolver=action, nargs="*" | ||||
|     ) | ||||
|     args = await parser.parse_args(["--tags", "a", "b", "c"]) | ||||
|     assert args["tags"] == "a-b-c" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_nargs_plus_missing(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("noop", lambda *args: args) | ||||
|     parser.add_argument("--x", action=ArgumentAction.ACTION, resolver=action, nargs="+") | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["--x"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_default(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("default", lambda value: value) | ||||
|     parser.add_argument( | ||||
|         "--default", | ||||
|         action=ArgumentAction.ACTION, | ||||
|         resolver=action, | ||||
|         default="default_value", | ||||
|     ) | ||||
|     args = await parser.parse_args([]) | ||||
|     assert args["default"] == "default_value" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_default_and_value(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("default", lambda value: value) | ||||
|     parser.add_argument( | ||||
|         "--default", | ||||
|         action=ArgumentAction.ACTION, | ||||
|         resolver=action, | ||||
|         default="default_value", | ||||
|     ) | ||||
|     args = await parser.parse_args(["--default", "new_value"]) | ||||
|     assert args["default"] == "new_value" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_default_and_value_not(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("default", lambda: "default_value") | ||||
|     parser.add_argument( | ||||
|         "--default", | ||||
|         action=ArgumentAction.ACTION, | ||||
|         resolver=action, | ||||
|         default="default_value", | ||||
|     ) | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["--default", "new_value"]) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_action_with_default_and_value_positional(): | ||||
|     parser = CommandArgumentParser() | ||||
|     action = Action("default", lambda: "default_value") | ||||
|     parser.add_argument("default", action=ArgumentAction.ACTION, resolver=action) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args([]) | ||||
|  | ||||
|     with pytest.raises(CommandArgumentError): | ||||
|         await parser.parse_args(["be"]) | ||||
							
								
								
									
										98
									
								
								tests/test_parsers/test_argument.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								tests/test_parsers/test_argument.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| import pytest | ||||
|  | ||||
| from falyx.parser import Argument, ArgumentAction | ||||
|  | ||||
|  | ||||
| def test_positional_text_with_choices(): | ||||
|     arg = Argument(flags=("path",), dest="path", positional=True, choices=["a", "b"]) | ||||
|     assert arg.get_positional_text() == "{a,b}" | ||||
|  | ||||
|  | ||||
| def test_positional_text_without_choices(): | ||||
|     arg = Argument(flags=("path",), dest="path", positional=True) | ||||
|     assert arg.get_positional_text() == "path" | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "nargs,expected", | ||||
|     [ | ||||
|         (None, "VALUE"), | ||||
|         (1, "VALUE"), | ||||
|         ("?", "[VALUE]"), | ||||
|         ("*", "[VALUE ...]"), | ||||
|         ("+", "VALUE [VALUE ...]"), | ||||
|     ], | ||||
| ) | ||||
| def test_choice_text_store_action_variants(nargs, expected): | ||||
|     arg = Argument( | ||||
|         flags=("--value",), dest="value", action=ArgumentAction.STORE, nargs=nargs | ||||
|     ) | ||||
|     assert arg.get_choice_text() == expected | ||||
|  | ||||
|  | ||||
| @pytest.mark.parametrize( | ||||
|     "nargs,expected", | ||||
|     [ | ||||
|         (None, "value"), | ||||
|         (1, "value"), | ||||
|         ("?", "[value]"), | ||||
|         ("*", "[value ...]"), | ||||
|         ("+", "value [value ...]"), | ||||
|     ], | ||||
| ) | ||||
| def test_choice_text_store_action_variants_positional(nargs, expected): | ||||
|     arg = Argument( | ||||
|         flags=("value",), | ||||
|         dest="value", | ||||
|         action=ArgumentAction.STORE, | ||||
|         nargs=nargs, | ||||
|         positional=True, | ||||
|     ) | ||||
|     assert arg.get_choice_text() == expected | ||||
|  | ||||
|  | ||||
| def test_choice_text_with_choices(): | ||||
|     arg = Argument(flags=("--mode",), dest="mode", choices=["dev", "prod"]) | ||||
|     assert arg.get_choice_text() == "{dev,prod}" | ||||
|  | ||||
|  | ||||
| def test_choice_text_append_and_extend(): | ||||
|     for action in [ArgumentAction.APPEND, ArgumentAction.EXTEND]: | ||||
|         arg = Argument(flags=("--tag",), dest="tag", action=action) | ||||
|         assert arg.get_choice_text() == "TAG" | ||||
|  | ||||
|  | ||||
| def test_equality(): | ||||
|     a1 = Argument(flags=("--f",), dest="f") | ||||
|     a2 = Argument(flags=("--f",), dest="f") | ||||
|     a3 = Argument(flags=("-x",), dest="x") | ||||
|  | ||||
|     assert a1 == a2 | ||||
|     assert a1 != a3 | ||||
|     assert hash(a1) == hash(a2) | ||||
|  | ||||
|  | ||||
| def test_inequality_with_non_argument(): | ||||
|     arg = Argument(flags=("--f",), dest="f") | ||||
|     assert arg != "not an argument" | ||||
|  | ||||
|  | ||||
| def test_argument_equality(): | ||||
|     arg = Argument("--foo", dest="foo", type=str, default="default_value") | ||||
|     arg2 = Argument("--foo", dest="foo", type=str, default="default_value") | ||||
|     arg3 = Argument("--bar", dest="bar", type=int, default=42) | ||||
|     arg4 = Argument("--foo", dest="foo", type=str, default="foobar") | ||||
|     assert arg == arg2 | ||||
|     assert arg != arg3 | ||||
|     assert arg != arg4 | ||||
|     assert arg != "not an argument" | ||||
|     assert arg is not None | ||||
|     assert arg != object() | ||||
|  | ||||
|  | ||||
| def test_argument_required(): | ||||
|     arg = Argument("--foo", dest="foo", required=True) | ||||
|     assert arg.required is True | ||||
|  | ||||
|     arg2 = Argument("--bar", dest="bar", required=False) | ||||
|     assert arg2.required is False | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user