Hide ioactions, Add doc strings, Add tests
This commit is contained in:
parent
e9fdd9cec6
commit
80de941335
|
@ -1,3 +1,9 @@
|
||||||
|
"""
|
||||||
|
Falyx CLI Framework
|
||||||
|
|
||||||
|
Copyright (c) 2025 rtj.dev LLC.
|
||||||
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .action import Action, ActionGroup, ChainedAction, ProcessAction
|
from .action import Action, ActionGroup, ChainedAction, ProcessAction
|
||||||
|
|
|
@ -1,42 +1,77 @@
|
||||||
# falyx/__main__.py
|
"""
|
||||||
|
Falyx CLI Framework
|
||||||
|
|
||||||
|
Copyright (c) 2025 rtj.dev LLC.
|
||||||
|
Licensed under the MIT License. See LICENSE file for details.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
|
||||||
|
|
||||||
from falyx.action import Action
|
from falyx.action import Action, ActionGroup, ChainedAction
|
||||||
from falyx.falyx import Falyx
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
def build_falyx() -> Falyx:
|
def build_falyx() -> Falyx:
|
||||||
"""Build and return a Falyx instance with all your commands."""
|
"""Build and return a Falyx instance with all your commands."""
|
||||||
app = Falyx(title="🚀 Falyx CLI")
|
flx = Falyx(title="🚀 Falyx CLI")
|
||||||
|
|
||||||
# Example commands
|
# Example commands
|
||||||
app.add_command(
|
flx.add_command(
|
||||||
key="B",
|
key="B",
|
||||||
description="Build project",
|
description="Build project",
|
||||||
action=Action("Build", lambda: print("📦 Building...")),
|
action=Action("Build", lambda: print("📦 Building...")),
|
||||||
tags=["build"]
|
tags=["build"]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_command(
|
flx.add_command(
|
||||||
key="T",
|
key="T",
|
||||||
description="Run tests",
|
description="Run tests",
|
||||||
action=Action("Test", lambda: print("🧪 Running tests...")),
|
action=Action("Test", lambda: print("🧪 Running tests...")),
|
||||||
tags=["test"]
|
tags=["test"]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_command(
|
flx.add_command(
|
||||||
key="D",
|
key="D",
|
||||||
description="Deploy project",
|
description="Deploy project",
|
||||||
action=Action("Deploy", lambda: print("🚀 Deploying...")),
|
action=Action("Deploy", lambda: print("🚀 Deploying...")),
|
||||||
tags=["deploy"]
|
tags=["deploy"]
|
||||||
)
|
)
|
||||||
|
|
||||||
return app
|
# Example of ChainedAction (pipeline)
|
||||||
|
build_pipeline = ChainedAction(
|
||||||
|
name="Full Build Pipeline",
|
||||||
|
actions=[
|
||||||
|
Action("Clean", lambda: print("🧹 Cleaning...")),
|
||||||
|
Action("Build", lambda: print("🔨 Building...")),
|
||||||
|
Action("Package", lambda: print("📦 Packaging...")),
|
||||||
|
],
|
||||||
|
auto_inject=False,
|
||||||
|
)
|
||||||
|
flx.add_command(
|
||||||
|
key="P",
|
||||||
|
description="Run Build Pipeline",
|
||||||
|
action=build_pipeline,
|
||||||
|
tags=["build", "pipeline"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Example of ActionGroup (parallel tasks)
|
||||||
|
test_suite = ActionGroup(
|
||||||
|
name="Test Suite",
|
||||||
|
actions=[
|
||||||
|
Action("Unit Tests", lambda: print("🧪 Running unit tests...")),
|
||||||
|
Action("Integration Tests", lambda: print("🔗 Running integration tests...")),
|
||||||
|
Action("Lint", lambda: print("🧹 Running linter...")),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
flx.add_command(
|
||||||
|
key="G",
|
||||||
|
description="Run All Tests",
|
||||||
|
action=test_suite,
|
||||||
|
tags=["test", "parallel"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return flx
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logging.basicConfig(level=logging.WARNING)
|
flx = build_falyx()
|
||||||
falyx = build_falyx()
|
asyncio.run(flx.run())
|
||||||
asyncio.run(falyx.run())
|
|
||||||
|
|
||||||
|
|
252
falyx/action.py
252
falyx/action.py
|
@ -1,12 +1,30 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""action.py
|
"""action.py
|
||||||
|
|
||||||
Any Action or Command is callable and supports the signature:
|
Core action system for Falyx.
|
||||||
result = thing(*args, **kwargs)
|
|
||||||
|
|
||||||
This guarantees:
|
This module defines the building blocks for executable actions and workflows,
|
||||||
- Hook lifecycle (before/after/error/teardown)
|
providing a structured way to compose, execute, recover, and manage sequences of operations.
|
||||||
- Timing
|
|
||||||
- Consistent return values
|
All actions are callable and follow a unified signature:
|
||||||
|
result = action(*args, **kwargs)
|
||||||
|
|
||||||
|
Core guarantees:
|
||||||
|
- Full hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||||
|
- Consistent timing and execution context tracking for each run.
|
||||||
|
- Unified, predictable result handling and error propagation.
|
||||||
|
- Optional last_result injection to enable flexible, data-driven workflows.
|
||||||
|
- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery.
|
||||||
|
|
||||||
|
Key components:
|
||||||
|
- Action: wraps a function or coroutine into a standard executable unit.
|
||||||
|
- ChainedAction: runs actions sequentially, optionally injecting last results.
|
||||||
|
- ActionGroup: runs actions in parallel and gathers results.
|
||||||
|
- ProcessAction: executes CPU-bound functions in a separate process.
|
||||||
|
- LiteralInputAction: injects static values into workflows.
|
||||||
|
- FallbackAction: gracefully recovers from failures or missing data.
|
||||||
|
|
||||||
|
This design promotes clean, fault-tolerant, modular CLI and automation systems.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
@ -14,7 +32,7 @@ import asyncio
|
||||||
import random
|
import random
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from functools import partial
|
from functools import cached_property, partial
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
@ -22,6 +40,7 @@ from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.context import ExecutionContext, ResultsContext
|
from falyx.context import ExecutionContext, ResultsContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
|
from falyx.exceptions import EmptyChainError
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import Hook, HookManager, HookType
|
from falyx.hook_manager import Hook, HookManager, HookType
|
||||||
from falyx.retry import RetryHandler, RetryPolicy
|
from falyx.retry import RetryHandler, RetryPolicy
|
||||||
|
@ -35,11 +54,12 @@ class BaseAction(ABC):
|
||||||
"""
|
"""
|
||||||
Base class for actions. Actions can be simple functions or more
|
Base class for actions. Actions can be simple functions or more
|
||||||
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
complex actions like `ChainedAction` or `ActionGroup`. They can also
|
||||||
be run independently or as part of Menu.
|
be run independently or as part of Falyx.
|
||||||
|
|
||||||
inject_last_result (bool): Whether to inject the previous action's result into kwargs.
|
inject_last_result (bool): Whether to inject the previous action's result into kwargs.
|
||||||
inject_last_result_as (str): The name of the kwarg key to inject the result as
|
inject_last_result_as (str): The name of the kwarg key to inject the result as
|
||||||
(default: 'last_result').
|
(default: 'last_result').
|
||||||
|
_requires_injection (bool): Whether the action requires input injection.
|
||||||
"""
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -55,7 +75,8 @@ class BaseAction(ABC):
|
||||||
self.results_context: ResultsContext | None = None
|
self.results_context: ResultsContext | None = None
|
||||||
self.inject_last_result: bool = inject_last_result
|
self.inject_last_result: bool = inject_last_result
|
||||||
self.inject_last_result_as: str = inject_last_result_as
|
self.inject_last_result_as: str = inject_last_result_as
|
||||||
self.requires_injection: bool = False
|
self._requires_injection: bool = False
|
||||||
|
self._skip_in_chain: bool = False
|
||||||
|
|
||||||
if logging_hooks:
|
if logging_hooks:
|
||||||
register_debug_hooks(self.hooks)
|
register_debug_hooks(self.hooks)
|
||||||
|
@ -122,7 +143,7 @@ class BaseAction(ABC):
|
||||||
|
|
||||||
def requires_io_injection(self) -> bool:
|
def requires_io_injection(self) -> bool:
|
||||||
"""Checks to see if the action requires input injection."""
|
"""Checks to see if the action requires input injection."""
|
||||||
return self.requires_injection
|
return self._requires_injection
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"<{self.__class__.__name__} '{self.name}'>"
|
return f"<{self.__class__.__name__} '{self.name}'>"
|
||||||
|
@ -132,7 +153,27 @@ class BaseAction(ABC):
|
||||||
|
|
||||||
|
|
||||||
class Action(BaseAction):
|
class Action(BaseAction):
|
||||||
"""A simple action that runs a callable. It can be a function or a coroutine."""
|
"""
|
||||||
|
Action wraps a simple function or coroutine into a standard executable unit.
|
||||||
|
|
||||||
|
It supports:
|
||||||
|
- Optional retry logic.
|
||||||
|
- Hook lifecycle (before, success, error, after, teardown).
|
||||||
|
- Last result injection for chaining.
|
||||||
|
- Optional rollback handlers for undo logic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the action.
|
||||||
|
action (Callable): The function or coroutine to execute.
|
||||||
|
rollback (Callable, optional): Rollback function to undo the action.
|
||||||
|
args (tuple, optional): Static positional arguments.
|
||||||
|
kwargs (dict, optional): Static keyword arguments.
|
||||||
|
hooks (HookManager, optional): Hook manager for lifecycle events.
|
||||||
|
inject_last_result (bool, optional): Enable last_result injection.
|
||||||
|
inject_last_result_as (str, optional): Name of injected key.
|
||||||
|
retry (bool, optional): Whether to enable retries.
|
||||||
|
retry_policy (RetryPolicy, optional): Retry settings.
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
@ -147,7 +188,7 @@ class Action(BaseAction):
|
||||||
retry_policy: RetryPolicy | None = None,
|
retry_policy: RetryPolicy | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||||
self.action = ensure_async(action)
|
self.action = action
|
||||||
self.rollback = rollback
|
self.rollback = rollback
|
||||||
self.args = args
|
self.args = args
|
||||||
self.kwargs = kwargs or {}
|
self.kwargs = kwargs or {}
|
||||||
|
@ -156,9 +197,17 @@ class Action(BaseAction):
|
||||||
if retry or (retry_policy and retry_policy.enabled):
|
if retry or (retry_policy and retry_policy.enabled):
|
||||||
self.enable_retry()
|
self.enable_retry()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action(self) -> Callable[..., Any]:
|
||||||
|
return self._action
|
||||||
|
|
||||||
|
@action.setter
|
||||||
|
def action(self, value: Callable[..., Any]):
|
||||||
|
self._action = ensure_async(value)
|
||||||
|
|
||||||
def enable_retry(self):
|
def enable_retry(self):
|
||||||
"""Enable retry with the existing retry policy."""
|
"""Enable retry with the existing retry policy."""
|
||||||
self.retry_policy.enabled = True
|
self.retry_policy.enable_policy()
|
||||||
logger.debug(f"[Action:{self.name}] Registering retry handler")
|
logger.debug(f"[Action:{self.name}] Registering retry handler")
|
||||||
handler = RetryHandler(self.retry_policy)
|
handler = RetryHandler(self.retry_policy)
|
||||||
self.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
|
self.hooks.register(HookType.ON_ERROR, handler.retry_on_error)
|
||||||
|
@ -166,6 +215,7 @@ class Action(BaseAction):
|
||||||
def set_retry_policy(self, policy: RetryPolicy):
|
def set_retry_policy(self, policy: RetryPolicy):
|
||||||
"""Set a new retry policy and re-register the handler."""
|
"""Set a new retry policy and re-register the handler."""
|
||||||
self.retry_policy = policy
|
self.retry_policy = policy
|
||||||
|
if policy.enabled:
|
||||||
self.enable_retry()
|
self.enable_retry()
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> Any:
|
async def _run(self, *args, **kwargs) -> Any:
|
||||||
|
@ -213,12 +263,64 @@ class Action(BaseAction):
|
||||||
else:
|
else:
|
||||||
console.print(Tree("".join(label)))
|
console.print(Tree("".join(label)))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Action(name={self.name}, action={self.action.__name__})"
|
||||||
|
|
||||||
|
|
||||||
class LiteralInputAction(Action):
|
class LiteralInputAction(Action):
|
||||||
|
"""
|
||||||
|
LiteralInputAction injects a static value into a ChainedAction.
|
||||||
|
|
||||||
|
This allows embedding hardcoded values mid-pipeline, useful when:
|
||||||
|
- Providing default or fallback inputs.
|
||||||
|
- Starting a pipeline with a fixed input.
|
||||||
|
- Supplying missing context manually.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (Any): The static value to inject.
|
||||||
|
"""
|
||||||
def __init__(self, value: Any):
|
def __init__(self, value: Any):
|
||||||
|
self._value = value
|
||||||
async def literal(*args, **kwargs):
|
async def literal(*args, **kwargs):
|
||||||
return value
|
return value
|
||||||
super().__init__("Input", literal, inject_last_result=True)
|
super().__init__("Input", literal)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def value(self) -> Any:
|
||||||
|
"""Return the literal value."""
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"LiteralInputAction(value={self.value})"
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackAction(Action):
|
||||||
|
"""
|
||||||
|
FallbackAction provides a default value if the previous action failed or returned None.
|
||||||
|
|
||||||
|
It injects the last result and checks:
|
||||||
|
- If last_result is not None, it passes it through unchanged.
|
||||||
|
- If last_result is None (e.g., due to failure), it replaces it with a fallback value.
|
||||||
|
|
||||||
|
Used in ChainedAction pipelines to gracefully recover from errors or missing data.
|
||||||
|
When activated, it consumes the preceding error and allows the chain to continue normally.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fallback (Any): The fallback value to use if last_result is None.
|
||||||
|
"""
|
||||||
|
def __init__(self, fallback: Any):
|
||||||
|
self._fallback = fallback
|
||||||
|
async def _fallback_logic(last_result):
|
||||||
|
return last_result if last_result is not None else fallback
|
||||||
|
super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def fallback(self) -> Any:
|
||||||
|
"""Return the fallback value."""
|
||||||
|
return self._fallback
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"FallbackAction(fallback={self.fallback})"
|
||||||
|
|
||||||
|
|
||||||
class ActionListMixin:
|
class ActionListMixin:
|
||||||
|
@ -253,7 +355,26 @@ class ActionListMixin:
|
||||||
|
|
||||||
|
|
||||||
class ChainedAction(BaseAction, ActionListMixin):
|
class ChainedAction(BaseAction, ActionListMixin):
|
||||||
"""A ChainedAction is a sequence of actions that are executed in order."""
|
"""
|
||||||
|
ChainedAction executes a sequence of actions one after another.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Supports optional automatic last_result injection (auto_inject).
|
||||||
|
- Recovers from intermediate errors using FallbackAction if present.
|
||||||
|
- Rolls back all previously executed actions if a failure occurs.
|
||||||
|
- Handles literal values with LiteralInputAction.
|
||||||
|
|
||||||
|
Best used for defining robust, ordered workflows where each step can depend on previous results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the chain.
|
||||||
|
actions (list): List of actions or literals to execute.
|
||||||
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
||||||
|
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
|
||||||
|
inject_last_result_as (str, optional): Key name for injection.
|
||||||
|
auto_inject (bool, optional): Auto-enable injection for subsequent actions.
|
||||||
|
return_list (bool, optional): Whether to return a list of all results. False returns the last result.
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
@ -262,28 +383,28 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
inject_last_result: bool = False,
|
inject_last_result: bool = False,
|
||||||
inject_last_result_as: str = "last_result",
|
inject_last_result_as: str = "last_result",
|
||||||
auto_inject: bool = False,
|
auto_inject: bool = False,
|
||||||
|
return_list: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
super().__init__(name, hooks, inject_last_result, inject_last_result_as)
|
||||||
ActionListMixin.__init__(self)
|
ActionListMixin.__init__(self)
|
||||||
self.auto_inject = auto_inject
|
self.auto_inject = auto_inject
|
||||||
|
self.return_list = return_list
|
||||||
if actions:
|
if actions:
|
||||||
self.set_actions(actions)
|
self.set_actions(actions)
|
||||||
|
|
||||||
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction:
|
||||||
return LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
return LiteralInputAction(action) if not isinstance(action, BaseAction) else action
|
||||||
|
|
||||||
def _apply_auto_inject(self, action: BaseAction) -> None:
|
def add_action(self, action: BaseAction | Any) -> None:
|
||||||
if self.auto_inject and not action.inject_last_result:
|
|
||||||
action.inject_last_result = True
|
|
||||||
|
|
||||||
def set_actions(self, actions: list[BaseAction | Any]):
|
|
||||||
self.actions.clear()
|
|
||||||
for action in actions:
|
|
||||||
action = self._wrap_literal_if_needed(action)
|
action = self._wrap_literal_if_needed(action)
|
||||||
self._apply_auto_inject(action)
|
if self.actions and self.auto_inject and not action.inject_last_result:
|
||||||
self.add_action(action)
|
action.inject_last_result = True
|
||||||
|
super().add_action(action)
|
||||||
|
|
||||||
async def _run(self, *args, **kwargs) -> list[Any]:
|
async def _run(self, *args, **kwargs) -> list[Any]:
|
||||||
|
if not self.actions:
|
||||||
|
raise EmptyChainError(f"[{self.name}] No actions to execute.")
|
||||||
|
|
||||||
results_context = ResultsContext(name=self.name)
|
results_context = ResultsContext(name=self.name)
|
||||||
if self.results_context:
|
if self.results_context:
|
||||||
results_context.add_result(self.results_context.last_result())
|
results_context.add_result(self.results_context.last_result())
|
||||||
|
@ -300,18 +421,35 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
await self.hooks.trigger(HookType.BEFORE, context)
|
await self.hooks.trigger(HookType.BEFORE, context)
|
||||||
|
|
||||||
for index, action in enumerate(self.actions):
|
for index, action in enumerate(self.actions):
|
||||||
|
if action._skip_in_chain:
|
||||||
|
logger.debug("[%s] ⚠️ Skipping consumed action '%s'", self.name, action.name)
|
||||||
|
continue
|
||||||
results_context.current_index = index
|
results_context.current_index = index
|
||||||
prepared = action.prepare_for_chain(results_context)
|
prepared = action.prepare_for_chain(results_context)
|
||||||
last_result = results_context.last_result()
|
last_result = results_context.last_result()
|
||||||
|
try:
|
||||||
if self.requires_io_injection() and last_result is not None:
|
if self.requires_io_injection() and last_result is not None:
|
||||||
result = await prepared(**{prepared.inject_last_result_as: last_result})
|
result = await prepared(**{prepared.inject_last_result_as: last_result})
|
||||||
else:
|
else:
|
||||||
result = await prepared(*args, **updated_kwargs)
|
result = await prepared(*args, **updated_kwargs)
|
||||||
|
except Exception as error:
|
||||||
|
if index + 1 < len(self.actions) and isinstance(self.actions[index + 1], FallbackAction):
|
||||||
|
logger.warning("[%s] ⚠️ Fallback triggered: %s, recovering with fallback '%s'.",
|
||||||
|
self.name, error, self.actions[index + 1].name)
|
||||||
|
results_context.add_result(None)
|
||||||
|
context.extra["results"].append(None)
|
||||||
|
fallback = self.actions[index + 1].prepare_for_chain(results_context)
|
||||||
|
result = await fallback()
|
||||||
|
fallback._skip_in_chain = True
|
||||||
|
else:
|
||||||
|
raise
|
||||||
results_context.add_result(result)
|
results_context.add_result(result)
|
||||||
context.extra["results"].append(result)
|
context.extra["results"].append(result)
|
||||||
context.extra["rollback_stack"].append(prepared)
|
context.extra["rollback_stack"].append(prepared)
|
||||||
|
|
||||||
context.result = context.extra["results"]
|
all_results = context.extra["results"]
|
||||||
|
assert all_results, f"[{self.name}] No results captured. Something seriously went wrong."
|
||||||
|
context.result = all_results if self.return_list else all_results[-1]
|
||||||
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
await self.hooks.trigger(HookType.ON_SUCCESS, context)
|
||||||
return context.result
|
return context.result
|
||||||
|
|
||||||
|
@ -328,6 +466,18 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
er.record(context)
|
er.record(context)
|
||||||
|
|
||||||
async def _rollback(self, rollback_stack, *args, **kwargs):
|
async def _rollback(self, rollback_stack, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Roll back all executed actions in reverse order.
|
||||||
|
|
||||||
|
Rollbacks run even if a fallback recovered from failure,
|
||||||
|
ensuring consistent undo of all side effects.
|
||||||
|
|
||||||
|
Actions without rollback handlers are skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rollback_stack (list): Actions to roll back.
|
||||||
|
*args, **kwargs: Passed to rollback handlers.
|
||||||
|
"""
|
||||||
for action in reversed(rollback_stack):
|
for action in reversed(rollback_stack):
|
||||||
rollback = getattr(action, "rollback", None)
|
rollback = getattr(action, "rollback", None)
|
||||||
if rollback:
|
if rollback:
|
||||||
|
@ -355,7 +505,37 @@ class ChainedAction(BaseAction, ActionListMixin):
|
||||||
|
|
||||||
|
|
||||||
class ActionGroup(BaseAction, ActionListMixin):
|
class ActionGroup(BaseAction, ActionListMixin):
|
||||||
"""An ActionGroup is a collection of actions that can be run in parallel."""
|
"""
|
||||||
|
ActionGroup executes multiple actions concurrently in parallel.
|
||||||
|
|
||||||
|
It is ideal for independent tasks that can be safely run simultaneously,
|
||||||
|
improving overall throughput and responsiveness of workflows.
|
||||||
|
|
||||||
|
Core features:
|
||||||
|
- Parallel execution of all contained actions.
|
||||||
|
- Shared last_result injection across all actions if configured.
|
||||||
|
- Aggregated collection of individual results as (name, result) pairs.
|
||||||
|
- Hook lifecycle support (before, on_success, on_error, after, on_teardown).
|
||||||
|
- Error aggregation: captures all action errors and reports them together.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- If any action fails, the group collects the errors but continues executing
|
||||||
|
other actions without interruption.
|
||||||
|
- After all actions complete, ActionGroup raises a single exception summarizing
|
||||||
|
all failures, or returns all results if successful.
|
||||||
|
|
||||||
|
Best used for:
|
||||||
|
- Batch processing multiple independent tasks.
|
||||||
|
- Reducing latency for workflows with parallelizable steps.
|
||||||
|
- Isolating errors while maximizing successful execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the chain.
|
||||||
|
actions (list): List of actions or literals to execute.
|
||||||
|
hooks (HookManager, optional): Hooks for lifecycle events.
|
||||||
|
inject_last_result (bool, optional): Whether to inject last results into kwargs by default.
|
||||||
|
inject_last_result_as (str, optional): Key name for injection.
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
@ -436,7 +616,25 @@ class ActionGroup(BaseAction, ActionListMixin):
|
||||||
|
|
||||||
|
|
||||||
class ProcessAction(BaseAction):
|
class ProcessAction(BaseAction):
|
||||||
"""A ProcessAction runs a function in a separate process using ProcessPoolExecutor."""
|
"""
|
||||||
|
ProcessAction runs a function in a separate process using ProcessPoolExecutor.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Executes CPU-bound or blocking tasks without blocking the main event loop.
|
||||||
|
- Supports last_result injection into the subprocess.
|
||||||
|
- Validates that last_result is pickleable when injection is enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name (str): Name of the action.
|
||||||
|
func (Callable): Function to execute in a new process.
|
||||||
|
args (tuple, optional): Positional arguments.
|
||||||
|
kwargs (dict, optional): Keyword arguments.
|
||||||
|
hooks (HookManager, optional): Hook manager for lifecycle events.
|
||||||
|
executor (ProcessPoolExecutor, optional): Custom executor if desired.
|
||||||
|
inject_last_result (bool, optional): Inject last result into the function.
|
||||||
|
inject_last_result_as (str, optional): Name of the injected key.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""bottom_bar.py"""
|
"""bottom_bar.py"""
|
||||||
|
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
@ -8,7 +9,7 @@ from rich.console import Console
|
||||||
|
|
||||||
from falyx.options_manager import OptionsManager
|
from falyx.options_manager import OptionsManager
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import CaseInsensitiveDict
|
from falyx.utils import CaseInsensitiveDict, chunks
|
||||||
|
|
||||||
|
|
||||||
class BottomBar:
|
class BottomBar:
|
||||||
|
@ -211,5 +212,8 @@ class BottomBar:
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""Render the bottom bar."""
|
"""Render the bottom bar."""
|
||||||
return merge_formatted_text([fn() for fn in self._named_items.values()])
|
lines = []
|
||||||
|
for chunk in chunks(self._named_items.values(), self.columns):
|
||||||
|
lines.extend([fn for fn in chunk])
|
||||||
|
lines.append(lambda: HTML("\n"))
|
||||||
|
return merge_formatted_text([fn() for fn in lines[:-1]])
|
||||||
|
|
105
falyx/command.py
105
falyx/command.py
|
@ -1,14 +1,24 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""command.py
|
"""command.py
|
||||||
Any Action or Command is callable and supports the signature:
|
|
||||||
result = thing(*args, **kwargs)
|
|
||||||
|
|
||||||
This guarantees:
|
Defines the Command class for Falyx CLI.
|
||||||
- Hook lifecycle (before/after/error/teardown)
|
|
||||||
- Timing
|
Commands are callable units representing a menu option or CLI task,
|
||||||
- Consistent return values
|
wrapping either a BaseAction or a simple function. They provide:
|
||||||
|
|
||||||
|
- Hook lifecycle (before, on_success, on_error, after, on_teardown)
|
||||||
|
- Execution timing and duration tracking
|
||||||
|
- Retry logic (single action or recursively through action trees)
|
||||||
|
- Confirmation prompts and spinner integration
|
||||||
|
- Result capturing and summary logging
|
||||||
|
- Rich-based preview for CLI display
|
||||||
|
|
||||||
|
Every Command is self-contained, configurable, and plays a critical role
|
||||||
|
in building robust interactive menus.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import cached_property
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from prompt_toolkit.formatted_text import FormattedText
|
from prompt_toolkit.formatted_text import FormattedText
|
||||||
|
@ -16,11 +26,12 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from falyx.action import Action, BaseAction
|
from falyx.action import Action, ActionGroup, BaseAction, ChainedAction
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.debug import register_debug_hooks
|
from falyx.debug import register_debug_hooks
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
|
from falyx.io_action import BaseIOAction
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
from falyx.utils import _noop, ensure_async, logger
|
from falyx.utils import _noop, ensure_async, logger
|
||||||
|
@ -29,13 +40,63 @@ console = Console()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseModel):
|
class Command(BaseModel):
|
||||||
"""Class representing an command in the menu."""
|
"""
|
||||||
|
Represents a selectable command in a Falyx menu system.
|
||||||
|
|
||||||
|
A Command wraps an executable action (function, coroutine, or BaseAction)
|
||||||
|
and enhances it with:
|
||||||
|
|
||||||
|
- Lifecycle hooks (before, success, error, after, teardown)
|
||||||
|
- Retry support (single action or recursive for chained/grouped actions)
|
||||||
|
- Confirmation prompts for safe execution
|
||||||
|
- Spinner visuals during execution
|
||||||
|
- Tagging for categorization and filtering
|
||||||
|
- Rich-based CLI previews
|
||||||
|
- Result tracking and summary reporting
|
||||||
|
|
||||||
|
Commands are built to be flexible yet robust, enabling dynamic CLI workflows
|
||||||
|
without sacrificing control or reliability.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
key (str): Primary trigger key for the command.
|
||||||
|
description (str): Short description for the menu display.
|
||||||
|
hidden (bool): Toggles visibility in the menu.
|
||||||
|
aliases (list[str]): Alternate keys or phrases.
|
||||||
|
action (BaseAction | Callable): The executable logic.
|
||||||
|
args (tuple): Static positional arguments.
|
||||||
|
kwargs (dict): Static keyword arguments.
|
||||||
|
help_text (str): Additional help or guidance text.
|
||||||
|
color (str): Color theme for CLI rendering.
|
||||||
|
confirm (bool): Whether to require confirmation before executing.
|
||||||
|
confirm_message (str): Custom confirmation prompt.
|
||||||
|
preview_before_confirm (bool): Whether to preview before confirming.
|
||||||
|
spinner (bool): Whether to show a spinner during execution.
|
||||||
|
spinner_message (str): Spinner text message.
|
||||||
|
spinner_type (str): Spinner style (e.g., dots, line, etc.).
|
||||||
|
spinner_style (str): Color or style of the spinner.
|
||||||
|
spinner_kwargs (dict): Extra spinner configuration.
|
||||||
|
hooks (HookManager): Hook manager for lifecycle events.
|
||||||
|
retry (bool): Enable retry on failure.
|
||||||
|
retry_all (bool): Enable retry across chained or grouped actions.
|
||||||
|
retry_policy (RetryPolicy): Retry behavior configuration.
|
||||||
|
tags (list[str]): Organizational tags for the command.
|
||||||
|
logging_hooks (bool): Whether to attach logging hooks automatically.
|
||||||
|
requires_input (bool | None): Indicates if the action needs input.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
__call__(): Executes the command, respecting hooks and retries.
|
||||||
|
preview(): Rich tree preview of the command.
|
||||||
|
confirmation_prompt(): Formatted prompt for confirmation.
|
||||||
|
result: Property exposing the last result.
|
||||||
|
log_summary(): Summarizes execution details to the console.
|
||||||
|
"""
|
||||||
key: str
|
key: str
|
||||||
description: str
|
description: str
|
||||||
aliases: list[str] = Field(default_factory=list)
|
|
||||||
action: BaseAction | Callable[[], Any] = _noop
|
action: BaseAction | Callable[[], Any] = _noop
|
||||||
args: tuple = ()
|
args: tuple = ()
|
||||||
kwargs: dict[str, Any] = Field(default_factory=dict)
|
kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
hidden: bool = False
|
||||||
|
aliases: list[str] = Field(default_factory=list)
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
color: str = OneColors.WHITE
|
color: str = OneColors.WHITE
|
||||||
confirm: bool = False
|
confirm: bool = False
|
||||||
|
@ -52,6 +113,7 @@ class Command(BaseModel):
|
||||||
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
|
||||||
tags: list[str] = Field(default_factory=list)
|
tags: list[str] = Field(default_factory=list)
|
||||||
logging_hooks: bool = False
|
logging_hooks: bool = False
|
||||||
|
requires_input: bool | None = None
|
||||||
|
|
||||||
_context: ExecutionContext | None = PrivateAttr(default=None)
|
_context: ExecutionContext | None = PrivateAttr(default=None)
|
||||||
|
|
||||||
|
@ -65,12 +127,32 @@ class Command(BaseModel):
|
||||||
self.action.set_retry_policy(self.retry_policy)
|
self.action.set_retry_policy(self.retry_policy)
|
||||||
elif self.retry:
|
elif self.retry:
|
||||||
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.")
|
logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.")
|
||||||
if self.retry_all:
|
if self.retry_all and isinstance(self.action, BaseAction):
|
||||||
|
self.retry_policy.enabled = True
|
||||||
self.action.enable_retries_recursively(self.action, self.retry_policy)
|
self.action.enable_retries_recursively(self.action, self.retry_policy)
|
||||||
|
elif self.retry_all:
|
||||||
|
logger.warning(f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance.")
|
||||||
|
|
||||||
if self.logging_hooks and isinstance(self.action, BaseAction):
|
if self.logging_hooks and isinstance(self.action, BaseAction):
|
||||||
register_debug_hooks(self.action.hooks)
|
register_debug_hooks(self.action.hooks)
|
||||||
|
|
||||||
|
if self.requires_input is None and self.detect_requires_input:
|
||||||
|
self.requires_input = True
|
||||||
|
self.hidden = True
|
||||||
|
elif self.requires_input is None:
|
||||||
|
self.requires_input = False
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def detect_requires_input(self) -> bool:
|
||||||
|
"""Detect if the action requires input based on its type."""
|
||||||
|
if isinstance(self.action, BaseIOAction):
|
||||||
|
return True
|
||||||
|
elif isinstance(self.action, ChainedAction):
|
||||||
|
return isinstance(self.action.actions[0], BaseIOAction) if self.action.actions else False
|
||||||
|
elif isinstance(self.action, ActionGroup):
|
||||||
|
return any(isinstance(action, BaseIOAction) for action in self.action.actions)
|
||||||
|
return False
|
||||||
|
|
||||||
@field_validator("action", mode="before")
|
@field_validator("action", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def wrap_callable_as_async(cls, action: Any) -> Any:
|
def wrap_callable_as_async(cls, action: Any) -> Any:
|
||||||
|
@ -81,7 +163,8 @@ class Command(BaseModel):
|
||||||
raise TypeError("Action must be a callable or an instance of BaseAction")
|
raise TypeError("Action must be a callable or an instance of BaseAction")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Command(key='{self.key}', description='{self.description}')"
|
return (f"Command(key='{self.key}', description='{self.description}' "
|
||||||
|
f"action='{self.action}')")
|
||||||
|
|
||||||
async def __call__(self, *args, **kwargs):
|
async def __call__(self, *args, **kwargs):
|
||||||
"""Run the action with full hook lifecycle, timing, and error handling."""
|
"""Run the action with full hook lifecycle, timing, and error handling."""
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""config.py
|
"""config.py
|
||||||
Configuration loader for Falyx CLI commands."""
|
Configuration loader for Falyx CLI commands."""
|
||||||
|
|
||||||
|
@ -79,11 +80,12 @@ def loader(file_path: str) -> list[dict[str, Any]]:
|
||||||
command_dict = {
|
command_dict = {
|
||||||
"key": entry["key"],
|
"key": entry["key"],
|
||||||
"description": entry["description"],
|
"description": entry["description"],
|
||||||
"aliases": entry.get("aliases", []),
|
|
||||||
"action": wrap_if_needed(import_action(entry["action"]),
|
"action": wrap_if_needed(import_action(entry["action"]),
|
||||||
name=entry["description"]),
|
name=entry["description"]),
|
||||||
"args": tuple(entry.get("args", ())),
|
"args": tuple(entry.get("args", ())),
|
||||||
"kwargs": entry.get("kwargs", {}),
|
"kwargs": entry.get("kwargs", {}),
|
||||||
|
"hidden": entry.get("hidden", False),
|
||||||
|
"aliases": entry.get("aliases", []),
|
||||||
"help_text": entry.get("help_text", ""),
|
"help_text": entry.get("help_text", ""),
|
||||||
"color": entry.get("color", "white"),
|
"color": entry.get("color", "white"),
|
||||||
"confirm": entry.get("confirm", False),
|
"confirm": entry.get("confirm", False),
|
||||||
|
@ -94,10 +96,18 @@ def loader(file_path: str) -> list[dict[str, Any]]:
|
||||||
"spinner_type": entry.get("spinner_type", "dots"),
|
"spinner_type": entry.get("spinner_type", "dots"),
|
||||||
"spinner_style": entry.get("spinner_style", "cyan"),
|
"spinner_style": entry.get("spinner_style", "cyan"),
|
||||||
"spinner_kwargs": entry.get("spinner_kwargs", {}),
|
"spinner_kwargs": entry.get("spinner_kwargs", {}),
|
||||||
"tags": entry.get("tags", []),
|
"before_hooks": entry.get("before_hooks", []),
|
||||||
|
"success_hooks": entry.get("success_hooks", []),
|
||||||
|
"error_hooks": entry.get("error_hooks", []),
|
||||||
|
"after_hooks": entry.get("after_hooks", []),
|
||||||
|
"teardown_hooks": entry.get("teardown_hooks", []),
|
||||||
|
"retry": entry.get("retry", False),
|
||||||
|
"retry_all": entry.get("retry_all", False),
|
||||||
"retry_policy": RetryPolicy(**entry.get("retry_policy", {})),
|
"retry_policy": RetryPolicy(**entry.get("retry_policy", {})),
|
||||||
|
"tags": entry.get("tags", []),
|
||||||
|
"logging_hooks": entry.get("logging_hooks", False),
|
||||||
|
"requires_input": entry.get("requires_input", None),
|
||||||
}
|
}
|
||||||
commands.append(command_dict)
|
commands.append(command_dict)
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""context.py"""
|
"""context.py"""
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.utils import logger
|
from falyx.utils import logger
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
class FalyxError(Exception):
|
class FalyxError(Exception):
|
||||||
"""Custom exception for the Menu class."""
|
"""Custom exception for the Menu class."""
|
||||||
|
|
||||||
|
@ -20,3 +21,7 @@ class NotAFalyxError(FalyxError):
|
||||||
|
|
||||||
class CircuitBreakerOpen(FalyxError):
|
class CircuitBreakerOpen(FalyxError):
|
||||||
"""Exception raised when the circuit breaker is open."""
|
"""Exception raised when the circuit breaker is open."""
|
||||||
|
|
||||||
|
class EmptyChainError(FalyxError):
|
||||||
|
"""Exception raised when the chain is empty."""
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""execution_registry.py"""
|
"""execution_registry.py"""
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
@ -62,6 +63,8 @@ class ExecutionRegistry:
|
||||||
else:
|
else:
|
||||||
status = "[green]✅ Success"
|
status = "[green]✅ Success"
|
||||||
result = repr(ctx.result)
|
result = repr(ctx.result)
|
||||||
|
if len(result) > 1000:
|
||||||
|
result = f"{result[:1000]}..."
|
||||||
|
|
||||||
table.add_row(ctx.name, start, end, duration, status, result)
|
table.add_row(ctx.name, start, end, duration, status, result)
|
||||||
|
|
||||||
|
|
164
falyx/falyx.py
164
falyx/falyx.py
|
@ -1,16 +1,23 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""falyx.py
|
"""falyx.py
|
||||||
|
|
||||||
This class creates a Falyx object that creates a selectable menu
|
Main class for constructing and running Falyx CLI menus.
|
||||||
with customizable commands and functionality.
|
|
||||||
|
|
||||||
It allows for adding commands, and their accompanying actions,
|
Falyx provides a structured, customizable interactive menu system
|
||||||
and provides a method to display the menu and handle user input.
|
for running commands, actions, and workflows. It supports:
|
||||||
|
|
||||||
This class uses the `rich` library to display the menu in a
|
- Hook lifecycle management (before/on_success/on_error/after/on_teardown)
|
||||||
formatted and visually appealing way.
|
- Dynamic command addition and alias resolution
|
||||||
|
- Rich-based menu display with multi-column layouts
|
||||||
|
- Interactive input validation and auto-completion
|
||||||
|
- History tracking and help menu generation
|
||||||
|
- Confirmation prompts and spinners
|
||||||
|
- Headless mode for automated script execution
|
||||||
|
- CLI argument parsing with argparse integration
|
||||||
|
- Retry policy configuration for actions
|
||||||
|
|
||||||
This class also uses the `prompt_toolkit` library to handle
|
Falyx enables building flexible, robust, and user-friendly
|
||||||
user input and create an interactive experience.
|
terminal applications with minimal boilerplate.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
@ -30,7 +37,7 @@ from rich.console import Console
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from falyx.action import BaseAction
|
from falyx.action import Action, BaseAction
|
||||||
from falyx.bottom_bar import BottomBar
|
from falyx.bottom_bar import BottomBar
|
||||||
from falyx.command import Command
|
from falyx.command import Command
|
||||||
from falyx.context import ExecutionContext
|
from falyx.context import ExecutionContext
|
||||||
|
@ -43,37 +50,57 @@ from falyx.options_manager import OptionsManager
|
||||||
from falyx.parsers import get_arg_parsers
|
from falyx.parsers import get_arg_parsers
|
||||||
from falyx.retry import RetryPolicy
|
from falyx.retry import RetryPolicy
|
||||||
from falyx.themes.colors import OneColors, get_nord_theme
|
from falyx.themes.colors import OneColors, get_nord_theme
|
||||||
from falyx.utils import CaseInsensitiveDict, async_confirm, chunks, logger
|
from falyx.utils import (CaseInsensitiveDict, async_confirm, chunks,
|
||||||
|
get_program_invocation, logger)
|
||||||
from falyx.version import __version__
|
from falyx.version import __version__
|
||||||
|
|
||||||
|
|
||||||
class Falyx:
|
class Falyx:
|
||||||
"""Class to create a menu with commands.
|
|
||||||
|
|
||||||
Hook functions must have the signature:
|
|
||||||
def hook(command: Command) -> None:
|
|
||||||
where `command` is the selected command.
|
|
||||||
|
|
||||||
Error hook functions must have the signature:
|
|
||||||
def error_hook(command: Command, error: Exception) -> None:
|
|
||||||
where `command` is the selected command and `error` is the exception raised.
|
|
||||||
|
|
||||||
Hook execution order:
|
|
||||||
1. Before action hooks of the menu.
|
|
||||||
2. Before action hooks of the selected command.
|
|
||||||
3. Action of the selected command.
|
|
||||||
4. After action hooks of the selected command.
|
|
||||||
5. After action hooks of the menu.
|
|
||||||
6. On error hooks of the selected command (if an error occurs).
|
|
||||||
7. On error hooks of the menu (if an error occurs).
|
|
||||||
|
|
||||||
Parameters:
|
|
||||||
title (str|Markdown): The title of the menu.
|
|
||||||
columns (int): The number of columns to display the commands in.
|
|
||||||
prompt (AnyFormattedText): The prompt to display when asking for input.
|
|
||||||
bottom_bar (str|callable|None): The text to display in the bottom bar.
|
|
||||||
"""
|
"""
|
||||||
|
Main menu controller for Falyx CLI applications.
|
||||||
|
|
||||||
|
Falyx orchestrates the full lifecycle of an interactive menu system,
|
||||||
|
handling user input, command execution, error recovery, and structured
|
||||||
|
CLI workflows.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Interactive menu with Rich rendering and Prompt Toolkit input handling
|
||||||
|
- Dynamic command management with alias and abbreviation matching
|
||||||
|
- Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels
|
||||||
|
- Built-in retry support, spinner visuals, and confirmation prompts
|
||||||
|
- Submenu nesting and action chaining
|
||||||
|
- History tracking, help generation, and headless execution modes
|
||||||
|
- Seamless CLI argument parsing and integration via argparse
|
||||||
|
- Extensible with user-defined hooks, bottom bars, and custom layouts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title (str | Markdown): Title displayed for the menu.
|
||||||
|
prompt (AnyFormattedText): Prompt displayed when requesting user input.
|
||||||
|
columns (int): Number of columns to use when rendering menu commands.
|
||||||
|
bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic.
|
||||||
|
welcome_message (str | Markdown | dict): Welcome message shown at startup.
|
||||||
|
exit_message (str | Markdown | dict): Exit message shown on shutdown.
|
||||||
|
key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings.
|
||||||
|
include_history_command (bool): Whether to add a built-in history viewer command.
|
||||||
|
include_help_command (bool): Whether to add a built-in help viewer command.
|
||||||
|
confirm_on_error (bool): Whether to prompt the user after errors.
|
||||||
|
never_confirm (bool): Whether to skip confirmation prompts entirely.
|
||||||
|
always_confirm (bool): Whether to force confirmation prompts for all actions.
|
||||||
|
cli_args (Namespace | None): Parsed CLI arguments, usually from argparse.
|
||||||
|
options (OptionsManager | None): Declarative option mappings.
|
||||||
|
custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator.
|
||||||
|
|
||||||
|
Methods:
|
||||||
|
run(): Main entry point for CLI argument-based workflows. Most users will use this.
|
||||||
|
menu(): Run the interactive menu loop.
|
||||||
|
headless(command_key, return_context): Run a command directly without showing the menu.
|
||||||
|
add_command(): Add a single command to the menu.
|
||||||
|
add_commands(): Add multiple commands at once.
|
||||||
|
register_all_hooks(): Register hooks across all commands and submenus.
|
||||||
|
debug_hooks(): Log hook registration for debugging.
|
||||||
|
build_default_table(): Construct the standard Rich table layout.
|
||||||
|
|
||||||
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
title: str | Markdown = "Menu",
|
title: str | Markdown = "Menu",
|
||||||
|
@ -84,7 +111,7 @@ class Falyx:
|
||||||
exit_message: str | Markdown | dict[str, Any] = "",
|
exit_message: str | Markdown | dict[str, Any] = "",
|
||||||
key_bindings: KeyBindings | None = None,
|
key_bindings: KeyBindings | None = None,
|
||||||
include_history_command: bool = True,
|
include_history_command: bool = True,
|
||||||
include_help_command: bool = False,
|
include_help_command: bool = True,
|
||||||
confirm_on_error: bool = True,
|
confirm_on_error: bool = True,
|
||||||
never_confirm: bool = False,
|
never_confirm: bool = False,
|
||||||
always_confirm: bool = False,
|
always_confirm: bool = False,
|
||||||
|
@ -207,6 +234,8 @@ class Falyx:
|
||||||
|
|
||||||
for command in self.commands.values():
|
for command in self.commands.values():
|
||||||
help_text = command.help_text or command.description
|
help_text = command.help_text or command.description
|
||||||
|
if command.requires_input:
|
||||||
|
help_text += " [dim](requires input)[/dim]"
|
||||||
table.add_row(
|
table.add_row(
|
||||||
f"[{command.color}]{command.key}[/]",
|
f"[{command.color}]{command.key}[/]",
|
||||||
", ".join(command.aliases) if command.aliases else "None",
|
", ".join(command.aliases) if command.aliases else "None",
|
||||||
|
@ -234,7 +263,7 @@ class Falyx:
|
||||||
"Show this help menu"
|
"Show this help menu"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.console.print(table)
|
self.console.print(table, justify="center")
|
||||||
|
|
||||||
def _get_help_command(self) -> Command:
|
def _get_help_command(self) -> Command:
|
||||||
"""Returns the help command for the menu."""
|
"""Returns the help command for the menu."""
|
||||||
|
@ -324,13 +353,12 @@ class Falyx:
|
||||||
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None:
|
||||||
"""Sets the bottom bar for the menu."""
|
"""Sets the bottom bar for the menu."""
|
||||||
if bottom_bar is None:
|
if bottom_bar is None:
|
||||||
self._bottom_bar = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available)
|
self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available)
|
||||||
elif isinstance(bottom_bar, BottomBar):
|
elif isinstance(bottom_bar, BottomBar):
|
||||||
bottom_bar.key_validator = self.is_key_available
|
bottom_bar.key_validator = self.is_key_available
|
||||||
bottom_bar.key_bindings = self.key_bindings
|
bottom_bar.key_bindings = self.key_bindings
|
||||||
self._bottom_bar = bottom_bar
|
self._bottom_bar = bottom_bar
|
||||||
elif (isinstance(bottom_bar, str) or
|
elif (isinstance(bottom_bar, str) or callable(bottom_bar)):
|
||||||
callable(bottom_bar)):
|
|
||||||
self._bottom_bar = bottom_bar
|
self._bottom_bar = bottom_bar
|
||||||
else:
|
else:
|
||||||
raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.")
|
raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.")
|
||||||
|
@ -339,12 +367,12 @@ class Falyx:
|
||||||
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None:
|
||||||
"""Returns the bottom bar for the menu."""
|
"""Returns the bottom bar for the menu."""
|
||||||
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items:
|
if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items:
|
||||||
return self._bottom_bar.render
|
return self.bottom_bar.render
|
||||||
elif callable(self._bottom_bar):
|
elif callable(self.bottom_bar):
|
||||||
return self._bottom_bar
|
return self.bottom_bar
|
||||||
elif isinstance(self._bottom_bar, str):
|
elif isinstance(self.bottom_bar, str):
|
||||||
return self._bottom_bar
|
return self.bottom_bar
|
||||||
elif self._bottom_bar is None:
|
elif self.bottom_bar is None:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -475,9 +503,10 @@ class Falyx:
|
||||||
key: str,
|
key: str,
|
||||||
description: str,
|
description: str,
|
||||||
action: BaseAction | Callable[[], Any],
|
action: BaseAction | Callable[[], Any],
|
||||||
aliases: list[str] | None = None,
|
|
||||||
args: tuple = (),
|
args: tuple = (),
|
||||||
kwargs: dict[str, Any] = {},
|
kwargs: dict[str, Any] = {},
|
||||||
|
hidden: bool = False,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
help_text: str = "",
|
help_text: str = "",
|
||||||
color: str = OneColors.WHITE,
|
color: str = OneColors.WHITE,
|
||||||
confirm: bool = False,
|
confirm: bool = False,
|
||||||
|
@ -491,25 +520,27 @@ class Falyx:
|
||||||
hooks: HookManager | None = None,
|
hooks: HookManager | None = None,
|
||||||
before_hooks: list[Callable] | None = None,
|
before_hooks: list[Callable] | None = None,
|
||||||
success_hooks: list[Callable] | None = None,
|
success_hooks: list[Callable] | None = None,
|
||||||
after_hooks: list[Callable] | None = None,
|
|
||||||
error_hooks: list[Callable] | None = None,
|
error_hooks: list[Callable] | None = None,
|
||||||
|
after_hooks: list[Callable] | None = None,
|
||||||
teardown_hooks: list[Callable] | None = None,
|
teardown_hooks: list[Callable] | None = None,
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
logging_hooks: bool = False,
|
logging_hooks: bool = False,
|
||||||
retry: bool = False,
|
retry: bool = False,
|
||||||
retry_all: bool = False,
|
retry_all: bool = False,
|
||||||
retry_policy: RetryPolicy | None = None,
|
retry_policy: RetryPolicy | None = None,
|
||||||
|
requires_input: bool | None = None,
|
||||||
) -> Command:
|
) -> Command:
|
||||||
"""Adds an command to the menu, preventing duplicates."""
|
"""Adds an command to the menu, preventing duplicates."""
|
||||||
self._validate_command_key(key)
|
self._validate_command_key(key)
|
||||||
command = Command(
|
command = Command(
|
||||||
key=key,
|
key=key,
|
||||||
description=description,
|
description=description,
|
||||||
aliases=aliases if aliases else [],
|
|
||||||
help_text=help_text,
|
|
||||||
action=action,
|
action=action,
|
||||||
args=args,
|
args=args,
|
||||||
kwargs=kwargs,
|
kwargs=kwargs,
|
||||||
|
hidden=hidden,
|
||||||
|
aliases=aliases if aliases else [],
|
||||||
|
help_text=help_text,
|
||||||
color=color,
|
color=color,
|
||||||
confirm=confirm,
|
confirm=confirm,
|
||||||
confirm_message=confirm_message,
|
confirm_message=confirm_message,
|
||||||
|
@ -524,6 +555,7 @@ class Falyx:
|
||||||
retry=retry,
|
retry=retry,
|
||||||
retry_all=retry_all,
|
retry_all=retry_all,
|
||||||
retry_policy=retry_policy or RetryPolicy(),
|
retry_policy=retry_policy or RetryPolicy(),
|
||||||
|
requires_input=requires_input,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hooks:
|
if hooks:
|
||||||
|
@ -558,13 +590,15 @@ class Falyx:
|
||||||
def build_default_table(self) -> Table:
|
def build_default_table(self) -> Table:
|
||||||
"""Build the standard table layout. Developers can subclass or call this in custom tables."""
|
"""Build the standard table layout. Developers can subclass or call this in custom tables."""
|
||||||
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True)
|
table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True)
|
||||||
for chunk in chunks(self.commands.items(), self.columns):
|
visible_commands = [item for item in self.commands.items() if not item[1].hidden]
|
||||||
|
for chunk in chunks(visible_commands, self.columns):
|
||||||
row = []
|
row = []
|
||||||
for key, command in chunk:
|
for key, command in chunk:
|
||||||
row.append(f"[{key}] [{command.color}]{command.description}")
|
row.append(f"[{key}] [{command.color}]{command.description}")
|
||||||
table.add_row(*row)
|
table.add_row(*row)
|
||||||
bottom_row = self.get_bottom_row()
|
bottom_row = self.get_bottom_row()
|
||||||
table.add_row(*bottom_row)
|
for row in chunks(bottom_row, self.columns):
|
||||||
|
table.add_row(*row)
|
||||||
return table
|
return table
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -617,9 +651,9 @@ class Falyx:
|
||||||
confirm_answer = await async_confirm(selected_command.confirmation_prompt)
|
confirm_answer = await async_confirm(selected_command.confirmation_prompt)
|
||||||
|
|
||||||
if confirm_answer:
|
if confirm_answer:
|
||||||
logger.info(f"[{OneColors.LIGHT_YELLOW}][{selected_command.description}]🔐 confirmed.")
|
logger.info(f"[{selected_command.description}]🔐 confirmed.")
|
||||||
else:
|
else:
|
||||||
logger.info(f"[{OneColors.DARK_RED}][{selected_command.description}]❌ cancelled.")
|
logger.info(f"[{selected_command.description}]❌ cancelled.")
|
||||||
return confirm_answer
|
return confirm_answer
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -658,8 +692,19 @@ class Falyx:
|
||||||
choice = await self.session.prompt_async()
|
choice = await self.session.prompt_async()
|
||||||
selected_command = self.get_command(choice)
|
selected_command = self.get_command(choice)
|
||||||
if not selected_command:
|
if not selected_command:
|
||||||
logger.info(f"[{OneColors.LIGHT_YELLOW}] Invalid command '{choice}'.")
|
logger.info(f"Invalid command '{choice}'.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if selected_command.requires_input:
|
||||||
|
program = get_program_invocation()
|
||||||
|
self.console.print(
|
||||||
|
f"[{OneColors.LIGHT_YELLOW}]⚠️ Command '{selected_command.key}' requires input "
|
||||||
|
f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] "
|
||||||
|
"with proper piping or arguments.[/]"
|
||||||
|
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
self.last_run_command = selected_command
|
self.last_run_command = selected_command
|
||||||
|
|
||||||
if selected_command == self.exit_command:
|
if selected_command == self.exit_command:
|
||||||
|
@ -667,7 +712,7 @@ class Falyx:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not await self._should_run_action(selected_command):
|
if not await self._should_run_action(selected_command):
|
||||||
logger.info(f"[{OneColors.DARK_RED}] {selected_command.description} cancelled.")
|
logger.info(f"{selected_command.description} cancelled.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
context = self._create_context(selected_command)
|
context = self._create_context(selected_command)
|
||||||
|
@ -750,7 +795,10 @@ class Falyx:
|
||||||
selected_command.retry_policy.delay = self.cli_args.retry_delay
|
selected_command.retry_policy.delay = self.cli_args.retry_delay
|
||||||
if self.cli_args.retry_backoff:
|
if self.cli_args.retry_backoff:
|
||||||
selected_command.retry_policy.backoff = self.cli_args.retry_backoff
|
selected_command.retry_policy.backoff = self.cli_args.retry_backoff
|
||||||
#selected_command.update_retry_policy(selected_command.retry_policy)
|
if isinstance(selected_command.action, Action):
|
||||||
|
selected_command.action.set_retry_policy(selected_command.retry_policy)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance.")
|
||||||
|
|
||||||
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
def print_message(self, message: str | Markdown | dict[str, Any]) -> None:
|
||||||
"""Prints a message to the console."""
|
"""Prints a message to the console."""
|
||||||
|
@ -773,14 +821,14 @@ class Falyx:
|
||||||
if self.welcome_message:
|
if self.welcome_message:
|
||||||
self.print_message(self.welcome_message)
|
self.print_message(self.welcome_message)
|
||||||
while True:
|
while True:
|
||||||
self.console.print(self.table)
|
self.console.print(self.table, justify="center")
|
||||||
try:
|
try:
|
||||||
task = asyncio.create_task(self.process_command())
|
task = asyncio.create_task(self.process_command())
|
||||||
should_continue = await task
|
should_continue = await task
|
||||||
if not should_continue:
|
if not should_continue:
|
||||||
break
|
break
|
||||||
except (EOFError, KeyboardInterrupt):
|
except (EOFError, KeyboardInterrupt):
|
||||||
logger.info(f"[{OneColors.DARK_RED}]EOF or KeyboardInterrupt. Exiting menu.")
|
logger.info("EOF or KeyboardInterrupt. Exiting menu.")
|
||||||
break
|
break
|
||||||
logger.info(f"Exiting menu: {self.get_title()}")
|
logger.info(f"Exiting menu: {self.get_title()}")
|
||||||
if self.exit_message:
|
if self.exit_message:
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""hook_manager.py"""
|
"""hook_manager.py"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
@ -64,5 +65,6 @@ class HookManager:
|
||||||
f" for '{context.name}': {hook_error}")
|
f" for '{context.name}': {hook_error}")
|
||||||
|
|
||||||
if hook_type == HookType.ON_ERROR:
|
if hook_type == HookType.ON_ERROR:
|
||||||
assert isinstance(context.exception, BaseException)
|
assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook"
|
||||||
raise context.exception from hook_error
|
raise context.exception from hook_error
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""hooks.py"""
|
"""hooks.py"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""importer.py"""
|
"""importer.py"""
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""io_action.py"""
|
"""io_action.py"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
@ -12,8 +13,8 @@ from falyx.context import ExecutionContext
|
||||||
from falyx.exceptions import FalyxError
|
from falyx.exceptions import FalyxError
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.utils import logger
|
|
||||||
from falyx.themes.colors import OneColors
|
from falyx.themes.colors import OneColors
|
||||||
|
from falyx.utils import logger
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
@ -34,7 +35,7 @@ class BaseIOAction(BaseAction):
|
||||||
inject_last_result=inject_last_result,
|
inject_last_result=inject_last_result,
|
||||||
)
|
)
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.requires_injection = True
|
self._requires_injection = True
|
||||||
|
|
||||||
def from_input(self, raw: str | bytes) -> Any:
|
def from_input(self, raw: str | bytes) -> Any:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
@ -178,3 +179,29 @@ class ShellAction(BaseIOAction):
|
||||||
parent.add("".join(label))
|
parent.add("".join(label))
|
||||||
else:
|
else:
|
||||||
console.print(Tree("".join(label)))
|
console.print(Tree("".join(label)))
|
||||||
|
|
||||||
|
class GrepAction(BaseIOAction):
|
||||||
|
def __init__(self, name: str, pattern: str, **kwargs):
|
||||||
|
super().__init__(name=name, **kwargs)
|
||||||
|
self.pattern = pattern
|
||||||
|
|
||||||
|
def from_input(self, raw: str | bytes) -> str:
|
||||||
|
if not isinstance(raw, (str, bytes)):
|
||||||
|
raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}")
|
||||||
|
return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip()
|
||||||
|
|
||||||
|
async def _run(self, parsed_input: str) -> str:
|
||||||
|
command = ["grep", "-n", self.pattern]
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||||
|
)
|
||||||
|
stdout, stderr = process.communicate(input=parsed_input)
|
||||||
|
if process.returncode == 1:
|
||||||
|
return ""
|
||||||
|
if process.returncode != 0:
|
||||||
|
raise RuntimeError(stderr.strip())
|
||||||
|
return stdout.strip()
|
||||||
|
|
||||||
|
def to_output(self, result: str) -> str:
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from rich.markdown import Markdown
|
|
||||||
|
|
||||||
from falyx import Action, Falyx
|
|
||||||
from falyx.hook_manager import HookType
|
|
||||||
from falyx.debug import log_before, log_success, log_error, log_after
|
|
||||||
from falyx.themes.colors import OneColors
|
|
||||||
from falyx.utils import setup_logging
|
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
setup_logging(console_log_level=logging.WARNING, json_log_to_file=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Create the menu
|
|
||||||
menu = Falyx(
|
|
||||||
title=Markdown("# 🚀 Falyx CLI Demo"),
|
|
||||||
welcome_message="Welcome to Falyx!",
|
|
||||||
exit_message="Thanks for using Falyx!",
|
|
||||||
include_history_command=True,
|
|
||||||
include_help_command=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define async actions
|
|
||||||
async def hello():
|
|
||||||
print("👋 Hello from Falyx CLI!")
|
|
||||||
|
|
||||||
def goodbye():
|
|
||||||
print("👋 Goodbye from Falyx CLI!")
|
|
||||||
|
|
||||||
async def do_task_and_increment(counter_name: str = "tasks"):
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
print("✅ Task completed.")
|
|
||||||
menu.bottom_bar.increment_total_counter(counter_name)
|
|
||||||
|
|
||||||
# Register global logging hooks
|
|
||||||
menu.hooks.register(HookType.BEFORE, log_before)
|
|
||||||
menu.hooks.register(HookType.ON_SUCCESS, log_success)
|
|
||||||
menu.hooks.register(HookType.ON_ERROR, log_error)
|
|
||||||
menu.hooks.register(HookType.AFTER, log_after)
|
|
||||||
|
|
||||||
# Add a toggle to the bottom bar
|
|
||||||
menu.add_toggle("D", "Debug Mode", state=False)
|
|
||||||
|
|
||||||
# Add a counter to the bottom bar
|
|
||||||
menu.add_total_counter("tasks", "Tasks", current=0, total=5)
|
|
||||||
|
|
||||||
# Add static text to the bottom bar
|
|
||||||
menu.add_static("env", "🌐 Local Env")
|
|
||||||
|
|
||||||
# Add commands with help_text
|
|
||||||
menu.add_command(
|
|
||||||
key="S",
|
|
||||||
description="Say Hello",
|
|
||||||
help_text="Greets the user with a friendly hello message.",
|
|
||||||
action=Action("Hello", hello),
|
|
||||||
color=OneColors.CYAN,
|
|
||||||
)
|
|
||||||
|
|
||||||
menu.add_command(
|
|
||||||
key="G",
|
|
||||||
description="Say Goodbye",
|
|
||||||
help_text="Bids farewell and thanks the user for using the app.",
|
|
||||||
action=Action("Goodbye", goodbye),
|
|
||||||
color=OneColors.MAGENTA,
|
|
||||||
)
|
|
||||||
|
|
||||||
menu.add_command(
|
|
||||||
key="T",
|
|
||||||
description="Run a Task",
|
|
||||||
aliases=["task", "run"],
|
|
||||||
help_text="Performs a task and increments the counter by 1.",
|
|
||||||
action=do_task_and_increment,
|
|
||||||
args=("tasks",),
|
|
||||||
color=OneColors.GREEN,
|
|
||||||
spinner=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(menu.run())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
"""
|
|
||||||
Entry point for the Falyx CLI demo application.
|
|
||||||
This function initializes the menu and runs it.
|
|
||||||
"""
|
|
||||||
main()
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""options_manager.py"""
|
"""options_manager.py"""
|
||||||
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""parsers.py
|
"""parsers.py
|
||||||
This module contains the argument parsers used for the Falyx CLI.
|
This module contains the argument parsers used for the Falyx CLI.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""retry.py"""
|
"""retry.py"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
@ -11,8 +13,16 @@ class RetryPolicy(BaseModel):
|
||||||
max_retries: int = Field(default=3, ge=0)
|
max_retries: int = Field(default=3, ge=0)
|
||||||
delay: float = Field(default=1.0, ge=0.0)
|
delay: float = Field(default=1.0, ge=0.0)
|
||||||
backoff: float = Field(default=2.0, ge=1.0)
|
backoff: float = Field(default=2.0, ge=1.0)
|
||||||
|
jitter: float = Field(default=0.0, ge=0.0)
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|
||||||
|
def enable_policy(self) -> None:
|
||||||
|
"""
|
||||||
|
Enable the retry policy.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self.enabled = True
|
||||||
|
|
||||||
def is_active(self) -> bool:
|
def is_active(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the retry policy is active.
|
Check if the retry policy is active.
|
||||||
|
@ -25,11 +35,18 @@ class RetryHandler:
|
||||||
def __init__(self, policy: RetryPolicy=RetryPolicy()):
|
def __init__(self, policy: RetryPolicy=RetryPolicy()):
|
||||||
self.policy = policy
|
self.policy = policy
|
||||||
|
|
||||||
def enable_policy(self, backoff=2, max_retries=3, delay=1):
|
def enable_policy(
|
||||||
|
self,
|
||||||
|
max_retries: int=3,
|
||||||
|
delay: float=1.0,
|
||||||
|
backoff: float=2.0,
|
||||||
|
jitter: float=0.0,
|
||||||
|
):
|
||||||
self.policy.enabled = True
|
self.policy.enabled = True
|
||||||
self.policy.max_retries = max_retries
|
self.policy.max_retries = max_retries
|
||||||
self.policy.delay = delay
|
self.policy.delay = delay
|
||||||
self.policy.backoff = backoff
|
self.policy.backoff = backoff
|
||||||
|
self.policy.jitter = jitter
|
||||||
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
logger.info(f"🔄 Retry policy enabled: {self.policy}")
|
||||||
|
|
||||||
async def retry_on_error(self, context: ExecutionContext):
|
async def retry_on_error(self, context: ExecutionContext):
|
||||||
|
@ -60,7 +77,15 @@ class RetryHandler:
|
||||||
|
|
||||||
while retries_done < self.policy.max_retries:
|
while retries_done < self.policy.max_retries:
|
||||||
retries_done += 1
|
retries_done += 1
|
||||||
logger.info(f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...")
|
|
||||||
|
sleep_delay = current_delay
|
||||||
|
if self.policy.jitter > 0:
|
||||||
|
sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[{name}] 🔄 Retrying ({retries_done}/{self.policy.max_retries}) "
|
||||||
|
f"in {current_delay}s due to '{last_error}'..."
|
||||||
|
)
|
||||||
await asyncio.sleep(current_delay)
|
await asyncio.sleep(current_delay)
|
||||||
try:
|
try:
|
||||||
result = await target.action(*context.args, **context.kwargs)
|
result = await target.action(*context.args, **context.kwargs)
|
||||||
|
@ -71,7 +96,10 @@ class RetryHandler:
|
||||||
except Exception as retry_error:
|
except Exception as retry_error:
|
||||||
last_error = retry_error
|
last_error = retry_error
|
||||||
current_delay *= self.policy.backoff
|
current_delay *= self.policy.backoff
|
||||||
logger.warning(f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.")
|
logger.warning(
|
||||||
|
f"[{name}] ⚠️ Retry attempt {retries_done}/{self.policy.max_retries} "
|
||||||
|
f"failed due to '{retry_error}'."
|
||||||
|
)
|
||||||
|
|
||||||
context.exception = last_error
|
context.exception = last_error
|
||||||
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
logger.error(f"[{name}] ❌ All {self.policy.max_retries} retries failed.")
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from rich import box
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from falyx.command import Command
|
||||||
|
from falyx.falyx import Falyx
|
||||||
|
|
||||||
|
|
||||||
|
def build_tagged_table(flx: Falyx) -> Table:
|
||||||
|
"""Custom table builder that groups commands by tags."""
|
||||||
|
table = Table(title=flx.title, show_header=False, box=box.SIMPLE)
|
||||||
|
|
||||||
|
# Group commands by first tag
|
||||||
|
grouped: dict[str, list[Command]] = defaultdict(list)
|
||||||
|
for cmd in flx.commands.values():
|
||||||
|
first_tag = cmd.tags[0] if cmd.tags else "Other"
|
||||||
|
grouped[first_tag.capitalize()].append(cmd)
|
||||||
|
|
||||||
|
# Add grouped commands to table
|
||||||
|
for group_name, commands in grouped.items():
|
||||||
|
table.add_row(f"[bold underline]{group_name} Commands[/]")
|
||||||
|
for cmd in commands:
|
||||||
|
table.add_row(f"[{cmd.key}] [{cmd.color}]{cmd.description}")
|
||||||
|
table.add_row("")
|
||||||
|
|
||||||
|
# Add bottom row
|
||||||
|
for row in flx.get_bottom_row():
|
||||||
|
table.add_row(row)
|
||||||
|
|
||||||
|
return table
|
|
@ -1,8 +1,11 @@
|
||||||
|
# Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
|
||||||
"""utils.py"""
|
"""utils.py"""
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from typing import Any, Awaitable, Callable, TypeVar
|
from typing import Any, Awaitable, Callable, TypeVar
|
||||||
|
|
||||||
|
@ -21,6 +24,20 @@ T = TypeVar("T")
|
||||||
async def _noop(*args, **kwargs):
|
async def _noop(*args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_program_invocation() -> str:
|
||||||
|
"""Returns the recommended program invocation prefix."""
|
||||||
|
script = sys.argv[0]
|
||||||
|
program = shutil.which(script)
|
||||||
|
if program:
|
||||||
|
return os.path.basename(program)
|
||||||
|
|
||||||
|
executable = sys.executable
|
||||||
|
if "python" in executable:
|
||||||
|
return f"python {script}"
|
||||||
|
return script
|
||||||
|
|
||||||
|
|
||||||
def is_coroutine(function: Callable[..., Any]) -> bool:
|
def is_coroutine(function: Callable[..., Any]) -> bool:
|
||||||
return inspect.iscoroutinefunction(function)
|
return inspect.iscoroutinefunction(function)
|
||||||
|
|
||||||
|
@ -32,6 +49,9 @@ def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]:
|
||||||
@functools.wraps(function)
|
@functools.wraps(function)
|
||||||
async def async_wrapper(*args, **kwargs) -> T:
|
async def async_wrapper(*args, **kwargs) -> T:
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
if not callable(function):
|
||||||
|
raise TypeError(f"{function} is not callable")
|
||||||
return async_wrapper
|
return async_wrapper
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
__version__ = "0.1.5"
|
__version__ = "0.1.6"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "falyx"
|
name = "falyx"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
description = "Reliable and introspectable async CLI action framework."
|
description = "Reliable and introspectable async CLI action framework."
|
||||||
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
authors = ["Roland Thomas Jr <roland@rtj.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
@ -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 pytest
|
||||||
import asyncio
|
|
||||||
import pickle
|
from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction
|
||||||
import warnings
|
|
||||||
from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction
|
|
||||||
from falyx.execution_registry import ExecutionRegistry as er
|
from falyx.execution_registry import ExecutionRegistry as er
|
||||||
from falyx.hook_manager import HookManager, HookType
|
from falyx.hook_manager import HookManager, HookType
|
||||||
from falyx.context import ExecutionContext, ResultsContext
|
from falyx.context import ExecutionContext
|
||||||
|
|
||||||
asyncio_default_fixture_loop_scope = "function"
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
async def dummy_action(x: int = 0) -> int:
|
|
||||||
return x + 1
|
|
||||||
|
|
||||||
async def capturing_hook(context: ExecutionContext):
|
async def capturing_hook(context: ExecutionContext):
|
||||||
context.extra["hook_triggered"] = True
|
context.extra["hook_triggered"] = True
|
||||||
|
|
||||||
# --- Fixtures ---
|
# --- Fixtures ---
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_action():
|
|
||||||
return Action(name="increment", action=dummy_action, kwargs={"x": 5})
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def hook_manager():
|
def hook_manager():
|
||||||
hm = HookManager()
|
hm = HookManager()
|
||||||
|
@ -38,15 +27,18 @@ def clean_registry():
|
||||||
# --- Tests ---
|
# --- Tests ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_runs_correctly(sample_action):
|
async def test_action_runs_correctly():
|
||||||
|
async def dummy_action(x: int = 0) -> int: return x + 1
|
||||||
|
sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5})
|
||||||
result = await sample_action()
|
result = await sample_action()
|
||||||
assert result == 6
|
assert result == 6
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_hook_lifecycle(hook_manager):
|
async def test_action_hook_lifecycle(hook_manager):
|
||||||
|
async def a1(): return 42
|
||||||
action = Action(
|
action = Action(
|
||||||
name="hooked",
|
name="hooked",
|
||||||
action=lambda: 42,
|
action=a1,
|
||||||
hooks=hook_manager
|
hooks=hook_manager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -58,21 +50,30 @@ async def test_action_hook_lifecycle(hook_manager):
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_result_injection():
|
async def test_chained_action_with_result_injection():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(last_result): return last_result + 5
|
||||||
|
async def a3(last_result): return last_result * 2
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="start", action=lambda: 1),
|
Action(name="start", action=a1),
|
||||||
Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True),
|
Action(name="add_last", action=a2, inject_last_result=True),
|
||||||
Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True)
|
Action(name="multiply", action=a3, inject_last_result=True)
|
||||||
]
|
]
|
||||||
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 6, 12]
|
assert result == [1, 6, 12]
|
||||||
|
chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True)
|
||||||
|
result = await chain()
|
||||||
|
assert result == 12
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_group_runs_in_parallel():
|
async def test_action_group_runs_in_parallel():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(): return 2
|
||||||
|
async def a3(): return 3
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="a", action=lambda: 1),
|
Action(name="a", action=a1),
|
||||||
Action(name="b", action=lambda: 2),
|
Action(name="b", action=a2),
|
||||||
Action(name="c", action=lambda: 3),
|
Action(name="c", action=a3),
|
||||||
]
|
]
|
||||||
group = ActionGroup(name="parallel", actions=actions)
|
group = ActionGroup(name="parallel", actions=actions)
|
||||||
result = await group()
|
result = await group()
|
||||||
|
@ -81,39 +82,48 @@ async def test_action_group_runs_in_parallel():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_inject_from_action():
|
async def test_chained_action_inject_from_action():
|
||||||
|
async def a1(last_result): return last_result + 10
|
||||||
|
async def a2(last_result): return last_result + 5
|
||||||
inner_chain = ChainedAction(
|
inner_chain = ChainedAction(
|
||||||
name="inner_chain",
|
name="inner_chain",
|
||||||
actions=[
|
actions=[
|
||||||
Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True),
|
Action(name="inner_first", action=a1, inject_last_result=True),
|
||||||
Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True),
|
Action(name="inner_second", action=a2, inject_last_result=True),
|
||||||
]
|
],
|
||||||
|
return_list=True,
|
||||||
)
|
)
|
||||||
|
async def a3(): return 1
|
||||||
|
async def a4(last_result): return last_result + 2
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=lambda: 1),
|
Action(name="first", action=a3),
|
||||||
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
Action(name="second", action=a4, inject_last_result=True),
|
||||||
inner_chain,
|
inner_chain,
|
||||||
|
|
||||||
]
|
]
|
||||||
outer_chain = ChainedAction(name="test_chain", actions=actions)
|
outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
||||||
result = await outer_chain()
|
result = await outer_chain()
|
||||||
assert result == [1, 3, [13, 18]]
|
assert result == [1, 3, [13, 18]]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chained_action_with_group():
|
async def test_chained_action_with_group():
|
||||||
|
async def a1(last_result): return last_result + 1
|
||||||
|
async def a2(last_result): return last_result + 2
|
||||||
|
async def a3(): return 3
|
||||||
group = ActionGroup(
|
group = ActionGroup(
|
||||||
name="group",
|
name="group",
|
||||||
actions=[
|
actions=[
|
||||||
Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True),
|
Action(name="a", action=a1, inject_last_result=True),
|
||||||
Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True),
|
Action(name="b", action=a2, inject_last_result=True),
|
||||||
Action(name="c", action=lambda: 3),
|
Action(name="c", action=a3),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
async def a4(): return 1
|
||||||
|
async def a5(last_result): return last_result + 2
|
||||||
actions = [
|
actions = [
|
||||||
Action(name="first", action=lambda: 1),
|
Action(name="first", action=a4),
|
||||||
Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True),
|
Action(name="second", action=a5, inject_last_result=True),
|
||||||
group,
|
group,
|
||||||
]
|
]
|
||||||
chain = ChainedAction(name="test_chain", actions=actions)
|
chain = ChainedAction(name="test_chain", actions=actions, return_list=True)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
|
assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]]
|
||||||
|
|
||||||
|
@ -161,37 +171,21 @@ async def test_chained_action_rollback_on_failure():
|
||||||
|
|
||||||
assert rollback_called == ["rolled back"]
|
assert rollback_called == ["rolled back"]
|
||||||
|
|
||||||
def slow_add(x, y):
|
|
||||||
return x + y
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_action_executes_correctly():
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
|
|
||||||
action = ProcessAction(name="proc", func=slow_add, args=(2, 3))
|
|
||||||
result = await action()
|
|
||||||
assert result == 5
|
|
||||||
|
|
||||||
unpickleable = lambda x: x + 1
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_process_action_rejects_unpickleable():
|
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", DeprecationWarning)
|
|
||||||
|
|
||||||
action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,))
|
|
||||||
with pytest.raises(pickle.PicklingError, match="Can't pickle"):
|
|
||||||
await action()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_register_hooks_recursively_propagates():
|
async def test_register_hooks_recursively_propagates():
|
||||||
hook = lambda ctx: ctx.extra.update({"test_marker": True})
|
def hook(context):
|
||||||
|
context.extra.update({"test_marker": True})
|
||||||
|
|
||||||
chain = ChainedAction(name="chain", actions=[
|
async def a1(): return 1
|
||||||
Action(name="a", action=lambda: 1),
|
async def a2(): return 2
|
||||||
Action(name="b", action=lambda: 2),
|
|
||||||
])
|
chain = ChainedAction(
|
||||||
|
name="chain",
|
||||||
|
actions=[
|
||||||
|
Action(name="a", action=a1),
|
||||||
|
Action(name="b", action=a2),
|
||||||
|
],
|
||||||
|
)
|
||||||
chain.register_hooks_recursively(HookType.BEFORE, hook)
|
chain.register_hooks_recursively(HookType.BEFORE, hook)
|
||||||
|
|
||||||
await chain()
|
await chain()
|
||||||
|
@ -217,14 +211,255 @@ async def test_action_hook_recovers_error():
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_action_group_injects_last_result():
|
async def test_action_group_injects_last_result():
|
||||||
|
async def a1(last_result): return last_result + 10
|
||||||
|
async def a2(last_result): return last_result + 20
|
||||||
group = ActionGroup(name="group", actions=[
|
group = ActionGroup(name="group", actions=[
|
||||||
Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True),
|
Action(name="g1", action=a1, inject_last_result=True),
|
||||||
Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True),
|
Action(name="g2", action=a2, inject_last_result=True),
|
||||||
])
|
])
|
||||||
chain = ChainedAction(name="with_group", actions=[
|
async def a3(): return 5
|
||||||
Action(name="first", action=lambda: 5),
|
chain = ChainedAction(
|
||||||
|
name="with_group",
|
||||||
|
actions=[
|
||||||
|
Action(name="first", action=a3),
|
||||||
group,
|
group,
|
||||||
])
|
],
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
result = await chain()
|
result = await chain()
|
||||||
result_dict = dict(result[1])
|
result_dict = dict(result[1])
|
||||||
assert result_dict == {"g1": 15, "g2": 25}
|
assert result_dict == {"g1": 15, "g2": 25}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_inject_last_result():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(last_result): return last_result + 1
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
||||||
|
chain = ChainedAction(name="chain", actions=[a1, a2])
|
||||||
|
result = await chain()
|
||||||
|
assert result == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_inject_last_result_fail():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(last_result): return last_result + 1
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2)
|
||||||
|
chain = ChainedAction(name="chain", actions=[a1, a2])
|
||||||
|
|
||||||
|
with pytest.raises(TypeError) as exc_info:
|
||||||
|
await chain()
|
||||||
|
|
||||||
|
assert "last_result" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_auto_inject():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(last_result): return last_result + 2
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2)
|
||||||
|
chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True)
|
||||||
|
result = await chain()
|
||||||
|
assert result == [1, 3] # a2 receives last_result=1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_no_auto_inject():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(): return 2
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2)
|
||||||
|
chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True)
|
||||||
|
result = await chain()
|
||||||
|
assert result == [1, 2] # a2 does not receive 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_auto_inject_after_first():
|
||||||
|
async def a1(): return 1
|
||||||
|
async def a2(last_result): return last_result + 1
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2)
|
||||||
|
chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True)
|
||||||
|
result = await chain()
|
||||||
|
assert result == 2 # a2 receives last_result=1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_with_literal_input():
|
||||||
|
async def a1(last_result): return last_result + " world"
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True)
|
||||||
|
result = await chain()
|
||||||
|
assert result == "hello world" # "hello" is injected as last_result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_manual_inject_override():
|
||||||
|
async def a1(): return 10
|
||||||
|
async def a2(last_result): return last_result * 2
|
||||||
|
a1 = Action(name="a1", action=a1)
|
||||||
|
a2 = Action(name="a2", action=a2, inject_last_result=True)
|
||||||
|
chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False)
|
||||||
|
result = await chain()
|
||||||
|
assert result == 20 # Even without auto_inject, a2 still gets last_result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_with_mid_literal():
|
||||||
|
async def fetch_data():
|
||||||
|
# Imagine this is some dynamic API call
|
||||||
|
return None # Simulate failure or missing data
|
||||||
|
|
||||||
|
async def validate_data(last_result):
|
||||||
|
if last_result is None:
|
||||||
|
raise ValueError("Missing data!")
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def enrich_data(last_result):
|
||||||
|
return f"Enriched: {last_result}"
|
||||||
|
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="fallback_pipeline",
|
||||||
|
actions=[
|
||||||
|
Action(name="FetchData", action=fetch_data),
|
||||||
|
"default_value", # <-- literal fallback injected mid-chain
|
||||||
|
Action(name="ValidateData", action=validate_data),
|
||||||
|
Action(name="EnrichData", action=enrich_data),
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_with_mid_fallback():
|
||||||
|
async def fetch_data():
|
||||||
|
# Imagine this is some dynamic API call
|
||||||
|
return None # Simulate failure or missing data
|
||||||
|
|
||||||
|
async def validate_data(last_result):
|
||||||
|
if last_result is None:
|
||||||
|
raise ValueError("Missing data!")
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def enrich_data(last_result):
|
||||||
|
return f"Enriched: {last_result}"
|
||||||
|
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="fallback_pipeline",
|
||||||
|
actions=[
|
||||||
|
Action(name="FetchData", action=fetch_data),
|
||||||
|
FallbackAction(fallback="default_value"),
|
||||||
|
Action(name="ValidateData", action=validate_data),
|
||||||
|
Action(name="EnrichData", action=enrich_data),
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
assert result == [None, "default_value", "default_value", "Enriched: default_value"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_with_success_mid_fallback():
|
||||||
|
async def fetch_data():
|
||||||
|
# Imagine this is some dynamic API call
|
||||||
|
return "Result" # Simulate success
|
||||||
|
|
||||||
|
async def validate_data(last_result):
|
||||||
|
if last_result is None:
|
||||||
|
raise ValueError("Missing data!")
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def enrich_data(last_result):
|
||||||
|
return f"Enriched: {last_result}"
|
||||||
|
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="fallback_pipeline",
|
||||||
|
actions=[
|
||||||
|
Action(name="FetchData", action=fetch_data),
|
||||||
|
FallbackAction(fallback="default_value"),
|
||||||
|
Action(name="ValidateData", action=validate_data),
|
||||||
|
Action(name="EnrichData", action=enrich_data),
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
assert result == ["Result", "Result", "Result", "Enriched: Result"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_action_group_partial_failure():
|
||||||
|
async def succeed(): return "ok"
|
||||||
|
async def fail(): raise ValueError("oops")
|
||||||
|
|
||||||
|
group = ActionGroup(name="partial_group", actions=[
|
||||||
|
Action(name="succeed_action", action=succeed),
|
||||||
|
Action(name="fail_action", action=fail),
|
||||||
|
])
|
||||||
|
|
||||||
|
with pytest.raises(Exception) as exc_info:
|
||||||
|
await group()
|
||||||
|
|
||||||
|
assert er.get_by_name("succeed_action")[0].result == "ok"
|
||||||
|
assert er.get_by_name("fail_action")[0].exception is not None
|
||||||
|
assert "fail_action" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_with_nested_group():
|
||||||
|
async def g1(last_result): return last_result + "10"
|
||||||
|
async def g2(last_result): return last_result + "20"
|
||||||
|
group = ActionGroup(
|
||||||
|
name="nested_group",
|
||||||
|
actions=[
|
||||||
|
Action(name="g1", action=g1, inject_last_result=True),
|
||||||
|
Action(name="g2", action=g2, inject_last_result=True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="chain_with_group",
|
||||||
|
actions=[
|
||||||
|
"start",
|
||||||
|
group,
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
# "start" -> group both receive "start" as last_result
|
||||||
|
assert result[0] == "start"
|
||||||
|
assert dict(result[1]) == {"g1": "start10", "g2": "start20"} # Assuming string concatenation for example
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chained_action_double_fallback():
|
||||||
|
async def fetch_data(last_result=None):
|
||||||
|
raise ValueError("No data!") # Simulate failure
|
||||||
|
|
||||||
|
async def validate_data(last_result):
|
||||||
|
if last_result is None:
|
||||||
|
raise ValueError("No data!")
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
async def enrich(last_result):
|
||||||
|
return f"Enriched: {last_result}"
|
||||||
|
|
||||||
|
chain = ChainedAction(
|
||||||
|
name="fallback_chain",
|
||||||
|
actions=[
|
||||||
|
Action(name="Fetch", action=fetch_data),
|
||||||
|
FallbackAction(fallback="default1"),
|
||||||
|
Action(name="Validate", action=validate_data),
|
||||||
|
Action(name="Fetch", action=fetch_data),
|
||||||
|
FallbackAction(fallback="default2"),
|
||||||
|
Action(name="Enrich", action=enrich),
|
||||||
|
],
|
||||||
|
auto_inject=True,
|
||||||
|
return_list=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await chain()
|
||||||
|
assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"]
|
||||||
|
|
||||||
|
|
|
@ -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