Compare commits
	
		
			4 Commits
		
	
	
		
			e9fdd9cec6
			...
			b9529d85ce
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b9529d85ce | |||
| fe9758adbf | |||
| bc1637143c | |||
| 80de941335 | 
| @@ -2,6 +2,7 @@ import asyncio | |||||||
|  |  | ||||||
| from falyx import Action, ActionGroup, ChainedAction | from falyx import Action, ActionGroup, ChainedAction | ||||||
|  |  | ||||||
|  |  | ||||||
| # Actions can be defined as synchronous functions | # Actions can be defined as synchronous functions | ||||||
| # Falyx will automatically convert them to async functions | # Falyx will automatically convert them to async functions | ||||||
| def hello() -> None: | def hello() -> None: | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import random | import random | ||||||
|  |  | ||||||
| from falyx import Falyx, Action, ChainedAction | from falyx import Action, ChainedAction, Falyx | ||||||
| from falyx.utils import setup_logging | from falyx.utils import setup_logging | ||||||
|  |  | ||||||
| setup_logging() | setup_logging() | ||||||
|   | |||||||
| @@ -1,8 +1,14 @@ | |||||||
|  | """ | ||||||
|  | Falyx CLI Framework | ||||||
|  |  | ||||||
|  | Copyright (c) 2025 rtj.dev LLC. | ||||||
|  | Licensed under the MIT License. See LICENSE file for details. | ||||||
|  | """ | ||||||
| import logging | import logging | ||||||
|  |  | ||||||
| from .action import Action, ActionGroup, ChainedAction, ProcessAction | from .action import Action, ActionGroup, ChainedAction, ProcessAction | ||||||
| from .command import Command | from .command import Command | ||||||
| from .context import ExecutionContext, ResultsContext | from .context import ExecutionContext, SharedContext | ||||||
| from .execution_registry import ExecutionRegistry | from .execution_registry import ExecutionRegistry | ||||||
| from .falyx import Falyx | from .falyx import Falyx | ||||||
|  |  | ||||||
| @@ -18,6 +24,6 @@ __all__ = [ | |||||||
|     "Falyx", |     "Falyx", | ||||||
|     "Command", |     "Command", | ||||||
|     "ExecutionContext", |     "ExecutionContext", | ||||||
|     "ResultsContext", |     "SharedContext", | ||||||
|     "ExecutionRegistry", |     "ExecutionRegistry", | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -1,42 +1,170 @@ | |||||||
| # falyx/__main__.py | """ | ||||||
|  | Falyx CLI Framework | ||||||
|  |  | ||||||
|  | Copyright (c) 2025 rtj.dev LLC. | ||||||
|  | Licensed under the MIT License. See LICENSE file for details. | ||||||
|  | """ | ||||||
| import asyncio | import asyncio | ||||||
| import logging | import random | ||||||
|  | from argparse import Namespace | ||||||
|  |  | ||||||
| from falyx.action import Action | from falyx.action import Action, ActionGroup, ChainedAction | ||||||
| from falyx.falyx import Falyx | from falyx.falyx import Falyx | ||||||
|  | from falyx.parsers import FalyxParsers, get_arg_parsers | ||||||
|  | from falyx.version import __version__ | ||||||
|  |  | ||||||
|  |  | ||||||
| def build_falyx() -> Falyx: | class Foo: | ||||||
|  |     def __init__(self, flx: Falyx) -> None: | ||||||
|  |         self.flx = flx | ||||||
|  |  | ||||||
|  |     async def build(self): | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Build complete!") | ||||||
|  |         return "Build complete!" | ||||||
|  |  | ||||||
|  |     async def test(self): | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Tests passed!") | ||||||
|  |         return "Tests passed!" | ||||||
|  |  | ||||||
|  |     async def deploy(self): | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Deployment complete!") | ||||||
|  |         return "Deployment complete!" | ||||||
|  |  | ||||||
|  |     async def clean(self): | ||||||
|  |         print("🧹 Cleaning...") | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Clean complete!") | ||||||
|  |         return "Clean complete!" | ||||||
|  |  | ||||||
|  |     async def build_package(self): | ||||||
|  |         print("🔨 Building...") | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Build finished!") | ||||||
|  |         return "Build finished!" | ||||||
|  |  | ||||||
|  |     async def package(self): | ||||||
|  |         print("📦 Packaging...") | ||||||
|  |         await asyncio.sleep(1) | ||||||
|  |         print("✅ Package complete!") | ||||||
|  |         return "Package complete!" | ||||||
|  |  | ||||||
|  |     async def run_tests(self): | ||||||
|  |         print("🧪 Running tests...") | ||||||
|  |         await asyncio.sleep(random.randint(1, 3)) | ||||||
|  |         print("✅ Tests passed!") | ||||||
|  |         return "Tests passed!" | ||||||
|  |  | ||||||
|  |     async def run_integration_tests(self): | ||||||
|  |         print("🔗 Running integration tests...") | ||||||
|  |         await asyncio.sleep(random.randint(1, 3)) | ||||||
|  |         print("✅ Integration tests passed!") | ||||||
|  |         return "Integration tests passed!" | ||||||
|  |  | ||||||
|  |     async def run_linter(self): | ||||||
|  |         print("🧹 Running linter...") | ||||||
|  |         await asyncio.sleep(random.randint(1, 3)) | ||||||
|  |         print("✅ Linter passed!") | ||||||
|  |         return "Linter passed!" | ||||||
|  |  | ||||||
|  |     async def run(self): | ||||||
|  |         await self.flx.run() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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.""" |     """Build and return a Falyx instance with all your commands.""" | ||||||
|     app = Falyx(title="🚀 Falyx CLI") |     args = parse_args() | ||||||
|  |     flx = Falyx( | ||||||
|  |         title="🚀 Falyx CLI", | ||||||
|  |         cli_args=args, | ||||||
|  |         columns=5, | ||||||
|  |         welcome_message="Welcome to Falyx CLI!", | ||||||
|  |         exit_message="Goodbye!", | ||||||
|  |     ) | ||||||
|  |     foo = Foo(flx) | ||||||
|  |  | ||||||
|     # Example commands |     # --- Bottom bar info --- | ||||||
|     app.add_command( |     flx.bottom_bar.columns = 3 | ||||||
|  |     flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose") | ||||||
|  |     flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks") | ||||||
|  |     flx.bottom_bar.add_static("Version", f"Falyx v{__version__}") | ||||||
|  |  | ||||||
|  |     # --- Command actions --- | ||||||
|  |  | ||||||
|  |     # --- Single Actions --- | ||||||
|  |     flx.add_command( | ||||||
|         key="B", |         key="B", | ||||||
|         description="Build project", |         description="Build project", | ||||||
|         action=Action("Build", lambda: print("📦 Building...")), |         action=Action("Build", foo.build), | ||||||
|         tags=["build"] |         tags=["build"], | ||||||
|  |         spinner=True, | ||||||
|  |         spinner_message="📦 Building...", | ||||||
|     ) |     ) | ||||||
|  |     flx.add_command( | ||||||
|     app.add_command( |  | ||||||
|         key="T", |         key="T", | ||||||
|         description="Run tests", |         description="Run tests", | ||||||
|         action=Action("Test", lambda: print("🧪 Running tests...")), |         action=Action("Test", foo.test), | ||||||
|         tags=["test"] |         tags=["test"], | ||||||
|  |         spinner=True, | ||||||
|  |         spinner_message="🧪 Running tests...", | ||||||
|     ) |     ) | ||||||
|  |     flx.add_command( | ||||||
|     app.add_command( |  | ||||||
|         key="D", |         key="D", | ||||||
|         description="Deploy project", |         description="Deploy project", | ||||||
|         action=Action("Deploy", lambda: print("🚀 Deploying...")), |         action=Action("Deploy", foo.deploy), | ||||||
|         tags=["deploy"] |         tags=["deploy"], | ||||||
|  |         spinner=True, | ||||||
|  |         spinner_message="🚀 Deploying...", | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     return app |     # --- Build pipeline (ChainedAction) --- | ||||||
|  |     pipeline = ChainedAction( | ||||||
|  |         name="Full Build Pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action("Clean", foo.clean), | ||||||
|  |             Action("Build", foo.build_package), | ||||||
|  |             Action("Package", foo.package), | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     flx.add_command( | ||||||
|  |         key="P", | ||||||
|  |         description="Run Build Pipeline", | ||||||
|  |         action=pipeline, | ||||||
|  |         tags=["build", "pipeline"], | ||||||
|  |         spinner=True, | ||||||
|  |         spinner_message="🔨 Running build pipeline...", | ||||||
|  |         spinner_type="line", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     # --- Test suite (ActionGroup) --- | ||||||
|  |     test_suite = ActionGroup( | ||||||
|  |         name="Test Suite", | ||||||
|  |         actions=[ | ||||||
|  |             Action("Unit Tests", foo.run_tests), | ||||||
|  |             Action("Integration Tests", foo.run_integration_tests), | ||||||
|  |             Action("Lint", foo.run_linter), | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     flx.add_command( | ||||||
|  |         key="G", | ||||||
|  |         description="Run All Tests", | ||||||
|  |         action=test_suite, | ||||||
|  |         tags=["test", "parallel"], | ||||||
|  |         spinner=True, | ||||||
|  |         spinner_type="line", | ||||||
|  |     ) | ||||||
|  |     await foo.run() | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     logging.basicConfig(level=logging.WARNING) |     try: | ||||||
|     falyx = build_falyx() |         asyncio.run(main()) | ||||||
|     asyncio.run(falyx.run()) |     except (KeyboardInterrupt, EOFError): | ||||||
|  |         pass | ||||||
|   | |||||||
							
								
								
									
										334
									
								
								falyx/action.py
									
									
									
									
									
								
							
							
						
						
									
										334
									
								
								falyx/action.py
									
									
									
									
									
								
							| @@ -1,12 +1,30 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """action.py | """action.py | ||||||
|  |  | ||||||
| Any Action or Command is callable and supports the signature: | Core action system for Falyx. | ||||||
|     result = thing(*args, **kwargs) |  | ||||||
|  |  | ||||||
| This guarantees: | This module defines the building blocks for executable actions and workflows, | ||||||
| - Hook lifecycle (before/after/error/teardown) | providing a structured way to compose, execute, recover, and manage sequences of operations. | ||||||
| - Timing |  | ||||||
| - Consistent return values | All actions are callable and follow a unified signature: | ||||||
|  |     result = action(*args, **kwargs) | ||||||
|  |  | ||||||
|  | Core guarantees: | ||||||
|  | - Full hook lifecycle support (before, on_success, on_error, after, on_teardown). | ||||||
|  | - Consistent timing and execution context tracking for each run. | ||||||
|  | - Unified, predictable result handling and error propagation. | ||||||
|  | - Optional last_result injection to enable flexible, data-driven workflows. | ||||||
|  | - Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery. | ||||||
|  |  | ||||||
|  | Key components: | ||||||
|  | - Action: wraps a function or coroutine into a standard executable unit. | ||||||
|  | - ChainedAction: runs actions sequentially, optionally injecting last results. | ||||||
|  | - ActionGroup: runs actions in parallel and gathers results. | ||||||
|  | - ProcessAction: executes CPU-bound functions in a separate process. | ||||||
|  | - LiteralInputAction: injects static values into workflows. | ||||||
|  | - FallbackAction: gracefully recovers from failures or missing data. | ||||||
|  |  | ||||||
|  | This design promotes clean, fault-tolerant, modular CLI and automation systems. | ||||||
| """ | """ | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| @@ -14,32 +32,32 @@ import asyncio | |||||||
| import random | import random | ||||||
| from abc import ABC, abstractmethod | from abc import ABC, abstractmethod | ||||||
| from concurrent.futures import ProcessPoolExecutor | from concurrent.futures import ProcessPoolExecutor | ||||||
| from functools import partial | from functools import cached_property, partial | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
|  |  | ||||||
| from rich.console import Console | from rich.console import Console | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext, ResultsContext | from falyx.context import ExecutionContext, SharedContext | ||||||
| from falyx.debug import register_debug_hooks | from falyx.debug import register_debug_hooks | ||||||
|  | from falyx.exceptions import EmptyChainError | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import Hook, HookManager, HookType | from falyx.hook_manager import Hook, HookManager, HookType | ||||||
| from falyx.retry import RetryHandler, RetryPolicy | from falyx.retry import RetryHandler, RetryPolicy | ||||||
| from falyx.themes.colors import OneColors | from falyx.themes.colors import OneColors | ||||||
| from falyx.utils import ensure_async, logger | from falyx.utils import ensure_async, logger | ||||||
|  |  | ||||||
| console = Console() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class BaseAction(ABC): | class BaseAction(ABC): | ||||||
|     """ |     """ | ||||||
|     Base class for actions. Actions can be simple functions or more |     Base class for actions. Actions can be simple functions or more | ||||||
|     complex actions like `ChainedAction` or `ActionGroup`. They can also |     complex actions like `ChainedAction` or `ActionGroup`. They can also | ||||||
|     be run independently or as part of Menu. |     be run independently or as part of Falyx. | ||||||
|  |  | ||||||
|     inject_last_result (bool): Whether to inject the previous action's result into kwargs. |     inject_last_result (bool): Whether to inject the previous action's result into kwargs. | ||||||
|     inject_last_result_as (str): The name of the kwarg key to inject the result as |     inject_last_result_as (str): The name of the kwarg key to inject the result as | ||||||
|                                  (default: 'last_result'). |                                  (default: 'last_result'). | ||||||
|  |     _requires_injection (bool): Whether the action requires input injection. | ||||||
|     """ |     """ | ||||||
|     def __init__( |     def __init__( | ||||||
|             self, |             self, | ||||||
| @@ -52,10 +70,12 @@ class BaseAction(ABC): | |||||||
|         self.name = name |         self.name = name | ||||||
|         self.hooks = hooks or HookManager() |         self.hooks = hooks or HookManager() | ||||||
|         self.is_retryable: bool = False |         self.is_retryable: bool = False | ||||||
|         self.results_context: ResultsContext | None = None |         self.shared_context: SharedContext | None = None | ||||||
|         self.inject_last_result: bool = inject_last_result |         self.inject_last_result: bool = inject_last_result | ||||||
|         self.inject_last_result_as: str = inject_last_result_as |         self.inject_last_result_as: str = inject_last_result_as | ||||||
|         self.requires_injection: bool = False |         self._requires_injection: bool = False | ||||||
|  |         self._skip_in_chain: bool = False | ||||||
|  |         self.console = Console(color_system="auto") | ||||||
|  |  | ||||||
|         if logging_hooks: |         if logging_hooks: | ||||||
|             register_debug_hooks(self.hooks) |             register_debug_hooks(self.hooks) | ||||||
| @@ -71,32 +91,32 @@ class BaseAction(ABC): | |||||||
|     async def preview(self, parent: Tree | None = None): |     async def preview(self, parent: Tree | None = None): | ||||||
|         raise NotImplementedError("preview must be implemented by subclasses") |         raise NotImplementedError("preview must be implemented by subclasses") | ||||||
|  |  | ||||||
|     def set_results_context(self, results_context: ResultsContext): |     def set_shared_context(self, shared_context: SharedContext): | ||||||
|         self.results_context = results_context |         self.shared_context = shared_context | ||||||
|  |  | ||||||
|     def prepare_for_chain(self, results_context: ResultsContext) -> BaseAction: |     def prepare_for_chain(self, shared_context: SharedContext) -> BaseAction: | ||||||
|         """ |         """ | ||||||
|         Prepare the action specifically for sequential (ChainedAction) execution. |         Prepare the action specifically for sequential (ChainedAction) execution. | ||||||
|         Can be overridden for chain-specific logic. |         Can be overridden for chain-specific logic. | ||||||
|         """ |         """ | ||||||
|         self.set_results_context(results_context) |         self.set_shared_context(shared_context) | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def prepare_for_group(self, results_context: ResultsContext) -> BaseAction: |     def prepare_for_group(self, shared_context: SharedContext) -> BaseAction: | ||||||
|         """ |         """ | ||||||
|         Prepare the action specifically for parallel (ActionGroup) execution. |         Prepare the action specifically for parallel (ActionGroup) execution. | ||||||
|         Can be overridden for group-specific logic. |         Can be overridden for group-specific logic. | ||||||
|         """ |         """ | ||||||
|         self.set_results_context(results_context) |         self.set_shared_context(shared_context) | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: |     def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: | ||||||
|         if self.inject_last_result and self.results_context: |         if self.inject_last_result and self.shared_context: | ||||||
|             key = self.inject_last_result_as |             key = self.inject_last_result_as | ||||||
|             if key in kwargs: |             if key in kwargs: | ||||||
|                 logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key) |                 logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key) | ||||||
|             kwargs = dict(kwargs) |             kwargs = dict(kwargs) | ||||||
|             kwargs[key] = self.results_context.last_result() |             kwargs[key] = self.shared_context.last_result() | ||||||
|         return kwargs |         return kwargs | ||||||
|  |  | ||||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
| @@ -122,17 +142,37 @@ class BaseAction(ABC): | |||||||
|  |  | ||||||
|     def requires_io_injection(self) -> bool: |     def requires_io_injection(self) -> bool: | ||||||
|         """Checks to see if the action requires input injection.""" |         """Checks to see if the action requires input injection.""" | ||||||
|         return self.requires_injection |         return self._requires_injection | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"<{self.__class__.__name__} '{self.name}'>" |         return f"{self.__class__.__name__}('{self.name}')" | ||||||
|  |  | ||||||
|     def __repr__(self): |     def __repr__(self): | ||||||
|         return str(self) |         return str(self) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Action(BaseAction): | class Action(BaseAction): | ||||||
|     """A simple action that runs a callable. It can be a function or a coroutine.""" |     """ | ||||||
|  |     Action wraps a simple function or coroutine into a standard executable unit. | ||||||
|  |  | ||||||
|  |     It supports: | ||||||
|  |     - Optional retry logic. | ||||||
|  |     - Hook lifecycle (before, success, error, after, teardown). | ||||||
|  |     - Last result injection for chaining. | ||||||
|  |     - Optional rollback handlers for undo logic. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         name (str): Name of the action. | ||||||
|  |         action (Callable): The function or coroutine to execute. | ||||||
|  |         rollback (Callable, optional): Rollback function to undo the action. | ||||||
|  |         args (tuple, optional): Static positional arguments. | ||||||
|  |         kwargs (dict, optional): Static keyword arguments. | ||||||
|  |         hooks (HookManager, optional): Hook manager for lifecycle events. | ||||||
|  |         inject_last_result (bool, optional): Enable last_result injection. | ||||||
|  |         inject_last_result_as (str, optional): Name of injected key. | ||||||
|  |         retry (bool, optional): Whether to enable retries. | ||||||
|  |         retry_policy (RetryPolicy, optional): Retry settings. | ||||||
|  |     """ | ||||||
|     def __init__( |     def __init__( | ||||||
|             self, |             self, | ||||||
|             name: str, |             name: str, | ||||||
| @@ -147,7 +187,7 @@ class Action(BaseAction): | |||||||
|             retry_policy: RetryPolicy | None = None, |             retry_policy: RetryPolicy | None = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) |         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||||
|         self.action = ensure_async(action) |         self.action = action | ||||||
|         self.rollback = rollback |         self.rollback = rollback | ||||||
|         self.args = args |         self.args = args | ||||||
|         self.kwargs = kwargs or {} |         self.kwargs = kwargs or {} | ||||||
| @@ -156,9 +196,17 @@ class Action(BaseAction): | |||||||
|         if retry or (retry_policy and retry_policy.enabled): |         if retry or (retry_policy and retry_policy.enabled): | ||||||
|             self.enable_retry() |             self.enable_retry() | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def action(self) -> Callable[..., Any]: | ||||||
|  |         return self._action | ||||||
|  |  | ||||||
|  |     @action.setter | ||||||
|  |     def action(self, value: Callable[..., Any]): | ||||||
|  |         self._action = ensure_async(value) | ||||||
|  |  | ||||||
|     def enable_retry(self): |     def enable_retry(self): | ||||||
|         """Enable retry with the existing retry policy.""" |         """Enable retry with the existing retry policy.""" | ||||||
|         self.retry_policy.enabled = True |         self.retry_policy.enable_policy() | ||||||
|         logger.debug(f"[Action:{self.name}] Registering retry handler") |         logger.debug(f"[Action:{self.name}] Registering retry handler") | ||||||
|         handler = RetryHandler(self.retry_policy) |         handler = RetryHandler(self.retry_policy) | ||||||
|         self.hooks.register(HookType.ON_ERROR, handler.retry_on_error) |         self.hooks.register(HookType.ON_ERROR, handler.retry_on_error) | ||||||
| @@ -166,7 +214,8 @@ class Action(BaseAction): | |||||||
|     def set_retry_policy(self, policy: RetryPolicy): |     def set_retry_policy(self, policy: RetryPolicy): | ||||||
|         """Set a new retry policy and re-register the handler.""" |         """Set a new retry policy and re-register the handler.""" | ||||||
|         self.retry_policy = policy |         self.retry_policy = policy | ||||||
|         self.enable_retry() |         if policy.enabled: | ||||||
|  |             self.enable_retry() | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> Any: |     async def _run(self, *args, **kwargs) -> Any: | ||||||
|         combined_args = args + self.args |         combined_args = args + self.args | ||||||
| @@ -211,14 +260,66 @@ class Action(BaseAction): | |||||||
|         if parent: |         if parent: | ||||||
|             parent.add("".join(label)) |             parent.add("".join(label)) | ||||||
|         else: |         else: | ||||||
|             console.print(Tree("".join(label))) |             self.console.print(Tree("".join(label))) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return f"Action(name={self.name}, action={self.action.__name__})" | ||||||
|  |  | ||||||
|  |  | ||||||
| class LiteralInputAction(Action): | 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): |     def __init__(self, value: Any): | ||||||
|  |         self._value = value | ||||||
|         async def literal(*args, **kwargs): |         async def literal(*args, **kwargs): | ||||||
|             return value |             return value | ||||||
|         super().__init__("Input", literal, inject_last_result=True) |         super().__init__("Input", literal) | ||||||
|  |  | ||||||
|  |     @cached_property | ||||||
|  |     def value(self) -> Any: | ||||||
|  |         """Return the literal value.""" | ||||||
|  |         return self._value | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"LiteralInputAction(value={self.value})" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |  | ||||||
|  |     def __str__(self) -> str: | ||||||
|  |         return f"FallbackAction(fallback={self.fallback})" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ActionListMixin: | class ActionListMixin: | ||||||
| @@ -253,7 +354,26 @@ class ActionListMixin: | |||||||
|  |  | ||||||
|  |  | ||||||
| class ChainedAction(BaseAction, ActionListMixin): | class ChainedAction(BaseAction, ActionListMixin): | ||||||
|     """A ChainedAction is a sequence of actions that are executed in order.""" |     """ | ||||||
|  |     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_last_result_as (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__( |     def __init__( | ||||||
|             self, |             self, | ||||||
|             name: str, |             name: str, | ||||||
| @@ -262,31 +382,31 @@ class ChainedAction(BaseAction, ActionListMixin): | |||||||
|             inject_last_result: bool = False, |             inject_last_result: bool = False, | ||||||
|             inject_last_result_as: str = "last_result", |             inject_last_result_as: str = "last_result", | ||||||
|             auto_inject: bool = False, |             auto_inject: bool = False, | ||||||
|  |             return_list: bool = False, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         super().__init__(name, hooks, inject_last_result, inject_last_result_as) |         super().__init__(name, hooks, inject_last_result, inject_last_result_as) | ||||||
|         ActionListMixin.__init__(self) |         ActionListMixin.__init__(self) | ||||||
|         self.auto_inject = auto_inject |         self.auto_inject = auto_inject | ||||||
|  |         self.return_list = return_list | ||||||
|         if actions: |         if actions: | ||||||
|             self.set_actions(actions) |             self.set_actions(actions) | ||||||
|  |  | ||||||
|     def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction: |     def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction: | ||||||
|         return LiteralInputAction(action) if not isinstance(action, BaseAction) else action |         return LiteralInputAction(action) if not isinstance(action, BaseAction) else action | ||||||
|  |  | ||||||
|     def _apply_auto_inject(self, action: BaseAction) -> None: |     def add_action(self, action: BaseAction | Any) -> None: | ||||||
|         if self.auto_inject and not action.inject_last_result: |         action = self._wrap_literal_if_needed(action) | ||||||
|  |         if self.actions and self.auto_inject and not action.inject_last_result: | ||||||
|             action.inject_last_result = True |             action.inject_last_result = True | ||||||
|  |         super().add_action(action) | ||||||
|     def set_actions(self, actions: list[BaseAction | Any]): |  | ||||||
|         self.actions.clear() |  | ||||||
|         for action in actions: |  | ||||||
|             action = self._wrap_literal_if_needed(action) |  | ||||||
|             self._apply_auto_inject(action) |  | ||||||
|             self.add_action(action) |  | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> list[Any]: |     async def _run(self, *args, **kwargs) -> list[Any]: | ||||||
|         results_context = ResultsContext(name=self.name) |         if not self.actions: | ||||||
|         if self.results_context: |             raise EmptyChainError(f"[{self.name}] No actions to execute.") | ||||||
|             results_context.add_result(self.results_context.last_result()) |  | ||||||
|  |         shared_context = SharedContext(name=self.name) | ||||||
|  |         if self.shared_context: | ||||||
|  |             shared_context.add_result(self.shared_context.last_result()) | ||||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
|             name=self.name, |             name=self.name, | ||||||
| @@ -294,30 +414,48 @@ class ChainedAction(BaseAction, ActionListMixin): | |||||||
|             kwargs=updated_kwargs, |             kwargs=updated_kwargs, | ||||||
|             action=self, |             action=self, | ||||||
|             extra={"results": [], "rollback_stack": []}, |             extra={"results": [], "rollback_stack": []}, | ||||||
|  |             shared_context=shared_context, | ||||||
|         ) |         ) | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
|         try: |         try: | ||||||
|             await self.hooks.trigger(HookType.BEFORE, context) |             await self.hooks.trigger(HookType.BEFORE, context) | ||||||
|  |  | ||||||
|             for index, action in enumerate(self.actions): |             for index, action in enumerate(self.actions): | ||||||
|                 results_context.current_index = index |                 if action._skip_in_chain: | ||||||
|                 prepared = action.prepare_for_chain(results_context) |                     logger.debug("[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name) | ||||||
|                 last_result = results_context.last_result() |                     continue | ||||||
|                 if self.requires_io_injection() and last_result is not None: |                 shared_context.current_index = index | ||||||
|                     result = await prepared(**{prepared.inject_last_result_as: last_result}) |                 prepared = action.prepare_for_chain(shared_context) | ||||||
|                 else: |                 last_result = shared_context.last_result() | ||||||
|                     result = await prepared(*args, **updated_kwargs) |                 try: | ||||||
|                 results_context.add_result(result) |                     if self.requires_io_injection() and last_result is not None: | ||||||
|  |                         result = await prepared(**{prepared.inject_last_result_as: 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_for_chain(shared_context) | ||||||
|  |                         result = await fallback() | ||||||
|  |                         fallback._skip_in_chain = True | ||||||
|  |                     else: | ||||||
|  |                         raise | ||||||
|  |                 shared_context.add_result(result) | ||||||
|                 context.extra["results"].append(result) |                 context.extra["results"].append(result) | ||||||
|                 context.extra["rollback_stack"].append(prepared) |                 context.extra["rollback_stack"].append(prepared) | ||||||
|  |  | ||||||
|             context.result = context.extra["results"] |             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) |             await self.hooks.trigger(HookType.ON_SUCCESS, context) | ||||||
|             return context.result |             return context.result | ||||||
|  |  | ||||||
|         except Exception as error: |         except Exception as error: | ||||||
|             context.exception = error |             context.exception = error | ||||||
|             results_context.errors.append((results_context.current_index, error)) |             shared_context.add_error(shared_context.current_index, error) | ||||||
|             await self._rollback(context.extra["rollback_stack"], *args, **kwargs) |             await self._rollback(context.extra["rollback_stack"], *args, **kwargs) | ||||||
|             await self.hooks.trigger(HookType.ON_ERROR, context) |             await self.hooks.trigger(HookType.ON_ERROR, context) | ||||||
|             raise |             raise | ||||||
| @@ -328,6 +466,18 @@ class ChainedAction(BaseAction, ActionListMixin): | |||||||
|             er.record(context) |             er.record(context) | ||||||
|  |  | ||||||
|     async def _rollback(self, rollback_stack, *args, **kwargs): |     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): |         for action in reversed(rollback_stack): | ||||||
|             rollback = getattr(action, "rollback", None) |             rollback = getattr(action, "rollback", None) | ||||||
|             if rollback: |             if rollback: | ||||||
| @@ -345,7 +495,7 @@ class ChainedAction(BaseAction, ActionListMixin): | |||||||
|         for action in self.actions: |         for action in self.actions: | ||||||
|             await action.preview(parent=tree) |             await action.preview(parent=tree) | ||||||
|         if not parent: |         if not parent: | ||||||
|             console.print(tree) |             self.console.print(tree) | ||||||
|  |  | ||||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
|         """Register a hook for all actions and sub-actions.""" |         """Register a hook for all actions and sub-actions.""" | ||||||
| @@ -353,9 +503,42 @@ class ChainedAction(BaseAction, ActionListMixin): | |||||||
|         for action in self.actions: |         for action in self.actions: | ||||||
|             action.register_hooks_recursively(hook_type, hook) |             action.register_hooks_recursively(hook_type, hook) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return f"ChainedAction(name={self.name}, actions={self.actions})" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ActionGroup(BaseAction, ActionListMixin): | class ActionGroup(BaseAction, ActionListMixin): | ||||||
|     """An ActionGroup is a collection of actions that can be run in parallel.""" |     """ | ||||||
|  |     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_last_result_as (str, optional): Key name for injection. | ||||||
|  |     """ | ||||||
|     def __init__( |     def __init__( | ||||||
|             self, |             self, | ||||||
|             name: str, |             name: str, | ||||||
| @@ -370,9 +553,9 @@ class ActionGroup(BaseAction, ActionListMixin): | |||||||
|             self.set_actions(actions) |             self.set_actions(actions) | ||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: |     async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: | ||||||
|         results_context = ResultsContext(name=self.name, is_parallel=True) |         shared_context = SharedContext(name=self.name, is_parallel=True) | ||||||
|         if self.results_context: |         if self.shared_context: | ||||||
|             results_context.set_shared_result(self.results_context.last_result()) |             shared_context.set_shared_result(self.shared_context.last_result()) | ||||||
|         updated_kwargs = self._maybe_inject_last_result(kwargs) |         updated_kwargs = self._maybe_inject_last_result(kwargs) | ||||||
|         context = ExecutionContext( |         context = ExecutionContext( | ||||||
|             name=self.name, |             name=self.name, | ||||||
| @@ -380,15 +563,16 @@ class ActionGroup(BaseAction, ActionListMixin): | |||||||
|             kwargs=updated_kwargs, |             kwargs=updated_kwargs, | ||||||
|             action=self, |             action=self, | ||||||
|             extra={"results": [], "errors": []}, |             extra={"results": [], "errors": []}, | ||||||
|  |             shared_context=shared_context, | ||||||
|         ) |         ) | ||||||
|         async def run_one(action: BaseAction): |         async def run_one(action: BaseAction): | ||||||
|             try: |             try: | ||||||
|                 prepared = action.prepare_for_group(results_context) |                 prepared = action.prepare_for_group(shared_context) | ||||||
|                 result = await prepared(*args, **updated_kwargs) |                 result = await prepared(*args, **updated_kwargs) | ||||||
|                 results_context.add_result((action.name, result)) |                 shared_context.add_result((action.name, result)) | ||||||
|                 context.extra["results"].append((action.name, result)) |                 context.extra["results"].append((action.name, result)) | ||||||
|             except Exception as error: |             except Exception as error: | ||||||
|                 results_context.errors.append((results_context.current_index, error)) |                 shared_context.add_error(shared_context.current_index, error) | ||||||
|                 context.extra["errors"].append((action.name, error)) |                 context.extra["errors"].append((action.name, error)) | ||||||
|  |  | ||||||
|         context.start_timer() |         context.start_timer() | ||||||
| @@ -426,7 +610,7 @@ class ActionGroup(BaseAction, ActionListMixin): | |||||||
|         random.shuffle(actions) |         random.shuffle(actions) | ||||||
|         await asyncio.gather(*(action.preview(parent=tree) for action in actions)) |         await asyncio.gather(*(action.preview(parent=tree) for action in actions)) | ||||||
|         if not parent: |         if not parent: | ||||||
|             console.print(tree) |             self.console.print(tree) | ||||||
|  |  | ||||||
|     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): |     def register_hooks_recursively(self, hook_type: HookType, hook: Hook): | ||||||
|         """Register a hook for all actions and sub-actions.""" |         """Register a hook for all actions and sub-actions.""" | ||||||
| @@ -434,9 +618,30 @@ class ActionGroup(BaseAction, ActionListMixin): | |||||||
|         for action in self.actions: |         for action in self.actions: | ||||||
|             action.register_hooks_recursively(hook_type, hook) |             action.register_hooks_recursively(hook_type, hook) | ||||||
|  |  | ||||||
|  |     def __str__(self): | ||||||
|  |         return f"ActionGroup(name={self.name}, actions={self.actions})" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProcessAction(BaseAction): | class ProcessAction(BaseAction): | ||||||
|     """A ProcessAction runs a function in a separate process using ProcessPoolExecutor.""" |     """ | ||||||
|  |     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_last_result_as (str, optional): Name of the injected key. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         name: str, |         name: str, | ||||||
| @@ -457,7 +662,7 @@ class ProcessAction(BaseAction): | |||||||
|  |  | ||||||
|     async def _run(self, *args, **kwargs): |     async def _run(self, *args, **kwargs): | ||||||
|         if self.inject_last_result: |         if self.inject_last_result: | ||||||
|             last_result = self.results_context.last_result() |             last_result = self.shared_context.last_result() | ||||||
|             if not self._validate_pickleable(last_result): |             if not self._validate_pickleable(last_result): | ||||||
|                 raise ValueError( |                 raise ValueError( | ||||||
|                     f"Cannot inject last result into {self.name}: " |                     f"Cannot inject last result into {self.name}: " | ||||||
| @@ -501,7 +706,7 @@ class ProcessAction(BaseAction): | |||||||
|         if parent: |         if parent: | ||||||
|             parent.add("".join(label)) |             parent.add("".join(label)) | ||||||
|         else: |         else: | ||||||
|             console.print(Tree("".join(label))) |             self.console.print(Tree("".join(label))) | ||||||
|  |  | ||||||
|     def _validate_pickleable(self, obj: Any) -> bool: |     def _validate_pickleable(self, obj: Any) -> bool: | ||||||
|         try: |         try: | ||||||
| @@ -510,3 +715,4 @@ class ProcessAction(BaseAction): | |||||||
|             return True |             return True | ||||||
|         except (pickle.PicklingError, TypeError): |         except (pickle.PicklingError, TypeError): | ||||||
|             return False |             return False | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """bottom_bar.py""" | """bottom_bar.py""" | ||||||
|  |  | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
| @@ -8,7 +9,7 @@ from rich.console import Console | |||||||
|  |  | ||||||
| from falyx.options_manager import OptionsManager | from falyx.options_manager import OptionsManager | ||||||
| from falyx.themes.colors import OneColors | from falyx.themes.colors import OneColors | ||||||
| from falyx.utils import CaseInsensitiveDict | from falyx.utils import CaseInsensitiveDict, chunks | ||||||
|  |  | ||||||
|  |  | ||||||
| class BottomBar: | class BottomBar: | ||||||
| @@ -211,5 +212,8 @@ class BottomBar: | |||||||
|  |  | ||||||
|     def render(self): |     def render(self): | ||||||
|         """Render the bottom bar.""" |         """Render the bottom bar.""" | ||||||
|         return merge_formatted_text([fn() for fn in self._named_items.values()]) |         lines = [] | ||||||
|  |         for chunk in chunks(self._named_items.values(), self.columns): | ||||||
|  |             lines.extend([fn for fn in chunk]) | ||||||
|  |             lines.append(lambda: HTML("\n")) | ||||||
|  |         return merge_formatted_text([fn() for fn in lines[:-1]]) | ||||||
|   | |||||||
							
								
								
									
										105
									
								
								falyx/command.py
									
									
									
									
									
								
							
							
						
						
									
										105
									
								
								falyx/command.py
									
									
									
									
									
								
							| @@ -1,14 +1,24 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """command.py | """command.py | ||||||
| Any Action or Command is callable and supports the signature: |  | ||||||
|     result = thing(*args, **kwargs) |  | ||||||
|  |  | ||||||
| This guarantees: | Defines the Command class for Falyx CLI. | ||||||
| - Hook lifecycle (before/after/error/teardown) |  | ||||||
| - Timing | Commands are callable units representing a menu option or CLI task, | ||||||
| - Consistent return values | wrapping either a BaseAction or a simple function. They provide: | ||||||
|  |  | ||||||
|  | - Hook lifecycle (before, on_success, on_error, after, on_teardown) | ||||||
|  | - Execution timing and duration tracking | ||||||
|  | - Retry logic (single action or recursively through action trees) | ||||||
|  | - Confirmation prompts and spinner integration | ||||||
|  | - Result capturing and summary logging | ||||||
|  | - Rich-based preview for CLI display | ||||||
|  |  | ||||||
|  | Every Command is self-contained, configurable, and plays a critical role | ||||||
|  | in building robust interactive menus. | ||||||
| """ | """ | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from functools import cached_property | ||||||
| from typing import Any, Callable | from typing import Any, Callable | ||||||
|  |  | ||||||
| from prompt_toolkit.formatted_text import FormattedText | from prompt_toolkit.formatted_text import FormattedText | ||||||
| @@ -16,11 +26,12 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator | |||||||
| from rich.console import Console | from rich.console import Console | ||||||
| from rich.tree import Tree | from rich.tree import Tree | ||||||
|  |  | ||||||
| from falyx.action import Action, BaseAction | from falyx.action import Action, ActionGroup, BaseAction, ChainedAction | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.debug import register_debug_hooks | from falyx.debug import register_debug_hooks | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.io_action import BaseIOAction | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| from falyx.themes.colors import OneColors | from falyx.themes.colors import OneColors | ||||||
| from falyx.utils import _noop, ensure_async, logger | from falyx.utils import _noop, ensure_async, logger | ||||||
| @@ -29,13 +40,63 @@ console = Console() | |||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseModel): | class Command(BaseModel): | ||||||
|     """Class representing an command in the menu.""" |     """ | ||||||
|  |     Represents a selectable command in a Falyx menu system. | ||||||
|  |  | ||||||
|  |     A Command wraps an executable action (function, coroutine, or BaseAction)  | ||||||
|  |     and enhances it with: | ||||||
|  |  | ||||||
|  |     - Lifecycle hooks (before, success, error, after, teardown) | ||||||
|  |     - Retry support (single action or recursive for chained/grouped actions) | ||||||
|  |     - Confirmation prompts for safe execution | ||||||
|  |     - Spinner visuals during execution | ||||||
|  |     - Tagging for categorization and filtering | ||||||
|  |     - Rich-based CLI previews | ||||||
|  |     - Result tracking and summary reporting | ||||||
|  |  | ||||||
|  |     Commands are built to be flexible yet robust, enabling dynamic CLI workflows | ||||||
|  |     without sacrificing control or reliability. | ||||||
|  |  | ||||||
|  |     Attributes: | ||||||
|  |         key (str): Primary trigger key for the command. | ||||||
|  |         description (str): Short description for the menu display. | ||||||
|  |         hidden (bool): Toggles visibility in the menu. | ||||||
|  |         aliases (list[str]): Alternate keys or phrases. | ||||||
|  |         action (BaseAction | Callable): The executable logic. | ||||||
|  |         args (tuple): Static positional arguments. | ||||||
|  |         kwargs (dict): Static keyword arguments. | ||||||
|  |         help_text (str): Additional help or guidance text. | ||||||
|  |         color (str): Color theme for CLI rendering. | ||||||
|  |         confirm (bool): Whether to require confirmation before executing. | ||||||
|  |         confirm_message (str): Custom confirmation prompt. | ||||||
|  |         preview_before_confirm (bool): Whether to preview before confirming. | ||||||
|  |         spinner (bool): Whether to show a spinner during execution. | ||||||
|  |         spinner_message (str): Spinner text message. | ||||||
|  |         spinner_type (str): Spinner style (e.g., dots, line, etc.). | ||||||
|  |         spinner_style (str): Color or style of the spinner. | ||||||
|  |         spinner_kwargs (dict): Extra spinner configuration. | ||||||
|  |         hooks (HookManager): Hook manager for lifecycle events. | ||||||
|  |         retry (bool): Enable retry on failure. | ||||||
|  |         retry_all (bool): Enable retry across chained or grouped actions. | ||||||
|  |         retry_policy (RetryPolicy): Retry behavior configuration. | ||||||
|  |         tags (list[str]): Organizational tags for the command. | ||||||
|  |         logging_hooks (bool): Whether to attach logging hooks automatically. | ||||||
|  |         requires_input (bool | None): Indicates if the action needs input. | ||||||
|  |  | ||||||
|  |     Methods: | ||||||
|  |         __call__(): Executes the command, respecting hooks and retries. | ||||||
|  |         preview(): Rich tree preview of the command. | ||||||
|  |         confirmation_prompt(): Formatted prompt for confirmation. | ||||||
|  |         result: Property exposing the last result. | ||||||
|  |         log_summary(): Summarizes execution details to the console. | ||||||
|  |     """ | ||||||
|     key: str |     key: str | ||||||
|     description: str |     description: str | ||||||
|     aliases: list[str] = Field(default_factory=list) |  | ||||||
|     action: BaseAction | Callable[[], Any] = _noop |     action: BaseAction | Callable[[], Any] = _noop | ||||||
|     args: tuple = () |     args: tuple = () | ||||||
|     kwargs: dict[str, Any] = Field(default_factory=dict) |     kwargs: dict[str, Any] = Field(default_factory=dict) | ||||||
|  |     hidden: bool = False | ||||||
|  |     aliases: list[str] = Field(default_factory=list) | ||||||
|     help_text: str = "" |     help_text: str = "" | ||||||
|     color: str = OneColors.WHITE |     color: str = OneColors.WHITE | ||||||
|     confirm: bool = False |     confirm: bool = False | ||||||
| @@ -52,6 +113,7 @@ class Command(BaseModel): | |||||||
|     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) |     retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) | ||||||
|     tags: list[str] = Field(default_factory=list) |     tags: list[str] = Field(default_factory=list) | ||||||
|     logging_hooks: bool = False |     logging_hooks: bool = False | ||||||
|  |     requires_input: bool | None = None | ||||||
|  |  | ||||||
|     _context: ExecutionContext | None = PrivateAttr(default=None) |     _context: ExecutionContext | None = PrivateAttr(default=None) | ||||||
|  |  | ||||||
| @@ -65,12 +127,32 @@ class Command(BaseModel): | |||||||
|             self.action.set_retry_policy(self.retry_policy) |             self.action.set_retry_policy(self.retry_policy) | ||||||
|         elif self.retry: |         elif self.retry: | ||||||
|             logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") |             logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") | ||||||
|         if self.retry_all: |         if self.retry_all and isinstance(self.action, BaseAction): | ||||||
|  |             self.retry_policy.enabled = True | ||||||
|             self.action.enable_retries_recursively(self.action, self.retry_policy) |             self.action.enable_retries_recursively(self.action, self.retry_policy) | ||||||
|  |         elif self.retry_all: | ||||||
|  |             logger.warning(f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance.") | ||||||
|  |  | ||||||
|         if self.logging_hooks and isinstance(self.action, BaseAction): |         if self.logging_hooks and isinstance(self.action, BaseAction): | ||||||
|             register_debug_hooks(self.action.hooks) |             register_debug_hooks(self.action.hooks) | ||||||
|  |  | ||||||
|  |         if self.requires_input is None and self.detect_requires_input: | ||||||
|  |             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 | ||||||
|  |         elif isinstance(self.action, ActionGroup): | ||||||
|  |             return any(isinstance(action, BaseIOAction) for action in self.action.actions) | ||||||
|  |         return False | ||||||
|  |  | ||||||
|     @field_validator("action", mode="before") |     @field_validator("action", mode="before") | ||||||
|     @classmethod |     @classmethod | ||||||
|     def wrap_callable_as_async(cls, action: Any) -> Any: |     def wrap_callable_as_async(cls, action: Any) -> Any: | ||||||
| @@ -81,7 +163,8 @@ class Command(BaseModel): | |||||||
|         raise TypeError("Action must be a callable or an instance of BaseAction") |         raise TypeError("Action must be a callable or an instance of BaseAction") | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         return f"Command(key='{self.key}', description='{self.description}')" |         return (f"Command(key='{self.key}', description='{self.description}' " | ||||||
|  |                 f"action='{self.action}')") | ||||||
|  |  | ||||||
|     async def __call__(self, *args, **kwargs): |     async def __call__(self, *args, **kwargs): | ||||||
|         """Run the action with full hook lifecycle, timing, and error handling.""" |         """Run the action with full hook lifecycle, timing, and error handling.""" | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """config.py | """config.py | ||||||
| Configuration loader for Falyx CLI commands.""" | Configuration loader for Falyx CLI commands.""" | ||||||
|  |  | ||||||
| @@ -79,11 +80,12 @@ def loader(file_path: str) -> list[dict[str, Any]]: | |||||||
|         command_dict = { |         command_dict = { | ||||||
|             "key": entry["key"], |             "key": entry["key"], | ||||||
|             "description": entry["description"], |             "description": entry["description"], | ||||||
|             "aliases": entry.get("aliases", []), |  | ||||||
|             "action": wrap_if_needed(import_action(entry["action"]), |             "action": wrap_if_needed(import_action(entry["action"]), | ||||||
|                                      name=entry["description"]), |                                      name=entry["description"]), | ||||||
|             "args": tuple(entry.get("args", ())), |             "args": tuple(entry.get("args", ())), | ||||||
|             "kwargs": entry.get("kwargs", {}), |             "kwargs": entry.get("kwargs", {}), | ||||||
|  |             "hidden": entry.get("hidden", False), | ||||||
|  |             "aliases": entry.get("aliases", []), | ||||||
|             "help_text": entry.get("help_text", ""), |             "help_text": entry.get("help_text", ""), | ||||||
|             "color": entry.get("color", "white"), |             "color": entry.get("color", "white"), | ||||||
|             "confirm": entry.get("confirm", False), |             "confirm": entry.get("confirm", False), | ||||||
| @@ -94,10 +96,18 @@ def loader(file_path: str) -> list[dict[str, Any]]: | |||||||
|             "spinner_type": entry.get("spinner_type", "dots"), |             "spinner_type": entry.get("spinner_type", "dots"), | ||||||
|             "spinner_style": entry.get("spinner_style", "cyan"), |             "spinner_style": entry.get("spinner_style", "cyan"), | ||||||
|             "spinner_kwargs": entry.get("spinner_kwargs", {}), |             "spinner_kwargs": entry.get("spinner_kwargs", {}), | ||||||
|             "tags": entry.get("tags", []), |             "before_hooks": entry.get("before_hooks", []), | ||||||
|  |             "success_hooks": entry.get("success_hooks", []), | ||||||
|  |             "error_hooks": entry.get("error_hooks", []), | ||||||
|  |             "after_hooks": entry.get("after_hooks", []), | ||||||
|  |             "teardown_hooks": entry.get("teardown_hooks", []), | ||||||
|  |             "retry": entry.get("retry", False), | ||||||
|  |             "retry_all": entry.get("retry_all", False), | ||||||
|             "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), |             "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), | ||||||
|  |             "tags": entry.get("tags", []), | ||||||
|  |             "logging_hooks": entry.get("logging_hooks", False), | ||||||
|  |             "requires_input": entry.get("requires_input", None), | ||||||
|         } |         } | ||||||
|         commands.append(command_dict) |         commands.append(command_dict) | ||||||
|  |  | ||||||
|     return commands |     return commands | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,7 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """context.py""" | """context.py""" | ||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import time | import time | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| from typing import Any | from typing import Any | ||||||
| @@ -23,6 +26,8 @@ class ExecutionContext(BaseModel): | |||||||
|     extra: dict[str, Any] = Field(default_factory=dict) |     extra: dict[str, Any] = Field(default_factory=dict) | ||||||
|     console: Console = Field(default_factory=lambda: Console(color_system="auto")) |     console: Console = Field(default_factory=lambda: Console(color_system="auto")) | ||||||
|  |  | ||||||
|  |     shared_context: SharedContext | None = None | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
|     def start_timer(self): |     def start_timer(self): | ||||||
| @@ -33,6 +38,9 @@ class ExecutionContext(BaseModel): | |||||||
|         self.end_time = time.perf_counter() |         self.end_time = time.perf_counter() | ||||||
|         self.end_wall = datetime.now() |         self.end_wall = datetime.now() | ||||||
|  |  | ||||||
|  |     def get_shared_context(self) -> SharedContext: | ||||||
|  |         return self.shared_context or SharedContext(name="default") | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def duration(self) -> float | None: |     def duration(self) -> float | None: | ||||||
|         if self.start_time is None: |         if self.start_time is None: | ||||||
| @@ -103,7 +111,7 @@ class ExecutionContext(BaseModel): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ResultsContext(BaseModel): | class SharedContext(BaseModel): | ||||||
|     name: str |     name: str | ||||||
|     results: list[Any] = Field(default_factory=list) |     results: list[Any] = Field(default_factory=list) | ||||||
|     errors: list[tuple[int, Exception]] = Field(default_factory=list) |     errors: list[tuple[int, Exception]] = Field(default_factory=list) | ||||||
| @@ -111,11 +119,16 @@ class ResultsContext(BaseModel): | |||||||
|     is_parallel: bool = False |     is_parallel: bool = False | ||||||
|     shared_result: Any | None = None |     shared_result: Any | None = None | ||||||
|  |  | ||||||
|  |     share: dict[str, Any] = Field(default_factory=dict) | ||||||
|  |  | ||||||
|     model_config = ConfigDict(arbitrary_types_allowed=True) |     model_config = ConfigDict(arbitrary_types_allowed=True) | ||||||
|  |  | ||||||
|     def add_result(self, result: Any) -> None: |     def add_result(self, result: Any) -> None: | ||||||
|         self.results.append(result) |         self.results.append(result) | ||||||
|  |  | ||||||
|  |     def add_error(self, index: int, error: Exception) -> None: | ||||||
|  |         self.errors.append((index, error)) | ||||||
|  |  | ||||||
|     def set_shared_result(self, result: Any) -> None: |     def set_shared_result(self, result: Any) -> None: | ||||||
|         self.shared_result = result |         self.shared_result = result | ||||||
|         if self.is_parallel: |         if self.is_parallel: | ||||||
| @@ -126,10 +139,16 @@ class ResultsContext(BaseModel): | |||||||
|             return self.shared_result |             return self.shared_result | ||||||
|         return self.results[-1] if self.results else None |         return self.results[-1] if self.results else None | ||||||
|  |  | ||||||
|  |     def get(self, key: str, default: Any = None) -> Any: | ||||||
|  |         return self.share.get(key, default) | ||||||
|  |  | ||||||
|  |     def set(self, key: str, value: Any) -> None: | ||||||
|  |         self.share[key] = value | ||||||
|  |  | ||||||
|     def __str__(self) -> str: |     def __str__(self) -> str: | ||||||
|         parallel_label = "Parallel" if self.is_parallel else "Sequential" |         parallel_label = "Parallel" if self.is_parallel else "Sequential" | ||||||
|         return ( |         return ( | ||||||
|             f"<{parallel_label}ResultsContext '{self.name}' | " |             f"<{parallel_label}SharedContext '{self.name}' | " | ||||||
|             f"Results: {self.results} | " |             f"Results: {self.results} | " | ||||||
|             f"Errors: {self.errors}>" |             f"Errors: {self.errors}>" | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.utils import logger | from falyx.utils import logger | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| class FalyxError(Exception): | class FalyxError(Exception): | ||||||
|     """Custom exception for the Menu class.""" |     """Custom exception for the Menu class.""" | ||||||
|  |  | ||||||
| @@ -20,3 +21,7 @@ class NotAFalyxError(FalyxError): | |||||||
|  |  | ||||||
| class CircuitBreakerOpen(FalyxError): | class CircuitBreakerOpen(FalyxError): | ||||||
|     """Exception raised when the circuit breaker is open.""" |     """Exception raised when the circuit breaker is open.""" | ||||||
|  |  | ||||||
|  | class EmptyChainError(FalyxError): | ||||||
|  |     """Exception raised when the chain is empty.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """execution_registry.py""" | """execution_registry.py""" | ||||||
| from collections import defaultdict | from collections import defaultdict | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
| @@ -62,6 +63,8 @@ class ExecutionRegistry: | |||||||
|             else: |             else: | ||||||
|                 status = "[green]✅ Success" |                 status = "[green]✅ Success" | ||||||
|                 result = repr(ctx.result) |                 result = repr(ctx.result) | ||||||
|  |                 if len(result) > 1000: | ||||||
|  |                     result = f"{result[:1000]}..." | ||||||
|  |  | ||||||
|             table.add_row(ctx.name, start, end, duration, status, result) |             table.add_row(ctx.name, start, end, duration, status, result) | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										171
									
								
								falyx/falyx.py
									
									
									
									
									
								
							
							
						
						
									
										171
									
								
								falyx/falyx.py
									
									
									
									
									
								
							| @@ -1,16 +1,23 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """falyx.py | """falyx.py | ||||||
|  |  | ||||||
| This class creates a Falyx object that creates a selectable menu | Main class for constructing and running Falyx CLI menus. | ||||||
| with customizable commands and functionality. |  | ||||||
|  |  | ||||||
| It allows for adding commands, and their accompanying actions, | Falyx provides a structured, customizable interactive menu system | ||||||
| and provides a method to display the menu and handle user input. | for running commands, actions, and workflows. It supports: | ||||||
|  |  | ||||||
| This class uses the `rich` library to display the menu in a | - Hook lifecycle management (before/on_success/on_error/after/on_teardown) | ||||||
| formatted and visually appealing way. | - Dynamic command addition and alias resolution | ||||||
|  | - Rich-based menu display with multi-column layouts | ||||||
|  | - Interactive input validation and auto-completion | ||||||
|  | - History tracking and help menu generation | ||||||
|  | - Confirmation prompts and spinners | ||||||
|  | - Headless mode for automated script execution | ||||||
|  | - CLI argument parsing with argparse integration | ||||||
|  | - Retry policy configuration for actions | ||||||
|  |  | ||||||
| This class also uses the `prompt_toolkit` library to handle | Falyx enables building flexible, robust, and user-friendly | ||||||
| user input and create an interactive experience. | terminal applications with minimal boilerplate. | ||||||
| """ | """ | ||||||
| import asyncio | import asyncio | ||||||
| import logging | import logging | ||||||
| @@ -30,7 +37,7 @@ from rich.console import Console | |||||||
| from rich.markdown import Markdown | from rich.markdown import Markdown | ||||||
| from rich.table import Table | from rich.table import Table | ||||||
|  |  | ||||||
| from falyx.action import BaseAction | from falyx.action import Action, BaseAction | ||||||
| from falyx.bottom_bar import BottomBar | from falyx.bottom_bar import BottomBar | ||||||
| from falyx.command import Command | from falyx.command import Command | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| @@ -43,37 +50,57 @@ from falyx.options_manager import OptionsManager | |||||||
| from falyx.parsers import get_arg_parsers | from falyx.parsers import get_arg_parsers | ||||||
| from falyx.retry import RetryPolicy | from falyx.retry import RetryPolicy | ||||||
| from falyx.themes.colors import OneColors, get_nord_theme | from falyx.themes.colors import OneColors, get_nord_theme | ||||||
| from falyx.utils import CaseInsensitiveDict, async_confirm, chunks, logger | from falyx.utils import (CaseInsensitiveDict, async_confirm, chunks, | ||||||
|  |                          get_program_invocation, logger) | ||||||
| from falyx.version import __version__ | from falyx.version import __version__ | ||||||
|  |  | ||||||
|  |  | ||||||
| class Falyx: | class Falyx: | ||||||
|     """Class to create a menu with commands. |  | ||||||
|  |  | ||||||
|     Hook functions must have the signature: |  | ||||||
|         def hook(command: Command) -> None: |  | ||||||
|     where `command` is the selected command. |  | ||||||
|  |  | ||||||
|     Error hook functions must have the signature: |  | ||||||
|         def error_hook(command: Command, error: Exception) -> None: |  | ||||||
|     where `command` is the selected command and `error` is the exception raised. |  | ||||||
|  |  | ||||||
|     Hook execution order: |  | ||||||
|     1. Before action hooks of the menu. |  | ||||||
|     2. Before action hooks of the selected command. |  | ||||||
|     3. Action of the selected command. |  | ||||||
|     4. After action hooks of the selected command. |  | ||||||
|     5. After action hooks of the menu. |  | ||||||
|     6. On error hooks of the selected command (if an error occurs). |  | ||||||
|     7. On error hooks of the menu (if an error occurs). |  | ||||||
|  |  | ||||||
|     Parameters: |  | ||||||
|         title (str|Markdown): The title of the menu. |  | ||||||
|         columns (int): The number of columns to display the commands in. |  | ||||||
|         prompt (AnyFormattedText): The prompt to display when asking for input. |  | ||||||
|         bottom_bar (str|callable|None): The text to display in the bottom bar. |  | ||||||
|     """ |     """ | ||||||
|  |     Main menu controller for Falyx CLI applications. | ||||||
|  |  | ||||||
|  |     Falyx orchestrates the full lifecycle of an interactive menu system,  | ||||||
|  |     handling user input, command execution, error recovery, and structured  | ||||||
|  |     CLI workflows. | ||||||
|  |  | ||||||
|  |     Key Features: | ||||||
|  |     - Interactive menu with Rich rendering and Prompt Toolkit input handling | ||||||
|  |     - Dynamic command management with alias and abbreviation matching | ||||||
|  |     - Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels | ||||||
|  |     - Built-in retry support, spinner visuals, and confirmation prompts | ||||||
|  |     - Submenu nesting and action chaining | ||||||
|  |     - History tracking, help generation, and headless execution modes | ||||||
|  |     - Seamless CLI argument parsing and integration via argparse | ||||||
|  |     - Extensible with user-defined hooks, bottom bars, and custom layouts | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         title (str | Markdown): Title displayed for the menu. | ||||||
|  |         prompt (AnyFormattedText): Prompt displayed when requesting user input. | ||||||
|  |         columns (int): Number of columns to use when rendering menu commands. | ||||||
|  |         bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic. | ||||||
|  |         welcome_message (str | Markdown | dict): Welcome message shown at startup. | ||||||
|  |         exit_message (str | Markdown | dict): Exit message shown on shutdown. | ||||||
|  |         key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings. | ||||||
|  |         include_history_command (bool): Whether to add a built-in history viewer command. | ||||||
|  |         include_help_command (bool): Whether to add a built-in help viewer command. | ||||||
|  |         confirm_on_error (bool): Whether to prompt the user after errors. | ||||||
|  |         never_confirm (bool): Whether to skip confirmation prompts entirely. | ||||||
|  |         always_confirm (bool): Whether to force confirmation prompts for all actions. | ||||||
|  |         cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. | ||||||
|  |         options (OptionsManager | None): Declarative option mappings. | ||||||
|  |         custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator. | ||||||
|  |  | ||||||
|  |     Methods: | ||||||
|  |         run(): Main entry point for CLI argument-based workflows. Most users will use this. | ||||||
|  |         menu(): Run the interactive menu loop. | ||||||
|  |         headless(command_key, return_context): Run a command directly without showing the menu. | ||||||
|  |         add_command(): Add a single command to the menu. | ||||||
|  |         add_commands(): Add multiple commands at once. | ||||||
|  |         register_all_hooks(): Register hooks across all commands and submenus. | ||||||
|  |         debug_hooks(): Log hook registration for debugging. | ||||||
|  |         build_default_table(): Construct the standard Rich table layout. | ||||||
|  |  | ||||||
|  |     """ | ||||||
|     def __init__( |     def __init__( | ||||||
|         self, |         self, | ||||||
|         title: str | Markdown = "Menu", |         title: str | Markdown = "Menu", | ||||||
| @@ -84,7 +111,7 @@ class Falyx: | |||||||
|         exit_message: str | Markdown | dict[str, Any] = "", |         exit_message: str | Markdown | dict[str, Any] = "", | ||||||
|         key_bindings: KeyBindings | None = None, |         key_bindings: KeyBindings | None = None, | ||||||
|         include_history_command: bool = True, |         include_history_command: bool = True, | ||||||
|         include_help_command: bool = False, |         include_help_command: bool = True, | ||||||
|         confirm_on_error: bool = True, |         confirm_on_error: bool = True, | ||||||
|         never_confirm: bool = False, |         never_confirm: bool = False, | ||||||
|         always_confirm: bool = False, |         always_confirm: bool = False, | ||||||
| @@ -207,6 +234,8 @@ class Falyx: | |||||||
|  |  | ||||||
|         for command in self.commands.values(): |         for command in self.commands.values(): | ||||||
|             help_text = command.help_text or command.description |             help_text = command.help_text or command.description | ||||||
|  |             if command.requires_input: | ||||||
|  |                 help_text += " [dim](requires input)[/dim]" | ||||||
|             table.add_row( |             table.add_row( | ||||||
|                 f"[{command.color}]{command.key}[/]", |                 f"[{command.color}]{command.key}[/]", | ||||||
|                 ", ".join(command.aliases) if command.aliases else "None", |                 ", ".join(command.aliases) if command.aliases else "None", | ||||||
| @@ -234,7 +263,7 @@ class Falyx: | |||||||
|                 "Show this help menu" |                 "Show this help menu" | ||||||
|             ) |             ) | ||||||
|  |  | ||||||
|         self.console.print(table) |         self.console.print(table, justify="center") | ||||||
|  |  | ||||||
|     def _get_help_command(self) -> Command: |     def _get_help_command(self) -> Command: | ||||||
|         """Returns the help command for the menu.""" |         """Returns the help command for the menu.""" | ||||||
| @@ -324,13 +353,12 @@ class Falyx: | |||||||
|     def bottom_bar(self, bottom_bar: BottomBar | str |  Callable[[], Any] | None) -> None: |     def bottom_bar(self, bottom_bar: BottomBar | str |  Callable[[], Any] | None) -> None: | ||||||
|         """Sets the bottom bar for the menu.""" |         """Sets the bottom bar for the menu.""" | ||||||
|         if bottom_bar is None: |         if bottom_bar is None: | ||||||
|             self._bottom_bar = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available) |             self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available) | ||||||
|         elif isinstance(bottom_bar, BottomBar): |         elif isinstance(bottom_bar, BottomBar): | ||||||
|             bottom_bar.key_validator = self.is_key_available |             bottom_bar.key_validator = self.is_key_available | ||||||
|             bottom_bar.key_bindings = self.key_bindings |             bottom_bar.key_bindings = self.key_bindings | ||||||
|             self._bottom_bar = bottom_bar |             self._bottom_bar = bottom_bar | ||||||
|         elif (isinstance(bottom_bar, str) or |         elif (isinstance(bottom_bar, str) or callable(bottom_bar)): | ||||||
|             callable(bottom_bar)): |  | ||||||
|             self._bottom_bar = bottom_bar |             self._bottom_bar = bottom_bar | ||||||
|         else: |         else: | ||||||
|             raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.") |             raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.") | ||||||
| @@ -339,12 +367,12 @@ class Falyx: | |||||||
|     def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None: |     def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None: | ||||||
|         """Returns the bottom bar for the menu.""" |         """Returns the bottom bar for the menu.""" | ||||||
|         if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items: |         if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items: | ||||||
|             return self._bottom_bar.render |             return self.bottom_bar.render | ||||||
|         elif callable(self._bottom_bar): |         elif callable(self.bottom_bar): | ||||||
|             return self._bottom_bar |             return self.bottom_bar | ||||||
|         elif isinstance(self._bottom_bar, str): |         elif isinstance(self.bottom_bar, str): | ||||||
|             return self._bottom_bar |             return self.bottom_bar | ||||||
|         elif self._bottom_bar is None: |         elif self.bottom_bar is None: | ||||||
|             return None |             return None | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
| @@ -475,9 +503,10 @@ class Falyx: | |||||||
|         key: str, |         key: str, | ||||||
|         description: str, |         description: str, | ||||||
|         action: BaseAction | Callable[[], Any], |         action: BaseAction | Callable[[], Any], | ||||||
|         aliases: list[str] | None = None, |  | ||||||
|         args: tuple = (), |         args: tuple = (), | ||||||
|         kwargs: dict[str, Any] = {}, |         kwargs: dict[str, Any] = {}, | ||||||
|  |         hidden: bool = False, | ||||||
|  |         aliases: list[str] | None = None, | ||||||
|         help_text: str = "", |         help_text: str = "", | ||||||
|         color: str = OneColors.WHITE, |         color: str = OneColors.WHITE, | ||||||
|         confirm: bool = False, |         confirm: bool = False, | ||||||
| @@ -491,25 +520,27 @@ class Falyx: | |||||||
|         hooks: HookManager | None = None, |         hooks: HookManager | None = None, | ||||||
|         before_hooks: list[Callable] | None = None, |         before_hooks: list[Callable] | None = None, | ||||||
|         success_hooks: list[Callable] | None = None, |         success_hooks: list[Callable] | None = None, | ||||||
|         after_hooks: list[Callable] | None = None, |  | ||||||
|         error_hooks: list[Callable] | None = None, |         error_hooks: list[Callable] | None = None, | ||||||
|  |         after_hooks: list[Callable] | None = None, | ||||||
|         teardown_hooks: list[Callable] | None = None, |         teardown_hooks: list[Callable] | None = None, | ||||||
|         tags: list[str] | None = None, |         tags: list[str] | None = None, | ||||||
|         logging_hooks: bool = False, |         logging_hooks: bool = False, | ||||||
|         retry: bool = False, |         retry: bool = False, | ||||||
|         retry_all: bool = False, |         retry_all: bool = False, | ||||||
|         retry_policy: RetryPolicy | None = None, |         retry_policy: RetryPolicy | None = None, | ||||||
|  |         requires_input: bool | None = None, | ||||||
|     ) -> Command: |     ) -> Command: | ||||||
|         """Adds an command to the menu, preventing duplicates.""" |         """Adds an command to the menu, preventing duplicates.""" | ||||||
|         self._validate_command_key(key) |         self._validate_command_key(key) | ||||||
|         command = Command( |         command = Command( | ||||||
|             key=key, |             key=key, | ||||||
|             description=description, |             description=description, | ||||||
|             aliases=aliases if aliases else [], |  | ||||||
|             help_text=help_text, |  | ||||||
|             action=action, |             action=action, | ||||||
|             args=args, |             args=args, | ||||||
|             kwargs=kwargs, |             kwargs=kwargs, | ||||||
|  |             hidden=hidden, | ||||||
|  |             aliases=aliases if aliases else [], | ||||||
|  |             help_text=help_text, | ||||||
|             color=color, |             color=color, | ||||||
|             confirm=confirm, |             confirm=confirm, | ||||||
|             confirm_message=confirm_message, |             confirm_message=confirm_message, | ||||||
| @@ -524,6 +555,7 @@ class Falyx: | |||||||
|             retry=retry, |             retry=retry, | ||||||
|             retry_all=retry_all, |             retry_all=retry_all, | ||||||
|             retry_policy=retry_policy or RetryPolicy(), |             retry_policy=retry_policy or RetryPolicy(), | ||||||
|  |             requires_input=requires_input, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         if hooks: |         if hooks: | ||||||
| @@ -558,13 +590,15 @@ class Falyx: | |||||||
|     def build_default_table(self) -> Table: |     def build_default_table(self) -> Table: | ||||||
|         """Build the standard table layout. Developers can subclass or call this in custom tables.""" |         """Build the standard table layout. Developers can subclass or call this in custom tables.""" | ||||||
|         table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) |         table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) | ||||||
|         for chunk in chunks(self.commands.items(), self.columns): |         visible_commands = [item for item in self.commands.items() if not item[1].hidden] | ||||||
|  |         for chunk in chunks(visible_commands, self.columns): | ||||||
|             row = [] |             row = [] | ||||||
|             for key, command in chunk: |             for key, command in chunk: | ||||||
|                 row.append(f"[{key}] [{command.color}]{command.description}") |                 row.append(f"[{key}] [{command.color}]{command.description}") | ||||||
|             table.add_row(*row) |             table.add_row(*row) | ||||||
|         bottom_row = self.get_bottom_row() |         bottom_row = self.get_bottom_row() | ||||||
|         table.add_row(*bottom_row) |         for row in chunks(bottom_row, self.columns): | ||||||
|  |             table.add_row(*row) | ||||||
|         return table |         return table | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
| @@ -617,9 +651,9 @@ class Falyx: | |||||||
|             confirm_answer = await async_confirm(selected_command.confirmation_prompt) |             confirm_answer = await async_confirm(selected_command.confirmation_prompt) | ||||||
|  |  | ||||||
|             if confirm_answer: |             if confirm_answer: | ||||||
|                 logger.info(f"[{OneColors.LIGHT_YELLOW}][{selected_command.description}]🔐 confirmed.") |                 logger.info(f"[{selected_command.description}]🔐 confirmed.") | ||||||
|             else: |             else: | ||||||
|                 logger.info(f"[{OneColors.DARK_RED}][{selected_command.description}]❌ cancelled.") |                 logger.info(f"[{selected_command.description}]❌ cancelled.") | ||||||
|             return confirm_answer |             return confirm_answer | ||||||
|         return True |         return True | ||||||
|  |  | ||||||
| @@ -658,8 +692,19 @@ class Falyx: | |||||||
|         choice = await self.session.prompt_async() |         choice = await self.session.prompt_async() | ||||||
|         selected_command = self.get_command(choice) |         selected_command = self.get_command(choice) | ||||||
|         if not selected_command: |         if not selected_command: | ||||||
|             logger.info(f"[{OneColors.LIGHT_YELLOW}] Invalid command '{choice}'.") |             logger.info(f"Invalid command '{choice}'.") | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
|  |         if selected_command.requires_input: | ||||||
|  |             program = get_program_invocation() | ||||||
|  |             self.console.print( | ||||||
|  |                 f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input " | ||||||
|  |                 f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] " | ||||||
|  |                  "with proper piping or arguments.[/]" | ||||||
|  |  | ||||||
|  |             ) | ||||||
|  |             return True | ||||||
|  |  | ||||||
|         self.last_run_command = selected_command |         self.last_run_command = selected_command | ||||||
|  |  | ||||||
|         if selected_command == self.exit_command: |         if selected_command == self.exit_command: | ||||||
| @@ -667,7 +712,7 @@ class Falyx: | |||||||
|             return False |             return False | ||||||
|  |  | ||||||
|         if not await self._should_run_action(selected_command): |         if not await self._should_run_action(selected_command): | ||||||
|             logger.info(f"[{OneColors.DARK_RED}] {selected_command.description} cancelled.") |             logger.info(f"{selected_command.description} cancelled.") | ||||||
|             return True |             return True | ||||||
|  |  | ||||||
|         context = self._create_context(selected_command) |         context = self._create_context(selected_command) | ||||||
| @@ -750,7 +795,10 @@ class Falyx: | |||||||
|                 selected_command.retry_policy.delay = self.cli_args.retry_delay |                 selected_command.retry_policy.delay = self.cli_args.retry_delay | ||||||
|             if self.cli_args.retry_backoff: |             if self.cli_args.retry_backoff: | ||||||
|                 selected_command.retry_policy.backoff = self.cli_args.retry_backoff |                 selected_command.retry_policy.backoff = self.cli_args.retry_backoff | ||||||
|             #selected_command.update_retry_policy(selected_command.retry_policy) |             if isinstance(selected_command.action, Action): | ||||||
|  |                 selected_command.action.set_retry_policy(selected_command.retry_policy) | ||||||
|  |             else: | ||||||
|  |                 logger.warning(f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance.") | ||||||
|  |  | ||||||
|     def print_message(self, message: str | Markdown | dict[str, Any]) -> None: |     def print_message(self, message: str | Markdown | dict[str, Any]) -> None: | ||||||
|         """Prints a message to the console.""" |         """Prints a message to the console.""" | ||||||
| @@ -773,18 +821,19 @@ class Falyx: | |||||||
|         if self.welcome_message: |         if self.welcome_message: | ||||||
|             self.print_message(self.welcome_message) |             self.print_message(self.welcome_message) | ||||||
|         while True: |         while True: | ||||||
|             self.console.print(self.table) |             self.console.print(self.table, justify="center") | ||||||
|             try: |             try: | ||||||
|                 task = asyncio.create_task(self.process_command()) |                 task = asyncio.create_task(self.process_command()) | ||||||
|                 should_continue = await task |                 should_continue = await task | ||||||
|                 if not should_continue: |                 if not should_continue: | ||||||
|                     break |                     break | ||||||
|             except (EOFError, KeyboardInterrupt): |             except (EOFError, KeyboardInterrupt): | ||||||
|                 logger.info(f"[{OneColors.DARK_RED}]EOF or KeyboardInterrupt. Exiting menu.") |                 logger.info("EOF or KeyboardInterrupt. Exiting menu.") | ||||||
|                 break |                 break | ||||||
|         logger.info(f"Exiting menu: {self.get_title()}") |             finally: | ||||||
|         if self.exit_message: |                 logger.info(f"Exiting menu: {self.get_title()}") | ||||||
|             self.print_message(self.exit_message) |                 if self.exit_message: | ||||||
|  |                     self.print_message(self.exit_message) | ||||||
|  |  | ||||||
|     async def run(self) -> None: |     async def run(self) -> None: | ||||||
|         """Run Falyx CLI with structured subcommands.""" |         """Run Falyx CLI with structured subcommands.""" | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """hook_manager.py""" | """hook_manager.py""" | ||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| @@ -64,5 +65,6 @@ class HookManager: | |||||||
|                                f" for '{context.name}': {hook_error}") |                                f" for '{context.name}': {hook_error}") | ||||||
|  |  | ||||||
|                 if hook_type == HookType.ON_ERROR: |                 if hook_type == HookType.ON_ERROR: | ||||||
|                     assert isinstance(context.exception, BaseException) |                     assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook" | ||||||
|                     raise context.exception from hook_error |                     raise context.exception from hook_error | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """hooks.py""" | """hooks.py""" | ||||||
| import time | import time | ||||||
|  | from typing import Callable | ||||||
|  |  | ||||||
| from falyx.context import ExecutionContext | from falyx.context import ExecutionContext | ||||||
| from falyx.exceptions import CircuitBreakerOpen | from falyx.exceptions import CircuitBreakerOpen | ||||||
| @@ -7,8 +9,9 @@ from falyx.themes.colors import OneColors | |||||||
| from falyx.utils import logger | from falyx.utils import logger | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ResultReporter: | class ResultReporter: | ||||||
|     def __init__(self, formatter: callable = None): |     def __init__(self, formatter: Callable[[], str] | None = None): | ||||||
|         """ |         """ | ||||||
|         Optional result formatter. If not provided, uses repr(result). |         Optional result formatter. If not provided, uses repr(result). | ||||||
|         """ |         """ | ||||||
|   | |||||||
							
								
								
									
										109
									
								
								falyx/http_action.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								falyx/http_action.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | |||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | import aiohttp | ||||||
|  | from rich.tree import Tree | ||||||
|  |  | ||||||
|  | from falyx.action import Action | ||||||
|  | from falyx.context import ExecutionContext, SharedContext | ||||||
|  | from falyx.themes.colors import OneColors | ||||||
|  | from falyx.utils import logger | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def close_shared_http_session(context: ExecutionContext) -> None: | ||||||
|  |     try: | ||||||
|  |         shared_context: SharedContext = context.get_shared_context() | ||||||
|  |         session = shared_context.get("http_session") | ||||||
|  |         should_close = shared_context.get("_session_should_close", False) | ||||||
|  |         if session and should_close: | ||||||
|  |             await session.close() | ||||||
|  |     except Exception as error: | ||||||
|  |         logger.warning("⚠️ Error closing shared HTTP session: %s", error) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class HTTPAction(Action): | ||||||
|  |     """ | ||||||
|  |     Specialized Action that performs an HTTP request using aiohttp and the shared context. | ||||||
|  |  | ||||||
|  |     Automatically reuses a shared aiohttp.ClientSession stored in SharedContext. | ||||||
|  |     Closes the session at the end of the ActionGroup (via an after-hook). | ||||||
|  |     """ | ||||||
|  |     def __init__( | ||||||
|  |         self, | ||||||
|  |         name: str, | ||||||
|  |         method: str, | ||||||
|  |         url: str, | ||||||
|  |         *, | ||||||
|  |         args: tuple[Any, ...] = (), | ||||||
|  |         headers: dict[str, str] | None = None, | ||||||
|  |         params: dict[str, Any] | None = None, | ||||||
|  |         json: dict[str, Any] | None = None, | ||||||
|  |         data: Any = None, | ||||||
|  |         hooks=None, | ||||||
|  |         inject_last_result: bool = False, | ||||||
|  |         inject_last_result_as: str = "last_result", | ||||||
|  |         retry: bool = False, | ||||||
|  |         retry_policy=None, | ||||||
|  |     ): | ||||||
|  |         self.method = method.upper() | ||||||
|  |         self.url = url | ||||||
|  |         self.headers = headers | ||||||
|  |         self.params = params | ||||||
|  |         self.json = json | ||||||
|  |         self.data = data | ||||||
|  |  | ||||||
|  |         super().__init__( | ||||||
|  |             name=name, | ||||||
|  |             action=self._request, | ||||||
|  |             args=args, | ||||||
|  |             kwargs={}, | ||||||
|  |             hooks=hooks, | ||||||
|  |             inject_last_result=inject_last_result, | ||||||
|  |             inject_last_result_as=inject_last_result_as, | ||||||
|  |             retry=retry, | ||||||
|  |             retry_policy=retry_policy, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     async def _request(self, *args, **kwargs) -> dict[str, Any]: | ||||||
|  |         assert self.shared_context is not None, "SharedContext is not set" | ||||||
|  |         context: SharedContext = self.shared_context | ||||||
|  |  | ||||||
|  |         session = context.get("http_session") | ||||||
|  |         if session is None: | ||||||
|  |             session = aiohttp.ClientSession() | ||||||
|  |             context.set("http_session", session) | ||||||
|  |             context.set("_session_should_close", True) | ||||||
|  |  | ||||||
|  |         async with session.request( | ||||||
|  |             self.method, | ||||||
|  |             self.url, | ||||||
|  |             headers=self.headers, | ||||||
|  |             params=self.params, | ||||||
|  |             json=self.json, | ||||||
|  |             data=self.data, | ||||||
|  |         ) as response: | ||||||
|  |             body = await response.text() | ||||||
|  |             return { | ||||||
|  |                 "status": response.status, | ||||||
|  |                 "url": str(response.url), | ||||||
|  |                 "headers": dict(response.headers), | ||||||
|  |                 "body": body, | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |     async def preview(self, parent: Tree | None = None): | ||||||
|  |         label = [ | ||||||
|  |             f"[{OneColors.CYAN_b}]🌐 HTTPAction[/] '{self.name}'", | ||||||
|  |             f"\n[dim]Method:[/] {self.method}", | ||||||
|  |             f"\n[dim]URL:[/] {self.url}", | ||||||
|  |         ] | ||||||
|  |         if self.inject_last_result: | ||||||
|  |             label.append(f"\n[dim]Injects:[/] '{self.inject_last_result_as}'") | ||||||
|  |         if self.retry_policy and self.retry_policy.enabled: | ||||||
|  |             label.append( | ||||||
|  |                 f"\n[dim]↻ Retries:[/] {self.retry_policy.max_retries}x, " | ||||||
|  |                 f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         if parent: | ||||||
|  |             parent.add("".join(label)) | ||||||
|  |         else: | ||||||
|  |             self.console.print(Tree("".join(label))) | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """importer.py""" | """importer.py""" | ||||||
|  |  | ||||||
| import importlib | import importlib | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """io_action.py""" | """io_action.py""" | ||||||
| import asyncio | import asyncio | ||||||
| import subprocess | import subprocess | ||||||
| @@ -12,8 +13,8 @@ from falyx.context import ExecutionContext | |||||||
| from falyx.exceptions import FalyxError | from falyx.exceptions import FalyxError | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.utils import logger |  | ||||||
| from falyx.themes.colors import OneColors | from falyx.themes.colors import OneColors | ||||||
|  | from falyx.utils import logger | ||||||
|  |  | ||||||
| console = Console() | console = Console() | ||||||
|  |  | ||||||
| @@ -34,7 +35,7 @@ class BaseIOAction(BaseAction): | |||||||
|             inject_last_result=inject_last_result, |             inject_last_result=inject_last_result, | ||||||
|         ) |         ) | ||||||
|         self.mode = mode |         self.mode = mode | ||||||
|         self.requires_injection = True |         self._requires_injection = True | ||||||
|  |  | ||||||
|     def from_input(self, raw: str | bytes) -> Any: |     def from_input(self, raw: str | bytes) -> Any: | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
| @@ -178,3 +179,29 @@ class ShellAction(BaseIOAction): | |||||||
|             parent.add("".join(label)) |             parent.add("".join(label)) | ||||||
|         else: |         else: | ||||||
|             console.print(Tree("".join(label))) |             console.print(Tree("".join(label))) | ||||||
|  |  | ||||||
|  | class GrepAction(BaseIOAction): | ||||||
|  |     def __init__(self, name: str, pattern: str, **kwargs): | ||||||
|  |         super().__init__(name=name, **kwargs) | ||||||
|  |         self.pattern = pattern | ||||||
|  |  | ||||||
|  |     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: | ||||||
|  |         command = ["grep", "-n", self.pattern] | ||||||
|  |         process = subprocess.Popen( | ||||||
|  |             command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True | ||||||
|  |         ) | ||||||
|  |         stdout, stderr = process.communicate(input=parsed_input) | ||||||
|  |         if process.returncode == 1: | ||||||
|  |             return "" | ||||||
|  |         if process.returncode != 0: | ||||||
|  |             raise RuntimeError(stderr.strip()) | ||||||
|  |         return stdout.strip() | ||||||
|  |  | ||||||
|  |     def to_output(self, result: str) -> str: | ||||||
|  |         return result | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,88 +0,0 @@ | |||||||
| import asyncio |  | ||||||
| import logging |  | ||||||
| from rich.markdown import Markdown |  | ||||||
|  |  | ||||||
| from falyx import Action, Falyx |  | ||||||
| from falyx.hook_manager import HookType |  | ||||||
| from falyx.debug import log_before, log_success, log_error, log_after |  | ||||||
| from falyx.themes.colors import OneColors |  | ||||||
| from falyx.utils import setup_logging |  | ||||||
|  |  | ||||||
| # Setup logging |  | ||||||
| setup_logging(console_log_level=logging.WARNING, json_log_to_file=True) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def main(): |  | ||||||
|     # Create the menu |  | ||||||
|     menu = Falyx( |  | ||||||
|         title=Markdown("# 🚀 Falyx CLI Demo"), |  | ||||||
|         welcome_message="Welcome to Falyx!", |  | ||||||
|         exit_message="Thanks for using Falyx!", |  | ||||||
|         include_history_command=True, |  | ||||||
|         include_help_command=True, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     # Define async actions |  | ||||||
|     async def hello(): |  | ||||||
|         print("👋 Hello from Falyx CLI!") |  | ||||||
|  |  | ||||||
|     def goodbye(): |  | ||||||
|         print("👋 Goodbye from Falyx CLI!") |  | ||||||
|  |  | ||||||
|     async def do_task_and_increment(counter_name: str = "tasks"): |  | ||||||
|         await asyncio.sleep(3) |  | ||||||
|         print("✅ Task completed.") |  | ||||||
|         menu.bottom_bar.increment_total_counter(counter_name) |  | ||||||
|  |  | ||||||
|     # Register global logging hooks |  | ||||||
|     menu.hooks.register(HookType.BEFORE, log_before) |  | ||||||
|     menu.hooks.register(HookType.ON_SUCCESS, log_success) |  | ||||||
|     menu.hooks.register(HookType.ON_ERROR, log_error) |  | ||||||
|     menu.hooks.register(HookType.AFTER, log_after) |  | ||||||
|  |  | ||||||
|     # Add a toggle to the bottom bar |  | ||||||
|     menu.add_toggle("D", "Debug Mode", state=False) |  | ||||||
|  |  | ||||||
|     # Add a counter to the bottom bar |  | ||||||
|     menu.add_total_counter("tasks", "Tasks", current=0, total=5) |  | ||||||
|  |  | ||||||
|     # Add static text to the bottom bar |  | ||||||
|     menu.add_static("env", "🌐 Local Env") |  | ||||||
|  |  | ||||||
|     # Add commands with help_text |  | ||||||
|     menu.add_command( |  | ||||||
|         key="S", |  | ||||||
|         description="Say Hello", |  | ||||||
|         help_text="Greets the user with a friendly hello message.", |  | ||||||
|         action=Action("Hello", hello), |  | ||||||
|         color=OneColors.CYAN, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     menu.add_command( |  | ||||||
|         key="G", |  | ||||||
|         description="Say Goodbye", |  | ||||||
|         help_text="Bids farewell and thanks the user for using the app.", |  | ||||||
|         action=Action("Goodbye", goodbye), |  | ||||||
|         color=OneColors.MAGENTA, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     menu.add_command( |  | ||||||
|         key="T", |  | ||||||
|         description="Run a Task", |  | ||||||
|         aliases=["task", "run"], |  | ||||||
|         help_text="Performs a task and increments the counter by 1.", |  | ||||||
|         action=do_task_and_increment, |  | ||||||
|         args=("tasks",), |  | ||||||
|         color=OneColors.GREEN, |  | ||||||
|         spinner=True, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     asyncio.run(menu.run()) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     """ |  | ||||||
|     Entry point for the Falyx CLI demo application. |  | ||||||
|     This function initializes the menu and runs it. |  | ||||||
|     """ |  | ||||||
|     main() |  | ||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """options_manager.py""" | """options_manager.py""" | ||||||
|  |  | ||||||
| from argparse import Namespace | from argparse import Namespace | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """parsers.py | """parsers.py | ||||||
| This module contains the argument parsers used for the Falyx CLI. | This module contains the argument parsers used for the Falyx CLI. | ||||||
| """ | """ | ||||||
|   | |||||||
| @@ -1,5 +1,7 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """retry.py""" | """retry.py""" | ||||||
| import asyncio | import asyncio | ||||||
|  | import random | ||||||
|  |  | ||||||
| from pydantic import BaseModel, Field | from pydantic import BaseModel, Field | ||||||
|  |  | ||||||
| @@ -11,8 +13,16 @@ class RetryPolicy(BaseModel): | |||||||
|     max_retries: int = Field(default=3, ge=0) |     max_retries: int = Field(default=3, ge=0) | ||||||
|     delay: float = Field(default=1.0, ge=0.0) |     delay: float = Field(default=1.0, ge=0.0) | ||||||
|     backoff: float = Field(default=2.0, ge=1.0) |     backoff: float = Field(default=2.0, ge=1.0) | ||||||
|  |     jitter: float = Field(default=0.0, ge=0.0) | ||||||
|     enabled: bool = False |     enabled: bool = False | ||||||
|  |  | ||||||
|  |     def enable_policy(self) -> None: | ||||||
|  |         """ | ||||||
|  |         Enable the retry policy. | ||||||
|  |         :return: None | ||||||
|  |         """ | ||||||
|  |         self.enabled = True | ||||||
|  |  | ||||||
|     def is_active(self) -> bool: |     def is_active(self) -> bool: | ||||||
|         """ |         """ | ||||||
|         Check if the retry policy is active. |         Check if the retry policy is active. | ||||||
| @@ -25,11 +35,18 @@ class RetryHandler: | |||||||
|     def __init__(self, policy: RetryPolicy=RetryPolicy()): |     def __init__(self, policy: RetryPolicy=RetryPolicy()): | ||||||
|         self.policy = policy |         self.policy = policy | ||||||
|  |  | ||||||
|     def enable_policy(self, backoff=2, max_retries=3, delay=1): |     def enable_policy( | ||||||
|  |         self, | ||||||
|  |         max_retries: int=3, | ||||||
|  |         delay: float=1.0, | ||||||
|  |         backoff: float=2.0, | ||||||
|  |         jitter: float=0.0, | ||||||
|  |     ): | ||||||
|         self.policy.enabled = True |         self.policy.enabled = True | ||||||
|         self.policy.max_retries = max_retries |         self.policy.max_retries = max_retries | ||||||
|         self.policy.delay = delay |         self.policy.delay = delay | ||||||
|         self.policy.backoff = backoff |         self.policy.backoff = backoff | ||||||
|  |         self.policy.jitter = jitter | ||||||
|         logger.info(f"🔄 Retry policy enabled: {self.policy}") |         logger.info(f"🔄 Retry policy enabled: {self.policy}") | ||||||
|  |  | ||||||
|     async def retry_on_error(self, context: ExecutionContext): |     async def retry_on_error(self, context: ExecutionContext): | ||||||
| @@ -60,7 +77,15 @@ class RetryHandler: | |||||||
|  |  | ||||||
|         while retries_done < self.policy.max_retries: |         while retries_done < self.policy.max_retries: | ||||||
|             retries_done += 1 |             retries_done += 1 | ||||||
|             logger.info(f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...") |  | ||||||
|  |             sleep_delay = current_delay | ||||||
|  |             if self.policy.jitter > 0: | ||||||
|  |                 sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) | ||||||
|  |  | ||||||
|  |             logger.info( | ||||||
|  |                 f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) " | ||||||
|  |                 f"in {current_delay}s due to '{last_error}'..." | ||||||
|  |             ) | ||||||
|             await asyncio.sleep(current_delay) |             await asyncio.sleep(current_delay) | ||||||
|             try: |             try: | ||||||
|                 result = await target.action(*context.args, **context.kwargs) |                 result = await target.action(*context.args, **context.kwargs) | ||||||
| @@ -71,7 +96,10 @@ class RetryHandler: | |||||||
|             except Exception as retry_error: |             except Exception as retry_error: | ||||||
|                 last_error = retry_error |                 last_error = retry_error | ||||||
|                 current_delay *= self.policy.backoff |                 current_delay *= self.policy.backoff | ||||||
|                 logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.") |                 logger.warning( | ||||||
|  |                     f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} " | ||||||
|  |                     f"failed due to '{retry_error}'." | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|         context.exception = last_error |         context.exception = last_error | ||||||
|         logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") |         logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.") | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								falyx/tagged_table.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								falyx/tagged_table.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | from collections import defaultdict | ||||||
|  |  | ||||||
|  | from rich import box | ||||||
|  | from rich.table import Table | ||||||
|  |  | ||||||
|  | from falyx.command import Command | ||||||
|  | from falyx.falyx import Falyx | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def build_tagged_table(flx: Falyx) -> Table: | ||||||
|  |     """Custom table builder that groups commands by tags.""" | ||||||
|  |     table = Table(title=flx.title, show_header=False, box=box.SIMPLE) | ||||||
|  |  | ||||||
|  |     # Group commands by first tag | ||||||
|  |     grouped: dict[str, list[Command]] = defaultdict(list) | ||||||
|  |     for cmd in flx.commands.values(): | ||||||
|  |         first_tag = cmd.tags[0] if cmd.tags else "Other" | ||||||
|  |         grouped[first_tag.capitalize()].append(cmd) | ||||||
|  |  | ||||||
|  |     # Add grouped commands to table | ||||||
|  |     for group_name, commands in grouped.items(): | ||||||
|  |         table.add_row(f"[bold underline]{group_name} Commands[/]") | ||||||
|  |         for cmd in commands: | ||||||
|  |             table.add_row(f"[{cmd.key}] [{cmd.color}]{cmd.description}") | ||||||
|  |         table.add_row("") | ||||||
|  |  | ||||||
|  |     # Add bottom row | ||||||
|  |     for row in flx.get_bottom_row(): | ||||||
|  |         table.add_row(row) | ||||||
|  |  | ||||||
|  |     return table | ||||||
| @@ -1,8 +1,11 @@ | |||||||
|  | # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed | ||||||
| """utils.py""" | """utils.py""" | ||||||
| import functools | import functools | ||||||
| import inspect | import inspect | ||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
|  | import shutil | ||||||
|  | import sys | ||||||
| from itertools import islice | from itertools import islice | ||||||
| from typing import Any, Awaitable, Callable, TypeVar | from typing import Any, Awaitable, Callable, TypeVar | ||||||
|  |  | ||||||
| @@ -21,6 +24,20 @@ T = TypeVar("T") | |||||||
| async def _noop(*args, **kwargs): | async def _noop(*args, **kwargs): | ||||||
|     pass |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_program_invocation() -> str: | ||||||
|  |     """Returns the recommended program invocation prefix.""" | ||||||
|  |     script = sys.argv[0] | ||||||
|  |     program = shutil.which(script) | ||||||
|  |     if program: | ||||||
|  |         return os.path.basename(program) | ||||||
|  |  | ||||||
|  |     executable = sys.executable | ||||||
|  |     if "python" in executable: | ||||||
|  |         return f"python {script}" | ||||||
|  |     return script | ||||||
|  |  | ||||||
|  |  | ||||||
| def is_coroutine(function: Callable[..., Any]) -> bool: | def is_coroutine(function: Callable[..., Any]) -> bool: | ||||||
|     return inspect.iscoroutinefunction(function) |     return inspect.iscoroutinefunction(function) | ||||||
|  |  | ||||||
| @@ -32,6 +49,9 @@ def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: | |||||||
|     @functools.wraps(function) |     @functools.wraps(function) | ||||||
|     async def async_wrapper(*args, **kwargs) -> T: |     async def async_wrapper(*args, **kwargs) -> T: | ||||||
|         return function(*args, **kwargs) |         return function(*args, **kwargs) | ||||||
|  |  | ||||||
|  |     if not callable(function): | ||||||
|  |         raise TypeError(f"{function} is not callable") | ||||||
|     return async_wrapper |     return async_wrapper | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1 +1 @@ | |||||||
| __version__ = "0.1.5" | __version__ = "0.1.6" | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "falyx" | name = "falyx" | ||||||
| version = "0.1.5" | version = "0.1.6" | ||||||
| description = "Reliable and introspectable async CLI action framework." | description = "Reliable and introspectable async CLI action framework." | ||||||
| authors = ["Roland Thomas Jr <roland@rtj.dev>"] | authors = ["Roland Thomas Jr <roland@rtj.dev>"] | ||||||
| license = "MIT" | license = "MIT" | ||||||
| @@ -20,7 +20,6 @@ pytest-asyncio = "^0.20" | |||||||
| ruff = "^0.3" | ruff = "^0.3" | ||||||
|  |  | ||||||
| [tool.poetry.scripts] | [tool.poetry.scripts] | ||||||
| falyx = "falyx.cli.main:main" |  | ||||||
| sync-version = "scripts.sync_version:main" | sync-version = "scripts.sync_version:main" | ||||||
|  |  | ||||||
| [build-system] | [build-system] | ||||||
|   | |||||||
							
								
								
									
										78
									
								
								tests/test_action_basic.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								tests/test_action_basic.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  |  | ||||||
|  | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  | # --- Helpers --- | ||||||
|  | async def capturing_hook(context: ExecutionContext): | ||||||
|  |     context.extra["hook_triggered"] = True | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_callable(): | ||||||
|  |     """Test if Action can be created with a callable.""" | ||||||
|  |     action = Action("test_action", lambda: "Hello, World!") | ||||||
|  |     result = await action() | ||||||
|  |     assert result == "Hello, World!" | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_async_callable(): | ||||||
|  |     """Test if Action can be created with an async callable.""" | ||||||
|  |     async def async_callable(): | ||||||
|  |         return "Hello, World!" | ||||||
|  |     action = Action("test_action", async_callable) | ||||||
|  |     result = await action() | ||||||
|  |     assert result == "Hello, World!" | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_non_callable(): | ||||||
|  |     """Test if Action raises an error when created with a non-callable.""" | ||||||
|  |     with pytest.raises(TypeError): | ||||||
|  |         Action("test_action", 42) | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.parametrize("return_list, expected", [ | ||||||
|  |     (True, [1, 2, 3]), | ||||||
|  |     (False, 3), | ||||||
|  | ]) | ||||||
|  | async def test_chained_action_return_modes(return_list, expected): | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="Simple Chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="one", action=lambda: 1), | ||||||
|  |             Action(name="two", action=lambda: 2), | ||||||
|  |             Action(name="three", action=lambda: 3), | ||||||
|  |         ], | ||||||
|  |         return_list=return_list | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == expected | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | @pytest.mark.parametrize("return_list, auto_inject, expected", [ | ||||||
|  |     (True, True, [1, 2, 3]), | ||||||
|  |     (True, False, [1, 2, 3]), | ||||||
|  |     (False, True, 3), | ||||||
|  |     (False, False, 3), | ||||||
|  | ]) | ||||||
|  | async def test_chained_action_literals(return_list, auto_inject, expected): | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="Literal Chain", | ||||||
|  |         actions=[1, 2, 3], | ||||||
|  |         return_list=return_list, | ||||||
|  |         auto_inject=auto_inject, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == expected | ||||||
							
								
								
									
										0
									
								
								tests/test_action_fallback.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_action_fallback.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								tests/test_action_hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_action_hooks.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										40
									
								
								tests/test_action_process.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								tests/test_action_process.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | import pickle | ||||||
|  | import warnings | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import ProcessAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  |  | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  | def slow_add(x, y): | ||||||
|  |     return x + y | ||||||
|  |  | ||||||
|  | # --- Tests --- | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_process_action_executes_correctly(): | ||||||
|  |     with warnings.catch_warnings(): | ||||||
|  |         warnings.simplefilter("ignore", DeprecationWarning) | ||||||
|  |  | ||||||
|  |         action = ProcessAction(name="proc", func=slow_add, args=(2, 3)) | ||||||
|  |         result = await action() | ||||||
|  |         assert result == 5 | ||||||
|  |  | ||||||
|  | unpickleable = lambda x: x + 1 | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_process_action_rejects_unpickleable(): | ||||||
|  |     with warnings.catch_warnings(): | ||||||
|  |         warnings.simplefilter("ignore", DeprecationWarning) | ||||||
|  |  | ||||||
|  |         action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,)) | ||||||
|  |         with pytest.raises(pickle.PicklingError, match="Can't pickle"): | ||||||
|  |             await action() | ||||||
|  |  | ||||||
							
								
								
									
										30
									
								
								tests/test_action_retries.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								tests/test_action_retries.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookManager, HookType | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  |  | ||||||
|  | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  | # --- Helpers --- | ||||||
|  | async def capturing_hook(context: ExecutionContext): | ||||||
|  |     context.extra["hook_triggered"] = True | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  | @pytest.fixture | ||||||
|  | def hook_manager(): | ||||||
|  |     hm = HookManager() | ||||||
|  |     hm.register(HookType.BEFORE, capturing_hook) | ||||||
|  |     return hm | ||||||
|  |  | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  | def test_action_enable_retry(): | ||||||
|  |     """Test if Action can be created with retry=True.""" | ||||||
|  |     action = Action("test_action", lambda: "Hello, World!", retry=True) | ||||||
|  |     assert action.retry_policy.enabled is True | ||||||
| @@ -1,28 +1,17 @@ | |||||||
| import pytest | import pytest | ||||||
| import asyncio |  | ||||||
| import pickle | from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction | ||||||
| import warnings |  | ||||||
| from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction |  | ||||||
| from falyx.execution_registry import ExecutionRegistry as er | from falyx.execution_registry import ExecutionRegistry as er | ||||||
| from falyx.hook_manager import HookManager, HookType | from falyx.hook_manager import HookManager, HookType | ||||||
| from falyx.context import ExecutionContext, ResultsContext | from falyx.context import ExecutionContext | ||||||
|  |  | ||||||
| asyncio_default_fixture_loop_scope = "function" | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
| # --- Helpers --- | # --- Helpers --- | ||||||
|  |  | ||||||
| async def dummy_action(x: int = 0) -> int: |  | ||||||
|     return x + 1 |  | ||||||
|  |  | ||||||
| async def capturing_hook(context: ExecutionContext): | async def capturing_hook(context: ExecutionContext): | ||||||
|     context.extra["hook_triggered"] = True |     context.extra["hook_triggered"] = True | ||||||
|  |  | ||||||
| # --- Fixtures --- | # --- Fixtures --- | ||||||
|  |  | ||||||
| @pytest.fixture |  | ||||||
| def sample_action(): |  | ||||||
|     return Action(name="increment", action=dummy_action, kwargs={"x": 5}) |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def hook_manager(): | def hook_manager(): | ||||||
|     hm = HookManager() |     hm = HookManager() | ||||||
| @@ -38,15 +27,18 @@ def clean_registry(): | |||||||
| # --- Tests --- | # --- Tests --- | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_runs_correctly(sample_action): | async def test_action_runs_correctly(): | ||||||
|  |     async def dummy_action(x: int = 0) -> int: return x + 1 | ||||||
|  |     sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5}) | ||||||
|     result = await sample_action() |     result = await sample_action() | ||||||
|     assert result == 6 |     assert result == 6 | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_hook_lifecycle(hook_manager): | async def test_action_hook_lifecycle(hook_manager): | ||||||
|  |     async def a1(): return 42 | ||||||
|     action = Action( |     action = Action( | ||||||
|         name="hooked", |         name="hooked", | ||||||
|         action=lambda: 42, |         action=a1, | ||||||
|         hooks=hook_manager |         hooks=hook_manager | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
| @@ -58,21 +50,30 @@ async def test_action_hook_lifecycle(hook_manager): | |||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_with_result_injection(): | async def test_chained_action_with_result_injection(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(last_result): return last_result + 5 | ||||||
|  |     async def a3(last_result): return last_result * 2 | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="start", action=lambda: 1), |         Action(name="start", action=a1), | ||||||
|         Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True), |         Action(name="add_last", action=a2, inject_last_result=True), | ||||||
|         Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True) |         Action(name="multiply", action=a3, inject_last_result=True) | ||||||
|     ] |     ] | ||||||
|     chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) |     chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     assert result == [1, 6, 12] |     assert result == [1, 6, 12] | ||||||
|  |     chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 12 | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_group_runs_in_parallel(): | async def test_action_group_runs_in_parallel(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(): return 2 | ||||||
|  |     async def a3(): return 3 | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="a", action=lambda: 1), |         Action(name="a", action=a1), | ||||||
|         Action(name="b", action=lambda: 2), |         Action(name="b", action=a2), | ||||||
|         Action(name="c", action=lambda: 3), |         Action(name="c", action=a3), | ||||||
|     ] |     ] | ||||||
|     group = ActionGroup(name="parallel", actions=actions) |     group = ActionGroup(name="parallel", actions=actions) | ||||||
|     result = await group() |     result = await group() | ||||||
| @@ -81,39 +82,48 @@ async def test_action_group_runs_in_parallel(): | |||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_inject_from_action(): | async def test_chained_action_inject_from_action(): | ||||||
|  |     async def a1(last_result): return last_result + 10 | ||||||
|  |     async def a2(last_result): return last_result + 5 | ||||||
|     inner_chain = ChainedAction( |     inner_chain = ChainedAction( | ||||||
|         name="inner_chain", |         name="inner_chain", | ||||||
|         actions=[ |         actions=[ | ||||||
|             Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True), |             Action(name="inner_first", action=a1, inject_last_result=True), | ||||||
|             Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True), |             Action(name="inner_second", action=a2, inject_last_result=True), | ||||||
|         ] |         ], | ||||||
|  |         return_list=True, | ||||||
|     ) |     ) | ||||||
|  |     async def a3(): return 1 | ||||||
|  |     async def a4(last_result): return last_result + 2 | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="first", action=lambda: 1), |         Action(name="first", action=a3), | ||||||
|         Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), |         Action(name="second", action=a4, inject_last_result=True), | ||||||
|         inner_chain, |         inner_chain, | ||||||
|  |  | ||||||
|     ] |     ] | ||||||
|     outer_chain = ChainedAction(name="test_chain", actions=actions) |     outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True) | ||||||
|     result = await outer_chain() |     result = await outer_chain() | ||||||
|     assert result == [1, 3, [13, 18]] |     assert result == [1, 3, [13, 18]] | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_chained_action_with_group(): | async def test_chained_action_with_group(): | ||||||
|  |     async def a1(last_result): return last_result + 1 | ||||||
|  |     async def a2(last_result): return last_result + 2 | ||||||
|  |     async def a3(): return 3 | ||||||
|     group = ActionGroup( |     group = ActionGroup( | ||||||
|         name="group", |         name="group", | ||||||
|         actions=[ |         actions=[ | ||||||
|             Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True), |             Action(name="a", action=a1, inject_last_result=True), | ||||||
|             Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True), |             Action(name="b", action=a2, inject_last_result=True), | ||||||
|             Action(name="c", action=lambda: 3), |             Action(name="c", action=a3), | ||||||
|         ] |         ] | ||||||
|     ) |     ) | ||||||
|  |     async def a4(): return 1 | ||||||
|  |     async def a5(last_result): return last_result + 2 | ||||||
|     actions = [ |     actions = [ | ||||||
|         Action(name="first", action=lambda: 1), |         Action(name="first", action=a4), | ||||||
|         Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), |         Action(name="second", action=a5, inject_last_result=True), | ||||||
|         group, |         group, | ||||||
|     ] |     ] | ||||||
|     chain = ChainedAction(name="test_chain", actions=actions) |     chain = ChainedAction(name="test_chain", actions=actions, return_list=True) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] |     assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] | ||||||
|  |  | ||||||
| @@ -161,37 +171,21 @@ async def test_chained_action_rollback_on_failure(): | |||||||
|  |  | ||||||
|     assert rollback_called == ["rolled back"] |     assert rollback_called == ["rolled back"] | ||||||
|  |  | ||||||
| def slow_add(x, y): |  | ||||||
|     return x + y |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio |  | ||||||
| async def test_process_action_executes_correctly(): |  | ||||||
|     with warnings.catch_warnings(): |  | ||||||
|         warnings.simplefilter("ignore", DeprecationWarning) |  | ||||||
|  |  | ||||||
|         action = ProcessAction(name="proc", func=slow_add, args=(2, 3)) |  | ||||||
|         result = await action() |  | ||||||
|         assert result == 5 |  | ||||||
|  |  | ||||||
| unpickleable = lambda x: x + 1 |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio |  | ||||||
| async def test_process_action_rejects_unpickleable(): |  | ||||||
|     with warnings.catch_warnings(): |  | ||||||
|         warnings.simplefilter("ignore", DeprecationWarning) |  | ||||||
|  |  | ||||||
|         action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,)) |  | ||||||
|         with pytest.raises(pickle.PicklingError, match="Can't pickle"): |  | ||||||
|             await action() |  | ||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_register_hooks_recursively_propagates(): | async def test_register_hooks_recursively_propagates(): | ||||||
|     hook = lambda ctx: ctx.extra.update({"test_marker": True}) |     def hook(context): | ||||||
|  |         context.extra.update({"test_marker": True}) | ||||||
|  |  | ||||||
|     chain = ChainedAction(name="chain", actions=[ |     async def a1(): return 1 | ||||||
|         Action(name="a", action=lambda: 1), |     async def a2(): return 2 | ||||||
|         Action(name="b", action=lambda: 2), |  | ||||||
|     ]) |     chain = ChainedAction( | ||||||
|  |         name="chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="a", action=a1), | ||||||
|  |             Action(name="b", action=a2), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|     chain.register_hooks_recursively(HookType.BEFORE, hook) |     chain.register_hooks_recursively(HookType.BEFORE, hook) | ||||||
|  |  | ||||||
|     await chain() |     await chain() | ||||||
| @@ -217,14 +211,255 @@ async def test_action_hook_recovers_error(): | |||||||
|  |  | ||||||
| @pytest.mark.asyncio | @pytest.mark.asyncio | ||||||
| async def test_action_group_injects_last_result(): | async def test_action_group_injects_last_result(): | ||||||
|  |     async def a1(last_result): return last_result + 10 | ||||||
|  |     async def a2(last_result): return last_result + 20 | ||||||
|     group = ActionGroup(name="group", actions=[ |     group = ActionGroup(name="group", actions=[ | ||||||
|         Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True), |         Action(name="g1", action=a1, inject_last_result=True), | ||||||
|         Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True), |         Action(name="g2", action=a2, inject_last_result=True), | ||||||
|     ]) |  | ||||||
|     chain = ChainedAction(name="with_group", actions=[ |  | ||||||
|         Action(name="first", action=lambda: 5), |  | ||||||
|         group, |  | ||||||
|     ]) |     ]) | ||||||
|  |     async def a3(): return 5 | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="with_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="first", action=a3), | ||||||
|  |             group, | ||||||
|  |         ], | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|     result = await chain() |     result = await chain() | ||||||
|     result_dict = dict(result[1]) |     result_dict = dict(result[1]) | ||||||
|     assert result_dict == {"g1": 15, "g2": 25} |     assert result_dict == {"g1": 15, "g2": 25} | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_inject_last_result(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(last_result): return last_result + 1 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2, inject_last_result=True) | ||||||
|  |     chain = ChainedAction(name="chain", actions=[a1, a2]) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 2 | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_inject_last_result_fail(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(last_result): return last_result + 1 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="chain", actions=[a1, a2]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(TypeError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "last_result" in str(exc_info.value) | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_auto_inject(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(last_result): return last_result + 2 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [1, 3] # a2 receives last_result=1 | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_no_auto_inject(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(): return 2 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [1, 2] # a2 does not receive 1 | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_auto_inject_after_first(): | ||||||
|  |     async def a1(): return 1 | ||||||
|  |     async def a2(last_result): return last_result + 1 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2) | ||||||
|  |     chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 2  # a2 receives last_result=1 | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_literal_input(): | ||||||
|  |     async def a1(last_result): return last_result + " world" | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == "hello world"  # "hello" is injected as last_result | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_manual_inject_override(): | ||||||
|  |     async def a1(): return 10 | ||||||
|  |     async def a2(last_result): return last_result * 2 | ||||||
|  |     a1 = Action(name="a1", action=a1) | ||||||
|  |     a2 = Action(name="a2", action=a2, inject_last_result=True) | ||||||
|  |     chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False) | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 20  # Even without auto_inject, a2 still gets last_result | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_mid_literal(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return None  # Simulate failure or missing data | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             "default_value",  # <-- literal fallback injected mid-chain | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default_value", "default_value", "Enriched: default_value"] | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_mid_fallback(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return None  # Simulate failure or missing data | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default_value"), | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default_value", "default_value", "Enriched: default_value"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_success_mid_fallback(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         # Imagine this is some dynamic API call | ||||||
|  |         return "Result"  # Simulate success | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("Missing data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich_data(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_pipeline", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="FetchData", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default_value"), | ||||||
|  |             Action(name="ValidateData", action=validate_data), | ||||||
|  |             Action(name="EnrichData", action=enrich_data), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == ["Result", "Result", "Result", "Enriched: Result"] | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_group_partial_failure(): | ||||||
|  |     async def succeed(): return "ok" | ||||||
|  |     async def fail(): raise ValueError("oops") | ||||||
|  |  | ||||||
|  |     group = ActionGroup(name="partial_group", actions=[ | ||||||
|  |         Action(name="succeed_action", action=succeed), | ||||||
|  |         Action(name="fail_action", action=fail), | ||||||
|  |     ]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         await group() | ||||||
|  |  | ||||||
|  |     assert er.get_by_name("succeed_action")[0].result == "ok" | ||||||
|  |     assert er.get_by_name("fail_action")[0].exception is not None | ||||||
|  |     assert "fail_action" in str(exc_info.value) | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_nested_group(): | ||||||
|  |     async def g1(last_result): return last_result + "10" | ||||||
|  |     async def g2(last_result): return last_result + "20" | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="nested_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="g1", action=g1, inject_last_result=True), | ||||||
|  |             Action(name="g2", action=g2, inject_last_result=True), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="chain_with_group", | ||||||
|  |         actions=[ | ||||||
|  |             "start", | ||||||
|  |             group, | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     # "start" -> group both receive "start" as last_result | ||||||
|  |     assert result[0] == "start" | ||||||
|  |     assert dict(result[1]) == {"g1": "start10", "g2": "start20"}  # Assuming string concatenation for example | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_double_fallback(): | ||||||
|  |     async def fetch_data(last_result=None): | ||||||
|  |         raise ValueError("No data!")  # Simulate failure | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("No data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="Fetch", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default1"), | ||||||
|  |             Action(name="Validate", action=validate_data), | ||||||
|  |             Action(name="Fetch", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default2"), | ||||||
|  |             Action(name="Enrich", action=enrich), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"] | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								tests/test_chained_action_empty.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								tests/test_chained_action_empty.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import ChainedAction | ||||||
|  | from falyx.exceptions import EmptyChainError | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_raises_empty_chain_error_when_no_actions(): | ||||||
|  |     """A ChainedAction with no actions should raise an EmptyChainError immediately.""" | ||||||
|  |     chain = ChainedAction(name="empty_chain", actions=[]) | ||||||
|  |  | ||||||
|  |     with pytest.raises(EmptyChainError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "No actions to execute." in str(exc_info.value) | ||||||
|  |     assert "empty_chain" in str(exc_info.value) | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_raises_empty_chain_error_when_actions_are_none(): | ||||||
|  |     """A ChainedAction with None as actions should raise an EmptyChainError immediately.""" | ||||||
|  |     chain = ChainedAction(name="none_chain", actions=None) | ||||||
|  |  | ||||||
|  |     with pytest.raises(EmptyChainError) as exc_info: | ||||||
|  |         await chain() | ||||||
|  |  | ||||||
|  |     assert "No actions to execute." in str(exc_info.value) | ||||||
|  |     assert "none_chain" in str(exc_info.value) | ||||||
|  |  | ||||||
							
								
								
									
										223
									
								
								tests/test_command.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								tests/test_command.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | |||||||
|  | # test_command.py | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from falyx.action import Action, ActionGroup, ChainedAction | ||||||
|  | from falyx.command import Command | ||||||
|  | from falyx.io_action import BaseIOAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.retry import RetryPolicy | ||||||
|  |  | ||||||
|  | asyncio_default_fixture_loop_scope = "function" | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  | # --- Dummy Action --- | ||||||
|  | async def dummy_action(): | ||||||
|  |     return "ok" | ||||||
|  |  | ||||||
|  | # --- Dummy IO Action --- | ||||||
|  | class DummyInputAction(BaseIOAction): | ||||||
|  |     async def _run(self, *args, **kwargs): | ||||||
|  |         return "needs input" | ||||||
|  |  | ||||||
|  |     async def preview(self, parent=None): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  | # --- Tests --- | ||||||
|  | def test_command_creation(): | ||||||
|  |     """Test if Command can be created with a callable.""" | ||||||
|  |     action = Action("test_action", dummy_action) | ||||||
|  |     cmd = Command( | ||||||
|  |         key="TEST", | ||||||
|  |         description="Test Command", | ||||||
|  |         action=action | ||||||
|  |     ) | ||||||
|  |     assert cmd.key == "TEST" | ||||||
|  |     assert cmd.description == "Test Command" | ||||||
|  |     assert cmd.action == action | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |     ) | ||||||
|  |     assert str(cmd) == "Command(key='TEST', description='Test Command' action='Action(name=test_action, action=dummy_action)')" | ||||||
|  |  | ||||||
|  | @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( | ||||||
|  |         key="A", | ||||||
|  |         description="Retry action", | ||||||
|  |         action=Action( | ||||||
|  |             name="retry_action", | ||||||
|  |             action=lambda: 42, | ||||||
|  |         ), | ||||||
|  |         retry=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry is True | ||||||
|  |     assert cmd.action.retry_policy.enabled is True | ||||||
|  |  | ||||||
|  | def test_enable_retry_with_retry_policy(): | ||||||
|  |     """Command should enable retry if action is an Action and retry_policy is set.""" | ||||||
|  |     retry_policy = RetryPolicy( | ||||||
|  |         max_retries=3, | ||||||
|  |         delay=1, | ||||||
|  |         backoff=2, | ||||||
|  |         enabled=True, | ||||||
|  |     ) | ||||||
|  |     cmd = Command( | ||||||
|  |         key="B", | ||||||
|  |         description="Retry action with policy", | ||||||
|  |         action=Action( | ||||||
|  |             name="retry_action_with_policy", | ||||||
|  |             action=lambda: 42, | ||||||
|  |         ), | ||||||
|  |         retry_policy=retry_policy, | ||||||
|  |     ) | ||||||
|  |     assert cmd.action.retry_policy.enabled is True | ||||||
|  |     assert cmd.action.retry_policy == retry_policy | ||||||
|  |  | ||||||
|  | def test_enable_retry_not_action(): | ||||||
|  |     """Command should not enable retry if action is not an Action.""" | ||||||
|  |     cmd = Command( | ||||||
|  |         key="C", | ||||||
|  |         description="Retry action", | ||||||
|  |         action=DummyInputAction, | ||||||
|  |         retry=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry is True | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         assert cmd.action.retry_policy.enabled is False | ||||||
|  |     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) | ||||||
|  |  | ||||||
|  | def test_chain_retry_all(): | ||||||
|  |     """retry_all should retry all Actions inside a ChainedAction recursively.""" | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="ChainWithRetry", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="action1", action=lambda: 1), | ||||||
|  |             Action(name="action2", action=lambda: 2), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     cmd = Command( | ||||||
|  |         key="D", | ||||||
|  |         description="Chain with retry", | ||||||
|  |         action=chain, | ||||||
|  |         retry_all=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert cmd.retry_all is True | ||||||
|  |     assert cmd.retry_policy.enabled is True | ||||||
|  |     assert chain.actions[0].retry_policy.enabled is True | ||||||
|  |     assert chain.actions[1].retry_policy.enabled is True | ||||||
|  |  | ||||||
|  | def test_chain_retry_all_not_base_action(): | ||||||
|  |     """retry_all should not be set if action is not a ChainedAction.""" | ||||||
|  |     cmd = Command( | ||||||
|  |         key="E", | ||||||
|  |         description="Chain with retry", | ||||||
|  |         action=DummyInputAction, | ||||||
|  |         retry_all=True, | ||||||
|  |     ) | ||||||
|  |     assert cmd.retry_all is True | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         assert cmd.action.retry_policy.enabled is False | ||||||
|  |     assert "'function' object has no attribute 'retry_policy'" in str(exc_info.value) | ||||||
|  |  | ||||||
							
								
								
									
										200
									
								
								tests/test_stress_actions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								tests/test_stress_actions.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | |||||||
|  | import pytest | ||||||
|  | import asyncio | ||||||
|  | from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction | ||||||
|  | from falyx.execution_registry import ExecutionRegistry as er | ||||||
|  | from falyx.hook_manager import HookType | ||||||
|  | from falyx.context import ExecutionContext | ||||||
|  |  | ||||||
|  | # --- Fixtures --- | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture(autouse=True) | ||||||
|  | def clean_registry(): | ||||||
|  |     er.clear() | ||||||
|  |     yield | ||||||
|  |     er.clear() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # --- Stress Tests --- | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_action_group_partial_failure(): | ||||||
|  |     async def succeed(): | ||||||
|  |         return "ok" | ||||||
|  |  | ||||||
|  |     async def fail(): | ||||||
|  |         raise ValueError("oops") | ||||||
|  |  | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="partial_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="succeed_action", action=succeed), | ||||||
|  |             Action(name="fail_action", action=fail), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     with pytest.raises(Exception) as exc_info: | ||||||
|  |         await group() | ||||||
|  |  | ||||||
|  |     assert "fail_action" in str(exc_info.value) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_nested_group(): | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="nested_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action( | ||||||
|  |                 name="g1", | ||||||
|  |                 action=lambda last_result: f"{last_result} + 10", | ||||||
|  |                 inject_last_result=True, | ||||||
|  |             ), | ||||||
|  |             Action( | ||||||
|  |                 name="g2", | ||||||
|  |                 action=lambda last_result: f"{last_result} + 20", | ||||||
|  |                 inject_last_result=True, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="chain_with_group", | ||||||
|  |         actions=[ | ||||||
|  |             "start", | ||||||
|  |             group, | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result[0] == "start" | ||||||
|  |     result_dict = dict(result[1]) | ||||||
|  |     assert result_dict == {"g1": "start + 10", "g2": "start + 20"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_with_error_mid_fallback(): | ||||||
|  |     async def ok(): | ||||||
|  |         return 1 | ||||||
|  |  | ||||||
|  |     async def fail(): | ||||||
|  |         raise RuntimeError("bad") | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="group_with_fallback", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="ok", action=ok), | ||||||
|  |             Action(name="fail", action=fail), | ||||||
|  |             FallbackAction(fallback="recovered"), | ||||||
|  |         ], | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [1, None, "recovered"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_chained_action_double_fallback(): | ||||||
|  |     async def fetch_data(): | ||||||
|  |         return None | ||||||
|  |  | ||||||
|  |     async def validate_data(last_result): | ||||||
|  |         if last_result is None: | ||||||
|  |             raise ValueError("No data!") | ||||||
|  |         return last_result | ||||||
|  |  | ||||||
|  |     async def enrich(last_result): | ||||||
|  |         return f"Enriched: {last_result}" | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="fallback_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="Fetch", action=fetch_data), | ||||||
|  |             FallbackAction(fallback="default1"), | ||||||
|  |             Action(name="Validate", action=validate_data), | ||||||
|  |             FallbackAction(fallback="default2"), | ||||||
|  |             Action(name="Enrich", action=enrich), | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |         return_list=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == [None, "default1", "default1", "default1", "Enriched: default1"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_large_chain_stress(): | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="large_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action( | ||||||
|  |                 name=f"a{i}", | ||||||
|  |                 action=lambda last_result: ( | ||||||
|  |                     last_result + 1 if last_result is not None else 0 | ||||||
|  |                 ), | ||||||
|  |                 inject_last_result=True, | ||||||
|  |             ) | ||||||
|  |             for i in range(50) | ||||||
|  |         ], | ||||||
|  |         auto_inject=True, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 49  # Start from 0 and add 1 fifty times | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_nested_chain_inside_group(): | ||||||
|  |     inner_chain = ChainedAction( | ||||||
|  |         name="inner", | ||||||
|  |         actions=[ | ||||||
|  |             1, | ||||||
|  |             Action( | ||||||
|  |                 name="a", | ||||||
|  |                 action=lambda last_result: last_result + 1, | ||||||
|  |                 inject_last_result=True, | ||||||
|  |             ), | ||||||
|  |             Action( | ||||||
|  |                 name="b", | ||||||
|  |                 action=lambda last_result: last_result + 2, | ||||||
|  |                 inject_last_result=True, | ||||||
|  |             ), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     group = ActionGroup( | ||||||
|  |         name="outer_group", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="starter", action=lambda: 10), | ||||||
|  |             inner_chain, | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await group() | ||||||
|  |     result_dict = dict(result) | ||||||
|  |     assert result_dict["starter"] == 10 | ||||||
|  |     assert result_dict["inner"] == 4 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.asyncio | ||||||
|  | async def test_mixed_sync_async_actions(): | ||||||
|  |     async def async_action(last_result): | ||||||
|  |         return last_result + 5 | ||||||
|  |  | ||||||
|  |     def sync_action(last_result): | ||||||
|  |         return last_result * 2 | ||||||
|  |  | ||||||
|  |     chain = ChainedAction( | ||||||
|  |         name="mixed_chain", | ||||||
|  |         actions=[ | ||||||
|  |             Action(name="start", action=lambda: 1), | ||||||
|  |             Action(name="double", action=sync_action, inject_last_result=True), | ||||||
|  |             Action(name="plus_five", action=async_action, inject_last_result=True), | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     result = await chain() | ||||||
|  |     assert result == 7 | ||||||
		Reference in New Issue
	
	Block a user