Merge pull request 'io-actions' (#1) from io-actions into main
Reviewed-on: #1
This commit is contained in:
commit
b9529d85ce
|
@ -2,6 +2,7 @@ import asyncio
|
|||
|
||||
from falyx import Action, ActionGroup, ChainedAction
|
||||
|
||||
|
||||
# Actions can be defined as synchronous functions
|
||||
# Falyx will automatically convert them to async functions
|
||||
def hello() -> None:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import random
|
||||
|
||||
from falyx import Falyx, Action, ChainedAction
|
||||
from falyx import Action, ChainedAction, Falyx
|
||||
from falyx.utils import 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
|
||||
|
||||
from .action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||
from .command import Command
|
||||
from .context import ExecutionContext, ResultsContext
|
||||
from .context import ExecutionContext, SharedContext
|
||||
from .execution_registry import ExecutionRegistry
|
||||
from .falyx import Falyx
|
||||
|
||||
|
@ -18,6 +24,6 @@ __all__ = [
|
|||
"Falyx",
|
||||
"Command",
|
||||
"ExecutionContext",
|
||||
"ResultsContext",
|
||||
"SharedContext",
|
||||
"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 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.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."""
|
||||
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
|
||||
app.add_command(
|
||||
# --- Bottom bar info ---
|
||||
flx.bottom_bar.columns = 3
|
||||
flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose")
|
||||
flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks")
|
||||
flx.bottom_bar.add_static("Version", f"Falyx v{__version__}")
|
||||
|
||||
# --- Command actions ---
|
||||
|
||||
# --- Single Actions ---
|
||||
flx.add_command(
|
||||
key="B",
|
||||
description="Build project",
|
||||
action=Action("Build", lambda: print("📦 Building...")),
|
||||
tags=["build"]
|
||||
action=Action("Build", foo.build),
|
||||
tags=["build"],
|
||||
spinner=True,
|
||||
spinner_message="📦 Building...",
|
||||
)
|
||||
|
||||
app.add_command(
|
||||
flx.add_command(
|
||||
key="T",
|
||||
description="Run tests",
|
||||
action=Action("Test", lambda: print("🧪 Running tests...")),
|
||||
tags=["test"]
|
||||
action=Action("Test", foo.test),
|
||||
tags=["test"],
|
||||
spinner=True,
|
||||
spinner_message="🧪 Running tests...",
|
||||
)
|
||||
|
||||
app.add_command(
|
||||
flx.add_command(
|
||||
key="D",
|
||||
description="Deploy project",
|
||||
action=Action("Deploy", lambda: print("🚀 Deploying...")),
|
||||
tags=["deploy"]
|
||||
action=Action("Deploy", foo.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__":
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
falyx = build_falyx()
|
||||
asyncio.run(falyx.run())
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
|
|
324
falyx/action.py
324
falyx/action.py
|
@ -1,12 +1,30 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""action.py
|
||||
|
||||
Any Action or Command is callable and supports the signature:
|
||||
result = thing(*args, **kwargs)
|
||||
Core action system for Falyx.
|
||||
|
||||
This guarantees:
|
||||
- Hook lifecycle (before/after/error/teardown)
|
||||
- Timing
|
||||
- Consistent return values
|
||||
This module defines the building blocks for executable actions and workflows,
|
||||
providing a structured way to compose, execute, recover, and manage sequences of operations.
|
||||
|
||||
All actions are callable and follow a unified signature:
|
||||
result = action(*args, **kwargs)
|
||||
|
||||
Core guarantees:
|
||||
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||
- Consistent timing and execution context tracking for each run.
|
||||
- Unified, predictable result handling and error propagation.
|
||||
- Optional last_result injection to enable flexible, data-driven workflows.
|
||||
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery.
|
||||
|
||||
Key components:
|
||||
- Action: wraps a function or coroutine into a standard executable unit.
|
||||
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
||||
- ActionGroup: runs actions in parallel and gathers results.
|
||||
- ProcessAction: executes CPU-bound functions in a separate process.
|
||||
- LiteralInputAction: injects static values into workflows.
|
||||
- FallbackAction: gracefully recovers from failures or missing data.
|
||||
|
||||
This design promotes clean, fault-tolerant, modular CLI and automation systems.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
@ -14,32 +32,32 @@ import asyncio
|
|||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from functools import partial
|
||||
from functools import cached_property, partial
|
||||
from typing import Any, Callable
|
||||
|
||||
from rich.console import Console
|
||||
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.exceptions import EmptyChainError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import Hook, HookManager, HookType
|
||||
from falyx.retry import RetryHandler, RetryPolicy
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import ensure_async, logger
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class BaseAction(ABC):
|
||||
"""
|
||||
Base class for actions. Actions can be simple functions or more
|
||||
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
||||
be run independently or as part of Menu.
|
||||
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_as (str): The name of the kwarg key to inject the result as
|
||||
(default: 'last_result').
|
||||
_requires_injection (bool): Whether the action requires input injection.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -52,10 +70,12 @@ class BaseAction(ABC):
|
|||
self.name = name
|
||||
self.hooks = hooks or HookManager()
|
||||
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_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:
|
||||
register_debug_hooks(self.hooks)
|
||||
|
@ -71,32 +91,32 @@ class BaseAction(ABC):
|
|||
async def preview(self, parent: Tree | None = None):
|
||||
raise NotImplementedError("preview must be implemented by subclasses")
|
||||
|
||||
def set_results_context(self, results_context: ResultsContext):
|
||||
self.results_context = results_context
|
||||
def set_shared_context(self, shared_context: SharedContext):
|
||||
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.
|
||||
Can be overridden for chain-specific logic.
|
||||
"""
|
||||
self.set_results_context(results_context)
|
||||
self.set_shared_context(shared_context)
|
||||
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.
|
||||
Can be overridden for group-specific logic.
|
||||
"""
|
||||
self.set_results_context(results_context)
|
||||
self.set_shared_context(shared_context)
|
||||
return self
|
||||
|
||||
def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
if self.inject_last_result and self.results_context:
|
||||
if self.inject_last_result and self.shared_context:
|
||||
key = self.inject_last_result_as
|
||||
if key in kwargs:
|
||||
logger.warning("[%s] ⚠️ Overriding '%s' with last_result", self.name, key)
|
||||
kwargs = dict(kwargs)
|
||||
kwargs[key] = self.results_context.last_result()
|
||||
kwargs[key] = self.shared_context.last_result()
|
||||
return kwargs
|
||||
|
||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||
|
@ -122,17 +142,37 @@ class BaseAction(ABC):
|
|||
|
||||
def requires_io_injection(self) -> bool:
|
||||
"""Checks to see if the action requires input injection."""
|
||||
return self.requires_injection
|
||||
return self._requires_injection
|
||||
|
||||
def __str__(self):
|
||||
return f"<{self.__class__.__name__} '{self.name}'>"
|
||||
return f"{self.__class__.__name__}('{self.name}')"
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
name: str,
|
||||
|
@ -147,7 +187,7 @@ class Action(BaseAction):
|
|||
retry_policy: RetryPolicy | None = None,
|
||||
) -> None:
|
||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||
self.action = ensure_async(action)
|
||||
self.action = action
|
||||
self.rollback = rollback
|
||||
self.args = args
|
||||
self.kwargs = kwargs or {}
|
||||
|
@ -156,9 +196,17 @@ class Action(BaseAction):
|
|||
if retry or (retry_policy and retry_policy.enabled):
|
||||
self.enable_retry()
|
||||
|
||||
@property
|
||||
def action(self) -> Callable[..., Any]:
|
||||
return self._action
|
||||
|
||||
@action.setter
|
||||
def action(self, value: Callable[..., Any]):
|
||||
self._action = ensure_async(value)
|
||||
|
||||
def enable_retry(self):
|
||||
"""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")
|
||||
handler = RetryHandler(self.retry_policy)
|
||||
self.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
|
||||
|
@ -166,6 +214,7 @@ class Action(BaseAction):
|
|||
def set_retry_policy(self, policy: RetryPolicy):
|
||||
"""Set a new retry policy and re-register the handler."""
|
||||
self.retry_policy = policy
|
||||
if policy.enabled:
|
||||
self.enable_retry()
|
||||
|
||||
async def _run(self, *args, **kwargs) -> Any:
|
||||
|
@ -211,14 +260,66 @@ class Action(BaseAction):
|
|||
if parent:
|
||||
parent.add("".join(label))
|
||||
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):
|
||||
"""
|
||||
LiteralInputAction injects a static value into a ChainedAction.
|
||||
|
||||
This allows embedding hardcoded values mid-pipeline, useful when:
|
||||
- Providing default or fallback inputs.
|
||||
- Starting a pipeline with a fixed input.
|
||||
- Supplying missing context manually.
|
||||
|
||||
Args:
|
||||
value (Any): The static value to inject.
|
||||
"""
|
||||
def __init__(self, value: Any):
|
||||
self._value = value
|
||||
async def literal(*args, **kwargs):
|
||||
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:
|
||||
|
@ -253,7 +354,26 @@ class 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__(
|
||||
self,
|
||||
name: str,
|
||||
|
@ -262,31 +382,31 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
inject_last_result: bool = False,
|
||||
inject_last_result_as: str = "last_result",
|
||||
auto_inject: bool = False,
|
||||
return_list: bool = False,
|
||||
) -> None:
|
||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||
ActionListMixin.__init__(self)
|
||||
self.auto_inject = auto_inject
|
||||
self.return_list = return_list
|
||||
if actions:
|
||||
self.set_actions(actions)
|
||||
|
||||
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||
return LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
||||
|
||||
def _apply_auto_inject(self, action: BaseAction) -> None:
|
||||
if self.auto_inject and not action.inject_last_result:
|
||||
action.inject_last_result = True
|
||||
|
||||
def set_actions(self, actions: list[BaseAction | Any]):
|
||||
self.actions.clear()
|
||||
for action in actions:
|
||||
def add_action(self, action: BaseAction | Any) -> None:
|
||||
action = self._wrap_literal_if_needed(action)
|
||||
self._apply_auto_inject(action)
|
||||
self.add_action(action)
|
||||
if self.actions and self.auto_inject and not action.inject_last_result:
|
||||
action.inject_last_result = True
|
||||
super().add_action(action)
|
||||
|
||||
async def _run(self, *args, **kwargs) -> list[Any]:
|
||||
results_context = ResultsContext(name=self.name)
|
||||
if self.results_context:
|
||||
results_context.add_result(self.results_context.last_result())
|
||||
if not self.actions:
|
||||
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
||||
|
||||
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)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
|
@ -294,30 +414,48 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
kwargs=updated_kwargs,
|
||||
action=self,
|
||||
extra={"results": [], "rollback_stack": []},
|
||||
shared_context=shared_context,
|
||||
)
|
||||
context.start_timer()
|
||||
try:
|
||||
await self.hooks.trigger(HookType.BEFORE, context)
|
||||
|
||||
for index, action in enumerate(self.actions):
|
||||
results_context.current_index = index
|
||||
prepared = action.prepare_for_chain(results_context)
|
||||
last_result = results_context.last_result()
|
||||
if action._skip_in_chain:
|
||||
logger.debug("[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name)
|
||||
continue
|
||||
shared_context.current_index = index
|
||||
prepared = action.prepare_for_chain(shared_context)
|
||||
last_result = shared_context.last_result()
|
||||
try:
|
||||
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)
|
||||
results_context.add_result(result)
|
||||
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["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)
|
||||
return context.result
|
||||
|
||||
except Exception as 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.hooks.trigger(HookType.ON_ERROR, context)
|
||||
raise
|
||||
|
@ -328,6 +466,18 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
er.record(context)
|
||||
|
||||
async def _rollback(self, rollback_stack, *args, **kwargs):
|
||||
"""
|
||||
Roll back all executed actions in reverse order.
|
||||
|
||||
Rollbacks run even if a fallback recovered from failure,
|
||||
ensuring consistent undo of all side effects.
|
||||
|
||||
Actions without rollback handlers are skipped.
|
||||
|
||||
Args:
|
||||
rollback_stack (list): Actions to roll back.
|
||||
*args, **kwargs: Passed to rollback handlers.
|
||||
"""
|
||||
for action in reversed(rollback_stack):
|
||||
rollback = getattr(action, "rollback", None)
|
||||
if rollback:
|
||||
|
@ -345,7 +495,7 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
for action in self.actions:
|
||||
await action.preview(parent=tree)
|
||||
if not parent:
|
||||
console.print(tree)
|
||||
self.console.print(tree)
|
||||
|
||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||
"""Register a hook for all actions and sub-actions."""
|
||||
|
@ -353,9 +503,42 @@ class ChainedAction(BaseAction, ActionListMixin):
|
|||
for action in self.actions:
|
||||
action.register_hooks_recursively(hook_type, hook)
|
||||
|
||||
def __str__(self):
|
||||
return f"ChainedAction(name={self.name}, actions={self.actions})"
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
name: str,
|
||||
|
@ -370,9 +553,9 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
self.set_actions(actions)
|
||||
|
||||
async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]:
|
||||
results_context = ResultsContext(name=self.name, is_parallel=True)
|
||||
if self.results_context:
|
||||
results_context.set_shared_result(self.results_context.last_result())
|
||||
shared_context = SharedContext(name=self.name, is_parallel=True)
|
||||
if self.shared_context:
|
||||
shared_context.set_shared_result(self.shared_context.last_result())
|
||||
updated_kwargs = self._maybe_inject_last_result(kwargs)
|
||||
context = ExecutionContext(
|
||||
name=self.name,
|
||||
|
@ -380,15 +563,16 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
kwargs=updated_kwargs,
|
||||
action=self,
|
||||
extra={"results": [], "errors": []},
|
||||
shared_context=shared_context,
|
||||
)
|
||||
async def run_one(action: BaseAction):
|
||||
try:
|
||||
prepared = action.prepare_for_group(results_context)
|
||||
prepared = action.prepare_for_group(shared_context)
|
||||
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))
|
||||
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.start_timer()
|
||||
|
@ -426,7 +610,7 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
random.shuffle(actions)
|
||||
await asyncio.gather(*(action.preview(parent=tree) for action in actions))
|
||||
if not parent:
|
||||
console.print(tree)
|
||||
self.console.print(tree)
|
||||
|
||||
def register_hooks_recursively(self, hook_type: HookType, hook: Hook):
|
||||
"""Register a hook for all actions and sub-actions."""
|
||||
|
@ -434,9 +618,30 @@ class ActionGroup(BaseAction, ActionListMixin):
|
|||
for action in self.actions:
|
||||
action.register_hooks_recursively(hook_type, hook)
|
||||
|
||||
def __str__(self):
|
||||
return f"ActionGroup(name={self.name}, actions={self.actions})"
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
name: str,
|
||||
|
@ -457,7 +662,7 @@ class ProcessAction(BaseAction):
|
|||
|
||||
async def _run(self, *args, **kwargs):
|
||||
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):
|
||||
raise ValueError(
|
||||
f"Cannot inject last result into {self.name}: "
|
||||
|
@ -501,7 +706,7 @@ class ProcessAction(BaseAction):
|
|||
if parent:
|
||||
parent.add("".join(label))
|
||||
else:
|
||||
console.print(Tree("".join(label)))
|
||||
self.console.print(Tree("".join(label)))
|
||||
|
||||
def _validate_pickleable(self, obj: Any) -> bool:
|
||||
try:
|
||||
|
@ -510,3 +715,4 @@ class ProcessAction(BaseAction):
|
|||
return True
|
||||
except (pickle.PicklingError, TypeError):
|
||||
return False
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""bottom_bar.py"""
|
||||
|
||||
from typing import Any, Callable
|
||||
|
@ -8,7 +9,7 @@ from rich.console import Console
|
|||
|
||||
from falyx.options_manager import OptionsManager
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import CaseInsensitiveDict
|
||||
from falyx.utils import CaseInsensitiveDict, chunks
|
||||
|
||||
|
||||
class BottomBar:
|
||||
|
@ -211,5 +212,8 @@ class BottomBar:
|
|||
|
||||
def render(self):
|
||||
"""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
|
||||
Any Action or Command is callable and supports the signature:
|
||||
result = thing(*args, **kwargs)
|
||||
|
||||
This guarantees:
|
||||
- Hook lifecycle (before/after/error/teardown)
|
||||
- Timing
|
||||
- Consistent return values
|
||||
Defines the Command class for Falyx CLI.
|
||||
|
||||
Commands are callable units representing a menu option or CLI task,
|
||||
wrapping either a BaseAction or a simple function. They provide:
|
||||
|
||||
- Hook lifecycle (before, on_success, on_error, after, on_teardown)
|
||||
- Execution timing and duration tracking
|
||||
- Retry logic (single action or recursively through action trees)
|
||||
- Confirmation prompts and spinner integration
|
||||
- Result capturing and summary logging
|
||||
- Rich-based preview for CLI display
|
||||
|
||||
Every Command is self-contained, configurable, and plays a critical role
|
||||
in building robust interactive menus.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from typing import Any, Callable
|
||||
|
||||
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.tree import Tree
|
||||
|
||||
from falyx.action import Action, BaseAction
|
||||
from falyx.action import Action, ActionGroup, BaseAction, ChainedAction
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.debug import register_debug_hooks
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.io_action import BaseIOAction
|
||||
from falyx.retry import RetryPolicy
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import _noop, ensure_async, logger
|
||||
|
@ -29,13 +40,63 @@ console = Console()
|
|||
|
||||
|
||||
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
|
||||
description: str
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
action: BaseAction | Callable[[], Any] = _noop
|
||||
args: tuple = ()
|
||||
kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
hidden: bool = False
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
help_text: str = ""
|
||||
color: str = OneColors.WHITE
|
||||
confirm: bool = False
|
||||
|
@ -52,6 +113,7 @@ class Command(BaseModel):
|
|||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
logging_hooks: bool = False
|
||||
requires_input: bool | None = None
|
||||
|
||||
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||
|
||||
|
@ -65,12 +127,32 @@ class Command(BaseModel):
|
|||
self.action.set_retry_policy(self.retry_policy)
|
||||
elif self.retry:
|
||||
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.")
|
||||
if self.retry_all:
|
||||
if self.retry_all and isinstance(self.action, BaseAction):
|
||||
self.retry_policy.enabled = True
|
||||
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):
|
||||
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")
|
||||
@classmethod
|
||||
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")
|
||||
|
||||
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):
|
||||
"""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
|
||||
Configuration loader for Falyx CLI commands."""
|
||||
|
||||
|
@ -79,11 +80,12 @@ def loader(file_path: str) -> list[dict[str, Any]]:
|
|||
command_dict = {
|
||||
"key": entry["key"],
|
||||
"description": entry["description"],
|
||||
"aliases": entry.get("aliases", []),
|
||||
"action": wrap_if_needed(import_action(entry["action"]),
|
||||
name=entry["description"]),
|
||||
"args": tuple(entry.get("args", ())),
|
||||
"kwargs": entry.get("kwargs", {}),
|
||||
"hidden": entry.get("hidden", False),
|
||||
"aliases": entry.get("aliases", []),
|
||||
"help_text": entry.get("help_text", ""),
|
||||
"color": entry.get("color", "white"),
|
||||
"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_style": entry.get("spinner_style", "cyan"),
|
||||
"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", {})),
|
||||
"tags": entry.get("tags", []),
|
||||
"logging_hooks": entry.get("logging_hooks", False),
|
||||
"requires_input": entry.get("requires_input", None),
|
||||
}
|
||||
commands.append(command_dict)
|
||||
|
||||
return commands
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""context.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
@ -23,6 +26,8 @@ class ExecutionContext(BaseModel):
|
|||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
console: Console = Field(default_factory=lambda: Console(color_system="auto"))
|
||||
|
||||
shared_context: SharedContext | None = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
def start_timer(self):
|
||||
|
@ -33,6 +38,9 @@ class ExecutionContext(BaseModel):
|
|||
self.end_time = time.perf_counter()
|
||||
self.end_wall = datetime.now()
|
||||
|
||||
def get_shared_context(self) -> SharedContext:
|
||||
return self.shared_context or SharedContext(name="default")
|
||||
|
||||
@property
|
||||
def duration(self) -> float | None:
|
||||
if self.start_time is None:
|
||||
|
@ -103,7 +111,7 @@ class ExecutionContext(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class ResultsContext(BaseModel):
|
||||
class SharedContext(BaseModel):
|
||||
name: str
|
||||
results: list[Any] = Field(default_factory=list)
|
||||
errors: list[tuple[int, Exception]] = Field(default_factory=list)
|
||||
|
@ -111,11 +119,16 @@ class ResultsContext(BaseModel):
|
|||
is_parallel: bool = False
|
||||
shared_result: Any | None = None
|
||||
|
||||
share: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
def add_result(self, result: Any) -> None:
|
||||
self.results.append(result)
|
||||
|
||||
def add_error(self, index: int, error: Exception) -> None:
|
||||
self.errors.append((index, error))
|
||||
|
||||
def set_shared_result(self, result: Any) -> None:
|
||||
self.shared_result = result
|
||||
if self.is_parallel:
|
||||
|
@ -126,10 +139,16 @@ class ResultsContext(BaseModel):
|
|||
return self.shared_result
|
||||
return self.results[-1] if self.results else None
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.share.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
self.share[key] = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
parallel_label = "Parallel" if self.is_parallel else "Sequential"
|
||||
return (
|
||||
f"<{parallel_label}ResultsContext '{self.name}' | "
|
||||
f"<{parallel_label}SharedContext '{self.name}' | "
|
||||
f"Results: {self.results} | "
|
||||
f"Errors: {self.errors}>"
|
||||
)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.utils import logger
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
class FalyxError(Exception):
|
||||
"""Custom exception for the Menu class."""
|
||||
|
||||
|
@ -20,3 +21,7 @@ class NotAFalyxError(FalyxError):
|
|||
|
||||
class CircuitBreakerOpen(FalyxError):
|
||||
"""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"""
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
@ -62,6 +63,8 @@ class ExecutionRegistry:
|
|||
else:
|
||||
status = "[green]✅ Success"
|
||||
result = repr(ctx.result)
|
||||
if len(result) > 1000:
|
||||
result = f"{result[:1000]}..."
|
||||
|
||||
table.add_row(ctx.name, start, end, duration, status, result)
|
||||
|
||||
|
|
165
falyx/falyx.py
165
falyx/falyx.py
|
@ -1,16 +1,23 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""falyx.py
|
||||
|
||||
This class creates a Falyx object that creates a selectable menu
|
||||
with customizable commands and functionality.
|
||||
Main class for constructing and running Falyx CLI menus.
|
||||
|
||||
It allows for adding commands, and their accompanying actions,
|
||||
and provides a method to display the menu and handle user input.
|
||||
Falyx provides a structured, customizable interactive menu system
|
||||
for running commands, actions, and workflows. It supports:
|
||||
|
||||
This class uses the `rich` library to display the menu in a
|
||||
formatted and visually appealing way.
|
||||
- Hook lifecycle management (before/on_success/on_error/after/on_teardown)
|
||||
- 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
|
||||
user input and create an interactive experience.
|
||||
Falyx enables building flexible, robust, and user-friendly
|
||||
terminal applications with minimal boilerplate.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
@ -30,7 +37,7 @@ from rich.console import Console
|
|||
from rich.markdown import Markdown
|
||||
from rich.table import Table
|
||||
|
||||
from falyx.action import BaseAction
|
||||
from falyx.action import Action, BaseAction
|
||||
from falyx.bottom_bar import BottomBar
|
||||
from falyx.command import Command
|
||||
from falyx.context import ExecutionContext
|
||||
|
@ -43,37 +50,57 @@ from falyx.options_manager import OptionsManager
|
|||
from falyx.parsers import get_arg_parsers
|
||||
from falyx.retry import RetryPolicy
|
||||
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__
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
title: str | Markdown = "Menu",
|
||||
|
@ -84,7 +111,7 @@ class Falyx:
|
|||
exit_message: str | Markdown | dict[str, Any] = "",
|
||||
key_bindings: KeyBindings | None = None,
|
||||
include_history_command: bool = True,
|
||||
include_help_command: bool = False,
|
||||
include_help_command: bool = True,
|
||||
confirm_on_error: bool = True,
|
||||
never_confirm: bool = False,
|
||||
always_confirm: bool = False,
|
||||
|
@ -207,6 +234,8 @@ class Falyx:
|
|||
|
||||
for command in self.commands.values():
|
||||
help_text = command.help_text or command.description
|
||||
if command.requires_input:
|
||||
help_text += " [dim](requires input)[/dim]"
|
||||
table.add_row(
|
||||
f"[{command.color}]{command.key}[/]",
|
||||
", ".join(command.aliases) if command.aliases else "None",
|
||||
|
@ -234,7 +263,7 @@ class Falyx:
|
|||
"Show this help menu"
|
||||
)
|
||||
|
||||
self.console.print(table)
|
||||
self.console.print(table, justify="center")
|
||||
|
||||
def _get_help_command(self) -> Command:
|
||||
"""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:
|
||||
"""Sets the bottom bar for the menu."""
|
||||
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):
|
||||
bottom_bar.key_validator = self.is_key_available
|
||||
bottom_bar.key_bindings = self.key_bindings
|
||||
self._bottom_bar = bottom_bar
|
||||
elif (isinstance(bottom_bar, str) or
|
||||
callable(bottom_bar)):
|
||||
elif (isinstance(bottom_bar, str) or callable(bottom_bar)):
|
||||
self._bottom_bar = bottom_bar
|
||||
else:
|
||||
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:
|
||||
"""Returns the bottom bar for the menu."""
|
||||
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items:
|
||||
return self._bottom_bar.render
|
||||
elif callable(self._bottom_bar):
|
||||
return self._bottom_bar
|
||||
elif isinstance(self._bottom_bar, str):
|
||||
return self._bottom_bar
|
||||
elif self._bottom_bar is None:
|
||||
return self.bottom_bar.render
|
||||
elif callable(self.bottom_bar):
|
||||
return self.bottom_bar
|
||||
elif isinstance(self.bottom_bar, str):
|
||||
return self.bottom_bar
|
||||
elif self.bottom_bar is None:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
@ -475,9 +503,10 @@ class Falyx:
|
|||
key: str,
|
||||
description: str,
|
||||
action: BaseAction | Callable[[], Any],
|
||||
aliases: list[str] | None = None,
|
||||
args: tuple = (),
|
||||
kwargs: dict[str, Any] = {},
|
||||
hidden: bool = False,
|
||||
aliases: list[str] | None = None,
|
||||
help_text: str = "",
|
||||
color: str = OneColors.WHITE,
|
||||
confirm: bool = False,
|
||||
|
@ -491,25 +520,27 @@ class Falyx:
|
|||
hooks: HookManager | None = None,
|
||||
before_hooks: list[Callable] | None = None,
|
||||
success_hooks: list[Callable] | None = None,
|
||||
after_hooks: list[Callable] | None = None,
|
||||
error_hooks: list[Callable] | None = None,
|
||||
after_hooks: list[Callable] | None = None,
|
||||
teardown_hooks: list[Callable] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
logging_hooks: bool = False,
|
||||
retry: bool = False,
|
||||
retry_all: bool = False,
|
||||
retry_policy: RetryPolicy | None = None,
|
||||
requires_input: bool | None = None,
|
||||
) -> Command:
|
||||
"""Adds an command to the menu, preventing duplicates."""
|
||||
self._validate_command_key(key)
|
||||
command = Command(
|
||||
key=key,
|
||||
description=description,
|
||||
aliases=aliases if aliases else [],
|
||||
help_text=help_text,
|
||||
action=action,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
hidden=hidden,
|
||||
aliases=aliases if aliases else [],
|
||||
help_text=help_text,
|
||||
color=color,
|
||||
confirm=confirm,
|
||||
confirm_message=confirm_message,
|
||||
|
@ -524,6 +555,7 @@ class Falyx:
|
|||
retry=retry,
|
||||
retry_all=retry_all,
|
||||
retry_policy=retry_policy or RetryPolicy(),
|
||||
requires_input=requires_input,
|
||||
)
|
||||
|
||||
if hooks:
|
||||
|
@ -558,13 +590,15 @@ class Falyx:
|
|||
def build_default_table(self) -> Table:
|
||||
"""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)
|
||||
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 = []
|
||||
for key, command in chunk:
|
||||
row.append(f"[{key}] [{command.color}]{command.description}")
|
||||
table.add_row(*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
|
||||
|
||||
@property
|
||||
|
@ -617,9 +651,9 @@ class Falyx:
|
|||
confirm_answer = await async_confirm(selected_command.confirmation_prompt)
|
||||
|
||||
if confirm_answer:
|
||||
logger.info(f"[{OneColors.LIGHT_YELLOW}][{selected_command.description}]🔐 confirmed.")
|
||||
logger.info(f"[{selected_command.description}]🔐 confirmed.")
|
||||
else:
|
||||
logger.info(f"[{OneColors.DARK_RED}][{selected_command.description}]❌ cancelled.")
|
||||
logger.info(f"[{selected_command.description}]❌ cancelled.")
|
||||
return confirm_answer
|
||||
return True
|
||||
|
||||
|
@ -658,8 +692,19 @@ class Falyx:
|
|||
choice = await self.session.prompt_async()
|
||||
selected_command = self.get_command(choice)
|
||||
if not selected_command:
|
||||
logger.info(f"[{OneColors.LIGHT_YELLOW}] Invalid command '{choice}'.")
|
||||
logger.info(f"Invalid command '{choice}'.")
|
||||
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
|
||||
|
||||
if selected_command == self.exit_command:
|
||||
|
@ -667,7 +712,7 @@ class Falyx:
|
|||
return False
|
||||
|
||||
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
|
||||
|
||||
context = self._create_context(selected_command)
|
||||
|
@ -750,7 +795,10 @@ class Falyx:
|
|||
selected_command.retry_policy.delay = self.cli_args.retry_delay
|
||||
if 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:
|
||||
"""Prints a message to the console."""
|
||||
|
@ -773,15 +821,16 @@ class Falyx:
|
|||
if self.welcome_message:
|
||||
self.print_message(self.welcome_message)
|
||||
while True:
|
||||
self.console.print(self.table)
|
||||
self.console.print(self.table, justify="center")
|
||||
try:
|
||||
task = asyncio.create_task(self.process_command())
|
||||
should_continue = await task
|
||||
if not should_continue:
|
||||
break
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info(f"[{OneColors.DARK_RED}]EOF or KeyboardInterrupt. Exiting menu.")
|
||||
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
||||
break
|
||||
finally:
|
||||
logger.info(f"Exiting menu: {self.get_title()}")
|
||||
if self.exit_message:
|
||||
self.print_message(self.exit_message)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""hook_manager.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
@ -64,5 +65,6 @@ class HookManager:
|
|||
f" for '{context.name}': {hook_error}")
|
||||
|
||||
if hook_type == HookType.ON_ERROR:
|
||||
assert isinstance(context.exception, BaseException)
|
||||
assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook"
|
||||
raise context.exception from hook_error
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""hooks.py"""
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from falyx.context import ExecutionContext
|
||||
from falyx.exceptions import CircuitBreakerOpen
|
||||
|
@ -7,8 +9,9 @@ from falyx.themes.colors import OneColors
|
|||
from falyx.utils import logger
|
||||
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
|
|
|
@ -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"""
|
||||
|
||||
import importlib
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""io_action.py"""
|
||||
import asyncio
|
||||
import subprocess
|
||||
|
@ -12,8 +13,8 @@ from falyx.context import ExecutionContext
|
|||
from falyx.exceptions import FalyxError
|
||||
from falyx.execution_registry import ExecutionRegistry as er
|
||||
from falyx.hook_manager import HookManager, HookType
|
||||
from falyx.utils import logger
|
||||
from falyx.themes.colors import OneColors
|
||||
from falyx.utils import logger
|
||||
|
||||
console = Console()
|
||||
|
||||
|
@ -34,7 +35,7 @@ class BaseIOAction(BaseAction):
|
|||
inject_last_result=inject_last_result,
|
||||
)
|
||||
self.mode = mode
|
||||
self.requires_injection = True
|
||||
self._requires_injection = True
|
||||
|
||||
def from_input(self, raw: str | bytes) -> Any:
|
||||
raise NotImplementedError
|
||||
|
@ -178,3 +179,29 @@ class ShellAction(BaseIOAction):
|
|||
parent.add("".join(label))
|
||||
else:
|
||||
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"""
|
||||
|
||||
from argparse import Namespace
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||
"""parsers.py
|
||||
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"""
|
||||
import asyncio
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
@ -11,8 +13,16 @@ class RetryPolicy(BaseModel):
|
|||
max_retries: int = Field(default=3, ge=0)
|
||||
delay: float = Field(default=1.0, ge=0.0)
|
||||
backoff: float = Field(default=2.0, ge=1.0)
|
||||
jitter: float = Field(default=0.0, ge=0.0)
|
||||
enabled: bool = False
|
||||
|
||||
def enable_policy(self) -> None:
|
||||
"""
|
||||
Enable the retry policy.
|
||||
:return: None
|
||||
"""
|
||||
self.enabled = True
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""
|
||||
Check if the retry policy is active.
|
||||
|
@ -25,11 +35,18 @@ class RetryHandler:
|
|||
def __init__(self, policy: RetryPolicy=RetryPolicy()):
|
||||
self.policy = policy
|
||||
|
||||
def enable_policy(self, backoff=2, max_retries=3, delay=1):
|
||||
def enable_policy(
|
||||
self,
|
||||
max_retries: int=3,
|
||||
delay: float=1.0,
|
||||
backoff: float=2.0,
|
||||
jitter: float=0.0,
|
||||
):
|
||||
self.policy.enabled = True
|
||||
self.policy.max_retries = max_retries
|
||||
self.policy.delay = delay
|
||||
self.policy.backoff = backoff
|
||||
self.policy.jitter = jitter
|
||||
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
||||
|
||||
async def retry_on_error(self, context: ExecutionContext):
|
||||
|
@ -60,7 +77,15 @@ class RetryHandler:
|
|||
|
||||
while retries_done < self.policy.max_retries:
|
||||
retries_done += 1
|
||||
logger.info(f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...")
|
||||
|
||||
sleep_delay = current_delay
|
||||
if self.policy.jitter > 0:
|
||||
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
||||
|
||||
logger.info(
|
||||
f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) "
|
||||
f"in {current_delay}s due to '{last_error}'..."
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
try:
|
||||
result = await target.action(*context.args, **context.kwargs)
|
||||
|
@ -71,7 +96,10 @@ class RetryHandler:
|
|||
except Exception as retry_error:
|
||||
last_error = retry_error
|
||||
current_delay *= self.policy.backoff
|
||||
logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.")
|
||||
logger.warning(
|
||||
f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} "
|
||||
f"failed due to '{retry_error}'."
|
||||
)
|
||||
|
||||
context.exception = last_error
|
||||
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
||||
|
|
|
@ -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"""
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from itertools import islice
|
||||
from typing import Any, Awaitable, Callable, TypeVar
|
||||
|
||||
|
@ -21,6 +24,20 @@ T = TypeVar("T")
|
|||
async def _noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def get_program_invocation() -> str:
|
||||
"""Returns the recommended program invocation prefix."""
|
||||
script = sys.argv[0]
|
||||
program = shutil.which(script)
|
||||
if program:
|
||||
return os.path.basename(program)
|
||||
|
||||
executable = sys.executable
|
||||
if "python" in executable:
|
||||
return f"python {script}"
|
||||
return script
|
||||
|
||||
|
||||
def is_coroutine(function: Callable[..., Any]) -> bool:
|
||||
return inspect.iscoroutinefunction(function)
|
||||
|
||||
|
@ -32,6 +49,9 @@ def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
|||
@functools.wraps(function)
|
||||
async def async_wrapper(*args, **kwargs) -> T:
|
||||
return function(*args, **kwargs)
|
||||
|
||||
if not callable(function):
|
||||
raise TypeError(f"{function} is not callable")
|
||||
return async_wrapper
|
||||
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[tool.poetry]
|
||||
name = "falyx"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "Reliable and introspectable async CLI action framework."
|
||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||
license = "MIT"
|
||||
|
@ -20,7 +20,6 @@ pytest-asyncio = "^0.20"
|
|||
ruff = "^0.3"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
falyx = "falyx.cli.main:main"
|
||||
sync-version = "scripts.sync_version:main"
|
||||
|
||||
[build-system]
|
||||
|
|
|
@ -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,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()
|
||||
|
|
@ -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 asyncio
|
||||
import pickle
|
||||
import warnings
|
||||
from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction
|
||||
|
||||
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, ResultsContext
|
||||
from falyx.context import ExecutionContext
|
||||
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
async def dummy_action(x: int = 0) -> int:
|
||||
return x + 1
|
||||
|
||||
async def capturing_hook(context: ExecutionContext):
|
||||
context.extra["hook_triggered"] = True
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@pytest.fixture
|
||||
def sample_action():
|
||||
return Action(name="increment", action=dummy_action, kwargs={"x": 5})
|
||||
|
||||
@pytest.fixture
|
||||
def hook_manager():
|
||||
hm = HookManager()
|
||||
|
@ -38,15 +27,18 @@ def clean_registry():
|
|||
# --- Tests ---
|
||||
|
||||
@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()
|
||||
assert result == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_action_hook_lifecycle(hook_manager):
|
||||
async def a1(): return 42
|
||||
action = Action(
|
||||
name="hooked",
|
||||
action=lambda: 42,
|
||||
action=a1,
|
||||
hooks=hook_manager
|
||||
)
|
||||
|
||||
|
@ -58,21 +50,30 @@ async def test_action_hook_lifecycle(hook_manager):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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 = [
|
||||
Action(name="start", action=lambda: 1),
|
||||
Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True),
|
||||
Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True)
|
||||
Action(name="start", action=a1),
|
||||
Action(name="add_last", action=a2, 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()
|
||||
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
|
||||
async def test_action_group_runs_in_parallel():
|
||||
async def a1(): return 1
|
||||
async def a2(): return 2
|
||||
async def a3(): return 3
|
||||
actions = [
|
||||
Action(name="a", action=lambda: 1),
|
||||
Action(name="b", action=lambda: 2),
|
||||
Action(name="c", action=lambda: 3),
|
||||
Action(name="a", action=a1),
|
||||
Action(name="b", action=a2),
|
||||
Action(name="c", action=a3),
|
||||
]
|
||||
group = ActionGroup(name="parallel", actions=actions)
|
||||
result = await group()
|
||||
|
@ -81,39 +82,48 @@ async def test_action_group_runs_in_parallel():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
name="inner_chain",
|
||||
actions=[
|
||||
Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True),
|
||||
Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True),
|
||||
]
|
||||
Action(name="inner_first", action=a1, 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 = [
|
||||
Action(name="first", action=lambda: 1),
|
||||
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
||||
Action(name="first", action=a3),
|
||||
Action(name="second", action=a4, inject_last_result=True),
|
||||
inner_chain,
|
||||
|
||||
]
|
||||
outer_chain = ChainedAction(name="test_chain", actions=actions)
|
||||
outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
||||
result = await outer_chain()
|
||||
assert result == [1, 3, [13, 18]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
name="group",
|
||||
actions=[
|
||||
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),
|
||||
Action(name="c", action=lambda: 3),
|
||||
Action(name="a", action=a1, inject_last_result=True),
|
||||
Action(name="b", action=a2, inject_last_result=True),
|
||||
Action(name="c", action=a3),
|
||||
]
|
||||
)
|
||||
async def a4(): return 1
|
||||
async def a5(last_result): return last_result + 2
|
||||
actions = [
|
||||
Action(name="first", action=lambda: 1),
|
||||
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
||||
Action(name="first", action=a4),
|
||||
Action(name="second", action=a5, inject_last_result=True),
|
||||
group,
|
||||
]
|
||||
chain = ChainedAction(name="test_chain", actions=actions)
|
||||
chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
||||
result = await chain()
|
||||
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"]
|
||||
|
||||
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
|
||||
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=[
|
||||
Action(name="a", action=lambda: 1),
|
||||
Action(name="b", action=lambda: 2),
|
||||
])
|
||||
async def a1(): return 1
|
||||
async def a2(): return 2
|
||||
|
||||
chain = ChainedAction(
|
||||
name="chain",
|
||||
actions=[
|
||||
Action(name="a", action=a1),
|
||||
Action(name="b", action=a2),
|
||||
],
|
||||
)
|
||||
chain.register_hooks_recursively(HookType.BEFORE, hook)
|
||||
|
||||
await chain()
|
||||
|
@ -217,14 +211,255 @@ async def test_action_hook_recovers_error():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
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=[
|
||||
Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True),
|
||||
Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True),
|
||||
Action(name="g1", action=a1, 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),
|
||||
async def a3(): return 5
|
||||
chain = ChainedAction(
|
||||
name="with_group",
|
||||
actions=[
|
||||
Action(name="first", action=a3),
|
||||
group,
|
||||
])
|
||||
],
|
||||
return_list=True,
|
||||
)
|
||||
result = await chain()
|
||||
result_dict = dict(result[1])
|
||||
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"]
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
Loading…
Reference in New Issue