From 80de9413354fd96c20db4176e157d95c302a1d38 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Tue, 29 Apr 2025 16:34:20 -0400 Subject: [PATCH 1/3] Hide ioactions, Add doc strings, Add tests --- falyx/__init__.py | 6 + falyx/__main__.py | 57 ++++- falyx/action.py | 262 +++++++++++++++++--- falyx/bottom_bar.py | 10 +- falyx/command.py | 105 +++++++- falyx/config.py | 16 +- falyx/context.py | 1 + falyx/debug.py | 1 + falyx/exceptions.py | 5 + falyx/execution_registry.py | 3 + falyx/falyx.py | 164 ++++++++----- falyx/hook_manager.py | 4 +- falyx/hooks.py | 1 + falyx/importer.py | 1 + falyx/io_action.py | 31 ++- falyx/main/main.py | 88 ------- falyx/options_manager.py | 1 + falyx/parsers.py | 1 + falyx/retry.py | 34 ++- falyx/tagged_table.py | 31 +++ falyx/utils.py | 20 ++ falyx/version.py | 2 +- pyproject.toml | 2 +- tests/test_action_basic.py | 78 ++++++ tests/test_action_fallback.py | 0 tests/test_action_hooks.py | 0 tests/test_action_process.py | 40 +++ tests/test_action_retries.py | 30 +++ tests/test_actions.py | 375 +++++++++++++++++++++++------ tests/test_chained_action_empty.py | 27 +++ tests/test_command.py | 223 +++++++++++++++++ tests/test_stress_actions.py | 200 +++++++++++++++ 32 files changed, 1535 insertions(+), 284 deletions(-) delete mode 100644 falyx/main/main.py create mode 100644 falyx/tagged_table.py create mode 100644 tests/test_action_basic.py create mode 100644 tests/test_action_fallback.py create mode 100644 tests/test_action_hooks.py create mode 100644 tests/test_action_process.py create mode 100644 tests/test_action_retries.py create mode 100644 tests/test_chained_action_empty.py create mode 100644 tests/test_command.py create mode 100644 tests/test_stress_actions.py diff --git a/falyx/__init__.py b/falyx/__init__.py index b9d6a55..5afa428 100644 --- a/falyx/__init__.py +++ b/falyx/__init__.py @@ -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 from .action import Action, ActionGroup, ChainedAction, ProcessAction diff --git a/falyx/__main__.py b/falyx/__main__.py index 81a4029..33698dd 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -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 logging -from falyx.action import Action +from falyx.action import Action, ActionGroup, ChainedAction from falyx.falyx import Falyx def build_falyx() -> Falyx: """Build and return a Falyx instance with all your commands.""" - app = Falyx(title="๐Ÿš€ Falyx CLI") + flx = Falyx(title="๐Ÿš€ Falyx CLI") # Example commands - app.add_command( + flx.add_command( key="B", description="Build project", action=Action("Build", lambda: print("๐Ÿ“ฆ Building...")), tags=["build"] ) - app.add_command( + flx.add_command( key="T", description="Run tests", action=Action("Test", lambda: print("๐Ÿงช Running tests...")), tags=["test"] ) - app.add_command( + flx.add_command( key="D", description="Deploy project", action=Action("Deploy", lambda: print("๐Ÿš€ Deploying...")), 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__": - logging.basicConfig(level=logging.WARNING) - falyx = build_falyx() - asyncio.run(falyx.run()) + flx = build_falyx() + asyncio.run(flx.run()) diff --git a/falyx/action.py b/falyx/action.py index 0b9e7d5..a082b56 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -1,12 +1,30 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """action.py -Any Action or Command is callable and supports the signature: - result = thing(*args, **kwargs) +Core action system for Falyx. -This guarantees: -- Hook lifecycle (before/after/error/teardown) -- Timing -- Consistent return values +This module defines the building blocks for executable actions and workflows, +providing a structured way to compose, execute, recover, and manage sequences of operations. + +All actions are callable and follow a unified signature: + result = action(*args, **kwargs) + +Core guarantees: +- Full hook lifecycle support (before, on_success, on_error, after, on_teardown). +- Consistent timing and execution context tracking for each run. +- Unified, predictable result handling and error propagation. +- Optional last_result injection to enable flexible, data-driven workflows. +- Built-in support for retries, rollbacks, parallel groups, chaining, and fallback recovery. + +Key components: +- Action: wraps a function or coroutine into a standard executable unit. +- ChainedAction: runs actions sequentially, optionally injecting last results. +- ActionGroup: runs actions in parallel and gathers results. +- ProcessAction: executes CPU-bound functions in a separate process. +- LiteralInputAction: injects static values into workflows. +- FallbackAction: gracefully recovers from failures or missing data. + +This design promotes clean, fault-tolerant, modular CLI and automation systems. """ from __future__ import annotations @@ -14,7 +32,7 @@ import asyncio import random from abc import ABC, abstractmethod from concurrent.futures import ProcessPoolExecutor -from functools import partial +from functools import cached_property, partial from typing import Any, Callable from rich.console import Console @@ -22,6 +40,7 @@ from rich.tree import Tree from falyx.context import ExecutionContext, ResultsContext from falyx.debug import register_debug_hooks +from falyx.exceptions import EmptyChainError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import Hook, HookManager, HookType from falyx.retry import RetryHandler, RetryPolicy @@ -35,11 +54,12 @@ class BaseAction(ABC): """ Base class for actions. Actions can be simple functions or more complex actions like `ChainedAction` or `ActionGroup`. They can also - be run independently or as part of Menu. + be run independently or as part of Falyx. inject_last_result (bool): Whether to inject the previous action's result into kwargs. inject_last_result_as (str): The name of the kwarg key to inject the result as (default: 'last_result'). + _requires_injection (bool): Whether the action requires input injection. """ def __init__( self, @@ -55,7 +75,8 @@ class BaseAction(ABC): self.results_context: ResultsContext | None = None self.inject_last_result: bool = inject_last_result self.inject_last_result_as: str = inject_last_result_as - self.requires_injection: bool = False + self._requires_injection: bool = False + self._skip_in_chain: bool = False if logging_hooks: register_debug_hooks(self.hooks) @@ -122,7 +143,7 @@ class BaseAction(ABC): def requires_io_injection(self) -> bool: """Checks to see if the action requires input injection.""" - return self.requires_injection + return self._requires_injection def __str__(self): return f"<{self.__class__.__name__} '{self.name}'>" @@ -132,7 +153,27 @@ class BaseAction(ABC): class Action(BaseAction): - """A simple action that runs a callable. It can be a function or a coroutine.""" + """ + Action wraps a simple function or coroutine into a standard executable unit. + + It supports: + - Optional retry logic. + - Hook lifecycle (before, success, error, after, teardown). + - Last result injection for chaining. + - Optional rollback handlers for undo logic. + + Args: + name (str): Name of the action. + action (Callable): The function or coroutine to execute. + rollback (Callable, optional): Rollback function to undo the action. + args (tuple, optional): Static positional arguments. + kwargs (dict, optional): Static keyword arguments. + hooks (HookManager, optional): Hook manager for lifecycle events. + inject_last_result (bool, optional): Enable last_result injection. + inject_last_result_as (str, optional): Name of injected key. + retry (bool, optional): Whether to enable retries. + retry_policy (RetryPolicy, optional): Retry settings. + """ def __init__( self, name: str, @@ -147,7 +188,7 @@ class Action(BaseAction): retry_policy: RetryPolicy | None = None, ) -> None: super().__init__(name, hooks, inject_last_result, inject_last_result_as) - self.action = ensure_async(action) + self.action = action self.rollback = rollback self.args = args self.kwargs = kwargs or {} @@ -156,9 +197,17 @@ class Action(BaseAction): if retry or (retry_policy and retry_policy.enabled): self.enable_retry() + @property + def action(self) -> Callable[..., Any]: + return self._action + + @action.setter + def action(self, value: Callable[..., Any]): + self._action = ensure_async(value) + def enable_retry(self): """Enable retry with the existing retry policy.""" - self.retry_policy.enabled = True + self.retry_policy.enable_policy() logger.debug(f"[Action:{self.name}] Registering retry handler") handler = RetryHandler(self.retry_policy) self.hooks.register(HookType.ON_ERROR, handler.retry_on_error) @@ -166,7 +215,8 @@ class Action(BaseAction): def set_retry_policy(self, policy: RetryPolicy): """Set a new retry policy and re-register the handler.""" self.retry_policy = policy - self.enable_retry() + if policy.enabled: + self.enable_retry() async def _run(self, *args, **kwargs) -> Any: combined_args = args + self.args @@ -213,12 +263,64 @@ class Action(BaseAction): else: console.print(Tree("".join(label))) + def __str__(self): + return f"Action(name={self.name}, action={self.action.__name__})" + class LiteralInputAction(Action): + """ + LiteralInputAction injects a static value into a ChainedAction. + + This allows embedding hardcoded values mid-pipeline, useful when: + - Providing default or fallback inputs. + - Starting a pipeline with a fixed input. + - Supplying missing context manually. + + Args: + value (Any): The static value to inject. + """ def __init__(self, value: Any): + self._value = value async def literal(*args, **kwargs): return value - super().__init__("Input", literal, inject_last_result=True) + super().__init__("Input", literal) + + @cached_property + def value(self) -> Any: + """Return the literal value.""" + return self._value + + def __str__(self) -> str: + return f"LiteralInputAction(value={self.value})" + + +class FallbackAction(Action): + """ + FallbackAction provides a default value if the previous action failed or returned None. + + It injects the last result and checks: + - If last_result is not None, it passes it through unchanged. + - If last_result is None (e.g., due to failure), it replaces it with a fallback value. + + Used in ChainedAction pipelines to gracefully recover from errors or missing data. + When activated, it consumes the preceding error and allows the chain to continue normally. + + Args: + fallback (Any): The fallback value to use if last_result is None. + """ + def __init__(self, fallback: Any): + self._fallback = fallback + async def _fallback_logic(last_result): + return last_result if last_result is not None else fallback + super().__init__(name="Fallback", action=_fallback_logic, inject_last_result=True) + + @cached_property + def fallback(self) -> Any: + """Return the fallback value.""" + return self._fallback + + def __str__(self) -> str: + return f"FallbackAction(fallback={self.fallback})" class ActionListMixin: @@ -253,7 +355,26 @@ class ActionListMixin: class ChainedAction(BaseAction, ActionListMixin): - """A ChainedAction is a sequence of actions that are executed in order.""" + """ + ChainedAction executes a sequence of actions one after another. + + Features: + - Supports optional automatic last_result injection (auto_inject). + - Recovers from intermediate errors using FallbackAction if present. + - Rolls back all previously executed actions if a failure occurs. + - Handles literal values with LiteralInputAction. + + Best used for defining robust, ordered workflows where each step can depend on previous results. + + Args: + name (str): Name of the chain. + actions (list): List of actions or literals to execute. + hooks (HookManager, optional): Hooks for lifecycle events. + inject_last_result (bool, optional): Whether to inject last results into kwargs by default. + inject_last_result_as (str, optional): Key name for injection. + auto_inject (bool, optional): Auto-enable injection for subsequent actions. + return_list (bool, optional): Whether to return a list of all results. False returns the last result. + """ def __init__( self, name: str, @@ -262,28 +383,28 @@ class ChainedAction(BaseAction, ActionListMixin): inject_last_result: bool = False, inject_last_result_as: str = "last_result", auto_inject: bool = False, + return_list: bool = False, ) -> None: super().__init__(name, hooks, inject_last_result, inject_last_result_as) ActionListMixin.__init__(self) self.auto_inject = auto_inject + self.return_list = return_list if actions: self.set_actions(actions) def _wrap_literal_if_needed(self, action: BaseAction | Any) -> BaseAction: return LiteralInputAction(action) if not isinstance(action, BaseAction) else action - def _apply_auto_inject(self, action: BaseAction) -> None: - if self.auto_inject and not action.inject_last_result: + def add_action(self, action: BaseAction | Any) -> None: + action = self._wrap_literal_if_needed(action) + if self.actions and self.auto_inject and not action.inject_last_result: action.inject_last_result = True - - def set_actions(self, actions: list[BaseAction | Any]): - self.actions.clear() - for action in actions: - action = self._wrap_literal_if_needed(action) - self._apply_auto_inject(action) - self.add_action(action) + super().add_action(action) 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) if self.results_context: results_context.add_result(self.results_context.last_result()) @@ -300,18 +421,35 @@ class ChainedAction(BaseAction, ActionListMixin): await self.hooks.trigger(HookType.BEFORE, context) for index, action in enumerate(self.actions): + if action._skip_in_chain: + logger.debug("[%s] โš ๏ธ Skipping consumed action '%s'", self.name, action.name) + continue results_context.current_index = index prepared = action.prepare_for_chain(results_context) last_result = results_context.last_result() - if self.requires_io_injection() and last_result is not None: - result = await prepared(**{prepared.inject_last_result_as: last_result}) - else: - result = await prepared(*args, **updated_kwargs) + try: + if self.requires_io_injection() and last_result is not None: + result = await prepared(**{prepared.inject_last_result_as: last_result}) + else: + result = await prepared(*args, **updated_kwargs) + 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) context.extra["results"].append(result) context.extra["rollback_stack"].append(prepared) - context.result = context.extra["results"] + all_results = context.extra["results"] + assert all_results, f"[{self.name}] No results captured. Something seriously went wrong." + context.result = all_results if self.return_list else all_results[-1] await self.hooks.trigger(HookType.ON_SUCCESS, context) return context.result @@ -328,6 +466,18 @@ class ChainedAction(BaseAction, ActionListMixin): er.record(context) async def _rollback(self, rollback_stack, *args, **kwargs): + """ + Roll back all executed actions in reverse order. + + Rollbacks run even if a fallback recovered from failure, + ensuring consistent undo of all side effects. + + Actions without rollback handlers are skipped. + + Args: + rollback_stack (list): Actions to roll back. + *args, **kwargs: Passed to rollback handlers. + """ for action in reversed(rollback_stack): rollback = getattr(action, "rollback", None) if rollback: @@ -355,7 +505,37 @@ class ChainedAction(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__( self, name: str, @@ -436,7 +616,25 @@ class ActionGroup(BaseAction, ActionListMixin): class ProcessAction(BaseAction): - """A ProcessAction runs a function in a separate process using ProcessPoolExecutor.""" + """ + ProcessAction runs a function in a separate process using ProcessPoolExecutor. + + Features: + - Executes CPU-bound or blocking tasks without blocking the main event loop. + - Supports last_result injection into the subprocess. + - Validates that last_result is pickleable when injection is enabled. + + Args: + name (str): Name of the action. + func (Callable): Function to execute in a new process. + args (tuple, optional): Positional arguments. + kwargs (dict, optional): Keyword arguments. + hooks (HookManager, optional): Hook manager for lifecycle events. + executor (ProcessPoolExecutor, optional): Custom executor if desired. + inject_last_result (bool, optional): Inject last result into the function. + inject_last_result_as (str, optional): Name of the injected key. + """ + def __init__( self, name: str, diff --git a/falyx/bottom_bar.py b/falyx/bottom_bar.py index ebe7726..bdb76a4 100644 --- a/falyx/bottom_bar.py +++ b/falyx/bottom_bar.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """bottom_bar.py""" from typing import Any, Callable @@ -8,7 +9,7 @@ from rich.console import Console from falyx.options_manager import OptionsManager from falyx.themes.colors import OneColors -from falyx.utils import CaseInsensitiveDict +from falyx.utils import CaseInsensitiveDict, chunks class BottomBar: @@ -211,5 +212,8 @@ class BottomBar: def render(self): """Render the bottom bar.""" - return merge_formatted_text([fn() for fn in self._named_items.values()]) - + lines = [] + for chunk in chunks(self._named_items.values(), self.columns): + lines.extend([fn for fn in chunk]) + lines.append(lambda: HTML("\n")) + return merge_formatted_text([fn() for fn in lines[:-1]]) diff --git a/falyx/command.py b/falyx/command.py index 3914f5a..ad4c31f 100644 --- a/falyx/command.py +++ b/falyx/command.py @@ -1,14 +1,24 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """command.py -Any Action or Command is callable and supports the signature: - result = thing(*args, **kwargs) -This guarantees: -- Hook lifecycle (before/after/error/teardown) -- Timing -- Consistent return values +Defines the Command class for Falyx CLI. + +Commands are callable units representing a menu option or CLI task, +wrapping either a BaseAction or a simple function. They provide: + +- Hook lifecycle (before, on_success, on_error, after, on_teardown) +- Execution timing and duration tracking +- Retry logic (single action or recursively through action trees) +- Confirmation prompts and spinner integration +- Result capturing and summary logging +- Rich-based preview for CLI display + +Every Command is self-contained, configurable, and plays a critical role +in building robust interactive menus. """ from __future__ import annotations +from functools import cached_property from typing import Any, Callable from prompt_toolkit.formatted_text import FormattedText @@ -16,11 +26,12 @@ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator from rich.console import Console from rich.tree import Tree -from falyx.action import Action, BaseAction +from falyx.action import Action, ActionGroup, BaseAction, ChainedAction from falyx.context import ExecutionContext from falyx.debug import register_debug_hooks from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType +from falyx.io_action import BaseIOAction from falyx.retry import RetryPolicy from falyx.themes.colors import OneColors from falyx.utils import _noop, ensure_async, logger @@ -29,13 +40,63 @@ console = Console() class Command(BaseModel): - """Class representing an command in the menu.""" + """ + Represents a selectable command in a Falyx menu system. + + A Command wraps an executable action (function, coroutine, or BaseAction) + and enhances it with: + + - Lifecycle hooks (before, success, error, after, teardown) + - Retry support (single action or recursive for chained/grouped actions) + - Confirmation prompts for safe execution + - Spinner visuals during execution + - Tagging for categorization and filtering + - Rich-based CLI previews + - Result tracking and summary reporting + + Commands are built to be flexible yet robust, enabling dynamic CLI workflows + without sacrificing control or reliability. + + Attributes: + key (str): Primary trigger key for the command. + description (str): Short description for the menu display. + hidden (bool): Toggles visibility in the menu. + aliases (list[str]): Alternate keys or phrases. + action (BaseAction | Callable): The executable logic. + args (tuple): Static positional arguments. + kwargs (dict): Static keyword arguments. + help_text (str): Additional help or guidance text. + color (str): Color theme for CLI rendering. + confirm (bool): Whether to require confirmation before executing. + confirm_message (str): Custom confirmation prompt. + preview_before_confirm (bool): Whether to preview before confirming. + spinner (bool): Whether to show a spinner during execution. + spinner_message (str): Spinner text message. + spinner_type (str): Spinner style (e.g., dots, line, etc.). + spinner_style (str): Color or style of the spinner. + spinner_kwargs (dict): Extra spinner configuration. + hooks (HookManager): Hook manager for lifecycle events. + retry (bool): Enable retry on failure. + retry_all (bool): Enable retry across chained or grouped actions. + retry_policy (RetryPolicy): Retry behavior configuration. + tags (list[str]): Organizational tags for the command. + logging_hooks (bool): Whether to attach logging hooks automatically. + requires_input (bool | None): Indicates if the action needs input. + + Methods: + __call__(): Executes the command, respecting hooks and retries. + preview(): Rich tree preview of the command. + confirmation_prompt(): Formatted prompt for confirmation. + result: Property exposing the last result. + log_summary(): Summarizes execution details to the console. + """ key: str description: str - aliases: list[str] = Field(default_factory=list) action: BaseAction | Callable[[], Any] = _noop args: tuple = () kwargs: dict[str, Any] = Field(default_factory=dict) + hidden: bool = False + aliases: list[str] = Field(default_factory=list) help_text: str = "" color: str = OneColors.WHITE confirm: bool = False @@ -52,6 +113,7 @@ class Command(BaseModel): retry_policy: RetryPolicy = Field(default_factory=RetryPolicy) tags: list[str] = Field(default_factory=list) logging_hooks: bool = False + requires_input: bool | None = None _context: ExecutionContext | None = PrivateAttr(default=None) @@ -65,12 +127,32 @@ class Command(BaseModel): self.action.set_retry_policy(self.retry_policy) elif self.retry: logger.warning(f"[Command:{self.key}] Retry requested, but action is not an Action instance.") - if self.retry_all: + if self.retry_all and isinstance(self.action, BaseAction): + self.retry_policy.enabled = True self.action.enable_retries_recursively(self.action, self.retry_policy) + elif self.retry_all: + logger.warning(f"[Command:{self.key}] Retry all requested, but action is not a BaseAction instance.") if self.logging_hooks and isinstance(self.action, BaseAction): register_debug_hooks(self.action.hooks) + if self.requires_input is None and self.detect_requires_input: + self.requires_input = True + self.hidden = True + elif self.requires_input is None: + self.requires_input = False + + @cached_property + def detect_requires_input(self) -> bool: + """Detect if the action requires input based on its type.""" + if isinstance(self.action, BaseIOAction): + return True + elif isinstance(self.action, ChainedAction): + return isinstance(self.action.actions[0], BaseIOAction) if self.action.actions else False + elif isinstance(self.action, ActionGroup): + return any(isinstance(action, BaseIOAction) for action in self.action.actions) + return False + @field_validator("action", mode="before") @classmethod def wrap_callable_as_async(cls, action: Any) -> Any: @@ -81,7 +163,8 @@ class Command(BaseModel): raise TypeError("Action must be a callable or an instance of BaseAction") def __str__(self): - return f"Command(key='{self.key}', description='{self.description}')" + return (f"Command(key='{self.key}', description='{self.description}' " + f"action='{self.action}')") async def __call__(self, *args, **kwargs): """Run the action with full hook lifecycle, timing, and error handling.""" diff --git a/falyx/config.py b/falyx/config.py index 9a41454..66dee57 100644 --- a/falyx/config.py +++ b/falyx/config.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """config.py Configuration loader for Falyx CLI commands.""" @@ -79,11 +80,12 @@ def loader(file_path: str) -> list[dict[str, Any]]: command_dict = { "key": entry["key"], "description": entry["description"], - "aliases": entry.get("aliases", []), "action": wrap_if_needed(import_action(entry["action"]), name=entry["description"]), "args": tuple(entry.get("args", ())), "kwargs": entry.get("kwargs", {}), + "hidden": entry.get("hidden", False), + "aliases": entry.get("aliases", []), "help_text": entry.get("help_text", ""), "color": entry.get("color", "white"), "confirm": entry.get("confirm", False), @@ -94,10 +96,18 @@ def loader(file_path: str) -> list[dict[str, Any]]: "spinner_type": entry.get("spinner_type", "dots"), "spinner_style": entry.get("spinner_style", "cyan"), "spinner_kwargs": entry.get("spinner_kwargs", {}), - "tags": entry.get("tags", []), + "before_hooks": entry.get("before_hooks", []), + "success_hooks": entry.get("success_hooks", []), + "error_hooks": entry.get("error_hooks", []), + "after_hooks": entry.get("after_hooks", []), + "teardown_hooks": entry.get("teardown_hooks", []), + "retry": entry.get("retry", False), + "retry_all": entry.get("retry_all", False), "retry_policy": RetryPolicy(**entry.get("retry_policy", {})), + "tags": entry.get("tags", []), + "logging_hooks": entry.get("logging_hooks", False), + "requires_input": entry.get("requires_input", None), } commands.append(command_dict) return commands - diff --git a/falyx/context.py b/falyx/context.py index bef9e6b..f8de893 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """context.py""" import time from datetime import datetime diff --git a/falyx/debug.py b/falyx/debug.py index 0c51887..eeac0f5 100644 --- a/falyx/debug.py +++ b/falyx/debug.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed from falyx.context import ExecutionContext from falyx.hook_manager import HookManager, HookType from falyx.utils import logger diff --git a/falyx/exceptions.py b/falyx/exceptions.py index 311d3e9..6600d22 100644 --- a/falyx/exceptions.py +++ b/falyx/exceptions.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed class FalyxError(Exception): """Custom exception for the Menu class.""" @@ -20,3 +21,7 @@ class NotAFalyxError(FalyxError): class CircuitBreakerOpen(FalyxError): """Exception raised when the circuit breaker is open.""" + +class EmptyChainError(FalyxError): + """Exception raised when the chain is empty.""" + diff --git a/falyx/execution_registry.py b/falyx/execution_registry.py index 7a6c988..9c9b435 100644 --- a/falyx/execution_registry.py +++ b/falyx/execution_registry.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """execution_registry.py""" from collections import defaultdict from datetime import datetime @@ -62,6 +63,8 @@ class ExecutionRegistry: else: status = "[green]โœ… Success" result = repr(ctx.result) + if len(result) > 1000: + result = f"{result[:1000]}..." table.add_row(ctx.name, start, end, duration, status, result) diff --git a/falyx/falyx.py b/falyx/falyx.py index d9db7a8..209f5de 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -1,16 +1,23 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """falyx.py -This class creates a Falyx object that creates a selectable menu -with customizable commands and functionality. +Main class for constructing and running Falyx CLI menus. -It allows for adding commands, and their accompanying actions, -and provides a method to display the menu and handle user input. +Falyx provides a structured, customizable interactive menu system +for running commands, actions, and workflows. It supports: -This class uses the `rich` library to display the menu in a -formatted and visually appealing way. +- Hook lifecycle management (before/on_success/on_error/after/on_teardown) +- Dynamic command addition and alias resolution +- Rich-based menu display with multi-column layouts +- Interactive input validation and auto-completion +- History tracking and help menu generation +- Confirmation prompts and spinners +- Headless mode for automated script execution +- CLI argument parsing with argparse integration +- Retry policy configuration for actions -This class also uses the `prompt_toolkit` library to handle -user input and create an interactive experience. +Falyx enables building flexible, robust, and user-friendly +terminal applications with minimal boilerplate. """ import asyncio import logging @@ -30,7 +37,7 @@ from rich.console import Console from rich.markdown import Markdown from rich.table import Table -from falyx.action import BaseAction +from falyx.action import Action, BaseAction from falyx.bottom_bar import BottomBar from falyx.command import Command from falyx.context import ExecutionContext @@ -43,37 +50,57 @@ from falyx.options_manager import OptionsManager from falyx.parsers import get_arg_parsers from falyx.retry import RetryPolicy from falyx.themes.colors import OneColors, get_nord_theme -from falyx.utils import CaseInsensitiveDict, async_confirm, chunks, logger +from falyx.utils import (CaseInsensitiveDict, async_confirm, chunks, + get_program_invocation, logger) from falyx.version import __version__ class Falyx: - """Class to create a menu with commands. - - Hook functions must have the signature: - def hook(command: Command) -> None: - where `command` is the selected command. - - Error hook functions must have the signature: - def error_hook(command: Command, error: Exception) -> None: - where `command` is the selected command and `error` is the exception raised. - - Hook execution order: - 1. Before action hooks of the menu. - 2. Before action hooks of the selected command. - 3. Action of the selected command. - 4. After action hooks of the selected command. - 5. After action hooks of the menu. - 6. On error hooks of the selected command (if an error occurs). - 7. On error hooks of the menu (if an error occurs). - - Parameters: - title (str|Markdown): The title of the menu. - columns (int): The number of columns to display the commands in. - prompt (AnyFormattedText): The prompt to display when asking for input. - bottom_bar (str|callable|None): The text to display in the bottom bar. """ + Main menu controller for Falyx CLI applications. + Falyx orchestrates the full lifecycle of an interactive menu system, + handling user input, command execution, error recovery, and structured + CLI workflows. + + Key Features: + - Interactive menu with Rich rendering and Prompt Toolkit input handling + - Dynamic command management with alias and abbreviation matching + - Full lifecycle hooks (before, success, error, after, teardown) at both menu and command levels + - Built-in retry support, spinner visuals, and confirmation prompts + - Submenu nesting and action chaining + - History tracking, help generation, and headless execution modes + - Seamless CLI argument parsing and integration via argparse + - Extensible with user-defined hooks, bottom bars, and custom layouts + + Args: + title (str | Markdown): Title displayed for the menu. + prompt (AnyFormattedText): Prompt displayed when requesting user input. + columns (int): Number of columns to use when rendering menu commands. + bottom_bar (BottomBar | str | Callable | None): Bottom toolbar content or logic. + welcome_message (str | Markdown | dict): Welcome message shown at startup. + exit_message (str | Markdown | dict): Exit message shown on shutdown. + key_bindings (KeyBindings | None): Custom Prompt Toolkit key bindings. + include_history_command (bool): Whether to add a built-in history viewer command. + include_help_command (bool): Whether to add a built-in help viewer command. + confirm_on_error (bool): Whether to prompt the user after errors. + never_confirm (bool): Whether to skip confirmation prompts entirely. + always_confirm (bool): Whether to force confirmation prompts for all actions. + cli_args (Namespace | None): Parsed CLI arguments, usually from argparse. + options (OptionsManager | None): Declarative option mappings. + custom_table (Callable[[Falyx], Table] | Table | None): Custom menu table generator. + + Methods: + run(): Main entry point for CLI argument-based workflows. Most users will use this. + menu(): Run the interactive menu loop. + headless(command_key, return_context): Run a command directly without showing the menu. + add_command(): Add a single command to the menu. + add_commands(): Add multiple commands at once. + register_all_hooks(): Register hooks across all commands and submenus. + debug_hooks(): Log hook registration for debugging. + build_default_table(): Construct the standard Rich table layout. + + """ def __init__( self, title: str | Markdown = "Menu", @@ -84,7 +111,7 @@ class Falyx: exit_message: str | Markdown | dict[str, Any] = "", key_bindings: KeyBindings | None = None, include_history_command: bool = True, - include_help_command: bool = False, + include_help_command: bool = True, confirm_on_error: bool = True, never_confirm: bool = False, always_confirm: bool = False, @@ -207,6 +234,8 @@ class Falyx: for command in self.commands.values(): help_text = command.help_text or command.description + if command.requires_input: + help_text += " [dim](requires input)[/dim]" table.add_row( f"[{command.color}]{command.key}[/]", ", ".join(command.aliases) if command.aliases else "None", @@ -234,7 +263,7 @@ class Falyx: "Show this help menu" ) - self.console.print(table) + self.console.print(table, justify="center") def _get_help_command(self) -> Command: """Returns the help command for the menu.""" @@ -324,13 +353,12 @@ class Falyx: def bottom_bar(self, bottom_bar: BottomBar | str | Callable[[], Any] | None) -> None: """Sets the bottom bar for the menu.""" if bottom_bar is None: - self._bottom_bar = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available) + self._bottom_bar: BottomBar | str | Callable[[], Any] = BottomBar(self.columns, self.key_bindings, key_validator=self.is_key_available) elif isinstance(bottom_bar, BottomBar): bottom_bar.key_validator = self.is_key_available bottom_bar.key_bindings = self.key_bindings self._bottom_bar = bottom_bar - elif (isinstance(bottom_bar, str) or - callable(bottom_bar)): + elif (isinstance(bottom_bar, str) or callable(bottom_bar)): self._bottom_bar = bottom_bar else: raise FalyxError("Bottom bar must be a string, callable, or BottomBar instance.") @@ -339,12 +367,12 @@ class Falyx: def _get_bottom_bar_render(self) -> Callable[[], Any] | str | None: """Returns the bottom bar for the menu.""" if isinstance(self.bottom_bar, BottomBar) and self.bottom_bar._named_items: - return self._bottom_bar.render - elif callable(self._bottom_bar): - return self._bottom_bar - elif isinstance(self._bottom_bar, str): - return self._bottom_bar - elif self._bottom_bar is None: + return self.bottom_bar.render + elif callable(self.bottom_bar): + return self.bottom_bar + elif isinstance(self.bottom_bar, str): + return self.bottom_bar + elif self.bottom_bar is None: return None return None @@ -475,9 +503,10 @@ class Falyx: key: str, description: str, action: BaseAction | Callable[[], Any], - aliases: list[str] | None = None, args: tuple = (), kwargs: dict[str, Any] = {}, + hidden: bool = False, + aliases: list[str] | None = None, help_text: str = "", color: str = OneColors.WHITE, confirm: bool = False, @@ -491,25 +520,27 @@ class Falyx: hooks: HookManager | None = None, before_hooks: list[Callable] | None = None, success_hooks: list[Callable] | None = None, - after_hooks: list[Callable] | None = None, error_hooks: list[Callable] | None = None, + after_hooks: list[Callable] | None = None, teardown_hooks: list[Callable] | None = None, tags: list[str] | None = None, logging_hooks: bool = False, retry: bool = False, retry_all: bool = False, retry_policy: RetryPolicy | None = None, + requires_input: bool | None = None, ) -> Command: """Adds an command to the menu, preventing duplicates.""" self._validate_command_key(key) command = Command( key=key, description=description, - aliases=aliases if aliases else [], - help_text=help_text, action=action, args=args, kwargs=kwargs, + hidden=hidden, + aliases=aliases if aliases else [], + help_text=help_text, color=color, confirm=confirm, confirm_message=confirm_message, @@ -524,6 +555,7 @@ class Falyx: retry=retry, retry_all=retry_all, retry_policy=retry_policy or RetryPolicy(), + requires_input=requires_input, ) if hooks: @@ -558,13 +590,15 @@ class Falyx: def build_default_table(self) -> Table: """Build the standard table layout. Developers can subclass or call this in custom tables.""" table = Table(title=self.title, show_header=False, box=box.SIMPLE, expand=True) - for chunk in chunks(self.commands.items(), self.columns): + visible_commands = [item for item in self.commands.items() if not item[1].hidden] + for chunk in chunks(visible_commands, self.columns): row = [] for key, command in chunk: row.append(f"[{key}] [{command.color}]{command.description}") table.add_row(*row) bottom_row = self.get_bottom_row() - table.add_row(*bottom_row) + for row in chunks(bottom_row, self.columns): + table.add_row(*row) return table @property @@ -617,9 +651,9 @@ class Falyx: confirm_answer = await async_confirm(selected_command.confirmation_prompt) if confirm_answer: - logger.info(f"[{OneColors.LIGHT_YELLOW}][{selected_command.description}]๐Ÿ” confirmed.") + logger.info(f"[{selected_command.description}]๐Ÿ” confirmed.") else: - logger.info(f"[{OneColors.DARK_RED}][{selected_command.description}]โŒ cancelled.") + logger.info(f"[{selected_command.description}]โŒ cancelled.") return confirm_answer return True @@ -658,8 +692,19 @@ class Falyx: choice = await self.session.prompt_async() selected_command = self.get_command(choice) if not selected_command: - logger.info(f"[{OneColors.LIGHT_YELLOW}] Invalid command '{choice}'.") + logger.info(f"Invalid command '{choice}'.") return True + + if selected_command.requires_input: + program = get_program_invocation() + self.console.print( + f"[{OneColors.LIGHT_YELLOW}]โš ๏ธ Command '{selected_command.key}' requires input " + f"and must be run via [{OneColors.MAGENTA}]'{program} run'[{OneColors.LIGHT_YELLOW}] " + "with proper piping or arguments.[/]" + + ) + return True + self.last_run_command = selected_command if selected_command == self.exit_command: @@ -667,7 +712,7 @@ class Falyx: return False if not await self._should_run_action(selected_command): - logger.info(f"[{OneColors.DARK_RED}] {selected_command.description} cancelled.") + logger.info(f"{selected_command.description} cancelled.") return True context = self._create_context(selected_command) @@ -750,7 +795,10 @@ class Falyx: selected_command.retry_policy.delay = self.cli_args.retry_delay if self.cli_args.retry_backoff: selected_command.retry_policy.backoff = self.cli_args.retry_backoff - #selected_command.update_retry_policy(selected_command.retry_policy) + if isinstance(selected_command.action, Action): + selected_command.action.set_retry_policy(selected_command.retry_policy) + else: + logger.warning(f"[Command:{selected_command.key}] Retry requested, but action is not an Action instance.") def print_message(self, message: str | Markdown | dict[str, Any]) -> None: """Prints a message to the console.""" @@ -773,14 +821,14 @@ class Falyx: if self.welcome_message: self.print_message(self.welcome_message) while True: - self.console.print(self.table) + self.console.print(self.table, justify="center") try: task = asyncio.create_task(self.process_command()) should_continue = await task if not should_continue: break except (EOFError, KeyboardInterrupt): - logger.info(f"[{OneColors.DARK_RED}]EOF or KeyboardInterrupt. Exiting menu.") + logger.info("EOF or KeyboardInterrupt. Exiting menu.") break logger.info(f"Exiting menu: {self.get_title()}") if self.exit_message: diff --git a/falyx/hook_manager.py b/falyx/hook_manager.py index e8e6ca7..c7701f4 100644 --- a/falyx/hook_manager.py +++ b/falyx/hook_manager.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """hook_manager.py""" from __future__ import annotations @@ -64,5 +65,6 @@ class HookManager: f" for '{context.name}': {hook_error}") if hook_type == HookType.ON_ERROR: - assert isinstance(context.exception, BaseException) + assert isinstance(context.exception, Exception), "Context exception should be set for ON_ERROR hook" raise context.exception from hook_error + diff --git a/falyx/hooks.py b/falyx/hooks.py index 3c394ef..0923718 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """hooks.py""" import time diff --git a/falyx/importer.py b/falyx/importer.py index d062ab0..4eace21 100644 --- a/falyx/importer.py +++ b/falyx/importer.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """importer.py""" import importlib diff --git a/falyx/io_action.py b/falyx/io_action.py index a0426d6..98f2541 100644 --- a/falyx/io_action.py +++ b/falyx/io_action.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """io_action.py""" import asyncio import subprocess @@ -12,8 +13,8 @@ from falyx.context import ExecutionContext from falyx.exceptions import FalyxError from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType -from falyx.utils import logger from falyx.themes.colors import OneColors +from falyx.utils import logger console = Console() @@ -34,7 +35,7 @@ class BaseIOAction(BaseAction): inject_last_result=inject_last_result, ) self.mode = mode - self.requires_injection = True + self._requires_injection = True def from_input(self, raw: str | bytes) -> Any: raise NotImplementedError @@ -178,3 +179,29 @@ class ShellAction(BaseIOAction): parent.add("".join(label)) else: console.print(Tree("".join(label))) + +class GrepAction(BaseIOAction): + def __init__(self, name: str, pattern: str, **kwargs): + super().__init__(name=name, **kwargs) + self.pattern = pattern + + def from_input(self, raw: str | bytes) -> str: + if not isinstance(raw, (str, bytes)): + raise TypeError(f"{self.name} expected str or bytes input, got {type(raw).__name__}") + return raw.strip() if isinstance(raw, str) else raw.decode("utf-8").strip() + + async def _run(self, parsed_input: str) -> str: + command = ["grep", "-n", self.pattern] + process = subprocess.Popen( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + stdout, stderr = process.communicate(input=parsed_input) + if process.returncode == 1: + return "" + if process.returncode != 0: + raise RuntimeError(stderr.strip()) + return stdout.strip() + + def to_output(self, result: str) -> str: + return result + diff --git a/falyx/main/main.py b/falyx/main/main.py deleted file mode 100644 index b7c4872..0000000 --- a/falyx/main/main.py +++ /dev/null @@ -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() diff --git a/falyx/options_manager.py b/falyx/options_manager.py index ae1398d..882e280 100644 --- a/falyx/options_manager.py +++ b/falyx/options_manager.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """options_manager.py""" from argparse import Namespace diff --git a/falyx/parsers.py b/falyx/parsers.py index bff9999..032c207 100644 --- a/falyx/parsers.py +++ b/falyx/parsers.py @@ -1,3 +1,4 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """parsers.py This module contains the argument parsers used for the Falyx CLI. """ diff --git a/falyx/retry.py b/falyx/retry.py index 32cdab6..7a509a4 100644 --- a/falyx/retry.py +++ b/falyx/retry.py @@ -1,5 +1,7 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """retry.py""" import asyncio +import random from pydantic import BaseModel, Field @@ -11,8 +13,16 @@ class RetryPolicy(BaseModel): max_retries: int = Field(default=3, ge=0) delay: float = Field(default=1.0, ge=0.0) backoff: float = Field(default=2.0, ge=1.0) + jitter: float = Field(default=0.0, ge=0.0) enabled: bool = False + def enable_policy(self) -> None: + """ + Enable the retry policy. + :return: None + """ + self.enabled = True + def is_active(self) -> bool: """ Check if the retry policy is active. @@ -25,11 +35,18 @@ class RetryHandler: def __init__(self, policy: RetryPolicy=RetryPolicy()): self.policy = policy - def enable_policy(self, backoff=2, max_retries=3, delay=1): + def enable_policy( + self, + max_retries: int=3, + delay: float=1.0, + backoff: float=2.0, + jitter: float=0.0, + ): self.policy.enabled = True self.policy.max_retries = max_retries self.policy.delay = delay self.policy.backoff = backoff + self.policy.jitter = jitter logger.info(f"๐Ÿ”„ Retry policy enabled: {self.policy}") async def retry_on_error(self, context: ExecutionContext): @@ -60,7 +77,15 @@ class RetryHandler: while retries_done < self.policy.max_retries: retries_done += 1 - logger.info(f"[{name}] ๐Ÿ”„ Retrying ({retries_done}/{self.policy.max_retries}) in {current_delay}s due to '{last_error}'...") + + sleep_delay = current_delay + if self.policy.jitter > 0: + sleep_delay += random.uniform(-self.policy.jitter, self.policy.jitter) + + logger.info( + f"[{name}] ๐Ÿ”„ Retrying ({retries_done}/{self.policy.max_retries}) " + f"in {current_delay}s due to '{last_error}'..." + ) await asyncio.sleep(current_delay) try: result = await target.action(*context.args, **context.kwargs) @@ -71,7 +96,10 @@ class RetryHandler: except Exception as retry_error: last_error = retry_error current_delay *= self.policy.backoff - logger.warning(f"[{name}] โš ๏ธ Retry attempt {retries_done}/{self.policy.max_retries} failed due to '{retry_error}'.") + logger.warning( + f"[{name}] โš ๏ธ Retry attempt {retries_done}/{self.policy.max_retries} " + f"failed due to '{retry_error}'." + ) context.exception = last_error logger.error(f"[{name}] โŒ All {self.policy.max_retries} retries failed.") diff --git a/falyx/tagged_table.py b/falyx/tagged_table.py new file mode 100644 index 0000000..83637c6 --- /dev/null +++ b/falyx/tagged_table.py @@ -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 diff --git a/falyx/utils.py b/falyx/utils.py index b205f4d..a3ff4ed 100644 --- a/falyx/utils.py +++ b/falyx/utils.py @@ -1,8 +1,11 @@ +# Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """utils.py""" import functools import inspect import logging import os +import shutil +import sys from itertools import islice from typing import Any, Awaitable, Callable, TypeVar @@ -21,6 +24,20 @@ T = TypeVar("T") async def _noop(*args, **kwargs): pass + +def get_program_invocation() -> str: + """Returns the recommended program invocation prefix.""" + script = sys.argv[0] + program = shutil.which(script) + if program: + return os.path.basename(program) + + executable = sys.executable + if "python" in executable: + return f"python {script}" + return script + + def is_coroutine(function: Callable[..., Any]) -> bool: return inspect.iscoroutinefunction(function) @@ -32,6 +49,9 @@ def ensure_async(function: Callable[..., T]) -> Callable[..., Awaitable[T]]: @functools.wraps(function) async def async_wrapper(*args, **kwargs) -> T: return function(*args, **kwargs) + + if not callable(function): + raise TypeError(f"{function} is not callable") return async_wrapper diff --git a/falyx/version.py b/falyx/version.py index 1276d02..0a8da88 100644 --- a/falyx/version.py +++ b/falyx/version.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" diff --git a/pyproject.toml b/pyproject.toml index 20ae61e..6de3bd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "falyx" -version = "0.1.5" +version = "0.1.6" description = "Reliable and introspectable async CLI action framework." authors = ["Roland Thomas Jr "] license = "MIT" diff --git a/tests/test_action_basic.py b/tests/test_action_basic.py new file mode 100644 index 0000000..df85191 --- /dev/null +++ b/tests/test_action_basic.py @@ -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 diff --git a/tests/test_action_fallback.py b/tests/test_action_fallback.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_action_hooks.py b/tests/test_action_hooks.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_action_process.py b/tests/test_action_process.py new file mode 100644 index 0000000..bc6f696 --- /dev/null +++ b/tests/test_action_process.py @@ -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() + diff --git a/tests/test_action_retries.py b/tests/test_action_retries.py new file mode 100644 index 0000000..d93cca1 --- /dev/null +++ b/tests/test_action_retries.py @@ -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 \ No newline at end of file diff --git a/tests/test_actions.py b/tests/test_actions.py index 89ee8b0..d7e2ccf 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,28 +1,17 @@ import pytest -import asyncio -import pickle -import warnings -from falyx.action import Action, ChainedAction, ActionGroup, ProcessAction + +from falyx.action import Action, ChainedAction, ActionGroup, FallbackAction from falyx.execution_registry import ExecutionRegistry as er from falyx.hook_manager import HookManager, HookType -from falyx.context import ExecutionContext, ResultsContext +from falyx.context import ExecutionContext asyncio_default_fixture_loop_scope = "function" # --- Helpers --- - -async def dummy_action(x: int = 0) -> int: - return x + 1 - async def capturing_hook(context: ExecutionContext): context.extra["hook_triggered"] = True # --- Fixtures --- - -@pytest.fixture -def sample_action(): - return Action(name="increment", action=dummy_action, kwargs={"x": 5}) - @pytest.fixture def hook_manager(): hm = HookManager() @@ -38,15 +27,18 @@ def clean_registry(): # --- Tests --- @pytest.mark.asyncio -async def test_action_runs_correctly(sample_action): +async def test_action_runs_correctly(): + async def dummy_action(x: int = 0) -> int: return x + 1 + sample_action = Action(name="increment", action=dummy_action, kwargs={"x": 5}) result = await sample_action() assert result == 6 @pytest.mark.asyncio async def test_action_hook_lifecycle(hook_manager): + async def a1(): return 42 action = Action( name="hooked", - action=lambda: 42, + action=a1, hooks=hook_manager ) @@ -58,21 +50,30 @@ async def test_action_hook_lifecycle(hook_manager): @pytest.mark.asyncio async def test_chained_action_with_result_injection(): + async def a1(): return 1 + async def a2(last_result): return last_result + 5 + async def a3(last_result): return last_result * 2 actions = [ - Action(name="start", action=lambda: 1), - Action(name="add_last", action=lambda last_result: last_result + 5, inject_last_result=True), - Action(name="multiply", action=lambda last_result: last_result * 2, inject_last_result=True) + Action(name="start", action=a1), + Action(name="add_last", action=a2, inject_last_result=True), + Action(name="multiply", action=a3, inject_last_result=True) ] - chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) + chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True, return_list=True) result = await chain() assert result == [1, 6, 12] + chain = ChainedAction(name="test_chain", actions=actions, inject_last_result=True) + result = await chain() + assert result == 12 @pytest.mark.asyncio async def test_action_group_runs_in_parallel(): + async def a1(): return 1 + async def a2(): return 2 + async def a3(): return 3 actions = [ - Action(name="a", action=lambda: 1), - Action(name="b", action=lambda: 2), - Action(name="c", action=lambda: 3), + Action(name="a", action=a1), + Action(name="b", action=a2), + Action(name="c", action=a3), ] group = ActionGroup(name="parallel", actions=actions) result = await group() @@ -81,39 +82,48 @@ async def test_action_group_runs_in_parallel(): @pytest.mark.asyncio async def test_chained_action_inject_from_action(): + async def a1(last_result): return last_result + 10 + async def a2(last_result): return last_result + 5 inner_chain = ChainedAction( name="inner_chain", actions=[ - Action(name="inner_first", action=lambda last_result: last_result + 10, inject_last_result=True), - Action(name="inner_second", action=lambda last_result: last_result + 5, inject_last_result=True), - ] + Action(name="inner_first", action=a1, inject_last_result=True), + Action(name="inner_second", action=a2, inject_last_result=True), + ], + return_list=True, ) + async def a3(): return 1 + async def a4(last_result): return last_result + 2 actions = [ - Action(name="first", action=lambda: 1), - Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), + Action(name="first", action=a3), + Action(name="second", action=a4, inject_last_result=True), inner_chain, - ] - outer_chain = ChainedAction(name="test_chain", actions=actions) + outer_chain = ChainedAction(name="test_chain", actions=actions, return_list=True) result = await outer_chain() assert result == [1, 3, [13, 18]] @pytest.mark.asyncio async def test_chained_action_with_group(): + async def a1(last_result): return last_result + 1 + async def a2(last_result): return last_result + 2 + async def a3(): return 3 group = ActionGroup( name="group", actions=[ - Action(name="a", action=lambda last_result: last_result + 1, inject_last_result=True), - Action(name="b", action=lambda last_result: last_result + 2, inject_last_result=True), - Action(name="c", action=lambda: 3), + Action(name="a", action=a1, inject_last_result=True), + Action(name="b", action=a2, inject_last_result=True), + Action(name="c", action=a3), ] ) + async def a4(): return 1 + async def a5(last_result): return last_result + 2 actions = [ - Action(name="first", action=lambda: 1), - Action(name="second", action=lambda last_result: last_result + 2, inject_last_result=True), + Action(name="first", action=a4), + Action(name="second", action=a5, inject_last_result=True), group, ] - chain = ChainedAction(name="test_chain", actions=actions) + chain = ChainedAction(name="test_chain", actions=actions, return_list=True) result = await chain() assert result == [1, 3, [("a", 4), ("b", 5), ("c", 3)]] @@ -161,37 +171,21 @@ async def test_chained_action_rollback_on_failure(): assert rollback_called == ["rolled back"] -def slow_add(x, y): - return x + y - -@pytest.mark.asyncio -async def test_process_action_executes_correctly(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - action = ProcessAction(name="proc", func=slow_add, args=(2, 3)) - result = await action() - assert result == 5 - -unpickleable = lambda x: x + 1 - -@pytest.mark.asyncio -async def test_process_action_rejects_unpickleable(): - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - - action = ProcessAction(name="proc_fail", func=unpickleable, args=(2,)) - with pytest.raises(pickle.PicklingError, match="Can't pickle"): - await action() - @pytest.mark.asyncio async def test_register_hooks_recursively_propagates(): - hook = lambda ctx: ctx.extra.update({"test_marker": True}) + def hook(context): + context.extra.update({"test_marker": True}) - chain = ChainedAction(name="chain", actions=[ - Action(name="a", action=lambda: 1), - Action(name="b", action=lambda: 2), - ]) + async def a1(): return 1 + async def a2(): return 2 + + chain = ChainedAction( + name="chain", + actions=[ + Action(name="a", action=a1), + Action(name="b", action=a2), + ], + ) chain.register_hooks_recursively(HookType.BEFORE, hook) await chain() @@ -217,14 +211,255 @@ async def test_action_hook_recovers_error(): @pytest.mark.asyncio async def test_action_group_injects_last_result(): + async def a1(last_result): return last_result + 10 + async def a2(last_result): return last_result + 20 group = ActionGroup(name="group", actions=[ - Action(name="g1", action=lambda last_result: last_result + 10, inject_last_result=True), - Action(name="g2", action=lambda last_result: last_result + 20, inject_last_result=True), - ]) - chain = ChainedAction(name="with_group", actions=[ - Action(name="first", action=lambda: 5), - group, + Action(name="g1", action=a1, inject_last_result=True), + Action(name="g2", action=a2, inject_last_result=True), ]) + async def a3(): return 5 + chain = ChainedAction( + name="with_group", + actions=[ + Action(name="first", action=a3), + group, + ], + return_list=True, + ) result = await chain() result_dict = dict(result[1]) assert result_dict == {"g1": 15, "g2": 25} + +@pytest.mark.asyncio +async def test_action_inject_last_result(): + async def a1(): return 1 + async def a2(last_result): return last_result + 1 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2, inject_last_result=True) + chain = ChainedAction(name="chain", actions=[a1, a2]) + result = await chain() + assert result == 2 + +@pytest.mark.asyncio +async def test_action_inject_last_result_fail(): + async def a1(): return 1 + async def a2(last_result): return last_result + 1 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2) + chain = ChainedAction(name="chain", actions=[a1, a2]) + + with pytest.raises(TypeError) as exc_info: + await chain() + + assert "last_result" in str(exc_info.value) + +@pytest.mark.asyncio +async def test_chained_action_auto_inject(): + async def a1(): return 1 + async def a2(last_result): return last_result + 2 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2) + chain = ChainedAction(name="chain", actions=[a1, a2], auto_inject=True, return_list=True) + result = await chain() + assert result == [1, 3] # a2 receives last_result=1 + +@pytest.mark.asyncio +async def test_chained_action_no_auto_inject(): + async def a1(): return 1 + async def a2(): return 2 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2) + chain = ChainedAction(name="no_inject", actions=[a1, a2], auto_inject=False, return_list=True) + result = await chain() + assert result == [1, 2] # a2 does not receive 1 + +@pytest.mark.asyncio +async def test_chained_action_auto_inject_after_first(): + async def a1(): return 1 + async def a2(last_result): return last_result + 1 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2) + chain = ChainedAction(name="auto_inject", actions=[a1, a2], auto_inject=True) + result = await chain() + assert result == 2 # a2 receives last_result=1 + +@pytest.mark.asyncio +async def test_chained_action_with_literal_input(): + async def a1(last_result): return last_result + " world" + a1 = Action(name="a1", action=a1) + chain = ChainedAction(name="literal_inject", actions=["hello", a1], auto_inject=True) + result = await chain() + assert result == "hello world" # "hello" is injected as last_result + +@pytest.mark.asyncio +async def test_chained_action_manual_inject_override(): + async def a1(): return 10 + async def a2(last_result): return last_result * 2 + a1 = Action(name="a1", action=a1) + a2 = Action(name="a2", action=a2, inject_last_result=True) + chain = ChainedAction(name="manual_override", actions=[a1, a2], auto_inject=False) + result = await chain() + assert result == 20 # Even without auto_inject, a2 still gets last_result + +@pytest.mark.asyncio +async def test_chained_action_with_mid_literal(): + async def fetch_data(): + # Imagine this is some dynamic API call + return None # Simulate failure or missing data + + async def validate_data(last_result): + if last_result is None: + raise ValueError("Missing data!") + return last_result + + async def enrich_data(last_result): + return f"Enriched: {last_result}" + + chain = ChainedAction( + name="fallback_pipeline", + actions=[ + Action(name="FetchData", action=fetch_data), + "default_value", # <-- literal fallback injected mid-chain + Action(name="ValidateData", action=validate_data), + Action(name="EnrichData", action=enrich_data), + ], + auto_inject=True, + return_list=True, + ) + + result = await chain() + assert result == [None, "default_value", "default_value", "Enriched: default_value"] + +@pytest.mark.asyncio +async def test_chained_action_with_mid_fallback(): + async def fetch_data(): + # Imagine this is some dynamic API call + return None # Simulate failure or missing data + + async def validate_data(last_result): + if last_result is None: + raise ValueError("Missing data!") + return last_result + + async def enrich_data(last_result): + return f"Enriched: {last_result}" + + chain = ChainedAction( + name="fallback_pipeline", + actions=[ + Action(name="FetchData", action=fetch_data), + FallbackAction(fallback="default_value"), + Action(name="ValidateData", action=validate_data), + Action(name="EnrichData", action=enrich_data), + ], + auto_inject=True, + return_list=True, + ) + + result = await chain() + assert result == [None, "default_value", "default_value", "Enriched: default_value"] + + +@pytest.mark.asyncio +async def test_chained_action_with_success_mid_fallback(): + async def fetch_data(): + # Imagine this is some dynamic API call + return "Result" # Simulate success + + async def validate_data(last_result): + if last_result is None: + raise ValueError("Missing data!") + return last_result + + async def enrich_data(last_result): + return f"Enriched: {last_result}" + + chain = ChainedAction( + name="fallback_pipeline", + actions=[ + Action(name="FetchData", action=fetch_data), + FallbackAction(fallback="default_value"), + Action(name="ValidateData", action=validate_data), + Action(name="EnrichData", action=enrich_data), + ], + auto_inject=True, + return_list=True, + ) + + result = await chain() + assert result == ["Result", "Result", "Result", "Enriched: Result"] + +@pytest.mark.asyncio +async def test_action_group_partial_failure(): + async def succeed(): return "ok" + async def fail(): raise ValueError("oops") + + group = ActionGroup(name="partial_group", actions=[ + Action(name="succeed_action", action=succeed), + Action(name="fail_action", action=fail), + ]) + + with pytest.raises(Exception) as exc_info: + await group() + + assert er.get_by_name("succeed_action")[0].result == "ok" + assert er.get_by_name("fail_action")[0].exception is not None + assert "fail_action" in str(exc_info.value) + +@pytest.mark.asyncio +async def test_chained_action_with_nested_group(): + async def g1(last_result): return last_result + "10" + async def g2(last_result): return last_result + "20" + group = ActionGroup( + name="nested_group", + actions=[ + Action(name="g1", action=g1, inject_last_result=True), + Action(name="g2", action=g2, inject_last_result=True), + ], + ) + + chain = ChainedAction( + name="chain_with_group", + actions=[ + "start", + group, + ], + auto_inject=True, + return_list=True, + ) + + result = await chain() + # "start" -> group both receive "start" as last_result + assert result[0] == "start" + assert dict(result[1]) == {"g1": "start10", "g2": "start20"} # Assuming string concatenation for example + +@pytest.mark.asyncio +async def test_chained_action_double_fallback(): + async def fetch_data(last_result=None): + raise ValueError("No data!") # Simulate failure + + async def validate_data(last_result): + if last_result is None: + raise ValueError("No data!") + return last_result + + async def enrich(last_result): + return f"Enriched: {last_result}" + + chain = ChainedAction( + name="fallback_chain", + actions=[ + Action(name="Fetch", action=fetch_data), + FallbackAction(fallback="default1"), + Action(name="Validate", action=validate_data), + Action(name="Fetch", action=fetch_data), + FallbackAction(fallback="default2"), + Action(name="Enrich", action=enrich), + ], + auto_inject=True, + return_list=True, + ) + + result = await chain() + assert result == [None, "default1", "default1", None, "default2", "Enriched: default2"] + diff --git a/tests/test_chained_action_empty.py b/tests/test_chained_action_empty.py new file mode 100644 index 0000000..81e407a --- /dev/null +++ b/tests/test_chained_action_empty.py @@ -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) + diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..3ed242c --- /dev/null +++ b/tests/test_command.py @@ -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) + diff --git a/tests/test_stress_actions.py b/tests/test_stress_actions.py new file mode 100644 index 0000000..67202e4 --- /dev/null +++ b/tests/test_stress_actions.py @@ -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 From bc1637143c045316d0521f0ba7c29b224a14efa4 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Wed, 30 Apr 2025 21:45:11 -0400 Subject: [PATCH 2/3] Rename ResultsContext -> SharedContext --- examples/action_example.py | 1 + examples/simple.py | 2 +- falyx/__init__.py | 4 +- falyx/action.py | 76 +++++++++++++++++++++----------------- falyx/context.py | 22 ++++++++++- falyx/hooks.py | 4 +- 6 files changed, 69 insertions(+), 40 deletions(-) diff --git a/examples/action_example.py b/examples/action_example.py index cd4666b..644043e 100644 --- a/examples/action_example.py +++ b/examples/action_example.py @@ -2,6 +2,7 @@ import asyncio from falyx import Action, ActionGroup, ChainedAction + # Actions can be defined as synchronous functions # Falyx will automatically convert them to async functions def hello() -> None: diff --git a/examples/simple.py b/examples/simple.py index 3b7c754..0ccb5e3 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -1,7 +1,7 @@ import asyncio import random -from falyx import Falyx, Action, ChainedAction +from falyx import Action, ChainedAction, Falyx from falyx.utils import setup_logging setup_logging() diff --git a/falyx/__init__.py b/falyx/__init__.py index 5afa428..2ebe73f 100644 --- a/falyx/__init__.py +++ b/falyx/__init__.py @@ -8,7 +8,7 @@ import logging from .action import Action, ActionGroup, ChainedAction, ProcessAction from .command import Command -from .context import ExecutionContext, ResultsContext +from .context import ExecutionContext, SharedContext from .execution_registry import ExecutionRegistry from .falyx import Falyx @@ -24,6 +24,6 @@ __all__ = [ "Falyx", "Command", "ExecutionContext", - "ResultsContext", + "SharedContext", "ExecutionRegistry", ] diff --git a/falyx/action.py b/falyx/action.py index a082b56..d050e99 100644 --- a/falyx/action.py +++ b/falyx/action.py @@ -38,7 +38,7 @@ from typing import Any, Callable from rich.console import Console from rich.tree import Tree -from falyx.context import ExecutionContext, ResultsContext +from falyx.context import ExecutionContext, SharedContext from falyx.debug import register_debug_hooks from falyx.exceptions import EmptyChainError from falyx.execution_registry import ExecutionRegistry as er @@ -47,8 +47,6 @@ from falyx.retry import RetryHandler, RetryPolicy from falyx.themes.colors import OneColors from falyx.utils import ensure_async, logger -console = Console() - class BaseAction(ABC): """ @@ -72,11 +70,12 @@ class BaseAction(ABC): self.name = name self.hooks = hooks or HookManager() self.is_retryable: bool = False - self.results_context: ResultsContext | None = None + self.shared_context: SharedContext | None = None self.inject_last_result: bool = inject_last_result self.inject_last_result_as: str = inject_last_result_as self._requires_injection: bool = False self._skip_in_chain: bool = False + self.console = Console(color_system="auto") if logging_hooks: register_debug_hooks(self.hooks) @@ -92,32 +91,32 @@ class BaseAction(ABC): async def preview(self, parent: Tree | None = None): raise NotImplementedError("preview must be implemented by subclasses") - def set_results_context(self, results_context: ResultsContext): - self.results_context = results_context + def set_shared_context(self, shared_context: SharedContext): + self.shared_context = shared_context - def prepare_for_chain(self, results_context: ResultsContext) -> BaseAction: + def prepare_for_chain(self, shared_context: SharedContext) -> BaseAction: """ Prepare the action specifically for sequential (ChainedAction) execution. Can be overridden for chain-specific logic. """ - self.set_results_context(results_context) + self.set_shared_context(shared_context) return self - def prepare_for_group(self, results_context: ResultsContext) -> BaseAction: + def prepare_for_group(self, shared_context: SharedContext) -> BaseAction: """ Prepare the action specifically for parallel (ActionGroup) execution. Can be overridden for group-specific logic. """ - self.set_results_context(results_context) + self.set_shared_context(shared_context) return self def _maybe_inject_last_result(self, kwargs: dict[str, Any]) -> dict[str, Any]: - if self.inject_last_result and self.results_context: + if self.inject_last_result and self.shared_context: key = self.inject_last_result_as if key in kwargs: logger.warning("[%s] โš ๏ธ Overriding '%s' with last_result", self.name, key) kwargs = dict(kwargs) - kwargs[key] = self.results_context.last_result() + kwargs[key] = self.shared_context.last_result() return kwargs def register_hooks_recursively(self, hook_type: HookType, hook: Hook): @@ -146,7 +145,7 @@ class BaseAction(ABC): return self._requires_injection def __str__(self): - return f"<{self.__class__.__name__} '{self.name}'>" + return f"{self.__class__.__name__}('{self.name}')" def __repr__(self): return str(self) @@ -261,7 +260,7 @@ class Action(BaseAction): if parent: parent.add("".join(label)) else: - console.print(Tree("".join(label))) + self.console.print(Tree("".join(label))) def __str__(self): return f"Action(name={self.name}, action={self.action.__name__})" @@ -405,9 +404,9 @@ class ChainedAction(BaseAction, ActionListMixin): if not self.actions: raise EmptyChainError(f"[{self.name}] No actions to execute.") - results_context = ResultsContext(name=self.name) - if self.results_context: - results_context.add_result(self.results_context.last_result()) + shared_context = SharedContext(name=self.name) + if self.shared_context: + shared_context.add_result(self.shared_context.last_result()) updated_kwargs = self._maybe_inject_last_result(kwargs) context = ExecutionContext( name=self.name, @@ -415,6 +414,7 @@ class ChainedAction(BaseAction, ActionListMixin): kwargs=updated_kwargs, action=self, extra={"results": [], "rollback_stack": []}, + shared_context=shared_context, ) context.start_timer() try: @@ -424,9 +424,9 @@ class ChainedAction(BaseAction, ActionListMixin): if action._skip_in_chain: logger.debug("[%s] โš ๏ธ Skipping consumed action '%s'", self.name, action.name) continue - results_context.current_index = index - prepared = action.prepare_for_chain(results_context) - last_result = results_context.last_result() + shared_context.current_index = index + prepared = action.prepare_for_chain(shared_context) + last_result = shared_context.last_result() try: if self.requires_io_injection() and last_result is not None: result = await prepared(**{prepared.inject_last_result_as: last_result}) @@ -436,14 +436,14 @@ class ChainedAction(BaseAction, ActionListMixin): 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) + shared_context.add_result(None) context.extra["results"].append(None) - fallback = self.actions[index + 1].prepare_for_chain(results_context) + fallback = self.actions[index + 1].prepare_for_chain(shared_context) result = await fallback() fallback._skip_in_chain = True else: raise - results_context.add_result(result) + shared_context.add_result(result) context.extra["results"].append(result) context.extra["rollback_stack"].append(prepared) @@ -455,7 +455,7 @@ class ChainedAction(BaseAction, ActionListMixin): except Exception as error: context.exception = error - results_context.errors.append((results_context.current_index, error)) + shared_context.add_error(shared_context.current_index, error) await self._rollback(context.extra["rollback_stack"], *args, **kwargs) await self.hooks.trigger(HookType.ON_ERROR, context) raise @@ -495,7 +495,7 @@ class ChainedAction(BaseAction, ActionListMixin): for action in self.actions: await action.preview(parent=tree) if not parent: - console.print(tree) + self.console.print(tree) def register_hooks_recursively(self, hook_type: HookType, hook: Hook): """Register a hook for all actions and sub-actions.""" @@ -503,6 +503,9 @@ class ChainedAction(BaseAction, ActionListMixin): for action in self.actions: action.register_hooks_recursively(hook_type, hook) + def __str__(self): + return f"ChainedAction(name={self.name}, actions={self.actions})" + class ActionGroup(BaseAction, ActionListMixin): """ @@ -550,9 +553,9 @@ class ActionGroup(BaseAction, ActionListMixin): self.set_actions(actions) async def _run(self, *args, **kwargs) -> list[tuple[str, Any]]: - results_context = ResultsContext(name=self.name, is_parallel=True) - if self.results_context: - results_context.set_shared_result(self.results_context.last_result()) + shared_context = SharedContext(name=self.name, is_parallel=True) + if self.shared_context: + shared_context.set_shared_result(self.shared_context.last_result()) updated_kwargs = self._maybe_inject_last_result(kwargs) context = ExecutionContext( name=self.name, @@ -560,15 +563,16 @@ class ActionGroup(BaseAction, ActionListMixin): kwargs=updated_kwargs, action=self, extra={"results": [], "errors": []}, + shared_context=shared_context, ) async def run_one(action: BaseAction): try: - prepared = action.prepare_for_group(results_context) + prepared = action.prepare_for_group(shared_context) result = await prepared(*args, **updated_kwargs) - results_context.add_result((action.name, result)) + shared_context.add_result((action.name, result)) context.extra["results"].append((action.name, result)) except Exception as error: - results_context.errors.append((results_context.current_index, error)) + shared_context.add_error(shared_context.current_index, error) context.extra["errors"].append((action.name, error)) context.start_timer() @@ -606,7 +610,7 @@ class ActionGroup(BaseAction, ActionListMixin): random.shuffle(actions) await asyncio.gather(*(action.preview(parent=tree) for action in actions)) if not parent: - console.print(tree) + self.console.print(tree) def register_hooks_recursively(self, hook_type: HookType, hook: Hook): """Register a hook for all actions and sub-actions.""" @@ -614,6 +618,9 @@ class ActionGroup(BaseAction, ActionListMixin): for action in self.actions: action.register_hooks_recursively(hook_type, hook) + def __str__(self): + return f"ActionGroup(name={self.name}, actions={self.actions})" + class ProcessAction(BaseAction): """ @@ -655,7 +662,7 @@ class ProcessAction(BaseAction): async def _run(self, *args, **kwargs): if self.inject_last_result: - last_result = self.results_context.last_result() + last_result = self.shared_context.last_result() if not self._validate_pickleable(last_result): raise ValueError( f"Cannot inject last result into {self.name}: " @@ -699,7 +706,7 @@ class ProcessAction(BaseAction): if parent: parent.add("".join(label)) else: - console.print(Tree("".join(label))) + self.console.print(Tree("".join(label))) def _validate_pickleable(self, obj: Any) -> bool: try: @@ -708,3 +715,4 @@ class ProcessAction(BaseAction): return True except (pickle.PicklingError, TypeError): return False + diff --git a/falyx/context.py b/falyx/context.py index f8de893..623bac0 100644 --- a/falyx/context.py +++ b/falyx/context.py @@ -1,5 +1,7 @@ # Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """context.py""" +from __future__ import annotations + import time from datetime import datetime from typing import Any @@ -24,6 +26,8 @@ class ExecutionContext(BaseModel): extra: dict[str, Any] = Field(default_factory=dict) console: Console = Field(default_factory=lambda: Console(color_system="auto")) + shared_context: SharedContext | None = None + model_config = ConfigDict(arbitrary_types_allowed=True) def start_timer(self): @@ -34,6 +38,9 @@ class ExecutionContext(BaseModel): self.end_time = time.perf_counter() self.end_wall = datetime.now() + def get_shared_context(self) -> SharedContext: + return self.shared_context or SharedContext(name="default") + @property def duration(self) -> float | None: if self.start_time is None: @@ -104,7 +111,7 @@ class ExecutionContext(BaseModel): ) -class ResultsContext(BaseModel): +class SharedContext(BaseModel): name: str results: list[Any] = Field(default_factory=list) errors: list[tuple[int, Exception]] = Field(default_factory=list) @@ -112,11 +119,16 @@ class ResultsContext(BaseModel): is_parallel: bool = False shared_result: Any | None = None + share: dict[str, Any] = Field(default_factory=dict) + model_config = ConfigDict(arbitrary_types_allowed=True) def add_result(self, result: Any) -> None: self.results.append(result) + def add_error(self, index: int, error: Exception) -> None: + self.errors.append((index, error)) + def set_shared_result(self, result: Any) -> None: self.shared_result = result if self.is_parallel: @@ -127,10 +139,16 @@ class ResultsContext(BaseModel): return self.shared_result return self.results[-1] if self.results else None + def get(self, key: str, default: Any = None) -> Any: + return self.share.get(key, default) + + def set(self, key: str, value: Any) -> None: + self.share[key] = value + def __str__(self) -> str: parallel_label = "Parallel" if self.is_parallel else "Sequential" return ( - f"<{parallel_label}ResultsContext '{self.name}' | " + f"<{parallel_label}SharedContext '{self.name}' | " f"Results: {self.results} | " f"Errors: {self.errors}>" ) diff --git a/falyx/hooks.py b/falyx/hooks.py index 0923718..7a7bc99 100644 --- a/falyx/hooks.py +++ b/falyx/hooks.py @@ -1,6 +1,7 @@ # Falyx CLI Framework โ€” (c) 2025 rtj.dev LLC โ€” MIT Licensed """hooks.py""" import time +from typing import Callable from falyx.context import ExecutionContext from falyx.exceptions import CircuitBreakerOpen @@ -8,8 +9,9 @@ from falyx.themes.colors import OneColors from falyx.utils import logger + class ResultReporter: - def __init__(self, formatter: callable = None): + def __init__(self, formatter: Callable[[], str] | None = None): """ Optional result formatter. If not provided, uses repr(result). """ From fe9758adbf44e8e6c4826271411d5f77aa11ff36 Mon Sep 17 00:00:00 2001 From: Roland Thomas Date: Wed, 30 Apr 2025 22:23:59 -0400 Subject: [PATCH 3/3] Add HTTPAction, update main demo --- falyx/__main__.py | 151 ++++++++++++++++++++++++++++++++++--------- falyx/falyx.py | 7 +- falyx/http_action.py | 109 +++++++++++++++++++++++++++++++ pyproject.toml | 1 - 4 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 falyx/http_action.py diff --git a/falyx/__main__.py b/falyx/__main__.py index 33698dd..59f509c 100644 --- a/falyx/__main__.py +++ b/falyx/__main__.py @@ -5,73 +5,166 @@ Copyright (c) 2025 rtj.dev LLC. Licensed under the MIT License. See LICENSE file for details. """ import asyncio +import random +from argparse import Namespace from falyx.action import Action, ActionGroup, ChainedAction from falyx.falyx import Falyx +from falyx.parsers import FalyxParsers, get_arg_parsers +from falyx.version import __version__ -def build_falyx() -> Falyx: +class Foo: + def __init__(self, flx: Falyx) -> None: + self.flx = flx + + async def build(self): + await asyncio.sleep(1) + print("โœ… Build complete!") + return "Build complete!" + + async def test(self): + await asyncio.sleep(1) + print("โœ… Tests passed!") + return "Tests passed!" + + async def deploy(self): + await asyncio.sleep(1) + print("โœ… Deployment complete!") + return "Deployment complete!" + + async def clean(self): + print("๐Ÿงน Cleaning...") + await asyncio.sleep(1) + print("โœ… Clean complete!") + return "Clean complete!" + + async def build_package(self): + print("๐Ÿ”จ Building...") + await asyncio.sleep(1) + print("โœ… Build finished!") + return "Build finished!" + + async def package(self): + print("๐Ÿ“ฆ Packaging...") + await asyncio.sleep(1) + print("โœ… Package complete!") + return "Package complete!" + + async def run_tests(self): + print("๐Ÿงช Running tests...") + await asyncio.sleep(random.randint(1, 3)) + print("โœ… Tests passed!") + return "Tests passed!" + + async def run_integration_tests(self): + print("๐Ÿ”— Running integration tests...") + await asyncio.sleep(random.randint(1, 3)) + print("โœ… Integration tests passed!") + return "Integration tests passed!" + + async def run_linter(self): + print("๐Ÿงน Running linter...") + await asyncio.sleep(random.randint(1, 3)) + print("โœ… Linter passed!") + return "Linter passed!" + + async def run(self): + await self.flx.run() + + +def parse_args() -> Namespace: + parsers: FalyxParsers = get_arg_parsers() + return parsers.parse_args() + + +async def main() -> None: """Build and return a Falyx instance with all your commands.""" - flx = Falyx(title="๐Ÿš€ Falyx CLI") + args = parse_args() + flx = Falyx( + title="๐Ÿš€ Falyx CLI", + cli_args=args, + columns=5, + welcome_message="Welcome to Falyx CLI!", + exit_message="Goodbye!", + ) + foo = Foo(flx) - # Example commands + # --- Bottom bar info --- + flx.bottom_bar.columns = 3 + flx.bottom_bar.add_toggle_from_option("V", "Verbose", flx.options, "verbose") + flx.bottom_bar.add_toggle_from_option("U", "Debug Hooks", flx.options, "debug_hooks") + flx.bottom_bar.add_static("Version", f"Falyx v{__version__}") + + # --- Command actions --- + + # --- Single Actions --- flx.add_command( key="B", description="Build project", - action=Action("Build", lambda: print("๐Ÿ“ฆ Building...")), - tags=["build"] + action=Action("Build", foo.build), + tags=["build"], + spinner=True, + spinner_message="๐Ÿ“ฆ Building...", ) - flx.add_command( key="T", description="Run tests", - action=Action("Test", lambda: print("๐Ÿงช Running tests...")), - tags=["test"] + action=Action("Test", foo.test), + tags=["test"], + spinner=True, + spinner_message="๐Ÿงช Running tests...", ) - flx.add_command( key="D", description="Deploy project", - action=Action("Deploy", lambda: print("๐Ÿš€ Deploying...")), - tags=["deploy"] + action=Action("Deploy", foo.deploy), + tags=["deploy"], + spinner=True, + spinner_message="๐Ÿš€ Deploying...", ) - # Example of ChainedAction (pipeline) - build_pipeline = ChainedAction( + # --- Build pipeline (ChainedAction) --- + 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, + Action("Clean", foo.clean), + Action("Build", foo.build_package), + Action("Package", foo.package), + ] ) flx.add_command( key="P", description="Run Build Pipeline", - action=build_pipeline, - tags=["build", "pipeline"] + action=pipeline, + tags=["build", "pipeline"], + spinner=True, + spinner_message="๐Ÿ”จ Running build pipeline...", + spinner_type="line", ) - # Example of ActionGroup (parallel tasks) + # --- Test suite (ActionGroup) --- 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...")), + Action("Unit Tests", foo.run_tests), + Action("Integration Tests", foo.run_integration_tests), + Action("Lint", foo.run_linter), ] ) flx.add_command( key="G", description="Run All Tests", action=test_suite, - tags=["test", "parallel"] + tags=["test", "parallel"], + spinner=True, + spinner_type="line", ) + await foo.run() - return flx if __name__ == "__main__": - flx = build_falyx() - asyncio.run(flx.run()) - + try: + asyncio.run(main()) + except (KeyboardInterrupt, EOFError): + pass diff --git a/falyx/falyx.py b/falyx/falyx.py index 209f5de..8651711 100644 --- a/falyx/falyx.py +++ b/falyx/falyx.py @@ -830,9 +830,10 @@ class Falyx: except (EOFError, KeyboardInterrupt): logger.info("EOF or KeyboardInterrupt. Exiting menu.") break - logger.info(f"Exiting menu: {self.get_title()}") - if self.exit_message: - self.print_message(self.exit_message) + finally: + logger.info(f"Exiting menu: {self.get_title()}") + if self.exit_message: + self.print_message(self.exit_message) async def run(self) -> None: """Run Falyx CLI with structured subcommands.""" diff --git a/falyx/http_action.py b/falyx/http_action.py new file mode 100644 index 0000000..be9ed46 --- /dev/null +++ b/falyx/http_action.py @@ -0,0 +1,109 @@ +from typing import Any + +import aiohttp +from rich.tree import Tree + +from falyx.action import Action +from falyx.context import ExecutionContext, SharedContext +from falyx.themes.colors import OneColors +from falyx.utils import logger + + +async def close_shared_http_session(context: ExecutionContext) -> None: + try: + shared_context: SharedContext = context.get_shared_context() + session = shared_context.get("http_session") + should_close = shared_context.get("_session_should_close", False) + if session and should_close: + await session.close() + except Exception as error: + logger.warning("โš ๏ธ Error closing shared HTTP session: %s", error) + + +class HTTPAction(Action): + """ + Specialized Action that performs an HTTP request using aiohttp and the shared context. + + Automatically reuses a shared aiohttp.ClientSession stored in SharedContext. + Closes the session at the end of the ActionGroup (via an after-hook). + """ + def __init__( + self, + name: str, + method: str, + url: str, + *, + args: tuple[Any, ...] = (), + headers: dict[str, str] | None = None, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + data: Any = None, + hooks=None, + inject_last_result: bool = False, + inject_last_result_as: str = "last_result", + retry: bool = False, + retry_policy=None, + ): + self.method = method.upper() + self.url = url + self.headers = headers + self.params = params + self.json = json + self.data = data + + super().__init__( + name=name, + action=self._request, + args=args, + kwargs={}, + hooks=hooks, + inject_last_result=inject_last_result, + inject_last_result_as=inject_last_result_as, + retry=retry, + retry_policy=retry_policy, + ) + + async def _request(self, *args, **kwargs) -> dict[str, Any]: + assert self.shared_context is not None, "SharedContext is not set" + context: SharedContext = self.shared_context + + session = context.get("http_session") + if session is None: + session = aiohttp.ClientSession() + context.set("http_session", session) + context.set("_session_should_close", True) + + async with session.request( + self.method, + self.url, + headers=self.headers, + params=self.params, + json=self.json, + data=self.data, + ) as response: + body = await response.text() + return { + "status": response.status, + "url": str(response.url), + "headers": dict(response.headers), + "body": body, + } + + async def preview(self, parent: Tree | None = None): + label = [ + f"[{OneColors.CYAN_b}]๐ŸŒ HTTPAction[/] '{self.name}'", + f"\n[dim]Method:[/] {self.method}", + f"\n[dim]URL:[/] {self.url}", + ] + if self.inject_last_result: + label.append(f"\n[dim]Injects:[/] '{self.inject_last_result_as}'") + if self.retry_policy and self.retry_policy.enabled: + label.append( + f"\n[dim]โ†ป Retries:[/] {self.retry_policy.max_retries}x, " + f"delay {self.retry_policy.delay}s, backoff {self.retry_policy.backoff}x" + ) + + if parent: + parent.add("".join(label)) + else: + self.console.print(Tree("".join(label))) diff --git a/pyproject.toml b/pyproject.toml index 6de3bd8..b3abb44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ pytest-asyncio = "^0.20" ruff = "^0.3" [tool.poetry.scripts] -falyx = "falyx.cli.main:main" sync-version = "scripts.sync_version:main" [build-system]